陈公子的博客

文字可以宣泄过往,但终究写不出流年

jenkins进阶

记录最近踩的jenkins大坑,知识点比较零散

悲剧的起因

最近计划通过测试覆盖率工具来量化测试工作,避免漏测,少测试,因此选型jenkins+jacoco来实现

悲剧时间线

  • jenkins插件市场,找到jacoco插件
  • 下载jacoco插件,提示当前jenkins版本过低
  • 升级jenkins版本
  • 重启jenkins失败
  • 登陆jenkins机器看日志,提示jdk版本过低
  • 安装jdk11,并设置java环境变量
  • jenkins 重启成功
  • jenkins tool 新增jdk8 和jdk11
  • 配置jenkins job,使用 jdk8
  • 运行job,提示编译失败,找不到jdk8的 rt.jar (jdk11没有改jar)
  • job新增打印环境变量,输出结果是 jdk8,但是maven 插件依然使用jdk 11
  • 修改jenkins所在机器的java环境变量为8,重启 jenkins 使用jdk11绝对路径启动
  • 运行job,依然提示失败,使用当前jenkins进程对应的jdk11
  • 回滚jenkins 版本到之前版本(jdk8)
  • 重启jenkins失败,提示config.xml解析异常

config.xml解析异常

百度和谷歌了一把,都没找到有效的,这里吐槽一下百度,大量重复的内容
参考了一番搜索结果,查了下jenkins官网资料,最终有效的办法是注释掉报错的xml节点

xml文件路径在 配置的{jenkins_home}目录下
根本原因是 新旧版本xml格式不兼容
jenkins 所有配置都使用xml方式存储的,遇到坏掉的插件,可以直接注释,或者修改成对应版本的格式
job配置的xml在{jenkins_home}/jobs/{job_name}/config.xml
只要xml没被删除,可以通过xml还原之前的配置

maven插件不使用设置的jdk

这个问题如果使用maven项目则比较麻烦(jenkins哪些版本没这个bug,具体不详),改成使用pipeline构建job

理论上来说,编译使用的jdk版本和jenkins进程使用的jdk版本完全是隔离,例如 jenkins 使用11,项目使用8,反之亦然
推断原因应该是jenkins的maven项目 使用 scriptEngine 执行shell的时候,写法有问题。

pipeline 进阶知识点

基础的 jenkins pipeline我不介绍了,搜索一下,或者自己看官网文档。下面列下进阶技巧

账户密码托管

jenkins job 有时候会涉及访问远程资源就需要使用对应的账户,例如git账户,oss账户,或者服务器账户
安全上来讲要统一使用凭证管理

新增凭证

pipeline 新增凭证参数,并且使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pipeline{
agent any
parameters{
credentials(
credentialType: 'com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl',
defaultValue: 'admin-user',
description: ''' 服务器登陆账户 ''',
name: 'server_user',
required: true
)
}
stages {
stage('打印变量') {
steps {
withCredentials([usernamePassword(credentialsId:
params.server_user, passwordVariable: 'upwd', usernameVariable: 'uname')]) {
// 在字符串里面使用${xx}
// 在函数里面直接使用变量
println(" uname : ${uname} " )
println(" upwd : ${upwd} ")
}

}
}

}

}


打印出来的用户名/密码是星号

Publish Over SSH 替代方案

大部分时候jenkins所在的服务器和java服务运行的服务器不是同一个,这个时候就需要上传jar到远程服务,且运行启动脚本
网上查了下资料,大部分使用publish over ssh,但这个插件已经不维护了,最新版本jenkins插件市场都找不到
推荐的替代方案是使用 SSH Pipeline Steps 替代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

pipeline{
agent any
parameters{
credentials(
credentialType: 'com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl',
defaultValue: 'admin-user',
description: ''' 服务器登陆账户 ''',
name: 'server_user',
required: true
)
}
stages {
stage('执行远程shell') {
steps {
withCredentials([usernamePassword(credentialsId:
params.server_user, passwordVariable: 'upwd', usernameVariable: 'uname')]) {
def remote = [:]
remote.password = upwd
remote.user = uname
remote.host = "127.0.0.2"
remote.name = "127.0.0.2"
remote.allowAnyHosts = true
writeFile file: 'abc.sh', text: 'ls'
// 执行远程命令
sshCommand remote: remote, command: 'for i in {1..5}; do echo -n \"Loop \$i \"; date ; sleep 1; done'
// 复制当前workspace下文件到 远程机器
sshPut remote: remote, from: 'abc.sh', into: '.'
// 复制当前 远程机器文件,到当前workspace
sshGet remote: remote, from: 'abc.sh', into: 'bac.sh', override: true
// 执行远程脚本
sshScript remote: remote, script: 'abc.sh'
// 删除远程文件
sshRemove remote: remote, path: 'abc.sh'

}

}
}

}

}


当前workspace 在{jenkins_home}/workspace/{job_name}

动态插件方案

有时候有些疑难杂症,难以解决,需要在脚本运行期间使用 jenkins进程相关信息
注意,不要勾选沙箱运行,因为要获取jenkins实例,要突破沙箱限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

import jenkins.model.*
// 有使用的插件也可以import其他的


pipeline{
agent any
parameters{
credentials(
credentialType: 'com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl',
defaultValue: 'admin-user',
description: ''' 服务器登陆账户 ''',
name: 'server_user',
required: true
)
}
stages {
stage('使用') {
steps {

script{
// 获取当前jenkins实例
def jks= Jenkins.getInstanceOrNull()
println(jks)
// 获取指定插件的配置,其他api,查看 https://javadoc.jenkins.io/jenkins/model/Jenkins.html
// 理论上所有插件和参数都能查看,也可以动态修改插件
def desc=jks.getDescriptor(com.cloudbees.plugins.credentials.Credentials.class)
println(desc)
def plugin=jks.getPlugin(com.cloudbees.plugins.credentials.Credentials.class)
println(plugin)
}


}
}

}

}


高版本的jenkins除了关闭沙箱之外,还需要授权同意

jenkins发布流水线参考demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194

import jenkins.model.*

/**
* see https://javadoc.jenkins.io/
* see https://github.com/jenkinsci/ssh-steps-plugin?tab=readme-ov-file#sshput
*/

pipeline {
agent any

tools {
// 多jdk情况下手动指定 jdk,也可以在steps拿到jenkins实例后里面动态切换jdk
jdk 'jdk8'
}

options {
timestamps() //设置在项目打印日志时带上对应时间
disableConcurrentBuilds() //不允许同时执行流水线,被用来防止同时访问共享资源等
buildDiscarder(logRotator(numToKeepStr: "5")) // 表示保留n次构建历史
}
parameters {
// maven home--字符串参数 -- 去掉空白字符串
string(
defaultValue: '/usr/local/maven/apache-maven-3.8.1',
description: ''' maven 目录 ''',
name: 'maven_home',
trim: true
)
// 发布的机器ip列表 逗号分割
string(
defaultValue: '',
description: ''' 发布的机器ip列表 ''',
name: 'remote_ip_list_str',
trim: true
)
// git_branches git 分支
string(
defaultValue: '*/test',
description: ''' git 分支 ''',
name: 'git_branches',
trim: true
)
// git_time_out 单位分钟
string(
defaultValue: '20',
description: ''' git_time_out ''',
name: 'git_time_out',
trim: true
)
// git_url
string(
defaultValue: '',
description: ''' git 仓库地址 ''',
name: 'git_url',
trim: true
)
// ssh 凭证,访问git使用
credentials(
credentialType: 'com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey',
defaultValue: 'gitlab_jenkins',
description: ''' ssh 凭证 ''',
name: 'credentials_id',
required: true
)
booleanParam(
defaultValue: false,
description: '是否启用测试覆盖率',
name: 'is_enable_jacoco'
)
credentials(
credentialType: 'com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl',
defaultValue: 'server-user-admin',
description: ''' 服务器登陆账户 ''',
name: 'server_user',
required: true
)


}

stages {
stage('打印环境变量') {
steps {
println("=========开始打印环境变量=========")
sh "printenv"
}
}

stage('拉取git代码') {
steps {
script {
def branches = params.git_branches
def gitTimeOut = params.git_time_out
def credentialsId = params.credentials_id
def gitUrl = params.git_url
println("========开始拉取git代码=======")
println(gitUrl)
checkout scmGit(branches: [[name: branches]],
extensions: [cloneOption(noTags: false, reference: '', shallow: true, timeout: gitTimeOut)], userRemoteConfigs: [[credentialsId: credentialsId, url: gitUrl]])

}

}
}
stage('maven编译') {
steps {

script {
// /usr/local/maven/apache-maven-3.8.1
println("========开始maven编译=======")
sh """
pwd
${params.maven_home}/bin/mvn clean install -T 1C -Dmaven.test.skip=true -Dmaven.compile.fork=true -U -B -f ${env.WORKSPACE}/pom.xml
"""
}

}
}


stage('部署代码') {
steps {
script {
// 获取当前Jekins 实例,理论上非空
// def inst = Jenkins.getInstanceOrNull()

withCredentials([usernamePassword(credentialsId: params.server_user, passwordVariable: 'upwd', usernameVariable: 'uname')]) {
def remote = [:]
def serveripList = params.remote_ip_list_str.split(",")
for (ip in serveripList) {
remote.password = upwd
remote.user = uname
remote.host = ip
remote.name = ip
remote.allowAnyHosts = true

println(remote)
sshPut remote: remote, from: 'test.jar', into: '/home/target/jenkins'
sshScript remote: remote, script: '/home/back/java_start.sh'

while (true) {
println("=== 开始健康检查===")
try {
def response = sh(
script: """ curl -X GET "http://${ip}:8191/test/health/check" """,
returnStdout: true
).trim()
if (response.contains("SUCCESS")) {
break
}
println(" 健康检查失败 sleep 10 s")
sleep(10)
} catch (exec) {
println(" 健康检查失败 ${exec} sleep 10 s ")
sleep(10)
}
}
println("====== 健康检查通过 =====")

}
}

}

}

}

stage('测试覆盖率统计'){
when {
expression {
return params.is_enable_jacoco
}
}
steps {
script{
def serveripList = params.remote_ip_list_str.split(",")
def ip=serveripList[0]
println (" === 统计单元测试覆盖率 ====")
sh """
${params.maven_home}/bin/mvn org.jacoco:jacoco-maven-plugin:0.8.4:dump -Djacoco.address=${ip} -Djacoco.port=6300
"""
}

println (" === 生成测试覆盖率html报告 ====")
jacoco()
}
}

}
}


营销主体

主体,哲学上讲事物的主要部分,本地生活场景下营销主体,主要指用户围绕什么下单。

  • 平台新客优惠 主体是平台,期望用户在平台下单
  • 店铺满减优惠 主体是店铺,期望用户在该店铺下单
  • 特价商品优惠 主体是商品,期望用户购买该商品。
  • 配送费优惠 主体是配送费,期望用户使用该平台的配送服务
  • 支付优惠 主体是支付方式(例如微信支付),期望用户使用该支付渠道

主体对技术架构的影响

  • 对优惠咨询的影响,即通过什么查询营销优惠信息。
  • 对优惠金额的最大值的影响,即优惠的内容能否溢出主体价格

对优惠咨询的影响

业务软件架构绝大部分都是基于业务模型来建设,最终业务模型反映到存储模型上。

举个具体例子,假设某个平台,有100w商户,每个商户有100个商品,每个商品可独立配置优惠,也可能是组合优惠。
可预计的优惠规则是个庞大的数量,主体的选择对性能影响是非常大。

以平台为主体方案

  • 优点 对平台新客等其他平台类优惠友好。
  • 缺点 对商户类优惠,或者商品类优惠,效率较低,无区分度,性能较差
  • 实践 实际上基本不采用该方案。

以商户为主体的方案

  • 优点 对商户类,例如满减优惠比较好友
  • 缺点 平台类活动会导致数据倾斜严重(假设平台对应的商户id为0),连锁店商品
  • 实践 一种做法是平台商户给一个池子列表(例如 商户id 1,2,3 都算平台商户), 通过池子二次hash,从而解决数据倾斜问题

以商品为主体的方案

  • 优点 对商品类,例如特价比较友好。
  • 缺点 数据量较大,有标准商品库是可以的。
  • 实践 例如品牌商户,可口可乐做优惠,这种新零售场景下的标准商品是比较复合的,但是像外卖,A店的辣椒炒肉和B店的辣椒炒肉完全是两个商品,就不和适合了。

以配送费为主体

  • 实践 小众场景,目前配送费是抽象成一个订单商品(动态定价的商品,受重量,时段,天气等影响)

以支付为主体

  • 实践 小众场景,目前抽象支付优惠作用的是支付单,不影响订单实付金额,新增一个支付方,等于是混合支付。

对优惠金额的影响

例如 一个商品原价10元,特价2元,配送费1元,有一个满20减5元的活动,假设满减的门槛是按照商品原价,
用户购买2件商品,则实际支付是1元还是0元,则取决于满件的优惠主体是什么,是否能作用于配送费,这个目前没有标准答案,选择不同,结果不同。

实际过程中,没有标准的答案(有优点就一定有缺点),要看业务发展阶段,技术架构是随着业务发展一起演变

营销元数据

会员用户A 在商店B 里面购买商品满30 减10元。我们用这个简单场景来展开讲讲营销名词。
营销包含的基础名词,有主体(围绕什么做营销),对象(营销的目标),形式(具体的规则)

营销主体

广义上有价值的都能做营销主体,在本地生活电商领域一般有

  • 实物商品(sku)
  • 商户
  • 虚拟商品 (会员等)
  • 配送费

营销对象

营销对象是对用户进行分层,不同用户使用的营销策略不一样。在本地生活电商领域一般有

  • 平台用户
  • 平台新用户
  • 平台会员用户
  • 商家新用户
  • 商家会员用户
  • 自定义标签

营销形式

营销形式一般包含以下几种

  • 条件 (时间,门槛,满额,实付,原价,满件,其他等)
  • 权益 (特价,立减,折扣,赠送,其他等)
  • 限制规则 (库存,人单限制,人店限制 ,其他等)
  • 出资规则 (平台,商家,品牌商,代理商 等)

抽象过程

场景分解

回到最开始的 会员用户A 在商店B 里面购买商品满30 减10元。

  • 主体 商店B
  • 对象 会员用户A
  • 形式 满30 减10元

本片文章比较枯燥,后续按照主体,对象,形式 三个展开讲讲里面的业务抽象,
和相关技术难点。

需求

  • 评论文章
  • 回复评论
  • 查询文章评论列表 不展开子评论
  • 查询文章评论列表 展开子评论
  • 展示子评论总数

方案设计

需求是一个典型的评论盖楼场景,传统的方案是一个自连接的表

自连接表,表新增一列(parent_id)外键,该列引用当前表的id值

自连接表的查询,一般都数据当成一个list全部查出来,在应用服务内构建
成一个树结构,再去做其他处理。如果一个文章的评论特别多,例如微博热帖这种场景,性能就回成为瓶颈。
因此通过自己构建树形索引结构,优化查询性能。

树形结构

核心字段

  • 节点id 自增id
  • 父节点id 父节点id
  • 左值
  • 右值
  • 树深 当前数深度, root节点是0
  • 编号 当前节点在整棵的位置,用来排序,root节点是1
  • 数据内容 节点存储的数据,评论内容

场景分析

场景1

  1. 假设db有评论A 。此时A的左值是1,右值是2,编号是1,对应图里面的step1

  2. 在A评论下新增B评论,对应图里面的step2

    • 左值=父节点(A)右值
    • 右值=左值+1
    • 编号= 父节点(A)编号 + (父节点(A)右值-父节点(A)左值+1)/2
  3. 当前树下其他节点左值更新

    • 条件 该节点左值> 新增节点(B)的父节点(A)右值 即>=2
    • 动作 左值+=2
    • 本场景 没有复合条件的,无更新
  4. 当前树下其他节点右值更新

    • 条件 右值>=新增节点(B)的父节点(A)右值 即>=2
    • 动作 右值+=2
    • 该场景节点A满足条件,则A的右值改成2, 对应图里面的step3
  5. 其他节点编号更新

    • 条件 节点编号 >= 新增节点(B)的编号
    • 动作 编号+=1
    • 该场景没有满足条件的

场景2

该场景与场景1一样

场景3

结构优点

  1. 计算子节点数量, (右值-左值)/2,例如 A (8-1)/2=3 ,B (5-2)/2=1
  2. 列出当前子节点(包括下层子节点),查询 节点左值> 当前节点左值 并且 节点右值<当前节点右值,a. 例如 B节点的所有子节点,即 where 左值>2 and 右值<5列出当前节点 一层子节点,通过parent node 即可
  3. 列出当前节点 一层子节点,通过parent node 即可
  4. 列出当前节点,特定层级的所有叶子结点, 结合2和树深即可
  5. 展开整个树,通过编号排序即可

代码实现

节点代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

/* Copyright © 2020 Yuech and/or its affiliates. All rights reserved. */
package wiki.chenxun.blog.example.reply;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
* @author 陈勋
* @version 1.0
* @date 2021-05-19 3:43 下午
*/


public class Node {

/**
* 节点id
*/
private String id;

/**
* 树id,一般是文章id
*/
private String treeId;

/**
*  左值
*/
private Integer left;

/**
* 右值
*/
private Integer right;

/**
* 树深
*/
private Integer depth;

/**
* 编号
*/
private Integer index;

/**
* 商城节点id
*/
private String parentId;

/**
* 数据
*/
private String data;

public static Node rootNote(String id, String treeId,String data) {
Node node = new Node(id, treeId, 1, 2, 0, 1, null, data);
return node;
}

public void incrementLeft() {
this.left += 2;
}

public void incrementRight() {
this.right += 2;
}

public void incrementIndex() {
this.index += 1;
}

}





树代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88

/* Copyright © 2020 Yuech and/or its affiliates. All rights reserved. */
package wiki.chenxun.blog.example.reply;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

/**
* @author 陈勋
* @version 1.0
* @date 2021-06-18 6:37 下午
*/
public class Tree {

private String treeId;

private List<Node> nodeList = new ArrayList<>();

private Node rootNode;

public Tree(String treeId,String data) {
this.treeId = treeId;
this.rootNode = Node.rootNote(UUID.randomUUID().toString(), treeId,data);
nodeList.add(rootNode);
}

public String addNode(String data, String parentNodeId) {

Node parentNode;
if (parentNodeId != null) {
parentNode = nodeList.stream()
.filter(i -> parentNodeId.equals(i.getId()))
.findFirst().orElse(null);
} else {
parentNode = rootNode;
}
if (parentNode == null) {
throw new IllegalArgumentException("parent Node not exist");
}

final Integer parentRight = parentNode.getRight();
// 本次新增node在树里面的编号
final Integer nodeIndex = parentNode.getIndex() + (parentNode.getRight() - parentNode.getLeft() + 1) / 2;
// 数深
final Integer nodeDepth = parentNode.getDepth() + 1;

// 构建子节点
Node node = new Node(UUID.randomUUID().toString(), this.treeId,
parentNode.getRight(), parentNode.getRight() + 1,
nodeDepth, nodeIndex,
parentNodeId, data);

// 跟新其他节点左值
nodeList.stream().filter(i -> i.getLeft() >= parentRight)
.forEach(Node::incrementLeft);

// 更新其他节点右值
nodeList.stream().filter(i -> i.getRight() >= parentRight)
.forEach(Node::incrementRight);

// 更新其他节点编号,大于等于index都往后挪一个位置
nodeList.stream().filter(i -> i.getIndex() >= nodeIndex)
.forEach(Node::incrementIndex);
//添加新节点
nodeList.add(node);

return node.getId();

}

/**
* 测试打印,左序遍历
*/
public void print() {
nodeList.sort(Comparator.comparing(Node::getIndex));
String s=String.join(",", nodeList.stream()
.map(Node::getData).collect(Collectors.toList()));

System.out.println(s);

}

}


测试代码
1
2
3
4
5
6
7
8
9
10
11
12
13

public void test(){
Tree tree=new Tree("tree_1","文章");

String a= tree.addNode("a",null);
String b=tree.addNode("b",null);
tree.addNode("c",a);
tree.addNode("d",a);
tree.addNode("e",b);

tree.print();
}

业务应用

  • 数据可以和索引剥离,即data字段存的是数据id,root节点存文章id,其他节点存评论id
  • 新增节点的时候,需要把整颗树锁住
    • 通过redis做分布式锁
    • 通过select for update 做数据库悲观锁 (不推荐)
    • 通过 新增version字段做乐观锁,树的版本等于root节点的版本(推荐)
  • 删除节点,该场景下其实不删除索引,只是把数据id指向的数据删除
    • 如果删除的太多,就需要把整个树reBuild.(本场景未实现,有需要的朋友可以给留言)

领域驱动与微服务落地实战-工程结构

传统项目工程结构

目前maven/gradle已经成为j2ee应用开发的事实标准,以maven项目为例,常规的微服务工程结构如下

api

  • dto 数据传输模型
  • facade(即soa interface)

service

  • serviceImpl 无状态 主要的业务代码实现,是事物的核心过程,
  • bo 对象 业务模型
  • transfer 对象 dto(包括自身接口和下游接口)->bo bo->po

infra

  • repo (mybatis|hibernate)
  • po 持久化模型
  • soa client 下游接口

maven分多module的目的主要是做代码依赖隔离,和软件打包控制。按上图所示,api依赖service,
service依赖infra,这个是最简单的一个方案,实际项目中还有以下几种变种。

api 基于cqrs原则拆分成读写两套api,不拆maven module,一般通过类或者包拆分
service 可以基于垂直业务领域拆包,或者拆module(不建议,如果需要到拆module的程度,建议拆服务)

基于水平业务拆分一层biz module 出来,解决service本身臃肿和复用问题。
把service拆成可复用的单事物业务,和不可复用的大型业务两种。单事物业务之间不可互相引用。

也可以单独把main函数拆成一个独立module,方便集成测试和单元测试,常见于spring boot项目

infra 把数据库访问单独拆 module,避免研发人员使用其他中间件api
基于代码稳定性考虑,把soa client单独拆一个module,最大限度的隔离下游服务对核心业务代码的影响。

基于领域驱动设计工程结构

api 与传统项目的api模块没有区别

service 与传统项目的service模块相比,都是无状态的,也是事物的核心过程,但不是业务逻辑的核心实现,
业务逻辑包装在domain内,service主要是协调一个或者多个domain对象,并且不能访问infra

infra 与传统项目的infra模块相比,依赖domain,代表是domain领域对象依赖组件的具体实现

domain 领域驱动架构的核心业务代码层,代码稳定,不依赖其他任何module,无外部依赖(基本只依赖java sdk)
便于单元测试 和代码迁移

application main函数所在模块,主要是方便集成测试,和service模块的单元测试,并且mq的消费者也建议在该模块

其他

  • maven的代码隔离,其实是编译阶段的隔离,通常在运行期业务代码都是appClassLoader加载,
    不同maven module之间的代码在运行期其实是不隔离,所以要禁止通过反射等其他手段绕过相关访问限制
  • 在maven出现之前,传统的java应用都是ant打包方案,通过java的访问控制修饰符来控制代码访问权限,
    但事实上,java代码访问控制修饰符大部分常见下基本只用private public,很少会基于包做严格的访问隔离,
    最终maven多模块成为事实上的隔离标准。

领域驱动与微服务落地实战-领域划分和建模

领域划分-战略设计

软件研发好比行军作战,领域划分则属于战略设计,影响整体大局。
现代软件研发大部分都是迭代研发,整体的领域划分也会随着迭代调整,是一个演变过程,贯穿整个研发周期。
如何做领域划分,有以下几个原则

水平划分

水平划分是基于业务层级,受应用架构的影响,一般会分 接入层(网关层),服务层,基础层。
水平层级的依赖一般是单向的,即上层依赖下层。

接入层(网关层)

直接提供给外网访问数据,一般对接app或者h5,处理鉴权等。

服务层

提供具体业务服务能力,一般不对外,只提供soa接口,业务变更频繁,迭代快

基础层

具体业务服务依赖的一些通用服务,例如统一账户服务等,业务相对稳定,迭代慢

垂直划分

垂直划分,是基于一个水平层级能,基于细分业务领域拆分,例如原来商品和商户在一个业务服务,后面随着
业务发展,拆成2个独立的垂直业务服务。垂直划分的服务依赖可以是双向,最终在同一个水平层级内不同的垂直服务的
依赖关系会发展成网状,这个会增大服务治理的难度。

非功能性拆分

非功能性拆分,一般有to B/to C拆分,实时/离线拆分

to B/to C 拆分

常见于cqrs风格架构,b/c两边的性能要求不一致,迭代频率不一致,稳定性保障不一致等导致切分服务

离线/实时 拆分

离线/实时场景下,大部分采用的技术栈不一样,语言不一样,虽然多语言应用在微服务里也是可行的,但为了简化研发和维护成本,一般还是拆分。

如何做领域划分

这个是一个没有标准答案的问题,仁者见仁,智者见智。以上几个原则是做划分时的一个主要参考。划分也不是一锤子买卖,
所有的抽象都是建立在已知用例上,业务会变,会新增用例,当新增用例无法套到现有的领域划分里面,就需要调整领域划分。

领域建模-战术设计

领域划分解决了战略设计问题,那么领域建模则是具体的战术设计。领域建模是面向对象分析的方法论,
它可以利用面向对象的特性(封装、多态,继承)有效地化解复杂性。区别于传统的面向数据模型建模(er图),
领域建模是以对象模型为中心,建模过程就是面向对象分析过程,具体的建模过程,建议使用uml来辅助建模,常见的
uml建模图有以下几种

用例图

用例图是基于场景的模型,描述了人员使用该软件系统做了什么,是需求功能的直观抽象

类图

类图描述了领域模型核心属性和功能,并体现了面向对象的特性(继承,封装,多态)

流程图

流程图描述了业务流程的节点,和相关流转

时序图

时序图细化某个用例的具体实现,体现在该场景下不同应用节点之间的交互时序

架构图

体现服务应用之间的层级,业务边界,依赖关系,支撑领域划分

附录书籍

  • 《UML面向对象分析与设计》
  • 《领域驱动设计:软件核心复杂性应对之道》

ringBuffer深入浅出

ringBuffer顾名思义就是环形缓冲区,常用来做高速缓冲队列,采用环形复用(缓存区写满之后,从首地址重新写入),使用较小的实际物理内存实现了线性缓存。例如著名的Disruptor高性能的主要原因就是使用了ringBuffer。

ringBuffer特性

  • 存在读写2个序号
  • 读/写序号一直累加
  • 读/写序号取模buffer长度等于当前当前读写指针位置
  • buffer里面的数据不需要删除,覆盖即可
  • ringBuffer采用数组实现,对cpu高速缓存友好(cache line会加载相邻元素,数组元素天生相邻),访问速度比链表快。

RingBuffer代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84


package wiki.chenxun.study.buffer;
import sun.misc.Unsafe;
import java.lang.reflect.Field;
import java.security.AccessController;
import java.security.PrivilegedExceptionAction;
import java.util.concurrent.TimeUnit;
/**
* @author chenxun
* @date 2019-11-19
* @since
*/
public class RingBuffer<T> {
private static Unsafe unsafe;
private final T[] buffer;
private volatile int readIndex =-1;
private volatile int writerIndex =-1;
private static long readIndexOffset;
private static long writerIndexOffset;
static {
try {
unsafe = AccessController.doPrivileged((PrivilegedExceptionAction<Unsafe>) () -> {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
return (Unsafe) theUnsafe.get(null);
});
readIndexOffset = unsafe.objectFieldOffset
(RingBuffer.class.getDeclaredField("readIndex"));
writerIndexOffset = unsafe.objectFieldOffset
(RingBuffer.class.getDeclaredField("writerIndex"));
} catch (Exception e) {
e.printStackTrace();
}
}
@SuppressWarnings("all")
public RingBuffer(int bufferSize) {
buffer = (T[])new Object[bufferSize];
}
/**
* 队列添加元素,如果队列满了,则等待到队列空余
* @param t
* @throws InterruptedException
*/
public void push(T t) throws InterruptedException {
while (true){
//判断是否可写 总长度-(写位置-读位置)
if(buffer.length-(writerIndex-readIndex)>0){
int nextWriterIndex=writerIndex+1;
boolean result=unsafe.compareAndSwapInt(this,writerIndexOffset,writerIndex,nextWriterIndex);
if(result){
//计算实际位置
int bufferIndex=writerIndex%buffer.length;
buffer[bufferIndex]=t;
break;
}
}
// 休眠1ms
TimeUnit.MILLISECONDS.sleep(1L);
}
}
/**
* 队列取出元素,如果没有可读元素,则等待到有可读元素
* @return
* @throws InterruptedException
*/
public T pop() throws InterruptedException {
while (true){
// 判断是否可读
if(writerIndex-readIndex>0){
int nextReadIndex=readIndex+1;
boolean result=unsafe.compareAndSwapInt(this,readIndexOffset,readIndex,nextReadIndex);
if(result){
//计算实际位置
int bufferIndex=readIndex%buffer.length;
return buffer[bufferIndex];
}
}
// 休眠1ms
TimeUnit.MILLISECONDS.sleep(1L);
}
}
}

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

package wiki.chenxun.study.buffer;
import java.io.IOException;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
* @author chenxun
* @date 2019-11-19
* @since
*/
public class RingBufferTest {
public static void main(String[] args) {
RingBuffer<String> ringBuffer=new RingBuffer<>(20);
for(int a=1;a<4;a++){
final int b=a;
new Thread(()->{
for(int i=0;i<10;i++){
try {
ringBuffer.push(String.valueOf(b*10+i));
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
Random r=new Random();
TimeUnit.MILLISECONDS.sleep(r.nextInt(5));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
for(int a=0;a<2;a++){
new Thread(()->{
while (true){
String s= null;
try {
s = ringBuffer.pop();
System.out.println(s);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
}

buffer下标计算优化

如果buffer的size是2的n次方,那么取模运算可以替换成位运算.代码参考如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24


package wiki.chenxun.study.buffer;

/**
* @author chenxun
* @date 2019-11-19
* @since
*/
public class NumberUtils {

public static int moduloOperationOn2(int a, int b) {
return a & b - 1;
}

public static void main(String[] args) {
int a=100%8;
int b=moduloOperationOn2(100,8);
System.out.println(a==b);

}
}


缓存伪共享优化

cpu加载数据是基于缓存行(具体原理不展开了),java8支持通过@sun.misc.Contended来解决这个问题,
非java8版本可以参考Disruptor的做法 https://github.com/LMAX-Exchange/disruptor/blob/master/src/main/java/com/lmax/disruptor/RingBuffer.java

读写序号java溢出问题

暂时没有想到好方案,目前大致思路是,设置一个阀值算法,满足阀值则重置大小,类似java集合扩容一样,但这个会导致读写锁住。

RingBuffer应用场景

  • 依赖一个优惠券的查询接口,假设该接口提供分页查询能力。单次最多返回100张优惠优惠券,单次rt是20ms
  • 优惠券的扩展信息是另一个接口,该接口性能提供批量查询能力,单次做多支持30张优惠券,单词rt是30ms
  • 假设某个用户有500张优惠券

如果是串行调用,则整体时间是 (500/100)20+(500/30)30.采用ringBuffer解耦两个接口依赖,则优化后的rt时间,
理论上可以缩减至30ms左右(排除cpu切换上下文损耗等)

JSR-133规范详解

JSR-133规范,JavaTM内存模型与线程规范.该规范语义不会去描述多线程程序该如何执行。
而是描述多线程程序允许表现出的行为。

JVM内存模型和Java内存模型

JMM来源于JSR-133,主要解决多线程对共享数据的读写一致性问题,
JVM内存模型则是指JVM的内存分区。两者本身没有关系。

共享变量/堆内存(Shared variables/Heap memory)

能够在线程间共享的内存称作共享内存或堆内存。所有的实例字段,静态字段以及数组元素都存储在堆内存
中。我们使用变量这个词来表示字段和数组元素。方法中的局部变量永远不会在线程间共享且不会被内存模型影响

线程间的动作(Inter-thread Actions)

线程间的动作是由某一线程执行,能被另一线程探测或直接影响的动作(action)。线程间的动作包括共享变量的读写以及同
步动作(synchronization action),如 lock 或 unlock 某个管程,读写某个 volatile 变量或启动一个线程。
也包括与外部世界交互的动作。

线程内语义(Intra-thread semantics)

线程内语义是单线程程序的标准语义,基于某个线程内读动作能看到的值,可以完整的预测这个线程的行为。线程内语义决定着某个线程孤立的执行过程;当从堆中读取值时,值是
由内存模型决定的。

同步动作(Synchronization Actions)

同步动作包括锁、解锁、读写 volatile 变量,用于启动线程的动作以及用于探测线程是否结束的动作。

volatile详细解释

jsr133强化了volatile语义,需要有acquire和release语义,对某个volatile字段的写操作happens-before每个后续对该volatile字段的读操作

jvm具体实现

jvm通过内存屏障来保障volatile语义,硬件层面的内存屏障分两种store Barrier和load Barrier,jvm通过两种组合成四种即loadload,
loadstore,storestore,storeload.

  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据
  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

cpu3级缓存介绍

CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多。
导致CPU可能会花费很长时间等待数据到来或把数据写入内存。

每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。

lock前缀详细解释

演示java代码

在新增jvm参数之后可以打印对应的汇编指令

1
2
3
   
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation -XX:+TraceClassLoading

jvm并没有采用Barrier指令而是采用了lock addl $0x0,(%rsp)来实际上保障volatile语义。
lock是一个前缀指令,是和其他指令配合使用 addl $0x0,(%rsp)实际上就是+0,是一个无效指令,配合lock前缀锁定cpu总线(实际上是锁缓存),
lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
相关资料
https://software.intel.com/sites/default/files/managed/a4/60/325384-sdm-vol-3abcd.pdf 2.85

MESI协议介绍

缓存是分段(cache line),每次加载的缓存是定长的,通常是64位。lock其实不是锁总线是是锁定缓存段.
cpu缓存一致性协议,把cpu缓存行的数据分成4个状态,用2个bit表示

  • Modified 这行数据有效,这行数据修改了,和内存中数据不一致,数据只存在本cache中
  • Exclusive 这行数据有效,数据和内存中一致,数据只存在本cache中
  • Shared 这行数据有效,数据和内存中一致,数据存在很多cache中
  • Invaid 这行数据无效

其中M和E解决独占问题(也就是锁的语义),lock前缀配合一个写操作,整个缓存行的状态会从s->e->m->i->s

缓存伪共享和java8优化

cpu每次load数据是基于缓存行加载的,如果两个变量公用一个缓存行(一般是相同低bit位地址),就会产生缓存伪共享的问题,只打算更新a变量的只,结果导致b变量也一起更新。
解决的思路是把a变量独占一行,例如独占64位。Java8中已经提供了官方的解决方案,
Java8中新增了一个注解@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效。

jvm里面的access_flag

access_flag

最近在看jvm相关知识,发现class字节码文件里面大量使用access_flag做标志位,其中做法非常nice。
例如class字段描述,java里面字段修饰符有以下几个

  • public
  • private
  • protected
  • static
  • final
  • volatile
  • transient
1
2

private static final String a="abc";

这段代码在class文件里面字段a的访问修饰符怎么描述的呢,答案是 1A(实际是2进制值,方便阅读转成16进制)

fields的access_flag规范

标志名称标志值(16进制)含义
ACC_PUBLIC0x0001字段是否为public
ACC_PRIVATE0x0002字段是否为private
ACC_PROTECTED0x0004字段是否为protected
ACC_STATIC0x0008字段是否为static
ACC_FINAL0x0010字段是否为final
ACC_VOLATILE0x0040字段是否为volatile
ACC_TRANSIENT0x0080字段是否为transient
ACC_SYNTHETIC0x1000字段是否为编译器自动产生
ACC_ENUM0x4000字段是否为enum

看完规范发现,1A这个值完全不在规范里面,那jvm怎么知道1A就是private static final 呢?

access_flag的秘密

我们把相关标志16进制转成二进制看下

16进制二进制
0x0001000001
0x0002000010
0x0004000100
0x0008001000
0x0010010000

童鞋们发现规律了吗?为什么二进制值是000001,000010 ,每一个标志独占一位。这样标志值就满足以下规律
a(标志值)|b(标志值)=c (class文件上的值)
jvm怎么处理的呢?
c & a != 0,则表示该字段有a修饰符

业务应用

access_flag的秘密其实非常简单,了解之后怎么应用到业务场景呢

  • 有限的值
  • 不同值组合关系是要么全是并且,要么全是或者
    例如 有一个优惠券,有N种使用条件,切每个条件必须同时满足才能使用
    此类场景就非常适合。

class装载

示例代码链接 https://github.com/ChenXun1989/study-example

演示demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

public class TestClassLoader {
public static void main(String[] args) {
ClassLoader myClassLoader = new ClassLoader() {};
try {
Class cls = myClassLoader.loadClass(TestClassLoader.class.getName());
System.out.println(myClassLoader.getParent());
System.out.println(cls.getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}


输出结果:

1
2
3

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2

从结果上我们可以得出以下两个结论

  1. 自定义的classloader的parent默认为AppClassLoader
  2. 当classloader加载某个class的时候会委托给parent(父加载器)加载

双亲委托细节

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,
而是把这个请求委派父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,
只有当父加载器反馈自己无法完成这个请求(他的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委托是如何实现的呢?秘密就在loadClass 这个方法

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,
而是把这个请求委派父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,
只有当父加载器反馈自己无法完成这个请求(他的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委托是如何实现的呢?秘密就在loadClass 这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}


从code上得出结论如果没有知道class,会先通过父加载起去加载,一直递归上去,如果没有父加载器直接通过bootstrap加载器加载
大致流程如下: 用户自定义的classlaoder – AppClassLoader —ExtClassLoader – bootStrapClassLoader

这么做有什么好处?

如果没有使用双亲委派模型,由各个类加载器自行加载的话,如果用户自己编写了一个称为java.lang.Object的类,
并放在程序的ClassPath中,那系统将会出现多个不同的Object类, Java类型体系中最基础的行为就无法保证。应用程序也将会变得一片混乱

如何破坏双亲委托

双亲委托并不是一个强制约束,只是一种大家都遵守规范,但有些场景会破坏双亲委托(例如SPI)

1
2
3
4
5
6
7

public static void test2(){
ServiceLoader<SPITest> loaders= ServiceLoader.load(SPITest.class);
loaders.forEach(i->{
i.test();
});
}

准确的说spi没有破坏双亲委托,只是绕过了双亲委托获取class,委托机制上层(例如bootstrapClassloader)通过Thread.currentThread().getContextClassLoader()来获取
委托机制里面下层(例如AppClassLoader)加载的类。具体code如下:

1
2
3
4
5
6

public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}

这样做是为了解决一些 上层类依赖下层类的情况,比如jdbc,jdbc相关的类文件在rt.jar中,但是driver对应的实现类是放在我们应用的jar的lib里面,
这样 extClassLoader 就加载不到,但是通过spi机制,rt.jar包中的jdbc相关类就能获取由AppClassLoader 加载的jdbc具体的driver了。
上面讲到 双亲委托机制是通过loaderclass这个方法来实现的,如果我们自定义的classloader覆盖改方法(或者覆盖findClass),就能强行破坏,例如osgi.
在jvm里面每个class的唯一标识符由 classloader全名+class全名构成,因此假设两个独立的classloader,加载一样的class,jvm也会认为是两个独立的class,
tomcat就是此机制来实现webapp目录下不同的web应用的隔离

0%