一只病猫

静坐常思己过,闲谈莫论人非

  • 首页
  • 归档
  • 标签
  • 分类
  • 搜索

Spock-Spring-Gradle 单元测试

发表于 2022-06-08 | 分类于 JAVA |

Spock介绍

Spock是国外一款优秀的测试框架,基于BDD(行为驱动开发)思想实现,功能非常强大。Spock结合Groovy动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。Spock作为测试框架,在开发效率、可读性和维护性方面均取得了不错的收益。

修改项目配置

项目用gradle管理,用的是7.4.2版本,spock用的是2.0-M3-groovy-3.0版本。

配置修改

修改文件build.gradle并添加依赖

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
List spockTest = [
"org.spockframework:spock-core:2.0-M3-groovy-3.0",
"org.spockframework:spock-spring:2.0-M3-groovy-3.0",
"org.mockito:mockito-core:4.3.1",
"org.mockito:mockito-inline:4.3.1",
"org.springframework.boot:spring-boot-starter-test"
]

dependencies {
implementation 'org.codehaus.groovy:groovy:3.0.5'
testImplementation spockTest
}

sourceSets {
main {
java { srcDirs = ['src/main/java'] }
resources { srcDirs = ['src/main/resources'] }
}

test {
java { srcDirs = ['src/test/groovy'] }
resources { srcDirs = ['src/test/resources'] }
}
}

apply from: 'test.gradle'

修改test.gradle,这步主要是和jacoco结合生成报告(带覆盖率,用的jacoco的offline特性)

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
apply from: 'testFilesConf.gradle'
apply plugin: 'java'
apply plugin: "groovy"
apply plugin: "jacoco"

jacoco {
toolVersion = "0.8.7"
reportsDir = file("$buildDir/customJacocoReportDir")
}

configurations {
jacocoAnt
jacocoRuntime
}

test {
useJUnitPlatform()
maxHeapSize = "1G"
jacoco {
destinationFile = file("$buildDir/jacoco/test.exec")
classDumpDir = file("$buildDir/jacoco/classpathdumps")
}
testLogging {
afterSuite { desc, result ->
if (!desc.parent) {
println "Unit Tests: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} successes, ${result.failedTestCount} failures, ${result.skippedTestCount} skipped)"
}
}
}
}

def excludedSourcesPattern = ['**/entity/**', '**/dto/**']

jacocoTestReport {
reports {
html.enabled true
csv.enabled false
xml.enabled true
xml.destination file("$buildDir/jacocoHtml/jacocoXml.xml")
html.destination file("$buildDir/reports/jacoco/test/html")
}
afterEvaluate {
getClassDirectories().setFrom(
classDirectories.files.collect {
fileTree(dir: it, excludes: excludedSourcesPattern)
}
)
}
}

def compileClassPath = [ "${project.buildDir}/classes/java/main"]

task instrument(dependsOn: ['classes']) {
ext.outputDir = buildDir.path + '/intermediates/classes-instrumented/Java'
doLast {
ant.taskdef(name: 'instrument',
classname: 'org.jacoco.ant.InstrumentTask',
classpath: configurations.jacocoAnt.asPath)
ant.instrument(destdir: outputDir) {
compileClassPath.each {
fileset(dir: it)
}
}
}
}

gradle.taskGraph.whenReady { graph ->
if (graph.hasTask(instrument)) {
tasks.withType(Test) {
doFirst {
systemProperty 'jacoco-agent.destfile', buildDir.path + '/jacoco/test.exec'
classpath = files(instrument.outputDir) + classpath + configurations.jacocoRuntime
}
}
}
}

task jacocoReport(dependsOn: ['instrument', 'test']) {
doLast {
ant.taskdef(name: 'report',
classname: 'org.jacoco.ant.ReportTask',
classpath: configurations.jacocoAnt.asPath)
ant.report() {
executiondata {
ant.file(file: buildDir.path + "/jacoco/test.exec")
}
structure(name: project.name) {
classfiles {
compileClassPath.each {
fileset(dir: it)
}
}
sourcefiles {
fileset(dir: 'src/main/java')
}
}
html(destdir: buildDir.path + '/reports/jacoco')
}
}
}


check.dependsOn jacocoTestReport

对Spring的封装

这边对Spring做了一个封装,可以做成一个测试的maven模块,先写一个自定义注解,主要是用作Spring容器的启动,同时制定启动的配置文件为test

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

/**
* @version 1.0
* @Author: dinghuang
* @Description:
* @Date: since 2022/5/30 16:03
* @Modify By: dinghuang
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootTest(webEnvironment = RANDOM_PORT, classes = ServerApplication.class)
@ActiveProfiles("test")
@Stepwise
public @interface CustomTest {

}

这个时候我们在test的resource下面的application.properties修改一下配置

1
spring.profiles.active=test

接下来就是写代码了

代码编写

代码写在test目录下,跟junit不一样的是,目录结构是:test->groovy->xxxx.xxx.xxx

需要IDEA把groovy的mark directory设置成test root

单元测试

这边不懂Spock的语法的,可以百度下,这边直接略过

先看业务代码,业务很简单,查一个字典表返回数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @Version 1.0
* @Author: dinghuang
* @Description:
* @Date: since 2022/4/1 18:05
* @Modify By: dinghuang
*/
@Service
public class SysDictService {

private static final Logger LOGGER = LoggerFactory.getLogger(SysDictService.class);

@Autowired
private SysDictDao sysDictDao;

public String getDictName(String dict) {
SysDict sysDict = sysDictDao.selectByPrimaryKey(dict);
return sysDict.getDictname();
}


}

Mock模拟

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

/**
*
* @Author: dinghuang
* @Description:
* @Date: since 2022/5/30 16:18
* @version 1.0
* @Modify By: dinghuang
*
*/
@CustomTest
class TestControllerTest extends Specification {

@Autowired
SysDictTestService sysDictTestService

@SpringBean
SysDictDao sysDictDao = Mock()

def 'Mock测试'() {
given:
def dict = '系统'
1 * sysDictDao.selectByPrimaryKey(dict) >> new SysDict('系统')

when:
def result = sysDictTestService.getDictName(dict)

then:
result == '系统'

}

}

Where使用

正常我们单元测试率有个分支覆盖率指标,如果用junit写太麻烦了,用spock可以非常简洁,假如字典的逻辑不同的dictName有不同的处理逻辑

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

/**
*
* @Author: dinghuang
* @Description:
* @Date: since 2022/5/30 16:18
* @version 1.0
* @Modify By: dinghuang
*
*/
@CustomTest
class TestControllerTest extends Specification {

@Autowired
SysDictTestService sysDictTestService

@SpringBean
SysDictDao sysDictDao = Mock()

def 'Where测试'() {
given:
sysDictDao.selectByPrimaryKey(a) >> d

when:
def b = sysDictTestService.getDictName(a)

then:
b == c

where:
a | d || c
'10000' | new SysDict('系统') || '系统'
'10004' | new SysDict('多点登录处理方式') || '多点登录处理方式'

}

}

这里可以用@Unroll注解,可以把每一次调用作为一个单独的测试用例运行,这样运行后的单元测试结果更加直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Unroll
def 'Where测试'() {
given:
sysDictDao.selectByPrimaryKey(a) >> d

when:
def b = sysDictTestService.getDictName(a)

then:
b == c

where:
a | d || c
'10000' | new SysDict('系统') || '系统'
'10004' | new SysDict('多点登录处理方式') || '多点登录处理方式'

}

测试异常

先修改一下业务代码,如下

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

/**
* @Version 1.0
* @Author: dinghuang
* @Description:
* @Date: since 2022/4/1 18:05
* @Modify By: dinghuang
*/
@Service
public class SysDictTestService {

private static final Logger LOGGER = LoggerFactory.getLogger(SysDictTestService.class);

@Autowired
private SysDictDao sysDictDao;

public String getDictName(String dict) {
if ("测试异常".equals(dict)) {
throw new BusinessException(ErrorCodeEnum.SYS_ERROR);
}
SysDict sysDict = sysDictDao.selectByPrimaryKey(dict);
return sysDict.getDictname();
}


}

写异常测试类

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

/**
*
* @Author: dinghuang
* @Description:
* @Date: since 2022/5/30 16:18
* @version 1.0
* @Modify By: dinghuang
*
*/
@CustomTest
class TestControllerTest extends Specification {

@Autowired
SysDictTestService sysDictTestService

def '测试异常'() {
given:
def dict = '测试异常'

when:
sysDictTestService.getDictName(dict)

then:
def exception = thrown(BusinessException)
exception.errorCode == '21379999'
exception.message == 'bizErrCode=21379999,bizErrMsg=系统异常'

}

}

静态方法Mock

先修改一下业务代码

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

/**
* @Version 1.0
* @Author: dinghuang
* @Description:
* @Date: since 2022/4/1 18:05
* @Modify By: dinghuang
*/
@Service
public class SysDictTestService {

private static final Logger LOGGER = LoggerFactory.getLogger(SysDictTestService.class);

@Autowired
private SysDictDao sysDictDao;

public String getDateStr() {
return DateUtil.getCurrentSysDate();
}

}

1
2
3
4
5
public class DateUtil {
public static String getCurrentSysDate() {
return getDate(new Date(), yyyyMMdd_PATTERN);
}
}

静态方法测试

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

/**
*
* @Author: dinghuang
* @Description:
* @Date: since 2022/5/30 16:18
* @version 1.0
* @Modify By: dinghuang
*
*/
@CustomTest
class TestControllerTest extends Specification {

@Autowired
SysDictTestService sysDictTestService

@Shared
MockedStatic<DateUtil> dateUtilMockedStatic

void setup() {
dateUtilMockedStatic = Mockito.mockStatic(DateUtil.class)
// mock静态类
}

def '测试异常'() {
given:
Mockito.when(DateUtil.getCurrentSysDate()).thenReturn("1991-01-02")

when:
def date = sysDictTestService.getDateStr()

then:
date == "1991-01-02"
}

}

测试sql

这个原理是在每个单元测试可以写一个前置后置处理方法,比如跑测试A,会自动跑测试A的前置,把数据库初始化进去,数据初始化进去,用的是H2内存数据库,但是这个东西对特定数据库的一些语法不支持,所以不太建议这么做,简单的sql的确可以测试出问题

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
/**
* 直接获取待测试的mapper
*/
def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)

/**
* 测试数据准备,通常为sql表结构创建用的ddl,支持多个文件以逗号分隔。
*/
def setup() {
executeSqlScriptFile("com/xxx/xxx/xxx/......../schema.sql")
}
/**
* 数据表清除,通常待drop的数据表
*/
def cleanup() {
dropTables("person_info")
}

/**
* 直接构造数据库中的数据表,此方法适用于数据量较小的mapper sql测试
*/
@MyDbUnit(
content = {
person_info(id: 1, name: "abc", age: 21)
person_info(id: 2, name: "bcd", age: 22)
person_info(id: 3, name: "cde", age: 23)
}
)
def "demo1_01"() {
when:
int beforeCount = personInfoMapper.count()
// groovy sql用于快速执行sql,不仅能验证数据结果,也可向数据中添加数据。
def result = new Sql(dataSource).firstRow("select * from `person_info`")
int deleteCount = personInfoMapper.deleteById(1L)
int afterCount = personInfoMapper.count()

then:
beforeCount == 3
result.name == "abc"
deleteCount == 1
afterCount == 2
}

报告

正常运行完成gradle test会生成报告在路径build\reports\tests\test\index.html,这个是html的,没有覆盖率的情况,如图所示:

运行我们test.gradle中自己写的task,gradle jacocoReport,会生成单元测试覆盖报告在路径build\reports\jacoco\index.html,如图所示

RocketMQ安装使用

发表于 2022-03-07 | 分类于 java |

简介

RocketMQ 一个纯 Java、分布式、队列模型的开源消息中间件,前身是 MetaQ,是阿里研发的一个队列模型的消息中间件,后开源给 apache 基金会成为了 apache的顶级开源项目,具有高性能、高可靠、高实时、分布式特点。

架构设计

image

  • Name Server:是一个几乎无状态节点,可集群部署,在消息队列RocketMQ版中提供命名服务,更新和发现Broker服务。
  • Broker:消息中转角色,负责存储消息,转发消息。分为Master Broker和Slave Broker,一个Master Broker可以对应多个Slave Broker,但是一个Slave Broker只能对应一个Master Broker。Broker启动后需要完成一次将自己注册至Name Server的操作;随后每隔30s定期向Name Server上报Topic路由信息。
  • 生产者:与Name Server集群中的其中一个节点(随机)建立长连接(Keep-alive),定期从Name Server读取Topic路由信息,并向提供Topic服务的Master Broker建立长连接,且定时向Master Broker发送心跳。
  • 消费者:与Name Server集群中的其中一个节点(随机)建立长连接,定期从Name Server拉取Topic路由信息,并向提供Topic服务的Master Broker、Slave Broker建立长连接,且定时向Master Broker、Slave Broker发送心跳。Consumer既可以从Master Broker订阅消息,也可以从Slave Broker订阅消息,订阅规则由Broker配置决定。

部署架构

image

  • NameServer是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。

  • Broker部署相对复杂,Broker分为Master与Slave,一个Master可以对应多个Slave,但是一个Slave只能对应一个Master,Master与Slave 的对应关系通过指定相同的BrokerName,不同的BrokerId 来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。每个Broker与NameServer集群中的所有节点建立长连接,定时注册Topic信息到所有NameServer。 注意:当前RocketMQ版本在部署架构上支持一Master多Slave,但只有BrokerId=1的从服务器才会参与消息的读负载。

  • Producer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic 服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可集群部署。

  • Consumer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,消费者在向Master拉取消息时,Master服务器会根据拉取偏移量与最大偏移量的距离(判断是否读老消息,产生读I/O),以及从服务器是否可读等因素建议下一次是从Master还是Slave拉取。

结合部署架构图,描述集群工作流程:

  • 启动NameServer,NameServer起来后监听端口,等待Broker、Producer、Consumer连上来,相当于一个路由控制中心。
  • Broker启动,跟所有的NameServer保持长连接,定时发送心跳包。心跳包中包含当前Broker信息(IP+端口等)以及存储所有Topic信息。注册成功后,NameServer集群中就有Topic跟Broker的映射关系。
  • 收发消息前,先创建Topic,创建Topic时需要指定该Topic要存储在哪些Broker上,也可以在发送消息时自动创建Topic。
  • Producer发送消息,启动时先跟NameServer集群中的其中一台建立长连接,并从NameServer中获取当前发送的Topic存在哪些Broker上,轮询从队列列表中选择一个队列,然后与队列所在的Broker建立长连接从而向Broker发消息。
  • Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取当前订阅Topic存在哪些Broker上,然后直接跟Broker建立连接通道,开始消费消息。

    概念

消息模型(Message Model)

RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,每个 Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。Message Queue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。

消息生产者(Producer)

负责生产消息,一般由业务系统负责生产消息。一个消息生产者会把业务应用系统里产生的消息发送到broker服务器。RocketMQ提供多种发送方式,同步发送、异步发送、顺序发送、单向发送。同步和异步方式均需要Broker返回确认信息,单向发送不需要。

消息消费者(Consumer)

负责消费消息,一般是后台系统负责异步消费。一个消息消费者会从Broker服务器拉取消息、并将其提供给应用程序。从用户应用的角度而言提供了两种消费形式:拉取式消费、推动式消费。

主题(Topic)

表示一类消息的集合,每个主题包含若干条消息,每条消息只能属于一个主题,是RocketMQ进行消息订阅的基本单位。

代理服务器(Broker Server)

消息中转角色,负责存储消息、转发消息。代理服务器在RocketMQ系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。

Broker包含了以下几个重要子模块:

  • Remoting Module:整个Broker的实体,负责处理来自clients端的请求。
  • Client Manager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息
  • Store Service:提供方便简单的API接口处理消息存储到物理硬盘和查询功能。
  • HA Service:高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能。
  • Index Service:根据特定的Message key对投递到Broker的消息进行索引服务,以提供消息的快速查询。
    image

    名字服务(Name Server)

    名称服务充当路由消息的提供者。生产者或消费者能够通过名字服务查找各主题相应的Broker IP列表。多个Namesrv实例组成集群,但相互独立,没有信息交换。

拉取式消费(Pull Consumer)

Consumer消费的一种类型,应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程。

推动式消费(Push Consumer)

Consumer消费的一种类型,该模式下Broker收到数据后会主动推送给消费端,该消费模式一般实时性较高。

生产者组(Producer Group)

同一类Producer的集合,这类Producer发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,则Broker服务器会联系同一生产者组的其他生产者实例以提交或回溯消费。

消费者组(Consumer Group)

同一类Consumer的集合,这类Consumer通常消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面,实现负载均衡和容错的目标变得非常容易。要注意的是,消费者组的消费者实例必须订阅完全相同的Topic。RocketMQ 支持两种消息模式:集群消费(Clustering)和广播消费(Broadcasting)。

集群消费(Clustering)

集群消费模式下,相同Consumer Group的每个Consumer实例平均分摊消息。

广播消费(Broadcasting)

广播消费模式下,相同Consumer Group的每个Consumer实例都接收全量的消息。

普通顺序消息(Normal Ordered Message)

普通顺序消费模式下,消费者通过同一个消息队列( Topic 分区,称作 Message Queue) 收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的。

严格顺序消息(Strictly Ordered Message)

严格顺序消息模式下,消费者收到的所有消息均是有顺序的。

消息(Message)

消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题。RocketMQ中每个消息拥有唯一的Message ID,且可以携带具有业务标识的Key。系统提供了通过Message ID和Key查询消息的功能。

标签(Tag)

为消息设置的标志,用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。

特性

订阅与发布

消息的发布是指某个生产者向某个topic发送消息;消息的订阅是指某个消费者关注了某个topic中带有某些tag的消息,进而从该topic消费数据。

消息顺序

消息有序指的是一类消息消费时,能按照发送的顺序来消费。例如:一个订单产生了三条消息分别是订单创建、订单付款、订单完成。消费时要按照这个顺序消费才能有意义,但是同时订单之间是可以并行消费的。RocketMQ可以严格的保证消息有序。

顺序消息分为全局顺序消息与分区顺序消息,全局顺序是指某个Topic下的所有消息都要保证顺序;部分顺序消息只要保证每一组消息被顺序消费即可。

  • 全局顺序
    对于指定的一个 Topic,所有消息按照严格的先入先出(FIFO)的顺序进行发布和消费。
    适用场景:性能要求不高,所有的消息严格按照 FIFO 原则进行消息发布和消费的场景
  • 分区顺序
    对于指定的一个 Topic,所有消息根据 sharding key 进行区块分区。 同一个分区内的消息按照严格的 FIFO 顺序进行发布和消费。 Sharding key 是顺序消息中用来区分不同分区的关键字段,和普通消息的 Key 是完全不同的概念。
    适用场景:性能要求高,以 sharding key 作为分区字段,在同一个区块中严格的按照 FIFO 原则进行消息发布和消费的场景。

    消息过滤

    RocketMQ的消费者可以根据Tag进行消息过滤,也支持自定义属性过滤。消息过滤目前是在Broker端实现的,优点是减少了对于Consumer无用消息的网络传输,缺点是增加了Broker的负担、而且实现相对复杂。

    消息可靠性

    RocketMQ支持消息的高可靠,影响消息可靠性的几种情况:
    1) Broker非正常关闭
    2) Broker异常Crash
    3) OS Crash
    4) 机器掉电,但是能立即恢复供电情况
    5) 机器无法开机(可能是cpu、主板、内存等关键设备损坏)
    6) 磁盘设备损坏

1)、2)、3)、4) 四种情况都属于硬件资源可立即恢复情况,RocketMQ在这四种情况下能保证消息不丢,或者丢失少量数据(依赖刷盘方式是同步还是异步)。

5)、6)属于单点故障,且无法恢复,一旦发生,在此单点上的消息全部丢失。RocketMQ在这两种情况下,通过异步复制,可保证99%的消息不丢,但是仍然会有极少量的消息可能丢失。通过同步双写技术可以完全避免单点,同步双写势必会影响性能,适合对消息可靠性要求极高的场合,例如与Money相关的应用。注:RocketMQ从3.0版本开始支持同步双写。

至少一次

至少一次(At least Once)指每个消息必须投递一次。Consumer先Pull消息到本地,消费完成后,才向服务器返回ack,如果没有消费一定不会ack消息,所以RocketMQ可以很好的支持此特性。

回溯消费

回溯消费是指Consumer已经消费成功的消息,由于业务上需求需要重新消费,要支持此功能,Broker在向Consumer投递成功消息后,消息仍然需要保留。并且重新消费一般是按照时间维度,例如由于Consumer系统故障,恢复后需要重新消费1小时前的数据,那么Broker要提供一种机制,可以按照时间维度来回退消费进度。RocketMQ支持按照时间回溯消费,时间维度精确到毫秒。

事务消息

RocketMQ事务消息(Transactional Message)是指应用本地事务和发送消息操作可以被定义到全局事务中,要么同时成功,要么同时失败。RocketMQ的事务消息提供类似 X/Open XA 的分布事务功能,通过事务消息能达到分布式事务的最终一致。

定时消息

定时消息(延迟队列)是指消息发送到broker后,不会立即被消费,等待特定时间投递给真正的topic。
broker有配置项messageDelayLevel,默认值为“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”,18个level。可以配置自定义messageDelayLevel。注意,messageDelayLevel是broker的属性,不属于某个topic。发消息时,设置delayLevel等级即可:msg.setDelayLevel(level)。level有以下三种情况:

  • level == 0,消息为非延迟消息
  • 1<=level<=maxLevel,消息延迟特定时间,例如level==1,延迟1s
  • level > maxLevel,则level== maxLevel,例如level==20,延迟2h

定时消息会暂存在名为SCHEDULE_TOPIC_XXXX的topic中,并根据delayTimeLevel存入特定的queue,queueId = delayTimeLevel – 1,即一个queue只存相同延迟的消息,保证具有相同发送延迟的消息能够顺序消费。broker会调度地消费SCHEDULE_TOPIC_XXXX,将消息写入真实的topic。

需要注意的是,定时消息会在第一次写入和调度写入真实topic时都会计数,因此发送数量、tps都会变高。

消息重试

Consumer消费消息失败后,要提供一种重试机制,令消息再消费一次。Consumer消费消息失败通常可以认为有以下几种情况:

  • 由于消息本身的原因,例如反序列化失败,消息数据本身无法处理(例如话费充值,当前消息的手机号被注销,无法充值)等。这种错误通常需要跳过这条消息,再消费其它消息,而这条失败的消息即使立刻重试消费,99%也不成功,所以最好提供一种定时重试机制,即过10秒后再重试。
  • 由于依赖的下游应用服务不可用,例如db连接不可用,外系统网络不可达等。遇到这种错误,即使跳过当前失败的消息,消费其他消息同样也会报错。这种情况建议应用sleep 30s,再消费下一条消息,这样可以减轻Broker重试消息的压力。

RocketMQ会为每个消费组都设置一个Topic名称为“%RETRY%+consumerGroup”的重试队列(这里需要注意的是,这个Topic的重试队列是针对消费组,而不是针对每个Topic设置的),用于暂时保存因为各种异常而导致Consumer端无法消费的消息。考虑到异常恢复起来需要一些时间,会为重试队列设置多个重试级别,每个重试级别都有与之对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ对于重试消息的处理是先保存至Topic名称为“SCHEDULE_TOPIC_XXXX”的延迟队列中,后台定时任务按照对应的时间进行Delay后重新保存至“%RETRY%+consumerGroup”的重试队列中。

消息重投

生产者在发送消息时,同步消息失败会重投,异步消息有重试,oneway没有任何保证。消息重投保证消息尽可能发送成功、不丢失,但可能会造成消息重复,消息重复在RocketMQ中是无法避免的问题。消息重复在一般情况下不会发生,当出现消息量大、网络抖动,消息重复就会是大概率事件。另外,生产者主动重发、consumer负载变化也会导致重复消息。如下方法可以设置消息重试策略:

  • retryTimesWhenSendFailed:同步发送失败重投次数,默认为2,因此生产者会最多尝试发送retryTimesWhenSendFailed + 1次。不会选择上次失败的broker,尝试向其他broker发送,最大程度保证消息不丢。超过重投次数,抛出异常,由客户端保证消息不丢。当出现RemotingException、MQClientException和部分MQBrokerException时会重投。
  • retryTimesWhenSendAsyncFailed:异步发送失败重试次数,异步重试不会选择其他broker,仅在同一个broker上做重试,不保证消息不丢。
  • retryAnotherBrokerWhenNotStoreOK:消息刷盘(主或备)超时或slave不可用(返回状态非SEND_OK),是否尝试发送到其他broker,默认false。十分重要消息可以开启。

流量控制

生产者流控,因为broker处理能力达到瓶颈;消费者流控,因为消费能力达到瓶颈。

生产者流控:

  • commitLog文件被锁时间超过osPageCacheBusyTimeOutMills时,参数默认为1000ms,返回流控。
  • 如果开启transientStorePoolEnable == true,且broker为异步刷盘的主机,且transientStorePool中资源不足,拒绝当前send请求,返回流控。
  • broker每隔10ms检查send请求队列头部请求的等待时间,如果超过waitTimeMillsInSendQueue,默认200ms,拒绝当前send请求,返回流控。
  • broker通过拒绝send 请求方式实现流量控制。

注意,生产者流控,不会尝试消息重投。

消费者流控:

  • 消费者本地缓存消息数超过pullThresholdForQueue时,默认1000。
  • 消费者本地缓存消息大小超过pullThresholdSizeForQueue时,默认100MB。
  • 消费者本地缓存消息跨度超过consumeConcurrentlyMaxSpan时,默认2000。

消费者流控的结果是降低拉取频率。

死信队列

死信队列用于处理无法被正常消费的消息。当一条消息初次消费失败,消息队列会自动进行消息重试;达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,此时,消息队列 不会立刻将消息丢弃,而是将其发送到该消费者对应的特殊队列中。

RocketMQ将这种正常情况下无法被消费的消息称为死信消息(Dead-Letter Message),将存储死信消息的特殊队列称为死信队列(Dead-Letter Queue)。在RocketMQ中,可以通过使用console控制台对死信队列中的消息进行重发来使得消费者实例再次进行消费。

部署

Docker部署

构建镜像

克隆代码

1
git clone https://github.com/apache/rocketmq-docker.git

构建镜像rocketmq

1
2
3
cd image-build
#sh build-image.sh RMQ-VERSION BASE-IMAGE
sh build-image.sh 4.9.2 centos

查看镜像

1
2
docker images | grep rocket
apacherocketmq/rocketmq 4.9.2 67902205ef2c About a minute ago 519MB

构建rocketmq-dashboard镜像

1
sh build-image-dashboard.sh 1.0.0 centos

最后如下所示:

1
2
3
REPOSITORY                                             TAG                 IMAGE ID            CREATED             SIZE
apache/rocketmq-dashboard 1.0.0-centos 0cc392fe7d27 5 minutes ago 821MB
apacherocketmq/rocketmq 4.9.2 67902205ef2c 7 days ago 519MB

单机部署(单Master模式)

这种方式风险较大,一旦Broker重启或者宕机时,会导致整个服务不可用。不建议线上环境使用,可以用于本地测试。

1
2
3
4
5
6
7
# 配置日志路径以及存储路径
mkdir -p /Users/dinghuang/Documents/Tool/rocketMQ/data/namesrv/logs
mkdir -p /Users/dinghuang/Documents/Tool/rocketMQ/data/namesrv/store
# 创建日志、数据存储、以及配置存放的挂载路径
mkdir -p /Users/dinghuang/Documents/Tool/rocketMQ/data/broker/logs
mkdir -p /Users/dinghuang/Documents/Tool/rocketMQ/data/broker/store
mkdir -p /Users/dinghuang/Documents/Tool/rocketMQ/etc/broker

Broker配置文件创建

1
vi /Users/dinghuang/Documents/Tool/rocketMQ/etc/broker/broker.conf

文件内容如下

1
2
3
4
5
6
7
8
9
brokerClusterName = dh-docker
brokerName = dh-docker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
#Docker环境需要设置成宿主机IP
brokerIP1 = 10.38.1.201

编写Docker-compose文件

1
vi docker-compose.yml

内容如下

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
version: '3'
services:
#Service for nameserver
namesrv:
image: apacherocketmq/rocketmq:4.9.2
container_name: rocketmq-namesrv
ports:
- 9876:9876
environment:
- JAVA_OPT_EXT=-server -Xms256m -Xmx256m -Xmn256m
volumes:
- /Users/dinghuang/Documents/Tool/rocketMQ/data/namesrv/logs:/root/logs
command: sh mqnamesrv

#Service for broker
broker:
image: apacherocketmq/rocketmq:4.9.2
container_name: rocketmq-broker
links:
- namesrv
depends_on:
- namesrv
ports:
- 10909:10909
- 10911:10911
- 10912:10912
environment:
- NAMESRV_ADDR=namesrv:9876
- JAVA_OPT_EXT=-server -Xms512m -Xmx512m -Xmn256m
volumes:
- /Users/dinghuang/Documents/Tool/rocketMQ/data/broker/logs:/home/rocketmq/logs
- /Users/dinghuang/Documents/Tool/rocketMQ/data/broker/store:/home/rocketmq/store
- /Users/dinghuang/Documents/Tool/rocketMQ/etc/broker/broker.conf:/home/rocketmq/conf/broker.conf
command: sh mqbroker -c /home/rocketmq/conf/broker.conf

#Service for rocketmq-dashboard
dashboard:
image: apache/rocketmq-dashboard:1.0.0-centos
container_name: rocketmq-dashboard
ports:
- 8080:8080
links:
- namesrv
depends_on:
- namesrv
environment:
- NAMESRV_ADDR=namesrv:9876

运行命令

1
docker-compose -f ./docker-compose.yml up

运行日志如下:
image

1
2
3
4
CONTAINER ID        IMAGE                                                 COMMAND                  CREATED             STATUS                       PORTS                                                                      NAMES
ed6f1b7e8864 apache/rocketmq-dashboard:1.0.0-centos "java -jar bin/rocke…" 4 minutes ago Up 2 seconds 0.0.0.0:8080->8080/tcp rocketmq-dashboard
293cf31b9263 apacherocketmq/rocketmq:4.9.2 "sh mqbroker -c /hom…" 4 minutes ago Up 14 seconds 0.0.0.0:10909->10909/tcp, 9876/tcp, 0.0.0.0:10911-10912->10911-10912/tcp rocketmq-broker
3db70550d6c2 apacherocketmq/rocketmq:4.9.2 "sh mqnamesrv" 4 minutes ago Up 37 seconds 10909/tcp, 0.0.0.0:9876->9876/tcp, 10911-10912/tcp rocketmq-namesrv

登录地址:http://localhost:8080/#/ 访问,如图所示:
image

普通部署

可以参考官方文档

单Master模式

这种方式风险较大,一旦Broker重启或者宕机时,会导致整个服务不可用。不建议线上环境使用,可以用于本地测试。

1)启动 NameServer

1
2
3
4
5
6
### 首先启动Name Server
$ nohup sh mqnamesrv &

### 验证Name Server 是否启动成功
$ tail -f ~/logs/rocketmqlogs/namesrv.log
The Name Server boot success...

2)启动 Broker

1
2
3
4
5
6
### 启动Broker
$ nohup sh bin/mqbroker -n localhost:9876 &

### 验证Broker是否启动成功,例如Broker的IP为:192.168.1.2,且名称为broker-a
$ tail -f ~/logs/rocketmqlogs/broker.log
The broker[broker-a, 192.169.1.2:10911] boot success...

多Master模式

一个集群无Slave,全是Master,例如2个Master或者3个Master,这种模式的优缺点如下:

  • 优点:配置简单,单个Master宕机或重启维护对应用无影响,在磁盘配置为RAID10时,即使机器宕机不可恢复情况下,由于RAID10磁盘非常可靠,消息也不会丢(异步刷盘丢失少量消息,同步刷盘一条不丢),性能最高;
  • 缺点:单台机器宕机期间,这台机器上未被消费的消息在机器恢复之前不可订阅,消息实时性会受到影响。

1)启动NameServer

NameServer需要先于Broker启动,且如果在生产环境使用,为了保证高可用,建议一般规模的集群启动3个NameServer,各节点的启动命令相同,如下:

1
2
3
4
5
6
### 首先启动Name Server
$ nohup sh mqnamesrv &

### 验证Name Server 是否启动成功
$ tail -f ~/logs/rocketmqlogs/namesrv.log
The Name Server boot success...

2)启动Broker集群

1
2
3
4
5
6
7
### 在机器A,启动第一个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-noslave/broker-a.properties &

### 在机器B,启动第二个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-noslave/broker-b.properties &

...

如上启动命令是在单个NameServer情况下使用的。对于多个NameServer的集群,Broker启动命令中-n后面的地址列表用分号隔开即可,例如 192.168.1.1:9876;192.161.2:9876。

多Master多Slave模式-异步复制

每个Master配置一个Slave,有多对Master-Slave,HA采用异步复制方式,主备有短暂消息延迟(毫秒级),这种模式的优缺点如下:

  • 优点:即使磁盘损坏,消息丢失的非常少,且消息实时性不会受影响,同时Master宕机后,消费者仍然可以从Slave消费,而且此过程对应用透明,不需要人工干预,性能同多Master模式几乎一样;

  • 缺点:Master宕机,磁盘损坏情况下会丢失少量消息。

1)启动NameServer

1
2
3
4
5
6
### 首先启动Name Server
$ nohup sh mqnamesrv &

### 验证Name Server 是否启动成功
$ tail -f ~/logs/rocketmqlogs/namesrv.log
The Name Server boot success...

2)启动Broker集群

1
2
3
4
5
6
7
8
9
10
11
### 在机器A,启动第一个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-a.properties &

### 在机器B,启动第二个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-b.properties &

### 在机器C,启动第一个Slave,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-a-s.properties &

### 在机器D,启动第二个Slave,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-b-s.properties &

多Master多Slave模式-同步双写

每个Master配置一个Slave,有多对Master-Slave,HA采用同步双写方式,即只有主备都写成功,才向应用返回成功,这种模式的优缺点如下:

  • 优点:数据与服务都无单点故障,Master宕机情况下,消息无延迟,服务可用性与数据可用性都非常高;

  • 缺点:性能比异步复制模式略低(大约低10%左右),发送单个消息的RT会略高,且目前版本在主节点宕机后,备机不能自动切换为主机。

1)启动NameServer

1
2
3
4
5
6
### 首先启动Name Server
$ nohup sh mqnamesrv &

### 验证Name Server 是否启动成功
$ tail -f ~/logs/rocketmqlogs/namesrv.log
The Name Server boot success...

2)启动Broker集群

1
2
3
4
5
6
7
8
9
10
11
### 在机器A,启动第一个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-a.properties &

### 在机器B,启动第二个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-b.properties &

### 在机器C,启动第一个Slave,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-a-s.properties &

### 在机器D,启动第二个Slave,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-b-s.properties &

以上Broker与Slave配对是通过指定相同的BrokerName参数来配对,Master的BrokerId必须是0,Slave的BrokerId必须是大于0的数。另外一个Master下面可以挂载多个Slave,同一Master下的多个Slave通过指定不同的BrokerId来区分。$ROCKETMQ_HOME指的RocketMQ安装目录,需要用户自己设置此环境变量。

使用

加入依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.9.1</version>
</dependency>

Producer

发送同步消息

这种可靠性同步地发送方式使用的比较广泛,比如:重要的消息通知,短信通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SyncProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest" /* Topic */,
"TagA" /* Tag */,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
// 发送消息到一个Broker
SendResult sendResult = producer.send(msg);
// 通过sendResult返回消息是否成功送达
System.out.printf("%s%n", sendResult);
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

查看消息如图所示:
image

发送异步消息

异步消息通常用在对响应时间敏感的业务场景,即发送端不能容忍长时间地等待Broker的响应。

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
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
producer.setRetryTimesWhenSendAsyncFailed(0);

int messageCount = 100;
// 根据消息数量实例化倒计时计算器
final CountDownLatch2 countDownLatch = new CountDownLatch2(messageCount);
for (int i = 0; i < messageCount; i++) {
final int index = i;
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest",
"TagA",
"OrderID188",
"Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));
// SendCallback接收异步返回结果的回调
producer.send(msg, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
countDownLatch.countDown();
System.out.printf("%-10d OK %s %n", index,
sendResult.getMsgId());
}
@Override
public void onException(Throwable e) {
countDownLatch.countDown();
System.out.printf("%-10d Exception %s %n", index, e);
e.printStackTrace();
}
});
}
// 等待5s
countDownLatch.await(5, TimeUnit.SECONDS);
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}

单向发送消息

这种方式主要用在不特别关心发送结果的场景,例如日志发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class OnewayProducer {
public static void main(String[] args) throws Exception{
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest" /* Topic */,
"TagA" /* Tag */,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
// 发送单向消息,没有任何返回结果
producer.sendOneway(msg);

}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

Consumer

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
public class Consumer {

public static void main(String[] args) throws InterruptedException, MQClientException {

// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");

// 设置NameServer的地址
consumer.setNamesrvAddr("localhost:9876");

// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("TopicTest", "*");
// 注册回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
// 标记该消息已经被成功消费
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者实例
consumer.start();
System.out.printf("Consumer Started.%n");
}
}

顺序消息

RocketMQ可以严格的保证消息有序,可以分为分区有序或者全局有序。

顺序消费的原理解析,在默认的情况下消息发送会采取Round Robin轮询方式把消息发送到不同的queue(分区队列);而消费消息的时候从多个queue上拉取消息,这种情况发送和消费是不能保证顺序。但是如果控制发送的顺序消息只依次发送到同一个queue中,消费的时候只从这个queue上依次拉取,则就保证了顺序。当发送和消费参与的queue只有一个,则是全局有序;如果多个queue参与,则为分区有序,即相对每个queue,消息都是有序的。

下面用订单进行分区有序的示例。一个订单的顺序流程是:创建、付款、推送、完成。订单号相同的消息会被先后发送到同一个队列中,消费时,同一个OrderId获取到的肯定是同一个队列。

顺序消息生产

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
package org.apache.rocketmq.example.order2;

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
* Producer,发送顺序消息
*/
public class Producer {

public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");

producer.setNamesrvAddr("127.0.0.1:9876");

producer.start();

String[] tags = new String[]{"TagA", "TagC", "TagD"};

// 订单列表
List<OrderStep> orderList = new Producer().buildOrders();

Date date = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateStr = sdf.format(date);
for (int i = 0; i < 10; i++) {
// 加个时间前缀
String body = dateStr + " Hello RocketMQ " + orderList.get(i);
Message msg = new Message("TopicTest", tags[i % tags.length], "KEY" + i, body.getBytes());

SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Long id = (Long) arg; //根据订单id选择发送queue
long index = id % mqs.size();
return mqs.get((int) index);
}
}, orderList.get(i).getOrderId());//订单id

System.out.println(String.format("SendResult status:%s, queueId:%d, body:%s",
sendResult.getSendStatus(),
sendResult.getMessageQueue().getQueueId(),
body));
}

producer.shutdown();
}

/**
* 订单的步骤
*/
private static class OrderStep {
private long orderId;
private String desc;

public long getOrderId() {
return orderId;
}

public void setOrderId(long orderId) {
this.orderId = orderId;
}

public String getDesc() {
return desc;
}

public void setDesc(String desc) {
this.desc = desc;
}

@Override
public String toString() {
return "OrderStep{" +
"orderId=" + orderId +
", desc='" + desc + '\'' +
'}';
}
}

/**
* 生成模拟订单数据
*/
private List<OrderStep> buildOrders() {
List<OrderStep> orderList = new ArrayList<OrderStep>();

OrderStep orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("创建");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("付款");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111065L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("推送");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103117235L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

orderDemo = new OrderStep();
orderDemo.setOrderId(15103111039L);
orderDemo.setDesc("完成");
orderList.add(orderDemo);

return orderList;
}
}

顺序消费消息

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
package org.apache.rocketmq.example.order2;

import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

/**
* 顺序消息消费,带事务方式(应用可控制Offset什么时候提交)
*/
public class ConsumerInOrder {

public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
consumer.setNamesrvAddr("127.0.0.1:9876");
/**
* 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费<br>
* 如果非第一次启动,那么按照上次消费的位置继续消费
*/
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

consumer.subscribe("TopicTest", "TagA || TagC || TagD");

consumer.registerMessageListener(new MessageListenerOrderly() {

Random random = new Random();

@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
context.setAutoCommit(true);
for (MessageExt msg : msgs) {
// 可以看到每个queue有唯一的consume线程来消费, 订单对每个queue(分区)有序
System.out.println("consumeThread=" + Thread.currentThread().getName() + "queueId=" + msg.getQueueId() + ", content:" + new String(msg.getBody()));
}

try {
//模拟业务逻辑处理中...
TimeUnit.SECONDS.sleep(random.nextInt(10));
} catch (Exception e) {
e.printStackTrace();
}
return ConsumeOrderlyStatus.SUCCESS;
}
});

consumer.start();

System.out.println("Consumer Started.");
}
}

延时消息

比如电商里,提交了一个订单就可以发送一个延时消息,1h后去检查这个订单的状态,如果还是未付款就取消订单释放库存。

现在RocketMq并不支持任意时间的延时,需要设置几个固定的延时等级(1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h),从1s到2h分别对应着等级1到18 消息消费失败会进入延时消息队列,消息发送时间与设置的延时等级和重试次数有关,详见代码SendMessageProcessor.java

启动消费者等待传入订阅消息

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
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.List;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/4
*/
public class ScheduledMessageConsumer {

public static void main(String[] args) throws Exception {
// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ExampleConsumer");
// 订阅Topics
consumer.subscribe("TestTopic", "*");
consumer.setNamesrvAddr("127.0.0.1:9876");
// 注册消息监听者
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) {
for (MessageExt message : messages) {
// Print approximate delay time period
System.out.println("Receive message[msgId=" + message.getMsgId() + "] " + new String(message.getBody()) + (System.currentTimeMillis() - message.getBornTimestamp()) + "ms later");
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
}

}

发送延时消息

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

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/4
*/
public class ScheduledMessageProducer {

public static void main(String[] args) throws Exception {
// 实例化一个生产者来产生延时消息
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
// 启动生产者
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
int totalMessagesToSend = 100;
for (int i = 0; i < totalMessagesToSend; i++) {
Message message = new Message("TestTopic", ("Hello scheduled message " + i).getBytes());
// 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel)
message.setDelayTimeLevel(3);
// 发送消息
producer.send(message);
}
// 关闭生产者
producer.shutdown();
}

}

将会看到消息的消费比存储时间晚10秒。

1
2
Receive message[msgId=7F0000016C4918B4AAC2130BFEB90055] Hello scheduled message 859992ms later
Receive message[msgId=7F0000016C4918B4AAC2130BFEBD0056] Hello scheduled message 869992ms later

批量消息

批量发送消息能显著提高传递小消息的性能。限制是这些批量消息应该有相同的topic,相同的waitStoreMsgOK,而且不能是延时消息。此外,这一批消息的总大小不应超过4MB。

每次只发送不超过4MB的消息,则很容易使用批处理,样例如下

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
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;

import java.util.ArrayList;
import java.util.List;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/3
*/
public class BatchProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("127.0.0.1:9876");
// 启动Producer实例
producer.start();
List<Message> messages = new ArrayList<>();
messages.add(new Message("BatchTest", "TagA", "OrderID001", "Hello world 0".getBytes()));
messages.add(new Message("BatchTest", "TagA", "OrderID002", "Hello world 1".getBytes()));
messages.add(new Message("BatchTest", "TagA", "OrderID003", "Hello world 2".getBytes()));
try {
producer.send(messages);
} catch (Exception e) {
e.printStackTrace();
//处理error
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

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
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.List;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/3
*/
public class BatchConsumer {

public static void main(String[] args) throws MQClientException {

// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");

// 设置NameServer的地址
consumer.setNamesrvAddr("127.0.0.1:9876");
// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("BatchTest", "*");
//指定批量消费的最大值,默认是1
consumer.setConsumeMessageBatchMaxSize(5);
//批量拉取消息的数量,默认是32
consumer.setPullBatchSize(30);
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.println(Thread.currentThread().getName() + "一次收到" + msgs.size() + "消息");
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者实例
consumer.start();
System.out.printf("Consumer Started.%n");
}
}

消息列表分割

复杂度只有当你发送大批量时才会增长,你可能不确定它是否超过了大小限制(4MB)。这时候你最好把你的消息列表分割一下:

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
package com.rocketmq.demo.rocket;

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/3
*/
public class SplitBatchProducer {

public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("127.0.0.1:9876");
// 启动Producer实例
producer.start();
//把大的消息分裂成若干个小的消息
List<Message> messages = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
messages.add(new Message("BatchTest", "TagA", "OrderID002", ("Hello world " + i).getBytes()));
}
ListSplitter splitter = new ListSplitter(messages);
while (splitter.hasNext()) {
try {
List<Message> listItem = splitter.next();
producer.send(listItem);
} catch (Exception e) {
e.printStackTrace();
//处理error
}
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}

public static class ListSplitter implements Iterator<List<Message>> {

private final int SIZE_LIMIT = 1024 * 1024 * 4;
private final List<Message> messages;
private int currIndex;

public ListSplitter(List<Message> messages) {
this.messages = messages;
}

@Override
public boolean hasNext() {
return currIndex < messages.size();
}

@Override
public List<Message> next() {
int startIndex = getStartIndex();
int nextIndex = startIndex;
int totalSize = 0;
for (; nextIndex < messages.size(); nextIndex++) {
Message message = messages.get(nextIndex);
int tmpSize = calcMessageSize(message);
if (tmpSize + totalSize > SIZE_LIMIT) {
break;
} else {
totalSize += tmpSize;
}
}
List<Message> subList = messages.subList(startIndex, nextIndex);
currIndex = nextIndex;
return subList;
}

private int getStartIndex() {
Message currMessage = messages.get(currIndex);
int tmpSize = calcMessageSize(currMessage);
while (tmpSize > SIZE_LIMIT) {
currIndex += 1;
Message message = messages.get(currIndex);
tmpSize = calcMessageSize(message);
}
return currIndex;
}

private int calcMessageSize(Message message) {
int tmpSize = message.getTopic().length() + message.getBody().length;
Map<String, String> properties = message.getProperties();
for (Map.Entry<String, String> entry : properties.entrySet()) {
tmpSize += entry.getKey().length() + entry.getValue().length();
}
tmpSize = tmpSize + 20; // 增加⽇日志的开销20字节
return tmpSize;
}
}
}

过滤消息

TAG是一个简单而有用的设计,其可以来选择您想要的消息。例如:

1
2
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_EXAMPLE");
consumer.subscribe("TOPIC", "TAGA || TAGB || TAGC");

消费者将接收包含TAGA或TAGB或TAGC的消息。但是限制是一个消息只能有一个标签,这对于复杂的场景可能不起作用。在这种情况下,可以使用SQL表达式筛选消息。SQL特性可以通过发送消息时的属性来进行计算。在RocketMQ定义的语法下,可以实现一些简单的逻辑。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
------------
| message |
|----------| a > 5 AND b = 'abc'
| a = 10 | --------------------> Gotten
| b = 'abc'|
| c = true |
------------
------------
| message |
|----------| a > 5 AND b = 'abc'
| a = 1 | --------------------> Missed
| b = 'abc'|
| c = true |
------------

基本语法

RocketMQ只定义了一些基本语法来支持这个特性。你也可以很容易地扩展它。

  • 数值比较,比如:>,>=,<,<=,BETWEEN,=;
  • 字符比较,比如:=,<>,IN;
  • IS NULL 或者 IS NOT NULL;
  • 逻辑符号 AND,OR,NOT;

常量支持类型为:

  • 数值,比如:123,3.1415;
  • 字符,比如:’abc’,必须用单引号包裹起来;
  • NULL,特殊的常量
  • 布尔值,TRUE 或 FALSE

只有使用push模式的消费者才能用使用SQL92标准的sql语句,接口如下:

1
public void subscribe(finalString topic, final MessageSelector messageSelector)

使用

生产者

发送消息时,你能通过putUserProperty来设置消息的属性

1
2
3
4
5
6
7
8
9
10
11
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
producer.start();
Message msg = new Message("TopicTest",
tag,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
);
// 设置一些属性
msg.putUserProperty("a", String.valueOf(i));
SendResult sendResult = producer.send(msg);

producer.shutdown();

消费者

用MessageSelector.bySql来使用sql筛选消息

1
2
3
4
5
6
7
8
9
10
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4");
// 只有订阅的消息有这个属性a, a >=0 and a <= 3
consumer.subscribe("TopicTest", MessageSelector.bySql("a between 0 and 3");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();

消息事务

事务消息共有三种状态,提交状态、回滚状态、中间状态:

  • TransactionStatus.CommitTransaction: 提交事务,它允许消费者消费此消息。
  • TransactionStatus.RollbackTransaction: 回滚事务,它代表该消息将被删除,不允许被消费。
  • TransactionStatus.Unknown: 中间状态,它代表需要检查消息队列来确定状态。

创建事务性生产者

使用 TransactionMQProducer类创建生产者,并指定唯一的 ProducerGroup,就可以设置自定义线程池来处理这些检查请求。执行本地事务后、需要根据执行结果对消息队列进行回复。回传的事务状态在请参考前一节。

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 com.rocketmq.demo.rocket;

import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.TransactionListener;
import org.apache.rocketmq.client.producer.TransactionMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;

import java.io.UnsupportedEncodingException;
import java.util.concurrent.*;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/7
*/
public class TransactionProducer {

public static void main(String[] args) throws MQClientException, InterruptedException {
TransactionListener transactionListener = new TransactionListenerImpl();
TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name");
ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2000), new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("client-transaction-msg-check-thread");
return thread;
}
});
producer.setNamesrvAddr("127.0.0.1:9876");
producer.setExecutorService(executorService);
producer.setTransactionListener(transactionListener);
producer.start();
String[] tags = new String[]{"TagA", "TagB", "TagC", "TagD", "TagE"};
for (int i = 0; i < 10; i++) {
try {
Message msg =
new Message("TransactionTopicTest", tags[i % tags.length], "KEY" + i,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
System.out.printf("%s%n", sendResult);
Thread.sleep(10);
} catch (MQClientException | UnsupportedEncodingException e) {
e.printStackTrace();
}
}
for (int i = 0; i < 100000; i++) {
Thread.sleep(1000);
}
producer.shutdown();
}
}

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
package com.rocketmq.demo.rocket;

import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.List;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/3
*/
public class TransactionConsumer {

public static void main(String[] args) throws InterruptedException, MQClientException {

// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");

// 设置NameServer的地址
consumer.setNamesrvAddr("127.0.0.1:9876");

// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("TransactionTopicTest", "*");
// 注册回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
// 标记该消息已经被成功消费
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者实例
consumer.start();
System.out.printf("Consumer Started.%n");
}
}

实现事务的监听接口

当发送半消息成功时,我们使用 executeLocalTransaction 方法来执行本地事务。它返回前一节中提到的三个事务状态之一。checkLocalTransaction 方法用于检查本地事务状态,并回应消息队列的检查请求。它也是返回前一节中提到的三个事务状态之一。

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
package com.rocketmq.demo.rocket;

import org.apache.rocketmq.client.producer.LocalTransactionState;
import org.apache.rocketmq.client.producer.TransactionListener;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
* @author dinghuang123@gmail.com
* @since 2022/3/7
*/
public class TransactionListenerImpl implements TransactionListener {
private AtomicInteger transactionIndex = new AtomicInteger(0);
private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();

@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
int value = transactionIndex.getAndIncrement();
int status = value % 3;
localTrans.put(msg.getTransactionId(), status);
System.out.println(new String(msg.getBody()) + ":executeLocalTransaction:" + msg.getTransactionId() + ":" + status + ":" + value);
return LocalTransactionState.UNKNOW;
}

@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
Integer status = localTrans.get(msg.getTransactionId());
System.out.println(new String(msg.getBody()) + ":" + "checkLocalTransaction" + ":" + status );
if (null != status) {
switch (status) {
case 0:
return LocalTransactionState.UNKNOW;
case 1:
return LocalTransactionState.COMMIT_MESSAGE;
case 2:
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
return LocalTransactionState.COMMIT_MESSAGE;
}
}

事务消息使用上的限制

  • 事务消息不支持延时消息和批量消息。
  • 为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限制为 15 次,但是用户可以通过 Broker 配置文件的 transactionCheckMax参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax ) 则 Broker 将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionalMessageCheckListener 类来修改这个行为。
  • 事务消息将在 Broker 配置文件中的参数 transactionTimeout 这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionTimeout 参数。
  • 事务性消息可能不止一次被检查或消费。
  • 提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制。
  • 事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询、MQ服务器能通过它们的生产者 ID 查询到消费者。

其他

地址

最佳实践

1 生产者

1.1 发送消息注意事项

1 Tags的使用

一个应用尽可能用一个Topic,而消息子类型则可以用tags来标识。tags可以由应用自由设置,只有生产者在发送消息设置了tags,消费方在订阅消息时才可以利用tags通过broker做消息过滤:message.setTags(“TagA”)。

2 Keys的使用

每个消息在业务层面的唯一标识码要设置到keys字段,方便将来定位消息丢失问题。服务器会为每个消息创建索引(哈希索引),应用可以通过topic、key来查询这条消息内容,以及消息被谁消费。由于是哈希索引,请务必保证key尽可能唯一,这样可以避免潜在的哈希冲突。

1
2
3
// 订单Id   
String orderId = "20034568923546";
message.setKeys(orderId);

3 日志的打印

消息发送成功或者失败要打印消息日志,务必要打印SendResult和key字段。send消息方法只要不抛异常,就代表发送成功。发送成功会有多个状态,在sendResult里定义。以下对每个状态进行说明:

  • SEND_OK

消息发送成功。要注意的是消息发送成功也不意味着它是可靠的。要确保不会丢失任何消息,还应启用同步Master服务器或同步刷盘,即SYNC_MASTER或SYNC_FLUSH。

  • FLUSH_DISK_TIMEOUT

消息发送成功但是服务器刷盘超时。此时消息已经进入服务器队列(内存),只有服务器宕机,消息才会丢失。消息存储配置参数中可以设置刷盘方式和同步刷盘时间长度,如果Broker服务器设置了刷盘方式为同步刷盘,即FlushDiskType=SYNC_FLUSH(默认为异步刷盘方式),当Broker服务器未在同步刷盘时间内(默认为5s)完成刷盘,则将返回该状态——刷盘超时。

  • FLUSH_SLAVE_TIMEOUT

消息发送成功,但是服务器同步到Slave时超时。此时消息已经进入服务器队列,只有服务器宕机,消息才会丢失。如果Broker服务器的角色是同步Master,即SYNC_MASTER(默认是异步Master即ASYNC_MASTER),并且从Broker服务器未在同步刷盘时间(默认为5秒)内完成与主服务器的同步,则将返回该状态——数据同步到Slave服务器超时。

  • SLAVE_NOT_AVAILABLE

消息发送成功,但是此时Slave不可用。如果Broker服务器的角色是同步Master,即SYNC_MASTER(默认是异步Master服务器即ASYNC_MASTER),但没有配置slave Broker服务器,则将返回该状态——无Slave服务器可用。

1.2 消息发送失败处理方式

Producer的send方法本身支持内部重试,重试逻辑如下:

  • 至多重试2次。
  • 如果同步模式发送失败,则轮转到下一个Broker,如果异步模式发送失败,则只会在当前Broker进行重试。这个方法的总耗时时间不超过sendMsgTimeout设置的值,默认10s。
  • 如果本身向broker发送消息产生超时异常,就不会再重试。

以上策略也是在一定程度上保证了消息可以发送成功。如果业务对消息可靠性要求比较高,建议应用增加相应的重试逻辑:比如调用send同步方法发送失败时,则尝试将消息存储到db,然后由后台线程定时重试,确保消息一定到达Broker。

上述db重试方式为什么没有集成到MQ客户端内部做,而是要求应用自己去完成,主要基于以下几点考虑:首先,MQ的客户端设计为无状态模式,方便任意的水平扩展,且对机器资源的消耗仅仅是cpu、内存、网络。其次,如果MQ客户端内部集成一个KV存储模块,那么数据只有同步落盘才能较可靠,而同步落盘本身性能开销较大,所以通常会采用异步落盘,又由于应用关闭过程不受MQ运维人员控制,可能经常会发生 kill -9 这样暴力方式关闭,造成数据没有及时落盘而丢失。第三,Producer所在机器的可靠性较低,一般为虚拟机,不适合存储重要数据。综上,建议重试过程交由应用来控制。

1.3选择oneway形式发送

通常消息的发送是这样一个过程:

  • 客户端发送请求到服务器
  • 服务器处理请求
  • 服务器向客户端返回应答

所以,一次消息发送的耗时时间是上述三个步骤的总和,而某些场景要求耗时非常短,但是对可靠性要求并不高,例如日志收集类应用,此类应用可以采用oneway形式调用,oneway形式只发送请求不等待应答,而发送请求在客户端实现层面仅仅是一个操作系统系统调用的开销,即将数据写入客户端的socket缓冲区,此过程耗时通常在微秒级。

2 消费者

2.1 消费过程幂等

RocketMQ无法避免消息重复(Exactly-Once),所以如果业务对消费重复非常敏感,务必要在业务层面进行去重处理。可以借助关系数据库进行去重。首先需要确定消息的唯一键,可以是msgId,也可以是消息内容中的唯一标识字段,例如订单Id等。在消费之前判断唯一键是否在关系数据库中存在。如果不存在则插入,并消费,否则跳过。(实际过程要考虑原子性问题,判断是否存在可以尝试插入,如果报主键冲突,则插入失败,直接跳过)

msgId一定是全局唯一标识符,但是实际使用中,可能会存在相同的消息有两个不同msgId的情况(消费者主动重发、因客户端重投机制导致的重复等),这种情况就需要使业务字段进行重复消费。

2.2 消费速度慢的处理方式

1 提高消费并行度

绝大部分消息消费行为都属于 IO 密集型,即可能是操作数据库,或者调用 RPC,这类消费行为的消费速度在于后端数据库或者外系统的吞吐量,通过增加消费并行度,可以提高总的消费吞吐量,但是并行度增加到一定程度,反而会下降。所以,应用必须要设置合理的并行度。 如下有几种修改消费并行度的方法:

  • 同一个 ConsumerGroup 下,通过增加 Consumer 实例数量来提高并行度(需要注意的是超过订阅队列数的 Consumer 实例无效)。可以通过加机器,或者在已有机器启动多个进程的方式。
  • 提高单个 Consumer 的消费并行线程,通过修改参数 consumeThreadMin、consumeThreadMax实现。

2 批量方式消费

某些业务流程如果支持批量方式消费,则可以很大程度上提高消费吞吐量,例如订单扣款类应用,一次处理一个订单耗时 1 s,一次处理 10 个订单可能也只耗时 2 s,这样即可大幅度提高消费的吞吐量,通过设置 consumer的 consumeMessageBatchMaxSize 返个参数,默认是 1,即一次只消费一条消息,例如设置为 N,那么每次消费的消息数小于等于 N。

3 跳过非重要消息

发生消息堆积时,如果消费速度一直追不上发送速度,如果业务对数据要求不高的话,可以选择丢弃不重要的消息。例如,当某个队列的消息数堆积到100000条以上,则尝试丢弃部分或全部消息,这样就可以快速追上发送消息的速度。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
long offset = msgs.get(0).getQueueOffset();
String maxOffset =
msgs.get(0).getProperty(Message.PROPERTY_MAX_OFFSET);
long diff = Long.parseLong(maxOffset) - offset;
if (diff > 100000) {
// TODO 消息堆积情况的特殊处理
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}

4 优化每条消息消费过程

举例如下,某条消息的消费过程如下:

  • 根据消息从 DB 查询【数据 1】
  • 根据消息从 DB 查询【数据 2】
  • 复杂的业务计算
  • 向 DB 插入【数据 3】
  • 向 DB 插入【数据 4】

这条消息的消费过程中有4次与 DB的 交互,如果按照每次 5ms 计算,那么总共耗时 20ms,假设业务计算耗时 5ms,那么总过耗时 25ms,所以如果能把 4 次 DB 交互优化为 2 次,那么总耗时就可以优化到 15ms,即总体性能提高了 40%。所以应用如果对时延敏感的话,可以把DB部署在SSD硬盘,相比于SCSI磁盘,前者的RT会小很多。

2.3 消费打印日志

如果消息量较少,建议在消费入口方法打印消息,消费耗时等,方便后续排查问题。

1
2
3
4
5
6
7
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
log.info("RECEIVE_MSG_BEGIN: " + msgs.toString());
// TODO 正常消费过程
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}

如果能打印每条消息消费耗时,那么在排查消费慢等线上问题时,会更方便。

2.4 其他消费建议

1 关于消费者和订阅

第一件需要注意的事情是,不同的消费者组可以独立的消费一些 topic,并且每个消费者组都有自己的消费偏移量,请确保同一组内的每个消费者订阅信息保持一致。

2 关于有序消息

消费者将锁定每个消息队列,以确保他们被逐个消费,虽然这将会导致性能下降,但是当你关心消息顺序的时候会很有用。我们不建议抛出异常,你可以返回 ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT 作为替代。

3 关于并发消费

顾名思义,消费者将并发消费这些消息,建议你使用它来获得良好性能,我们不建议抛出异常,你可以返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 作为替代。

4 关于消费状态Consume Status

对于并发的消费监听器,你可以返回 RECONSUME_LATER 来通知消费者现在不能消费这条消息,并且希望可以稍后重新消费它。然后,你可以继续消费其他消息。对于有序的消息监听器,因为你关心它的顺序,所以不能跳过消息,但是你可以返回SUSPEND_CURRENT_QUEUE_A_MOMENT 告诉消费者等待片刻。

5 关于Blocking

不建议阻塞监听器,因为它会阻塞线程池,并最终可能会终止消费进程

6 关于线程数设置

消费者使用 ThreadPoolExecutor 在内部对消息进行消费,所以你可以通过设置 setConsumeThreadMin 或 setConsumeThreadMax 来改变它。

7 关于消费位点

当建立一个新的消费者组时,需要决定是否需要消费已经存在于 Broker 中的历史消息CONSUME_FROM_LAST_OFFSET 将会忽略历史消息,并消费之后生成的任何消息。CONSUME_FROM_FIRST_OFFSET 将会消费每个存在于 Broker 中的信息。你也可以使用 CONSUME_FROM_TIMESTAMP 来消费在指定时间戳后产生的消息。

3 Broker

3.1 Broker 角色

Broker 角色分为 ASYNC_MASTER(异步主机)、SYNC_MASTER(同步主机)以及SLAVE(从机)。如果对消息的可靠性要求比较严格,可以采用 SYNC_MASTER加SLAVE的部署方式。如果对消息可靠性要求不高,可以采用ASYNC_MASTER加SLAVE的部署方式。如果只是测试方便,则可以选择仅ASYNC_MASTER或仅SYNC_MASTER的部署方式。

3.2 FlushDiskType

SYNC_FLUSH(同步刷新)相比于ASYNC_FLUSH(异步处理)会损失很多性能,但是也更可靠,所以需要根据实际的业务场景做好权衡。

3.3 Broker 配置

参数名 默认值 说明
listenPort 10911 接受客户端连接的监听端口
namesrvAddr null nameServer 地址
brokerIP1 网卡的 InetAddress 当前 broker 监听的 IP
brokerIP2 跟 brokerIP1 一样 存在主从 broker 时,如果在 broker 主节点上配置了 brokerIP2 属性,broker 从节点会连接主节点配置的 brokerIP2 进行同步
brokerName null broker 的名称
brokerClusterName DefaultCluster 本 broker 所属的 Cluser 名称
brokerId 0 broker id, 0 表示 master, 其他的正整数表示 slave
storePathRootDir $HOME/store/ 存储根路径
storePathCommitLog $HOME/store/commitlog/ 存储 commit log 的路径
mappedFileSizeCommitLog 1024 1024 1024(1G) commit log 的映射文件大小
deleteWhen 04 在每天的什么时间删除已经超过文件保留时间的 commit log
fileReservedTime 72 以小时计算的文件保留时间
brokerRole ASYNC_MASTER SYNC_MASTER/ASYNC_MASTER/SLAVE
flushDiskType ASYNC_FLUSH SYNC_FLUSH/ASYNC_FLUSH SYNC_FLUSH 模式下的 broker 保证在收到确认生产者之前将消息刷盘。ASYNC_FLUSH 模式下的 broker 则利用刷盘一组消息的模式,可以取得更好的性能。

4 NameServer

RocketMQ 中,Name Servers 被设计用来做简单的路由管理。其职责包括:

  • Brokers 定期向每个名称服务器注册路由数据。
  • 名称服务器为客户端,包括生产者,消费者和命令行客户端提供最新的路由信息。

5 客户端配置

相对于RocketMQ的Broker集群,生产者和消费者都是客户端。本小节主要描述生产者和消费者公共的行为配置。

5.1 客户端寻址方式

RocketMQ可以令客户端找到Name Server, 然后通过Name Server再找到Broker。如下所示有多种配置方式,优先级由高到低,高优先级会覆盖低优先级。

  • 代码中指定Name Server地址,多个namesrv地址之间用分号分割
1
2
3
producer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");  

consumer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");
  • Java启动参数中指定Name Server地址
1
-Drocketmq.namesrv.addr=192.168.0.1:9876;192.168.0.2:9876
  • 环境变量指定Name Server地址
1
export   NAMESRV_ADDR=192.168.0.1:9876;192.168.0.2:9876
  • HTTP静态服务器寻址(默认)

客户端启动后,会定时访问一个静态HTTP服务器,地址如下:http://jmenv.tbsite.net:8080/rocketmq/nsaddr,这个URL的返回内容如下:

1
192.168.0.1:9876;192.168.0.2:9876

客户端默认每隔2分钟访问一次这个HTTP服务器,并更新本地的Name Server地址。URL已经在代码中硬编码,可通过修改/etc/hosts文件来改变要访问的服务器,例如在/etc/hosts增加如下配置:

1
10.232.22.67    jmenv.tbsite.net

推荐使用HTTP静态服务器寻址方式,好处是客户端部署简单,且Name Server集群可以热升级。

5.2 客户端配置

DefaultMQProducer、TransactionMQProducer、DefaultMQPushConsumer、DefaultMQPullConsumer都继承于ClientConfig类,ClientConfig为客户端的公共配置类。客户端的配置都是get、set形式,每个参数都可以用spring来配置,也可以在代码中配置,例如namesrvAddr这个参数可以这样配置,producer.setNamesrvAddr(“192.168.0.1:9876”),其他参数同理。

1 客户端的公共配置

参数名 默认值 说明
namesrvAddr Name Server地址列表,多个NameServer地址用分号隔开
clientIP 本机IP 客户端本机IP地址,某些机器会发生无法识别客户端IP地址情况,需要应用在代码中强制指定
instanceName DEFAULT 客户端实例名称,客户端创建的多个Producer、Consumer实际是共用一个内部实例(这个实例包含网络连接、线程资源等)
clientCallbackExecutorThreads 4 通信层异步回调线程数
pollNameServerInteval 30000 轮询Name Server间隔时间,单位毫秒
heartbeatBrokerInterval 30000 向Broker发送心跳间隔时间,单位毫秒
persistConsumerOffsetInterval 5000 持久化Consumer消费进度间隔时间,单位毫秒

2 Producer配置

参数名 默认值 说明
producerGroup DEFAULT_PRODUCER Producer组名,多个Producer如果属于一个应用,发送同样的消息,则应该将它们归为同一组
createTopicKey TBW102 在发送消息时,自动创建服务器不存在的topic,需要指定Key,该Key可用于配置发送消息所在topic的默认路由。
defaultTopicQueueNums 4 在发送消息,自动创建服务器不存在的topic时,默认创建的队列数
sendMsgTimeout 3000 发送消息超时时间,单位毫秒
compressMsgBodyOverHowmuch 4096 消息Body超过多大开始压缩(Consumer收到消息会自动解压缩),单位字节
retryAnotherBrokerWhenNotStoreOK FALSE 如果发送消息返回sendResult,但是sendStatus!=SEND_OK,是否重试发送
retryTimesWhenSendFailed 2 如果消息发送失败,最大重试次数,该参数只对同步发送模式起作用
maxMessageSize 4MB 客户端限制的消息大小,超过报错,同时服务端也会限制,所以需要跟服务端配合使用。
transactionCheckListener 事务消息回查监听器,如果发送事务消息,必须设置
checkThreadPoolMinSize 1 Broker回查Producer事务状态时,线程池最小线程数
checkThreadPoolMaxSize 1 Broker回查Producer事务状态时,线程池最大线程数
checkRequestHoldMax 2000 Broker回查Producer事务状态时,Producer本地缓冲请求队列大小
RPCHook null 该参数是在Producer创建时传入的,包含消息发送前的预处理和消息响应后的处理两个接口,用户可以在第一个接口中做一些安全控制或者其他操作。

3 PushConsumer配置

参数名 默认值 说明
consumerGroup DEFAULT_CONSUMER Consumer组名,多个Consumer如果属于一个应用,订阅同样的消息,且消费逻辑一致,则应该将它们归为同一组
messageModel CLUSTERING 消费模型支持集群消费和广播消费两种
consumeFromWhere CONSUME_FROM_LAST_OFFSET Consumer启动后,默认从上次消费的位置开始消费,这包含两种情况:一种是上次消费的位置未过期,则消费从上次中止的位置进行;一种是上次消费位置已经过期,则从当前队列第一条消息开始消费
consumeTimestamp 半个小时前 只有当consumeFromWhere值为CONSUME_FROM_TIMESTAMP时才起作用。
allocateMessageQueueStrategy AllocateMessageQueueAveragely Rebalance算法实现策略
subscription 订阅关系
messageListener 消息监听器
offsetStore 消费进度存储
consumeThreadMin 20 消费线程池最小线程数
consumeThreadMax 20 消费线程池最大线程数
consumeConcurrentlyMaxSpan 2000 单队列并行消费允许的最大跨度
pullThresholdForQueue 1000 拉消息本地队列缓存消息最大数
pullInterval 0 拉消息间隔,由于是长轮询,所以为0,但是如果应用为了流控,也可以设置大于0的值,单位毫秒
consumeMessageBatchMaxSize 1 批量消费,一次消费多少条消息
pullBatchSize 32 批量拉消息,一次最多拉多少条

4 PullConsumer配置

参数名 默认值 说明
consumerGroup DEFAULT_CONSUMER Consumer组名,多个Consumer如果属于一个应用,订阅同样的消息,且消费逻辑一致,则应该将它们归为同一组
brokerSuspendMaxTimeMillis 20000 长轮询,Consumer拉消息请求在Broker挂起最长时间,单位毫秒
consumerTimeoutMillisWhenSuspend 30000 长轮询,Consumer拉消息请求在Broker挂起超过指定时间,客户端认为超时,单位毫秒
consumerPullTimeoutMillis 10000 非长轮询,拉消息超时时间,单位毫秒
messageModel BROADCASTING 消息支持两种模式:集群消费和广播消费
messageQueueListener 监听队列变化
offsetStore 消费进度存储
registerTopics 注册的topic集合
allocateMessageQueueStrategy AllocateMessageQueueAveragely Rebalance算法实现策略

5 Message数据结构

字段名 默认值 说明
Topic null 必填,消息所属topic的名称
Body null 必填,消息体
Tags null 选填,消息标签,方便服务器过滤使用。目前只支持每个消息设置一个tag
Keys null 选填,代表这条消息的业务关键词,服务器会根据keys创建哈希索引,设置后,可以在Console系统根据Topic、Keys来查询消息,由于是哈希索引,请尽可能保证key唯一,例如订单号,商品Id等。
Flag 0 选填,完全由应用来设置,RocketMQ不做干预
DelayTimeLevel 0 选填,消息延时级别,0表示不延时,大于0会延时特定的时间才会被消费
WaitStoreMsgOK TRUE 选填,表示消息是否在服务器落盘后才返回应答。

6 系统配置

本小节主要介绍系统(JVM/OS)相关的配置。

6.1 JVM选项

推荐使用最新发布的JDK 1.8版本。通过设置相同的Xms和Xmx值来防止JVM调整堆大小以获得更好的性能。简单的JVM配置如下所示:

1
2

-server -Xms8g -Xmx8g -Xmn4g

如果您不关心RocketMQ Broker的启动时间,还有一种更好的选择,就是通过“预触摸”Java堆以确保在JVM初始化期间每个页面都将被分配。那些不关心启动时间的人可以启用它:

1
-XX:+AlwaysPreTouch

禁用偏置锁定可能会减少JVM暂停,

1
-XX:-UseBiasedLocking

至于垃圾回收,建议使用带JDK 1.8的G1收集器。

1
2
3
-XX:+UseG1GC -XX:G1HeapRegionSize=16m   
-XX:G1ReservePercent=25
-XX:InitiatingHeapOccupancyPercent=30

这些GC选项看起来有点激进,但事实证明它在我们的生产环境中具有良好的性能。另外不要把-XX:MaxGCPauseMillis的值设置太小,否则JVM将使用一个小的年轻代来实现这个目标,这将导致非常频繁的minor GC,所以建议使用rolling GC日志文件:

1
2
3
-XX:+UseGCLogFileRotation   
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=30m

如果写入GC文件会增加代理的延迟,可以考虑将GC日志文件重定向到内存文件系统:

1
-Xloggc:/dev/shm/mq_gc_%p.log123

6.2 Linux内核参数

os.sh脚本在bin文件夹中列出了许多内核参数,可以进行微小的更改然后用于生产用途。下面的参数需要注意,更多细节请参考/proc/sys/vm/*的文档

  • vm.extra_free_kbytes,告诉VM在后台回收(kswapd)启动的阈值与直接回收(通过分配进程)的阈值之间保留额外的可用内存。RocketMQ使用此参数来避免内存分配中的长延迟。(与具体内核版本相关)
  • vm.min_free_kbytes,如果将其设置为低于1024KB,将会巧妙的将系统破坏,并且系统在高负载下容易出现死锁。
  • vm.max_map_count,限制一个进程可能具有的最大内存映射区域数。RocketMQ将使用mmap加载CommitLog和ConsumeQueue,因此建议将为此参数设置较大的值。(agressiveness –> aggressiveness)
  • vm.swappiness,定义内核交换内存页面的积极程度。较高的值会增加攻击性,较低的值会减少交换量。建议将值设置为10来避免交换延迟。
  • File descriptor limits,RocketMQ需要为文件(CommitLog和ConsumeQueue)和网络连接打开文件描述符。我们建议设置文件描述符的值为655350。
  • Disk scheduler,RocketMQ建议使用I/O截止时间调度器,它试图为请求提供有保证的延迟。
    )

分布式系统认证方案

发表于 2022-01-14 | 分类于 JAVA |

分布式系统认证方案

需要满足以下需求

  • 统一认证授权:不同客户端均采用一致的认证、授权、会话判断机制,实现统一认证授权服务。
  • 多样的认证场景:支持用户名密码、短信验证码、二维码、人脸识别等各种认证方式,并可以灵活的切换和扩展。
  • 应用接入认证:,提供安全的系统对接机制,并可开放部分API给第三方使用。并且内部服务和外部第三方服务均采用统一的接入机制。

分布式认证方案

基于Session的认证方式

session复制

将服务器A的session,复制到服务器B,同样将服务器B的session也复制到服务器A,这样两台服务器的session就一致了。像tomcat等web容器都支持session复制的功能,在同一个局域网内,一台服务器的session会广播给其他服务器。

缺点:同一个网段内服务器太多,每个服务器都会去复制session,会造成服务器内存浪费。

image

session黏性

利用Nginx服务器的反向代理,将服务器A和服务器B进行代理,然后采用ip_hash的负载策略,将客户端和服务器进行绑定,也就是说客户端A第一次访问的是服务器B,那么第二次访问也必然是服务器B,这样就不存在session不一致的问题了。

缺点:如果服务器A宕机了,那么客户端A和客户端B的session就会出现丢失。
image

session集中管理

这种方式就是将所有服务器的session进行统一管理,可以使用v等高性能服务器来集中管理session,而且spring官方提供的spirng-session就是这样处理session的一致性问题。这也是目前企业开发用到的比较多的一种分布式session解决方案。

基于Token的认证方式

基于token的认证方式,服务端不用存储认证数据,易维护扩展性强, 客户端可以把token存在任意地方,并且可以实现web和app统一认证机制。
token认证方式对第三方应用接入更适合,因为它更开放,可使用当前有流行的开放协议Oauth2.0、JWT等。一般情况服务端无需存储会话信息,减轻了服务端的压力。

缺点:token由于自包含信息,因此一般数据量较大,而且每次请求
都需要传递,因此比较占带宽。另外,token的签名验签操作也会给cpu带来额外的处理负担。

image

验证流程

image
1 用户通过接入方(应用)登录,接入方采取OAuth2.0方式在统一认证服务(oauth-service)中认证。

2 认证服务调用验证该用户的身份是否合法,并获取用户权限信息。

3 认证服务获取接入方权限信息,并验证接入方是否合法。

4 若登录用户以及接入方都合法,认证服务生成jwt令牌返回给接入方,其中jwt中包含了用户权限及接入方权限。

5 后续,接入方携带jwt令牌对API网关内的微服务资源进行访问。

6 API网关对令牌解析、并验证接入方的权限是否能够访问本次请求的微服务。

7 如果接入方的权限没问题,API网关将原请求header中附加解析后的明文Token,并将请求转发至微服务。

8 微服务收到请求,明文token中包含登录用户的身份和权限信息。因此后续微服务自己可以干两件事:

  • 用户授权拦截(看当前用户是否有权访问该资源)
  • 将用户信息存储进当前线程上下文(有利于后续业务逻辑随时
    获取当前用户信息)

OAuth2.0

OAuth(开放授权)是一个开放标准,允许用户授权第三方移动应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。OAuth2.0是OAuth协议的延续版本,但不向后兼容OAuth1.0即完全废止了OAuth1.0。

OAuth2中的四种认证授权模式

  • 授权码模式(authorization code)
  • 简化模式/隐式授权模式(implicit)
  • 密码模式(password)
  • 客户端模式(client credentials)

OAuth2.0包括以下角色

  • 客户端:本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:Android客户端、Web客户端(浏览器端)、微信客户端等。
  • 资源拥有者:通常为用户,也可以是应用程序,即该资源的拥有者。
  • 授权服务器(认证服务器):用于服务提供商对资源拥有的身份进行认证、对访问资源进行授权,认证成功后会给客户端发放令牌(access_token),作为客户端访问资源服务器的凭据。
  • 资源服务器: 存储资源的服务器。
JWT token

JWT的全称是JSON Web Tokens,它由下面2部分组成:

  • Authorization (授权) : 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用。
  • Information Exchange (信息交换) : JWT可以被签名,例如,用公钥/私钥对,可以验证内容没有被篡改。

image

  • Header:token的类型(JWT)和算法名称(比如:HMAC SHA256或者RSA等等)
  • Payload:JWT的第二部分是payload,它包含声明(要求)。声明是关于实体(通常是用户)和其他数据的声明。声明有三种类型: registered, public 和 private。
  • Signature:签名是用于验证消息在传递过程中有没有被更改,并且,对于使用私钥签名的token,它还可以验证JWT的发送方是否为它所称的发送方。

注意,不要在JWT的payload或header中放置敏感信息,除非它们是加密的

授权码模式

举例:小米账户授权
image
Authorization Code授权分为两步,首先获取Authorization Code,然后用Code换取Access Token。其流程示意图如下
image

一般会有下面的注意事项:

  • Authorization Code只能使用一次,不可重复使用
  • Authorization Code有效时间为5分钟, 在返回Authorization Code5分钟之后失效
简化模式

简化模式相对于授权码模式,少了获取code以及用code换token这一步,用户授权后,认证服务器直接返回一个token。

密码模式

这个模式流程简单,但很不安全,一般用在强信任的两个系统。

客户端模式

分配一个账号密码,每一个账户密码资源服务器会对允许请求的资源做权限控制。可以用这个账号密码来获取token,通过这个token去获取资源服务器允许你请求的资源,之所以使用token而不直接使用账户密码还是为了安全考虑,token有过期机制,过期后需要使用账户密码重新获取token,很多时候这个重新获取的业务场景被称为签到。

认证服务器会给准入的接入方一个身份,用于接入时的凭据:
client_id:客户端标识,client_secret:客户端秘钥
因此,准确来说,授权服务器对两种OAuth2.0中的两个角色进行认证授权,分别是资源拥有者、客户端。

Spring-Security-OAuth2

Spring-Security-OAuth2是对OAuth2的一种实现,OAuth2.0的服务提供方涵盖两个服务,即授权服务 (Authorization Server,也叫认证服务) 和资源服务 (Resource Server),使用 Spring Security OAuth2 的时候你可以选择把它们在同一个应用程序中实现,也可以选择建立使用同一个授权服务的多个资源服务。

SpringSecurity 过滤器

两个至关重要的类:OncePerRequestFilter和GenericFilterBean,在过滤器链的过滤器中,或多或少间接或直接继承到

  • OncePerRequestFilter顾名思义,能够确保在一次请求只通过一次filter,而不需要重复执行。
  • GenericFilterBean是javax.servlet.Filter接口的一个基本的实现类

    • GenericFilterBean将web.xml中filter标签中的配置参数-init-param项作为bean的属性
    • GenericFilterBean可以简单地成为任何类型的filter的父类
    • GenericFilterBean的子类可以自定义一些自己需要的属性
    • GenericFilterBean,将实际的过滤工作留给他的子类来完成,这就导致了他的子类不得不实现doFilter方法
    • GenericFilterBean不依赖于Spring的ApplicationContext,Filters通常不会直接读取他们的容器信息(ApplicationContext concept)而是通过访问spring容器(Spring root application context)中的service beans来获取,通常是通过调用filter里面的getServletContext() 方法来获取

      WebAsyncManagerIntegrationFilter

  • 根据请求封装获取WebAsyncManager

  • 从WebAsyncManager获取/注册SecurityContextCallableProcessingInterceptor

SecurityContextPersistenceFilter

  • 先实例SecurityContextHolder->HttpSessionSecurityContextRepository(下面以repo代替).作用:其会从Session中取出已认证用户的信息,提高效率,避免每一次请求都要查询用户认证信息。
  • 根据请求和响应构建HttpRequestResponseHolder
  • repo根据HttpRequestResponseHolder加载context获取SecurityContext
  • SecurityContextHolder将获得到的SecurityContext设置到Context中,然后继续向下执行其他过滤器
  • finally-> SecurityContextHolder获取SecurityContext,然后清除,并将其和请求信息保存到repo,从请求中移除FILTER_APPLIED属性

HeaderWriterFilter

  • 往该请求的Header中添加相应的信息,在http标签内部使用security:headers来控制

CsrfFilter

  • csrf又称跨域请求伪造,攻击方通过伪造用户请求访问受信任站点。
  • 对需要验证的请求验证是否包含csrf的token信息,如果不包含,则报错。这样攻击网站无法获取到token信息,则跨域提交的信息都无法通过过滤器的校验。

LogoutFilter

  • 匹配URL,默认为/logout
  • 匹配成功后则用户退出,清除认证信息

RequestCacheAwareFilter

  • 通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存HttpServletRequest

SecurityContextHolderAwareRequestFilter

  • 针对ServletRequest进行了一次包装,使得request具有更加丰富的API

AnonymousAuthenticationFilter

  • 当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。匿名身份过滤器,这个过滤器很重要,需要将它与UsernamePasswordAuthenticationFilter 放在一起比较理解,spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
  • 匿名认证过滤器是Spirng Security为了整体逻辑的统一性,即使是未通过认证的用户,也给予了一个匿名身份。而AnonymousAuthenticationFilter该过滤器的位置也是非常的科学的,它位于常用的身份认证过滤器(如UsernamePasswordAuthenticationFilter、BasicAuthenticationFilter、RememberMeAuthenticationFilter)之后,意味着只有在上述身份过滤器执行完毕后,SecurityContext依旧没有用户信息,AnonymousAuthenticationFilte该过滤器才会有意义—-基于用户一个匿名身份。

SessionManagementFilter

  • securityContextRepository限制同一用户开启多个会话的数量
  • SessionAuthenticationStrategy防止session-fixation protection attack(保护非匿名用户)

ExceptionTranslationFilter

  • ExceptionTranslationFilter异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常
  • 此过滤器的作用是处理中FilterSecurityInterceptor抛出的异常,然后将请求重定向到对应页面,或返回对应的响应错误代码

FilterSecurityInterceptor

  • 获取到所配置资源访问的授权信息
  • 根据SecurityContextHolder中存储的用户信息来决定其是否有权限
  • 主要一些实现功能在其父类AbstractSecurityInterceptor中

UsernamePasswordAuthenticationFilter

  • 表单认证是最常用的一个认证方式,一个最直观的业务场景便是允许用户在表单中输入用户名和密码进行登录,而这背后的UsernamePasswordAuthenticationFilter,在整个Spring Security的认证体系中则扮演着至关重要的角色

使用

pom.xml

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
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>


<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

授权服务器配置:

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
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Autowired
private ClientDetailsService clientDetailsService;
@Autowired
private AuthorizationCodeServices authorizationCodeServices;
@Autowired
private AuthenticationManager authenticationManager;

//令牌访问端点
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(authenticationManager)
.authorizationCodeServices(authorizationCodeServices)
.tokenServices(tokenService())
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}
//注意这个部分,到时候和请求链接的参数比对
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory() //用内存存储
.withClient("c1")
.secret(new BCryptPasswordEncoder().encode("secret")) //客户端密钥
.resourceIds("res1") //资源列表
.authorizedGrantTypes("authorization_code", "password", "client_credentials", "refresh_token")
.scopes("all")//允许授权范围
.autoApprove(false)
.redirectUris("http://www.baidu.com"); //验证回调地址
}

//令牌管理服务
@Bean
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service = new DefaultTokenServices();
service.setClientDetailsService(clientDetailsService); //客户端服务信息
service.setSupportRefreshToken(true); // 是否产生刷新令牌
service.setTokenStore(tokenStore); //令牌存储策略
service.setAccessTokenValiditySeconds(7200);// 令牌默认有效期2小时
service.setRefreshTokenValiditySeconds(259200);// 刷新令牌默认有效期3天
return service;
}

// 令牌访问端点安全策略
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security
.tokenKeyAccess("permitAll()") //oauth2/token_key
.checkTokenAccess("permitAll()") //oauth/check_key
.allowFormAuthenticationForClients(); // 表单认证 (申请令牌)
}

@Bean
public AuthorizationCodeServices authorizationCodeServices() {
//设置授权码模式的授权码如何 存取,暂时采用内存方式
return new InMemoryAuthorizationCodeServices();
}
}

如果要用数据库就在这个UserDetailsService编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class CustomUserDetailsService implements UserDetailsService {

@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// 1. 查询用户 数据库查出角色权限


// 2. 设置角色
Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
GrantedAuthority grantedAuthority;

if ("admin".equals(login)) {
grantedAuthority = new SimpleGrantedAuthority("ADMIN");
} else {
grantedAuthority = new SimpleGrantedAuthority("USER");
}
grantedAuthorities.add(grantedAuthority);
//写死 用户密码123以及角色 USER 查出来的密码和比对的加密算法要传入进去
return new User(login,
new BCryptPasswordEncoder().encode("123"), grantedAuthorities);
}
}

1
2
3
4
5
6
7
8
9
10
11
/**
* 在config包下定义TokenConfig,
* 我们暂时先使用InMemoryTokenStore,生成一个普通的令牌。
*/
@Configuration
public class TokenConfig {
@Bean
public TokenStore tokenStore() {
return new InMemoryTokenStore();
}
}
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
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

//认证管理器
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

//安全拦截机制(最重要)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/r/r1").hasAnyAuthority("p1")
.antMatchers("/login*").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
;

}
}

访问http://localhost:8000/oauth/authorize?client_id=pa&reponse_type=code&scope=all&redirect_uri=http://www.baidu.com
image
确认授权:
image
获取到授权码:
image

[参考]

循序渐进之单点登录(4)–分布式系统认证(OAuth2,JWT)

Spring Security 核心过滤器链分析

分布式锁

发表于 2021-11-12 | 分类于 JAVA |

参考文章

PPT下载

分布式锁

为什么需要分布式锁

常见的是秒杀场景,订单服务部署了多个服务实例。如秒杀商品有 4 个,第一个用户购买 3 个,第二个用户购买 2 个,理想状态下第一个用户能购买成功,第二个用户提示购买失败,反之亦可。而实际可能出现的情况是,两个用户都得到库存为 4,第一个用户买到了 3 个,更新库存之前,第二个用户下了 2 个商品的订单,更新库存为 2,导致业务逻辑出错。

在上面的场景中,商品的库存是共享变量,面对高并发情形,需要保证对资源的访问互斥。在单机环境中,比如 Java 语言中其实提供了很多并发处理相关的 API,但是这些 API 在分布式场景中就无能为力了,由于分布式系统具备多线程和多进程的特点,且分布在不同机器中,synchronized 和 lock 关键字将失去原有锁的效果,。仅依赖这些语言自身提供的 API 并不能实现分布式锁的功能,因此需要想想其它方法实现分布式锁。

分布式锁三个属性和两大类

image

总的来说,分布式锁服务有三个必备的性质

  • 互斥(Mutual Exclusion),这是锁最基本的功能,同一时刻只能有一个客户端持有锁;
  • 避免死锁(Dead lock free),如果某个客户端获得锁之后花了太长时间处理,或者客户端发生了故障,锁无法释放会导致整个处理流程无法进行下去,所以要避免死锁。最常见的是通过设置一个 TTL(Time To Live,存活时间) 来避免死锁。假设设置 TTL 为 3 秒,如果 3 秒过后锁还没有被释放,系统也会自动释放该锁(TTL 的设置要非常小心!这个时长取决于你的业务逻辑)。可是这也存在一个问题,假如进程1获取了锁,然后由于某些原因(下面会说到)没有来得及更新 TTL;3秒后进程2来获取锁,由于 TTL 已过,进程2可以获得锁并开始处理,此时同时有两个客户端持有锁,可能会产生意外行为。所以不能只有 TTL,还需要给锁附加一个唯一 ID (或 fencing token)来标识锁。上述逻辑中,当进程 1 获取到锁后记为 LOCK_1;TTL 过后进程 2 获取到的锁记为 LOCK_2。之后,可以在应用层面或锁服务层面检查该 id,来阻断旧的请求。
  • 容错(Fault tolerance),为避免单点故障,锁服务需要具有一定容错性。大体有两种容错方式,一种是锁服务本身是一个集群,能够自动故障切换(ZooKeeper、etcd);另一种是客户端向多个独立的锁服务发起请求,其中某个锁服务故障时仍然可以从其他锁服务读取到锁信息(Redlock),代价是一个客户端要获取多把锁,并且要求每台机器的时钟都是一样的,否则 TTL 会不一致,可能有的机器会提前释放锁,有的机器会太晚释放锁,导致出现问题。

值得注意的是,容错会以性能为代价,容错性取决于你的系统级别,如果你的系统可以承担分布式锁存在误差,那么单节点或者简单的主从复制也许就能满足;如果你的系统非常严格,例如金融系统或航天系统,那么就要考虑每个 corner case

先把分布式锁分为两大类:自旋类和监听类。

  • 自旋类包括基于数据库的实现和基于 Redis 的实现,这类实现需要客户端不停反复请求锁服务查看是否能够获取到锁;

  • 监听类主要包括基于 ZooKeeper 或 etcd 实现的分布式锁,这类实现客户端只需监听(watch) 某个 key,当锁可用时锁服务会通知客户端,无需客户端不停请求锁服务。

基于数据库的实现

基于数据库表的增删

  • 互斥:通过某个独立的数据库(或文件),当获取到数据时,往数据库中插入一条数据。之后的进程想要获取数据,会先检查数据库是否存在记录,就能够知道是否有别的进程持有锁.
  • 避免死锁:增加时间戳字段和自增 id 字段,同时在后台启动一个线程定时释放和清理过期的锁.
  • 容错:通过主从同步复制来实现容错.
字段 作用
id 自增 id,唯一标识锁
key 锁名称
value 自定义字段
ttl 存活时间,定时清理,避免死锁

基于数据库排他锁

还可以通过数据库的排他锁来实现分布式锁。基于 Mysql 的 InnoDB 引擎,可以使用以下方法来实现加锁操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void lock(){
connection.setAutoCommit(false)
int count = 0;
while(count < 4){
try{
select * from lock where lock_name=xxx for update;
if(结果不为空){
//代表获取到锁
return;
}
}catch(Exception e){

}
//为空或者抛异常的话都表示没有获取到锁
sleep(1000);
count++;
}
throw new LockException();
}

在查询语句后面增加 for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。其他没有获取到锁的就会阻塞在上述 select 语句上,可能的结果有 2 种,在超时之前获取到了锁,在超时之前仍未获取到锁。

获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行业务逻辑,执行完业务之后释放锁。

总结

基于数据库的实现较为繁琐,要自己维护锁的 TTL;除非使用分布式数据库,否则主从复制的故障切换并不轻松。

除了麻烦之外,在高并发常见下数据库读写是非常缓慢的,会导致系统性能存在瓶颈。如果采用多个独立数据库进行容错,那性能就更差了。

于是,为了分布式锁的性能,开始转向基于 Redis 或者 memcache 等内存存储系统来实现分布式锁。

基于 Redis 的实现

分布式锁最多的恐怕就是基于 Redis 的实现。首先从单节点 Redis 开始。

基于单节点 Redis 的分布式锁

一条命令实现写 key + 设置过期时间,否则原子性无法保证可能出现死锁。于是就有了以下命令(redis的lua脚本):

1
set key value nx px 10000

set 命令后的 5 个参数分别是:

  • 第一个为 key 作为锁名;
  • 第二个为 value,一般传入一个唯一 id,例如一个随机数或者客户端 mac 地址 + uuid;
  • 第三个为 NX,意思是 SET IF NOT EXIST,即只有 key 不存在时才进行 set 操作;若 key 已经存在(锁已被占),则不做任何操作;
  • 第四个为 PX,作用是给这个 key 加一个过期时间,具体时间长短由第五个参数决定;
  • 第五个为具体的过期时间,对应第四个参数 PX 是毫秒,EX 是秒;

一般会使用开源的redission去实现,具体逻辑如图所示:
image

这个方案在互斥性和避免死锁上性能良好,且非常轻量。但单节点的 Redis 存在单点故障。注意,Redis 主从复制是异步的,所以加入从节点会增加破坏互斥性的风险。为了实现容错性,就有了基于多节点 Redis 的分布式锁,即 Redlock。

基于多节点 Redis 的分布式锁

Redlock 用到多个独立的 Redis 节点,其思想简而言之,是在多个 Redis 实际都获取锁,其中一个宕机了,只要还有超过半数节点可用,就可以继续提供锁服务。

image

如图所示,Redlock 获取锁的大致步骤如下,:

  • 依次对多个 Redis 实例进行加锁(一般是3个或5个),加锁命令使用单实例 Redis 的加锁命令;
  • 为了避免在某个节点长时间获取不到锁而阻塞,每次获取锁操作也有一个超时时间,远小于 TTL,超过超时时间则认为失败,继续向下一个节点获取锁;
  • 计算整个获取多把锁的总消耗时间,只有在超过半数节点都成功获取锁,并且总消耗时间小于 TTL,则认为成功持有锁;
  • 成功获取锁后,要重新计算 TTL = TTL - 总消耗时间;
  • 如果获取锁失败,要向所有 redis 实例发送释放锁的命令。
  • 释放锁操作就是向所有实例都发送删除 key 命令。

Redlock 容错性依赖于一个时间戳的计算,这在分布式系统中并不受待见,于是有了一场著名的论战。

Redlock 论战

DDIA 的作者 Martin Kleppmann 大佬发表了著名的文章《How to do distributed locking》,表示 Redlock 并不可靠,该文章主要阐述了两个观点:

  • Redis 命令避免了死锁但可能会不满足互斥性,因为没有自增 id 或 fencing token 来阻断同时获得锁的两个客户端;
  • Redlock 基于时间戳的合理性值得怀疑,多台服务器难以保证时间一致;

第一点如下图所示,Client 1 获取锁后发生了 STW GC(或缺页等问题),TTL 过期后 Client 2 获取了锁,此时两个客户端持有锁,违反了互斥性。后续写操作自然就可能存在问题。

image
我们在避免死锁时提到,需要另外用单调递增 id (Martin 称之为 fencing token,也叫序列号)来标识每一个锁。增加 id 后逻辑如下图所示,最后的 Client 1 的写请求因为 token 是旧的,会被存储系统拒绝。
image

第二点 Martin 认为,Redlock 的时间戳计算方式不可靠,每台服务器的走时并不绝对准确,例如 NTP 进行同步时系统会发生时钟漂移,即当前服务器的时间戳突然变大或变小,这都会影响 Redlock 的计算。

Martin 的这篇文章引起了大家对分布式锁广泛讨论。Redis 作者 antirez 也不甘示弱,发表文章《Is Redlock safe?》进行反驳,回应了上述两个问题,总结了 antirez 的论点:

  • 针对第一点,虽然 Redlock 提供不了自增 id 这样的字段,但是由客户端指定的字段 value 也可以实现唯一标识,并通过 read-modify-write 原子操作来进行检查;
  • 时钟发送漂移肯定会影响 Redlock 安全性,可是通过恰当的运维,例如不要随意人为修改时钟、将一次大的 NTP 时钟调整转换成多次微小的调整等方式,使时钟修改不超过某个范围就不会对 Redlock 产生影响。

非常推荐阅读争论的两篇文章,但篇幅所限我只提取了观点。关于争论的详细内容张铁蕾老师的文章《基于Redis的分布式锁到底安全吗(下)?》也有着比较完整的中文回顾。

对于这两个问题,我想谈谈我的理解。

对于第一个问题,文章开头“三大属性”我们就分析过,增加 TTL 来避免死锁就会对互斥性产生影响,无论基于 Redis 还是基于 Zookeeper 实现都会存在该问题。antirez 观点是 Redlock 也可以用 value 作为唯一标识来阻断操作,这确实没问题,我也挑不出毛病。但我们可以思考下,实际编程中读者您觉得使用一个自增 id 进行判断容易还是使用 read-modify-write 操作更容易呢?(实际上,一开始我都不怎么理解什么是 read-modify-write 操作)

我认为 fencing token 是一个更好的解决方案,一个单调自增的 id 更符合我们的直觉,同时也更好进行 debug。

作为 fencing token 的一个实际参考,Hazelcast 的文章 “Distributed Locks are Dead; Long Live Distributed Locks!” 给出了一个 FencedLock 解决方案,并且通过了 Jepsen 测试。

第二个问题,时钟漂移是否应该引起注意呢?antirez 的观点是时钟确实会影响 Redlock,但可以通过合理运维避免。

Julia Evans(也是很出名的技术博主)也写了一篇后续文章 “TIL: clock skew exists”,来讨论时钟漂移的问题是否真的值得引起注意。最终得出的结论是:有界的时钟漂移不是一个安全的假设。

事实上,时钟问题并不罕见,例如:

Nelson Minar 在1999年发表了论文,通过调查发现,NTP 服务器经常提供不正确的时间;

aphyr 的文章《The trouble with timestamps》也总结了时间戳在分布式系统中的麻烦;

Google 在 Spanner 中投入大量精力来处理时间问题,并发明了 TrueTime 这一授时系统;

闰秒也会导致时钟漂移,不过闰秒确实非常罕见(即使是现在,闰秒依然会导致许多问题,以后我们会专门谈谈)。

通过上述例子,时钟问题是真实存在的,如果你的系统对分布式锁的安全性要求严格,不想造成任何系统和金钱上的损失,那么你应该考虑所有的边缘情况。

Martin Kleppmann 没有回复任何 Hacker News 的评论,他觉得自己想要表达的都已经说完了,他不想参与争论,他认为实际上一个分布式系统到底该怎么做取决于你如何管理你的系统。

本文想表达的也是这样的观点,软件工程没有银弹,这些 trade-off 取决于你系统的重要级别,你怎么管理你的分布式系统。

只不过分布式系统研究人员通常会非常关注那些看似非常不可能在你的电脑上发生的事情(例如:时钟偏移),原因是:

需要找出某个算法来解决此类问题,因此需要考虑所有 corner case;

分布式系统中会有成千上万的机器,那么不大可能发生的事情会变得极有可能;

其中一些问题其实是很常见的(例如:网络分区)。

基于共识算法实现

分布式锁属于分布式互斥问题(distributed mutual exclusion),实际上 Lamport 在那篇经典论文 “Time, clocks, and the ordering of events in a distributed system” 中早就证明了使用状态机能够去中心化解决多进程互斥问题,而共识算法就能实现这样的状态机。

“共识”的意思是保证所有的参与者都有相同的认知(可以理解为强一致性)。共识算法本身可以依据是否有恶意节点分为两类,大部分时候共识算法指的都是没有恶意节点的那一类,即系统中的节点不会向其他节点发送恶意请求,比如欺骗请求。共识算法中最有名的应该是Paxos算法。

容错性当然离不开共识算法,这个时候不再让客户端依次上多个锁,而是让锁服务器通过共识算法复制到多数派节点,然后再回复客户端。由于共识算法本身不依赖系统时间戳而是逻辑时钟(Raft 的任期或 Paxos 的 epoch),故不存在时钟漂移问题。

其次,死锁避免问题依然需要 TTL 和自增 id 等手段,通过锁服务给每次加锁请求标识上单调递增 id。

通过以上两种方法,可以得到一个更可靠的分布式锁。代价是:需要一个实现共识算法的第三方组件。

基于 ZooKeeper 实现

ZooKeeper是一个分布式的、开放源码的分布式协调服务,是Google的Chubby一个开源的实现,是Hadoop和Hbase的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。由于Hadoop生态系统中很多项目都依赖于zookeeper,如Pig,Hive等, 似乎很像一个动物园管理员,于是取名为Zookeeper。

基于 ZooKeeper 实现的分布式锁依赖以下两个节点属性:

  • sequence:顺序节点,ZooKeeper 会将一个10位带有0填充的序列号附加到客户端设置的 znode 路径之后。例如 locknode/guid-lock- 会返回 locknode/guid-lock-0000000001;
  • ephemeral:临时节点,当客户端和 ZooKeeper 连接断开时,临时节点会被删除,能够避免死锁。但这个断开检测依然有一定心跳延迟,所以仍然需要自增 id 来避免互斥性被破坏。

ZooKeeper 官方文档有提供现成的分布式锁实现方法:

  • 首先调用 create(),锁路径例如 locknode/guid-lock-,并设置 sequence 和 ephemeral 标志。guid 是客户端的唯一标识,如果 create() 创建失败可以通过 guid 来进行检查,下面会提到;
  • 调用 getChildren() 获取子节点列表,不要设置 watch 标志(很重要,可以避免 Herd Effect,即惊群效应);
  • 检查 2 中的子节点列表,如果步骤 1 中创建成功并且返回的序列号后缀是最小的,则客户端持有该分布式锁,到此结束;
  • 如果发现序列不是最小的,则从子节点列表中选择比当前序列号小一位的节点记为 p,客户端调用 exist(p, watch=true),即监听 p,当 p 被删除时收到通知(该节点只有比自己小一位的节点释放时才能获得锁);
  • 如果 exist() 返回 null,即前一个分布式锁被释放了,转到步骤 2;否则需要一直等待步骤 4 中 watch 的通知。

image
如上图所示,每个客户端只监听比自己小的 znode,可以避免惊群效应。

获取锁的伪代码如下:

1
2
3
4
5
n = create(l + “/guid-lock-”, EPHEMERAL|SEQUENTIAL)
C = getChildren(l, false)
if n is lowest znode in C, exit
p = znode in C ordered just before n
goto 2

释放锁非常简单:客户端直接删除他们在步骤 1 创建的 znode 节点。

有几点需要注意:

  • 删除一个 znode 只会导致一个客户端被唤醒,因为每个节点正好被一个客户端 watch 着,通过这种方式,可以避免惊群效应;
  • 没有轮询或超时;
  • 如果在调用 create() 时 ZooKeeper 创建锁成功但没有返回给客户端就失败了,客户端收到错误响应后,应该先调用 getChildren() 并检查该路径是否包含 guid 来避免这一问题。

当然,虽然 ZooKeeper 的实现看起来更为可靠,但根据你实现锁的方式,可能还是会有大量的锁逻辑调试、锁争抢等问题。

基于 ZooKeeper 的分布式锁性能介于基于 Mysql 和基于 Redis 的实现之间,性能上当然不如单节点 Redis。

此外,Zookeeper 中创建和删除节点只能通过 Leader 节点来执行,然后将数据同步到集群中的其他节点。分布式环境中难免存在网络抖动,导致客户端和 Zookeeper 集群之间的 session 连接中断,此时 Zookeeper 服务端以为客户端挂了,就会删除临时节点。其他客户端就可以获取到分布式锁了,导致了同时获取锁的不一致问题。

ZooKeeper 的另一个缺点是需要另外维护一套 ZooKeeper 服务(已有则忽略)

etcd

Etcd 是著名的分布式 key-value 存储结构,因在 Kubernetes 中使用而闻名。etcd 同样可以用来实现分布式锁,官方也很贴心的提供了 clientv3 包给开发者快速实现分布式锁。

来看下 etcd 是如何解决分布式锁“三大问题”的:

  • 互斥:etcd 支持事务,通过事务创建 key 和检查 key 是否存在,可以保证互斥性;
  • 容错:etcd 基于 Raft 共识算法,写 key 成功至少需要超过半数节点确认,这就保证了容错性;
  • 死锁:etcd 支持租约(Lease)机制,可以对 key 设置租约存活时间(TTL),到期后该 key 将失效删除,避免死锁;etc 也支持租约续期,如果客户端还未处理完可以继续续约;同时 etcd 也有自增 id。

为了帮助开发者快速实现分布式锁,etcd 给出了 clientv3 包,其中分布式锁在 concurrency 包中。按照官方文档给出的案例1,首先创建一个新的会话(session)并指定租约的 TTL,然后实例化一个 NewMutex() 之后就可以调用 Lock() 和 Unlock() 进行抢锁和释放锁。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()

s, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
if err != nil {
log.Fatal(err)
}
defer s.Close()

m := concurrency.NewMutex(s, "/my-lock/")
if err := m.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("acquired lock for s")

if err := m.Unlock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("released lock for s")

基于如上分析的思路,绘制出实现 etcd 分布式锁的流程图

image
其中 Lock() 函数的源代码很容易找到,由于篇幅我就不放出来了,但源代码中可以看到的一些其他机制包括:

  • Revision 机制。一个全局序列号,跟 ZooKeeper 的序列号类似,可以用来避免 watch 惊群;
  • Prefix 机制。即上述代码中 etcd 会创建一个前缀为 /my-lock/ 的 key(/my-lock/ + LeaseID),分布式锁由该前缀下 revision 最小(最早创建)的 key 获得;
  • Watch 机制。跟 ZooKeeper 一样,客户端会监听 revision 比自己小的 key,当比自己小的 key 释放锁后,尝试去获得锁。

本质上 etcd 和 ZooKeeper 对分布式锁的实现是类似的。

选择 etcd 的原因可能有:

  • 生产环境中已经大规模部署了 etcd 集群;
  • etcd 在保证强一致性的同时真的够快,性能介于 Redis 和 ZooKeeper 之间;
  • 许多语言都有 etcd 的客户端库,很容易使用;

该如何实现分布式锁

应该区分,分布式锁的用途和业务场景,如果从安全性的角度上考虑,如果要保证绝对的一致性,建议使用zookeeper,同时还要考虑,是否还要使用数据库的锁。

如果只是为了协调各个服务,防止重复处理,锁偶尔失效也可以接受,可以使用Redis。

Seata部署使用(1.4.2)

发表于 2021-06-08 | 分类于 JAVA |

源码地址: https://github.com/seata/seata.git

前期准备

xa模式

确保mysql版本>5.7 并且开启了XA事务支持

at模式

每个mysql库执行脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE IF NOT EXISTS `undo_log`
(
`branch_id` BIGINT NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

saga模式

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
-- -------------------------------- The script used for sage  --------------------------------


CREATE TABLE IF NOT EXISTS `seata_state_machine_def`
(
`id` VARCHAR(32) NOT NULL COMMENT 'id',
`name` VARCHAR(128) NOT NULL COMMENT 'name',
`tenant_id` VARCHAR(32) NOT NULL COMMENT 'tenant id',
`app_name` VARCHAR(32) NOT NULL COMMENT 'application name',
`type` VARCHAR(20) COMMENT 'state language type',
`comment_` VARCHAR(255) COMMENT 'comment',
`ver` VARCHAR(16) NOT NULL COMMENT 'version',
`gmt_create` DATETIME(3) NOT NULL COMMENT 'create time',
`status` VARCHAR(2) NOT NULL COMMENT 'status(AC:active|IN:inactive)',
`content` TEXT COMMENT 'content',
`recover_strategy` VARCHAR(16) COMMENT 'transaction recover strategy(compensate|retry)',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

CREATE TABLE IF NOT EXISTS `seata_state_machine_inst`
(
`id` VARCHAR(128) NOT NULL COMMENT 'id',
`machine_id` VARCHAR(32) NOT NULL COMMENT 'state machine definition id',
`tenant_id` VARCHAR(32) NOT NULL COMMENT 'tenant id',
`parent_id` VARCHAR(128) COMMENT 'parent id',
`gmt_started` DATETIME(3) NOT NULL COMMENT 'start time',
`business_key` VARCHAR(48) COMMENT 'business key',
`start_params` TEXT COMMENT 'start parameters',
`gmt_end` DATETIME(3) COMMENT 'end time',
`excep` BLOB COMMENT 'exception',
`end_params` TEXT COMMENT 'end parameters',
`status` VARCHAR(2) COMMENT 'status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`compensation_status` VARCHAR(2) COMMENT 'compensation status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`is_running` TINYINT(1) COMMENT 'is running(0 no|1 yes)',
`gmt_updated` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unikey_buz_tenant` (`business_key`, `tenant_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

CREATE TABLE IF NOT EXISTS `seata_state_inst`
(
`id` VARCHAR(48) NOT NULL COMMENT 'id',
`machine_inst_id` VARCHAR(128) NOT NULL COMMENT 'state machine instance id',
`name` VARCHAR(128) NOT NULL COMMENT 'state name',
`type` VARCHAR(20) COMMENT 'state type',
`service_name` VARCHAR(128) COMMENT 'service name',
`service_method` VARCHAR(128) COMMENT 'method name',
`service_type` VARCHAR(16) COMMENT 'service type',
`business_key` VARCHAR(48) COMMENT 'business key',
`state_id_compensated_for` VARCHAR(50) COMMENT 'state compensated for',
`state_id_retried_for` VARCHAR(50) COMMENT 'state retried for',
`gmt_started` DATETIME(3) NOT NULL COMMENT 'start time',
`is_for_update` TINYINT(1) COMMENT 'is service for update',
`input_params` TEXT COMMENT 'input parameters',
`output_params` TEXT COMMENT 'output parameters',
`status` VARCHAR(2) NOT NULL COMMENT 'status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`excep` BLOB COMMENT 'exception',
`gmt_updated` DATETIME(3) COMMENT 'update time',
`gmt_end` DATETIME(3) COMMENT 'end time',
PRIMARY KEY (`id`, `machine_inst_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

部署TC

执行mysql脚本

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
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

如果使用源码编译的话,需要注释掉,或者idea导入插件protobuf support

1
<!--        <module>seata-serializer-protobuf</module>-->

修改配置registry.conf、file.conf,如果需要不同的环境区分不同的配置,新建文件registry-dev.conf、file-dev.conf,然后启动启动类io.seata.server.Server,启动的jvm参数加上变量-DseataEnv=uat,这样就可以实现多环境启动了。

到server目录下,打包部署服务器:

1
mvn clean package -Dmaven.test.skip=true -Prelease-seata

会生成制品,制品目录在/distribution,如果是部署服务器上面或者是通过docker镜像部署的话,可以参考/distribution/Dockerfile,或者直接通过下面的命令启动

1
sh ./bin/seata-server.sh -p 8091 -h 127.0.0.1 -n 1 -e uat

具体参数如下

参数 全写 作用 备注
-h –host 指定在注册中心注册的 IP 不指定时获取当前的 IP,外部访问部署在云环境和容器中的 server 建议指定
-p –port 指定 server 启动的端口 默认为 8091
-m –storeMode 事务日志存储方式 支持file,db,redis,默认为 file 注:redis需seata-server 1.3版本及以上
-n –serverNode 用于指定seata-server节点ID 如 1,2,3…, 默认为 1
-e –seataEnv 指定 seata-server 运行环境 如 dev, test 等, 服务启动时会使用 registry-dev.conf 这样的配置

可以查看在我本地输出脚本的完整的jvm参数

1
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/bin/java -server -Xmx2048m -Xms2048m -Xmn1024m -Xss512k -XX:SurvivorRatio=10 -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=1024m -XX:-OmitStackTraceInFastThrow -XX:-UseAdaptiveSizePolicy -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/Users/dinghuang/Documents/workSpace/github/seata/distribution/seata-server-1.4.2/logs/java_heapdump.hprof -XX:+DisableExplicitGC -XX:+CMSParallelRemarkEnabled -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=75 -Xloggc:/Users/dinghuang/Documents/workSpace/github/seata/distribution/seata-server-1.4.2/logs/seata_gc.log -verbose:gc -Dio.netty.leakDetectionLevel=advanced -Dlogback.color.disable-for-bat=true -classpath /Users/dinghuang/Documents/workSpace/github/seata/distribution/seata-server-1.4.2/conf:/Users/dinghuang/Documents/workSpace/github/seata/distribution/seata-server-1.4.2/lib/* -Dapp.name=seata-server -Dapp.pid=23748 -Dapp.repo=/Users/dinghuang/Documents/workSpace/github/seata/distribution/seata-server-1.4.2/lib -Dapp.home=/Users/dinghuang/Documents/workSpace/github/seata/distribution/seata-server-1.4.2 -Dbasedir=/Users/dinghuang/Documents/workSpace/github/seata/distribution/seata-server-1.4.2 io.seata.server.Server -e uat

如果配置中心使用apollo,可以参考这篇文章:https://www.studying.icu/pages/94a254/#seata (1.4.2有bug,apollo的配置不能用,建议用1.4.1)

Spring框架使用

pom.xml引入包

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/io.seata/seata-spring-boot-starter -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>

修改application.yml配置文件(1.4.2有bug,apollo的配置不能用,建议用1.4.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
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
seata:
enabled: true
application-id: applicationName
tx-service-group: my_test_tx_group
enable-auto-data-source-proxy: true
data-source-proxy-mode: AT
use-jdk-proxy: false
excludes-for-auto-proxying: firstClassNameForExclude,secondClassNameForExclude
client:
rm:
async-commit-buffer-limit: 10000
report-retry-count: 5
table-meta-check-enable: false
report-success-enable: false
saga-branch-register-enable: false
saga-json-parser: fastjson
saga-retry-persist-mode-update: false
saga-compensate-persist-mode-update: false
lock:
retry-interval: 10
retry-times: 30
retry-policy-branch-rollback-on-conflict: true
tm:
commit-retry-count: 5
rollback-retry-count: 5
default-global-transaction-timeout: 60000
degrade-check: false
degrade-check-period: 2000
degrade-check-allow-times: 10
undo:
data-validation: true
log-serialization: jackson
log-table: undo_log
only-care-update-columns: true
compress:
enable: true
type: zip
threshold: 64k
load-balance:
type: RandomLoadBalance
virtual-nodes: 10
service:
vgroup-mapping:
my_test_tx_group: default
grouplist:
default: 127.0.0.1:8091
enable-degrade: false
disable-global-transaction: false
transport:
shutdown:
wait: 3
thread-factory:
boss-thread-prefix: NettyBoss
worker-thread-prefix: NettyServerNIOWorker
server-executor-thread-prefix: NettyServerBizHandler
share-boss-worker: false
client-selector-thread-prefix: NettyClientSelector
client-selector-thread-size: 1
client-worker-thread-prefix: NettyClientWorkerThread
worker-thread-size: default
boss-thread-size: 1
type: TCP
server: NIO
heartbeat: true
serialization: seata
compressor: none
enable-client-batch-send-request: true
config:
type: file
consul:
server-addr: 127.0.0.1:8500
apollo:
apollo-meta: http://192.168.1.204:8801
app-id: seata-server
namespace: application
apollo-accesskey-secret: ""
etcd3:
server-addr: http://localhost:2379
nacos:
namespace: ""
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
username: ""
password: ""
zk:
server-addr: 127.0.0.1:2181
session-timeout: 6000
connect-timeout: 2000
username: ""
password: ""
custom:
name: ""
registry:
type: file
file:
name: file.conf
consul:
server-addr: 127.0.0.1:8500
acl-token: ""
etcd3:
server-addr: http://localhost:2379
eureka:
weight: 1
service-url: http://localhost:8761/eureka
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group : "SEATA_GROUP"
namespace: ""
username: ""
password: ""
redis:
server-addr: localhost:6379
db: 0
password: ""
timeout: 0
sofa:
server-addr: 127.0.0.1:9603
region: DEFAULT_ZONE
datacenter: DefaultDataCenter
group: SEATA_GROUP
address-wait-time: 3000
application: default
zk:
server-addr: 127.0.0.1:2181
session-timeout: 6000
connect-timeout: 2000
username: ""
password: ""
custom:
name: ""
log:
exception-rate: 100

具体的例子可以看
https://github.com/seata/seata-samples

DDD领域驱动设计

发表于 2021-03-18 | 分类于 JAVA |

代码地址

什么是DDD

对于一个架构师来说,在软件开发中如何降低系统复杂度是一个永恒的挑战,无论是 94 年 GoF 的 Design Patterns , 99 年的 Martin Fowler 的 Refactoring , 02 年的 P of EAA ,还是 03 年的 Enterprise Integration Patterns ,都是通过一系列的设计模式或范例来降低一些常见的复杂度。

但是问题在于,这些书的理念是通过技术手段解决技术问题,但并没有从根本上解决业务的问题。所以 03 年 Eric Evans 的 Domain Driven Design 一书,以及后续 Vaughn Vernon 的 Implementing DDD , Uncle Bob 的 Clean Architecture 等书,真正的从业务的角度出发,为全世界绝大部分做纯业务的开发提供了一整套的架构思路。

DDD 不是一套框架,而是一种架构思想,所以在代码层面缺乏了足够的约束,导致 DDD 在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差,所有认知的最终目的都是基于合理的代码结构、框架和约束,来降低 DDD 的实践门槛,提升代码质量、可测试性、安全性、健壮性。

好的架构设计

在做架构设计时,一个好的架构应该需要实现以下几个目标:

  • 独立于框架:架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
  • 独立于UI:前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
  • 独立于底层数据源:无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
  • 独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
    可测试:无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。

概念

领域模型(DOMAIN)

  • 模型是对客观世界事物的一种抽象和简化。
  • 它是从某个角度反映人对客观世界事物的一种认识。
  • 它用于对事物的本质进行深入细致的研究。

举一个常见的例子:一个函数写了几千行,里面的if-else写了一大堆,计算各种业务规则。另一个人接手之后,分析了好几个月,才把业务逻辑彻底理清楚。从表面来看,这是代码写的不规范,要重构,把一个几千行的函数拆成一个个小的函数。从根本上来讲,就是“重要逻辑”隐藏在代码里面,没有“显性”的表达出来。这里可以引用一个观点:建模的本质就是把“重要的东西进行显性化,并进而把这些显性化的构造块,互相串联起来,组成一个体系“。

DDD通过建立一个业务域到软件域的通用模型,把问题空间同解决方案空间联系在一起,真正把领域的知识挖掘出来,让领域专家可以去驱动软件的实现。

映射概念:切分的服务。

领域就是范围。范围的重点是边界。领域的核心思想是将问题逐级细分来减低业务和系统的复杂度,这也是 DDD 讨论的核心。

如果说你要盖一栋房子,那么你需要以下的东西

有一块地
楼共有6层
每层有4个套房
那么你的领域是什么呢?

领域是房子么?可能,但是你应该知道,如果你把房子作为你的领域,那么你可能会忽略很多的小的需求。你设计的房子必须设计那里是让人住的。那么,一般而言的“房子”可能会让我们忽略一些细节,所以,我们应该缩小我们的领域,那就是“民房”。

那么,当你和建筑工人或买房子的人说起你设计的房子时,你所说的“民房”就可以很容易的被人理解了。当承包商告诉你要设计一个6层每层4套房子的时候,在语言上你是否稍微修改了一下呢?现在如果你让建筑工人到一个地方建“房子”,他们可能并没有考虑到一些“民房”的一些特性。但是如果你说了“民房”呢,他们肯定会做出合理的分析。

这就是我们将要说到的“通用语言”

子域

映射概念:子服务。

领域可以进一步划分成子领域,即子域。这是处理高度复杂领域的设计思想,它试图分离技术实现的复杂性。这个拆分的里面在很多架构里都有,比如 C4。

核心域

映射概念:核心服务。

在领域划分过程中,会不断划分子域,子域按重要程度会被划分成三类:核心域、通用域、支撑域。

决定产品核心竞争力的子域就是核心域,没有太多个性化诉求。

桃树的例子,有根、茎、叶、花、果、种子等六个子域,不同人理解的核心域不同,比如在果园里,核心域就是果是核心域,在公园里,核心域则是花。有时为了核心域的营养供应,还会剪掉通用域和支撑域(茎、叶等)。

通用域

映射概念:中间件服务或第三方服务。

被多个子域使用的通用功能就是通用域,没有太多企业特征,比如权限认证。

支撑域

映射概念:企业公共服务。

对于功能来讲是必须存在的,但它不对产品核心竞争力产生影响,也不包含通用功能,有企业特征,不具有通用性,比如数据代码类的数字字典系统。

统一语言( Ubiquitous language )

映射概念:统一概念。

关于统一语言必要性,有一个经典的通天塔故事,人类想建一座通天塔,进度很快,上帝害怕了,于是上帝让建造者说不通的语言,这样通天塔就再也没有能建起来了。统一语言是一件事情能顺利开展的基础。

由于语言上存在鸿沟,领域专家们只能模糊地描述他们想要的东西,开发人员虽然努力去理解一个自己不熟悉的领域但也只能形成模糊的认识,结果就是各说各的话,或者都是一知半解,最后到上线前才会发现漏了这个漏了那个。

定义上下文的含义。它的价值是可以解决交流障碍,不管你是 RD、PM、QA 等什么角色,让每个团队使用统一的语言(概念)来交流,甚至可读性更好的代码。

通用语言包含属于和用例场景,并且能直接反应在代码中。

可以在事件风暴(开会)中来统一语言,甚至是中英文的映射、业务与代码模型的映射等。可以使用一个表格来记录。

通用语言也并不是像,XML、Schema或Java这样的语言,它是一种自然的但经过浓缩的领域语言,它是一种开发与用户共享的语言,用来描述问题和领域模型。通用语言不是把从用户那里听到的内容翻译为开发的语言,而是为了减少误解,让用户更容易理解的草图,从而可以真正的帮助纠正错误,帮助开发获取有关的领域新知识。

统一语言体现在两个方面:

  • 统一的领域术语
  • 领域术语通常表示对象命名,如商品、订单等,对应实体对象

例子

民航业的运输统计指标为例,牵涉到与运量、运力以及周转量相关的术语,就存在 ICAO(International Civil Aviation Organization,国际民用航空组织)与IATA(International Air Transport Association,国际航空运输协会)两大体系,而中国民航局又有自己的中文解释,航空公司和各大机场亦有自己衍生的定义

image

如果我们不明白城市对运量与航段运量的真正含义,就可能混淆这两种指标的统计计算规则。这种术语理解错误带来的缺陷往往难以发现,除非业务分析人员、开发人员与测试人员能就此知识达成一致的正确理解。

限界上下文( Bounded Context )

映射概念:服务职责划分的边界。

限界上下文,限的意思就是划分、规定,界就是界限、或者一个边界,上下文就是业务的整个流程,总的来说,可以称限界上下文为业务流程在一个划定的界限中,我们知道,业务的描述是通过通用语言来表述的,限界上下文和通用语言的关系就是:在一个特定的限界上下文只使用一套通用语言,并且保证它的清晰性和简洁性。

定义上下文的边界。领域模型存在边界之内。对于同一个概念,不同上下文会有不同的理解,比如商品,在销售阶段叫商品,在运输阶段就叫货品。理论上,限界上下文的边界就是微服务的边界,因此,理解限界上下文在设计中非常重要。

一个有界上下文可以是一个很小的程序,包括他自己的领域,自己的代码和自己的存储机制,在一个上下文里,他们应该在逻辑上一致,每个有界上下文应该独立于其他的有界上下文。

上下文映射图(Context Mapping)

image

有界上下文例子

考虑一下电商系统,最初你可以认为他是一个商店上下文,但是你看的更细点,你会发现其他的上下文,例如:库存,物流,账户等。

合理的拆分一个大型的有界上下文可以让你的应用程序模块化,并且让你区分不同的关注点,而且让应用程序更易于管理和升级。每个有界上下文都有相应的责任和操作。合理的上下文划分可以让我们更容易的找到逻辑所在的位置,从而避免“大泥球”。

什么是“大泥球”(BBOM,Big ball of mud)

大泥球是一个不规则的、杂乱的、松散的泥巴,这类系统是典型的高度重复,快速修复,无意义增长的系统。混乱的消息传递,在一个地方几乎所有的重要信息都是全局的和重复的,所有的系统架构都可能没有被很好的定义。

我们的目标是避免所有的BBOM

我们还来说说“民房领域”吧,我们有几个有界上下文:

  • 电力供应
  • 停车位
  • 套房
  • 等等
  • 详细说一下“套房”吧,套房是由不同的房间组成,每个房间又有不同的门窗。那么现在关于屋里面的窗户就有两个问题了:

  • 问题1:你可以想象出来没有屋子的窗户么?

  • 问题2:如果一个窗户没有了容纳他的屋子,那么它有没有唯一的标识?

回答上面的问题会揭示出DDD中下面的概念:

  • 实体(Entity)
  • 值对象(Value Object)
  • 聚合和聚合根(Aggreates & Aggrate root)

实体( Entity )

映射概念:Domain 或 entity。

许多对象不是由它们的属性来定义,而是通过一系列的连续性(continuity)和标识(identity)来从根本上定义的。只要一个对象在生命周期中能够保持连续性,并且独立于它的属性(即使这些属性对系统用户非常重要),那它就是一个实体。

对于实体Entity,实体核心是用唯一的标识符来定义,而不是通过属性来定义。即即使属性完全相同也可能是两个不同的对象。同时实体本身有状态的,实体又演进的生命周期,实体本身会体现出相关的业务行为,业务行为会实体属性或状态造成影响和改变。无论两个实体是多么的相似,他们都是不同的实体。

例如:

  • 你家的卧室
  • 博客的文章
  • 博客的用户

值对象( Value Objects )

映射概念:Domain 或 entity。

当你只关心某个对象的属性时,该对象便可作为一个值对象。为其添加有意义的属性,并赋予它相应的行为。我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。

如果从值对象本身无状态,不可变,并且不分配具体的标识层面来看。那么值对象可以仅仅理解为实际的Entity对象的一个属性结合而已。该值对象附属在一个实际的实体对象上面。值对象本身不存在一个独立的生命周期,也一般不会产生独立的行为。

定义值对象的关键是值对象没有“唯一标识”。

比如 money,让它具有 id 显然是不合理的,你也不可能通过 id 查询一个 money。

定义值对象要依照具体场景的区分来看,你甚至可以把 Article 中的 Author 当成一个值对象,但一定要清楚,Author 独立存在的时候是实体,或者要拿 Author 做复杂的业务逻辑,那么 Author 也会升级为聚合根。

防腐层(facade)

亦称适配层。在一个上下文中,有时需要对外部上下文进行访问,通常会引入防腐层的概念来对外部上下文的访问进行一次转义。

有以下几种情况会考虑引入防腐层:

  • 需要将外部上下文中的模型翻译成本上下文理解的模型。
  • 不同上下文之间的团队协作关系,如果是供奉者关系,建议引入防腐层,避免外部上下文变化对本上下文的侵蚀。
  • 该访问本上下文使用广泛,为了避免改动影响范围过大。

如果内部多个上下文对外部上下文需要访问,那么可以考虑将其放到通用上下文中。

领域服务( Domain Services )

领域中的一些概念不太适合建模为对象,即归类到实体对象或值对象,因为它们本质上就是一些操作,一些动作,而不是事物。这些操作或动作往往会涉及到多个领域对象,并且需要协调这些领域对象共同完成这个操作或动作。如果强行将这些操作职责分配给任何一个对象,则被分配的对象就是承担一些不该承担的职责,从而会导致对象的职责不明确很混乱。但是基于类的面向对象语言规定任何属性或行为都必须放在对象里面。所以我们需要寻找一种新的模式来表示这种跨多个对象的操作,DDD认为服务是一个很自然的范式用来对应这种跨多个对象的操作,所以就有了领域服务这个模式。和领域对象不同,领域服务是以动词开头来命名的,比如资金转帐服务可以命名为MoneyTransferService。当然,你也可以把服务理解为一个对象,但这和一般意义上的对象有些区别。因为一般的领域对象都是有状态和行为的,而领域服务没有状态只有行为。需要强调的是领域服务是无状态的,它存在的意义就是协调领域对象共完成某个操作,所有的状态还是都保存在相应的领域对象中。我觉得模型(实体)与服务(场景)是对领域的一种划分,模型关注领域的个体行为,场景关注领域的群体行为,模型关注领域的静态结构,场景关注领域的动态功能。这也符合了现实中出现的各种现象,有动有静,有独立有协作。

领域服务还有一个很重要的功能就是可以避免领域逻辑泄露到应用层。因为如果没有领域服务,那么应用层会直接调用领域对象完成本该是属于领域服务该做的操作,这样一来,领域层可能会把一部分领域知识泄露到应用层。因为应用层需要了解每个领域对象的业务功能,具有哪些信息,以及它可能会与哪些其他领域对象交互,怎么交互等一系列领域知识。因此,引入领域服务可以有效的防治领域层的逻辑泄露到应用层。对于应用层来说,从可理解的角度来讲,通过调用领域服务提供的简单易懂但意义明确的接口肯定也要比直接操纵领域对象容易的多。

说到领域服务,还需要提一下软件中一般有三种服务:应用层服务、领域服务、基础服务。

应用层服务

  • 获取输入(如一个XML请求);
  • 发送消息给领域层服务,要求其实现转帐的业务逻辑;
  • 领域层服务处理成功,则调用基础层服务发送Email通知;

领域层服务

  • 获取源帐号和目标帐号,分别通知源帐号和目标帐号进行扣除金额和增加金额的操作;
  • 提供返回结果给应用层;

基础层服务

按照应用层的请求,发送Email通知;

所以,从上面的例子中可以清晰的看出,每种服务的职责

领域事件( Domain Events )

领域专家所关心的发生在领域中的一些事件。
将领域中所发生的活动建模成一系列的离散事件。每个事件都用领域对象来表示。领域事件是领域模型的组成部分,表示领域中所发生的事情。

一个领域事件可以理解为是发生在一个特定领域中的事件,是你希望在同一个领域中其他部分知道并产生后续动作的事件。但是并不是所有发生过的事情都可以成为领域事件。一个领域事件必须对业务有价值,有助于形成完整的业务闭环,也即一个领域事件将导致进一步的业务操作。

领域事件可以是业务流程的一个步骤,例如订单提交,客户付费100元,订单完工等。领域事件也可以是定时发生的事情,例如每晚对账完成。或者是一个事件发生后引发的后续动作,例如客户输错密码三次后发生锁定账户的事件。

如果在通用语言中存在“当a发生时,我们就需要做到b。”这样的描述,则表明a可以定义成一个领域事件。领域事件的命名一般也就是“产生事件的对象名称+完成的动作的过去式”的形式,比如:订单已经发货的事件(OrderDispatchedEvent)、订单已被收货和确认的事件(OrderConfirmedEvent)等。

为什么需要领域事件

领域事件也是一种基于事件的架构(EDA)。事件架构的好处可以把处理的流程解耦,实现系统可扩展性,提高主业务流程的内聚性。

举例而言:用户提交一个订单,系统在完成订单保存后,可能还需要发送一个通知,另外可以产生一系列的后台服务的活动。如果把这一系列的动作放入一个处理过程中,会产生几个的明显问题:
一个是订单提交的的事务比较长,性能会有问题,甚至在极端情况下容易引发数据库的严重故障;另外订单提交的服务内聚性差,可维护性差,在业务流程发生变更时候,需要频繁修改主程序。

如果改为事件驱动模式,把订单提交后触发一个事件,在订单保存后,触发订单提交事件。通知和后续的各种服务动作可以通过订阅这个事件,在自己的实现空间内实现对应的逻辑,这样就把订单提交和后续其他非主要活动从订单提交业务中剥离,实现了订单提交业务高内聚和低耦合性。

领域事件也继承了事件的作用,当领域中发生了一些活动后,通过领域事件可以把这些活动所产生的副作用显式而不是隐式的表达出来,这种影响可以影响一个或多个聚合对象,而且可以获得更好的扩展性,对数据的锁影响也更小。

领域事件的特点

首先是解决领域的聚合性问题。DDD中的聚合有一个原则是,在单个事务中,只允许对一个聚合对象进行修改,由此产生的其他改变必须在单独的事务中完成。如果一个业务跨多个聚合对象,领域事件会是一个不错的工具来解决这个问题。通过领域事件的方式可以达到各个组件之间的数据一致性,通过最终一致性取代事务一致性。

  • 首先是解决领域的聚合性问题。DDD中的聚合有一个原则是,在单个事务中,只允许对一个聚合对象进行修改,由此产生的其他改变必须在单独的事务中完成。如果一个业务跨多个聚合对象,领域事件会是一个不错的工具来解决这个问题。通过领域事件的方式可以达到各个组件之间的数据一致性,通过最终一致性取代事务一致性。

  • 其次领域事件也是一种领域分析的工具,有时从领域专家的话中,我们看不出领域事件的迹象,但是业务需求依然有可能需要领域事件。动态流的事件模型加上结合DDD的聚合实体状态和BC,可以有效进行领域建模。

领域事件可以通过观察者模式和订阅模式进行实现。比较常见的实现方式是事件总线(Event Bus)。

模块( Modules )

模块(Module)是 DDD 中明确提到的一种控制限界上下文的手段,在我们的工程中,一般尽量用一个模块来表示一个领域的限界上下文。

如代码中所示,一般的工程中包的组织方式为 {com.公司名.组织架构.业务.上下文.*},这样的组织结构能够明确地将一个上下文限定在包的内部。

1
2
3
import com.company.team.bussiness.counter.*;//计数上下文
import com.company.team.bussiness.category.*;//分类上下文
import com.company.team.bussiness.comment.*;//评论上下文

对于模块内的组织结构,一般情况下我们是按照领域对象、领域服务、领域资源库、防腐层等组织方式定义的。

1
2
3
4
5
6
import com.company.team.bussiness.cms.domain.valobj.*;//领域对象-值对象
import com.company.team.bussiness.cms.domain.entity.*;//领域对象-实体
import com.company.team.bussiness.cms.domain.aggregate.*;//领域对象-聚合根
import com.company.team.bussiness.cms.service.*;//领域服务
import com.company.team.bussiness.cms.repo.*;//领域资源库
import com.company.team.bussiness.cms.facade.*;//领域防腐层

聚合( Aggregate )

映射概念:包。

聚合,它通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的难以维护的对象关系网的形成。聚合定义了一组具有内聚关系的相关对象的集合,我们把聚合看作是一个修改数据的单元。

聚合有以下一些特点:

  • 每个聚合有一个根和一个边界,边界定义了一个聚合内部有哪些实体或值对象,根是聚合内的某个实体;
  • 聚合内部的对象之间可以相互引用,但是聚合外部如果要访问聚合内部的对象时,必须通过聚合根开始导航,绝对不能绕过聚合根直接访问聚合内的对象,也就是说聚合根是外部可以保持 对它的引用的唯一元素;
  • 聚合内除根以外的其他实体的唯一标识都是本地标识,也就是只要在聚合内部保持唯一即可,因为它们总是从属于这个聚合的;
  • 聚合根负责与外部其他对象打交道并维护自己内部的业务规则;
  • 基于聚合的以上概念,我们可以推论出从数据库查询时的单元也是以聚合为一个单元,也就是说我们不能直接查询聚合内部的某个非根的对象;
  • 聚合内部的对象可以保持对其他聚合根的引用;
  • 删除一个聚合根时必须同时删除该聚合内

聚合根

映射概念:包。

一个上下文内可能包含多个聚合,每个聚合都有一个根实体,叫做聚合根,一个聚合只有一个聚合根。

关于如何识别聚合以及聚合根的问题:

可以先从业务的角度深入思考,然后慢慢分析出有哪些对象是:

  • 有独立存在的意义,即它是不依赖于其他对象的存在它才有意义的;
  • 可以被独立访问的,还是必须通过某个其他对象导航得到的;

如何识别聚合

我觉得这个需要从业务的角度深入分析哪些对象它们的关系是内聚的,即我们会把他们看成是一个整体来考虑的;然后这些对象我们就可以把它们放在一个聚合内。所谓关系是内聚的,是指这些对象之间必须保持一个固定规则,固定规则是指在数据变化时必须保持不变的一致性规则。当我们在修改一个聚合时,我们必须在事务级别确保整个聚合内的所有对象满足这个固定规则。作为一条建议,聚合尽量不要太大,否则即便能够做到在事务级别保持聚合的业务规则完整性,也可能会带来一定的性能问题。有分析报告显示,通常在大部分领域模型中,有70%的聚合通常只有一个实体,即聚合根,该实体内部没有包含其他实体,只包含一些值对象;另外30%的聚合中,基本上也只包含两到三个实体。这意味着大部分的聚合都只是一个实体,该实体同时也是聚合根。

如何识别聚合根

如果一个聚合只有一个实体,那么这个实体就是聚合根;如果有多个实体,那么我们可以思考聚合内哪个对象有独立存在的意义并且可以和外部直接进行交互。

上面给出的例子中:

房子、订单和问题是聚合根
窗户、订单备注和问题详情是聚合
当数据改变时,所有相关的对象被看作一个整体。

所有的外部数据访问都需要通过同一个根节点进入,这个根就是根节点

资源库( Repository )

  • 仓储被设计出来的目的是基于这个原因:领域模型中的对象自从被创建出来后不会一直留在内存中活动的,当它不活动时会被持久化到数据库中,然后当需要的时候我们会重建该对象;重建对象就是根据数据库中已存储的对象的状态重新创建对象的过程;所以,可见重建对象是一个和数据库打交道的过程。从更广义的角度来理解,我们经常会像集合一样从某个类似集合的地方根据某个条件获取一个或一些对象,往集合中添加对象或移除对象。也就是说,我们需要提供一种机制,可以提供类似集合的接口来帮助我们管理对象。仓储就是基于这样的思想被设计出来的;
  • 仓储里面存放的对象一定是聚合,原因是之前提到的领域模型中是以聚合的概念去划分边界的;聚合是我们更新对象的一个边界,事实上我们把整个聚合看成是一个整体概念,要么一起被取出来,要么一起被删除。我们永远不会单独对某个聚合内的子对象进行单独查询或做更新操作。因此,我们只对聚合设计仓储。
  • 仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。这样做的原因是:由于仓储背后的实现都是在和数据库打交道,但是我们又不希望客户(如应用层)把重点放在如何从数据库获取数据的问题上,因为这样做会导致客户(应用层)代码很混乱,很可能会因此而忽略了领域模型的存在。所以我们需要提供一个简单明了的接口,供客户使用,确保客户能以最简单的方式获取领域对象,从而可以让它专心的不会被什么数据访问代码打扰的情况下协调领域对象完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见。由于客户面对的是抽象的接口并不是具体的实现,所以我们可以随时替换仓储的真实实现,这很有助于我们做单元测试。
  • 尽管仓储可以像集合一样在内存中管理对象,但是仓储一般不负责事务处理。一般事务处理会交给一个叫“工作单元(Unit Of Work)”的东西。关于工作单元的详细信息我在下面的讨论中会讲到。
  • 另外,仓储在设计查询接口时,可能还会用到规格模式(Specification Pattern),我见过的最厉害的规格模式应该就是LINQ以及DLINQ查询了。一般我们会根据项目中查询的灵活度要求来选择适合的仓储查询接口设计。通常情况下只需要定义简单明了的具有固定查询参数的查询接口就可以了。只有是在查询条件是动态指定的情况下才可能需要用到Specification等模式。

领域对象需要资源存储,资源库可以理解成 DAO,但它比 DAO 更宽泛,存储的手段可以是多样化的,常见的无非是数据库、分布式缓存、本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。

在系统中,我们是通过如下的方式组织资源库的。

1
2
3
4
5
import com.company.team.bussiness.repo.dao.ArticleDao;//数据库访问对象-文章
import com.company.team.bussiness.repo.dao.CommentDao;//数据库访问对象-评论
import com.company.team.bussiness.repo.dao.po.ArticlePO;//数据库持久化对象-文章
import com.company.team.bussiness.repo.dao.po.CommentPO;//数据库持久化对象-评论
import com.company.team.bussiness.repo.cache.ArticleObj;//分布式缓存访问对象-文章缓存访问

资源库对外的整体访问由 Repository 提供,它聚合了各个资源库的数据信息,同时也承担了资源存储的逻辑(例如缓存更新机制等)。

四种 Domain 模式

除了晦涩难懂的概念外,让我们最难接受的可能就是模型的运用了,Spring 思想中,Domain 只是数据的载体,所有行为都在 Service 中使用 Domain 封装后流转,而 OOP 讲究一对象维度来执行业务,所以,DDD 中的对象是用行为的(理解这点非常重要哦)。

这里我为你总结了全部的四种领域模式,供你区分和理解:

  • 失血模型
  • 贫血模型
  • 充血模型
  • 胀血模型

失血模型

Domain Object 只有属性的 getter/setter 方法的纯数据类,所有的业务逻辑完全由 business object 来完成。

贫血模型

简单来说,就是 Domain Object 包含了不依赖于持久化的领域逻辑,而那些依赖持久化的领域逻辑被分离到 Service 层。

意这个模式不在 Domain 层里依赖 DAO。持久化的工作还需要在 DAO 或者 Service 中进行。

这样做的优缺点

优点:各层单向依赖,结构清晰。

缺点:

  • Domain Object 的部分比较紧密依赖的持久化 Domain Logic 被分离到 Service 层,显得不够 OO
  • Service 层过于厚重

充血模型

充血模型和第二种模型差不多,区别在于业务逻辑划分,将绝大多数业务逻辑放到 Domain 中,Service 是很薄的一层,封装少量业务逻辑,并且不和 DAO 打交道:

Service (事务封装) —> Domain Object <—> DAO

所有业务逻辑都在 Domain 中,事务管理也在 Item 中实现。这样做的优缺点如下。

优点:

  • 更加符合 OO 的原则;
  • Service 层很薄,只充当 Facade 的角色,不和 DAO 打交道。
    缺点:

  • DAO 和 Domain Object 形成了双向依赖,复杂的双向依赖会导致很多潜在的问题。

  • 如何划分 Service 层逻辑和 Domain 层逻辑是非常含混的,在实际项目中,由于设计和开发人员的水平差异,可能 导致整个结构的混乱无序。

充血模型

基于充血模型的第三个缺点,干脆取消 Service 层,只剩下 Domain Object 和 DAO 两层,在 Domain Object 的 Domain Logic 上面封装事务。

Domain Object (事务封装,业务逻辑) <—> DAO

似乎 Ruby on rails 就是这种模型,它甚至把 Domain Object 和 DAO 都合并了。

这样做的优缺点:

  • 简化了分层
  • 也算符合 OO

该模型缺点:

  • 很多不是 Domain Logic 的 Service 逻辑也被强行放入 Domain Object ,引起了 Domain Object 模型的不稳定;
  • Domain Object 暴露给 Web 层过多的信息,可能引起意想不到的副作用。

总结

image

DDD分层架构

优点:

  • 开发人员可以只关注整个结构中的某一层。
  • 可以很容易的用新的实现来替换原有层次的实现。
  • 可以降低层与层之间的依赖。
  • 有利于标准化。
  • 利于各层逻辑的复用。
    缺点:
  • 降低了系统的性能。这是显然的,因为增加了中间层,不过可以通过缓存机制来改善。
  • 可能会导致级联的修改。这种修改尤其体现在自上而下的方向,不过可以通过依赖倒置来改善。

四层架构

image

  • User Interface为用户界面层(或表示层),负责向用户显示信息和解释用户命令。这里指的用户可以是另一个计算机系统,不一定是使用用户界面的人。
  • Application为应用层,定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其它系统的应用层进行交互的必要渠道。应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作。它没有反映业务情况的状态,但是却可以具有另外一种状态,为用户或程序显示某个任务的进度。
  • Domain为领域层(或模型层),负责表达业务概念,业务状态信息以及业务规则。尽管保存业务状态的技术细节是由基础设施层实现的,但是反映业务情况的状态是由本层控制并且使用的。领域层是业务软件的核心,领域模型位于这一层。
  • Infrastructure层为基础实施层,向其他层提供通用的技术能力:为应用层传递消息,为领域层提供持久化机制,为用户界面层绘制屏幕组件,等等。基础设施层还能够通过架构框架来支持四个层次间的交互模式。
    传统的四层架构都是限定型松散分层架构,即Infrastructure层的任意上层都可以访问该层(“L”型),而其它层遵守严格分层架构

在四层架构模式的实践中,对于分层的本地化定义主要为:

  • User Interface层主要是Restful消息处理,配置文件解析,等等。
  • Application层主要是多进程管理及调度,多线程管理及调度,多协程调度和状态机管理,等等。
  • Domain层主要是领域模型的实现,包括领域对象的确立,这些对象的生命周期管理及关系,领域服务的定义,领域事件的发布,等等。
  • Infrastructure层主要是业务平台,编程框架,第三方库的封装,基础算法,等等。

严格意义上来说,User Interface指的是用户界面,Restful消息和配置文件解析等处理应该放在Application层,User Interface层没有的话就空缺。但User Interface也可以理解为用户接口,所以将Restful消息和配置文件解析等处理放在User Interface层也行。

五层架构

image

  • User Interface是用户接口层,主要用于处理用户发送的Restful请求和解析用户输入的配置文件等,并将信息传递给Application层的接口。
  • Application层是应用层,负责多进程管理及调度、多线程管理及调度、多协程调度和维护业务实例的状态模型。当调度层收到用户接口层的请求后,委托Context层与本次业务相关的上下文进行处理。
  • Context是环境层,以上下文为单位,将Domain层的领域对象cast成合适的role,让role交互起来完成业务逻辑。
  • Domain层是领域层,定义领域模型,不仅包括领域对象及其之间关系的建模,还包括对象的角色role的显式建模。
  • Infrastructure层是基础实施层,为其他层提供通用的技术能力:业务平台,编程框架,持久化机制,消息机制,第三方库的封装,通用算法,等等。

很多DDD落地实践,都是面向控制面或管理面且消息交互比较多的系统。这类系统的一次业务,包含一组同步消息或异步消息构成的序列,如果都放在Context层,会导致该层的代码比较复杂,于是我们考虑:

  • Context层在面向控制面或管理面且消息交互比较多的系统中又分裂成两层,即Context层和大Context层。
  • Context层处理单位为Action,对应一条同步消息或异步消息。
  • 大Context层对应一个事务处理,由一个Action序列组成,一般通过Transaction DSL实现,所以我们习惯把大Context层叫做Transaction DSL层。
  • Application层在面向控制面或管理面且消息交互比较多的系统中经常会做一些调度相关的工作,所以我们习惯把Application层叫做Scheduler层。

因此,在面向控制面或管理面且消息交互比较多的系统中,DDD分层架构模式就变成了六层,
image

在实践中,将这六层的本地化定义为:

  • User Interface是用户接口层,主要用于处理用户发送的Restful请求和解析用户输入的配置文件等,并将信息传递给Scheduler层的接口。
  • Scheduler是调度层,负责多进程管理及调度、多线程管理及调度、多协程调度和维护业务实例的状态模型。当调度层收到用户接口层的请求后,委托Transaction层与本次操作相关的事务进行处理。
  • Transaction是事务层,对应一个业务流程,比如UE Attach,将多个同步消息或异步消息的处理序列组合成一个事务,而且在大多场景下,都有选择结构。万一事务执行失败,则立即进行回滚。当事务层收到调度层的请求后,委托Context层的Action进行处理,常常还伴随使用Context层的Specification(谓词)进行Action的选择。
  • Context是环境层,以Action为单位,处理一条同步消息或异步消息,将Domain层的领域对象cast成合适的role,让role交互起来完成业务逻辑。环境层通常也包括Specification的实现,即通过Domain层的知识去完成一个条件判断。
  • Domain层是领域层,定义领域模型,不仅包括领域对象及其之间关系的建模,还包括对象的角色role的显式建模。
  • Infrastructure层是基础实施层,为其他层提供通用的技术能力:业务平台,编程框架,持久化机制,消息机制,第三方库的封装,通用算法,等等。

事务层的核心是事务模型,事务模型的框架代码一般放在基础设施层。

综上所述,DDD六层架构可以看做是DDD五层架构在特定领域的变体,我们统称为DDD五层架构,而DDD五层架构与传统的四层架构类似,都是限定型松散分层架构。

六边形架构

有一种方法可以改进分层架构,即依赖倒置原则(Dependency Inversion Principle, DIP),它通过改变不同层之间的依赖关系达到改进目的。

依赖倒置原则由Robert C. Martin提出,正式定义为:
高层模块不应该依赖于底层模块,两者都应该依赖于抽象。
抽象不应该依赖于细节,细节应该依赖于抽象。

根据该定义,DDD分层架构中的低层组件应该依赖于高层组件提供的接口,即无论高层还是低层都依赖于抽象,整个分层架构好像被推平了。如果我们把分层架构推平,再向其中加入一些对称性,就会出现一种具有对称性特征的架构风格,即六边形架构。六边形架构是Alistair Cockburn在2005年提出的,在这种架构中,不同的客户通过“平等”的方式与系统交互。需要新的客户吗?不是问题。只需要添加一个新的适配器将客户输入转化成能被系统API所理解的参数就行。同时,对于每种特定的输出,都有一个新建的适配器负责完成相应的转化功能。

六边形架构也称为端口与适配器,如下图所示:
image

image

六边形每条不同的边代表了不同类型的端口,端口要么处理输入,要么处理输出。对于每种外界类型,都有一个适配器与之对应,外界通过应用层API与内部进行交互。上图中有3个客户请求均抵达相同的输入端口(适配器A、B和C),另一个客户请求使用了适配器D。假设前3个请求使用了HTTP协议(浏览器、REST和SOAP等),而后一个请求使用了AMQP协议(比如RabbitMQ)。端口并没有明确的定义,它是一个非常灵活的概念。无论采用哪种方式对端口进行划分,当客户请求到达时,都应该有相应的适配器对输入进行转化,然后端口将调用应用程序的某个操作或者向应用程序发送一个事件,控制权由此交给内部区域。
应用程序通过公共API接收客户请求,使用领域模型来处理请求。我们可以将DDD战术设计的建模元素Repository的实现看作是持久化适配器,该适配器用于访问先前存储的聚合实例或者保存新的聚合实例。正如图中的适配器E、F和G所展示的,我们可以通过不同的方式实现资源库,比如关系型数据库、基于文档的存储、分布式缓存或内存存储等。如果应用程序向外界发送领域事件消息,我们将使用适配器H进行处理。该适配器处理消息输出,而上面提到的处理AMQP消息的适配器则是处理消息输入的,因此应该使用不同的端口。

我们在实际的项目开发中,不同层的组件可以同时开发。当一个组件的功能明确后,就可以立即启动开发。由于该组件的用户有多个,并且这些用户的侧重点不同,所以需要提供多个不同的接口。同时,这些用户的认识也是不断深入的,可能会多次重构相关的接口。于是,组件的多个用户经常会找组件的开发者讨论这些问题,无形中降低了组件的开发效率。
我们换一种方式,组件的开发者在明确了组件的功能后就专注于功能的开发,确保功能稳定和高效。组件的用户自己定义组件的接口(端口),然后基于接口写测试,并不断演进接口。在跨层集成测试时,由组件开发者或用户再开发一个适配器就可以了。

六边形架构模式的演变

尽管六边形架构模式已经很好,但是没有最好只有更好,演变没有尽头。在六边形架构模式提出后的这些年,又依次衍生出三种六边形架构模式的变体,感兴趣的读者可以点击链接自行学习:

  • Jeffrey Palermo在2008年提出了洋葱架构,六边形架构是洋葱架构的一个超集。
  • Robert C. Martin在2012年提出了干净架构(Clean Architecture),这是六边形架构的一个变体。
  • Russ Miles在2013年提出了Life Preserver设计,这是一种基于六边形架构的设计。

DDD实践

假如你是一个团队 Leader 或者架构师,当你接手一个旧系统维护及重构的任务时,你该如何改造呢?是否觉得哪里都不对但由于业务认知的不熟悉而无从下手呢?

  • 通过公共平台大概梳理出系统之间的调用关系(一般中等以上公司都具备 RPC 和 HTTP 调用关系,无脑的挨个系统查询即可),画出来的可能会很乱,也可能会比较清晰,但这就是现状。
  • 分配组员每个人认领几个项目,来梳理项目维度关系,这些关系包括:对外接口、交互、用例、MQ 等的详细说明。个别核心系统可以画出内部实体或者聚合根。
  • 小组开会,挨个 review 每个系统的业务概念,达到组内统一语言。
  • 根据以上资料,即可看出哪些不合理的调用关系(比如循环调用、不规范的调用等),甚至不合理的分层。
  • 根据主线业务自顶向下细分领域,以及限界上下文。此过程可能会颠覆之前的系统划分。
  • 根据业务复杂性,指定领域模型,选择贫血或者充血模型。团队内部最好实行统一习惯,以免出现交接成本过大。
  • 分工进行开发,并设置 deadline,注意,不要单一的设置一个 deadline,要设置中间 check 时间,比如 dealline 是 1 月 20 日,还要设置两个 check 时间,分别沟通代码风格及边界职责,以免 deadline 时延期。

事件风暴(Event Storming)

事件风暴也被称为事件建模,形式有点类似于头脑风暴的方法,通过事件风暴的方法可以快速分析复杂业务领域,完成领域建模的目标。

image

事件风暴是一项团队活动,旨在通过领域事件识别出聚合根,进而划分微服务的限界上下文。在活动中,团队先通过头脑风暴的形式罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对于每一个事件,标注出导致该事件的命令(Command),再然后为每个事件标注出命令发起方的角色,命令可以是用户发起,也可以是第三方系统调用或者是定时器触发等。最后对事件进行分类整理出聚合根以及限界上下文。

事件风暴方法通常有以下几个步骤:

  • 邀请合适的人参加
  • 提供无限制的建模空间
  • 发掘领域事件,可使用橙色贴纸标识
  • 发掘领域事件的来源,探询命令,可使用蓝色贴纸标识。多种来源也可以- 考虑用不同颜色来区分,例如用黄色表示角色,粉色表示外部系统,红色表示时间触发。
  • 寻找聚合,可用绿色贴纸标识
  • 持续探索,发掘子域,BC,用户角色,验收测试标准,补充信息等。

事件风暴的步骤可以如下图所示:
image

在我们的一次产品的重构活动中也采用了事件风暴方法。系统代码维护了10几年,代码中存在大量的“坏味道”:重复代码,过长函数,过大的类,过长的参数列表,发散式变化,霰弹式修改,镀金问题,注释不清等问题。实际研发过程中也是经常出现一点改动都可能会引起不可预测的结果,重构势在必行。

但是在重构过程中,也没有人可以说清楚现有系统的逻辑,如何重构成为了一个难题。重构过程我们引入了咨询公司给我们的方法,采用了事件风暴的办法,通过对领域中所发生的事情(也就是领域事件)来探索这个领域,并且使用便签来描述领域中的事件,这些便签会沿着时间轴贴到一个很大的建模面板上。

举例来说,能够引发事件的事情包括用户行为、外部系统所发生的事情以及时间的流逝。事件也有助于找到领域的边界,对术语的不同阐述可能就意味着存在边界。

准备工作,四色贴纸:

  • 橙色:事件,某个动作的结果,以“XX已XX”的方式表示,比如“用户信息已查询”
  • 蓝色:属性,事件相关的输入、输出数据等
  • 黄色:命令,某个动作,比如“查找用户信息”
  • 绿色:实体,命令的触发者
  • 开始梳理业务,将结果贴到白版上
  • 继续深入梳理,将整个过程的模型、关键数据等梳理出来,贴在白板上
    确定重构指导思路,执行重构动作,重构的同时引入单元测试保障重构的质量

实践

我们先看一个简单的例子,这个 case 的业务逻辑如下:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。

先不要去纠结这个根据用户电话去发奖金的业务逻辑是否合理,也先不要去管用户是否应该在注册时和业务员做绑定,这里我们看的主要还是如何更加合理的去实现这个逻辑。一个简单的用户和用户注册的代码实现如下:

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
public class User {
Long userId;
String name;
String phone;
String address;
Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {

private SalesRepRepository salesRepRepo;
private UserRepository userRepo;

public User register(String name, String phone, String address)
throws ValidationException {
// 校验逻辑
if (name == null || name.length() == 0) {
throw new ValidationException("name");
}
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
// 此处省略address的校验逻辑

// 取电话号里的区号,然后通过区号找到区域内的SalesRep
String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
break;
}
}
SalesRep rep = salesRepRepo.findRep(areaCode);

// 最后创建用户,落盘,然后返回
User user = new User();
user.name = name;
user.phone = phone;
user.address = address;
if (rep != null) {
user.repId = rep.repId;
}

return userRepo.save(user);
}

private boolean isValidPhoneNumber(String phone) {
String pattern = "^0[1-9]{2,3}-?\\d{8}$";
return phone.matches(pattern);
}
}

我们日常绝大部分代码和模型其实都跟这个是类似的,乍一看貌似没啥问题,但我们再深入一步,从以下四个维度去分析一下:接口的清晰度(可阅读性)、数据验证和错误处理、业务逻辑代码的清晰度、和可测试性。

问题1 - 接口的清晰度

在Java代码中,对于一个方法来说所有的参数名在编译时丢失,留下的仅仅是一个参数类型的列表,所以我们重新看一下以上的接口定义,其实在运行时仅仅是:

1
User register(String, String, String);

所以以下的代码是一段编译器完全不会报错的,很难通过看代码就能发现的 bug :

1
service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");

当然,在真实代码中运行时会报错,但这种 bug 是在运行时被发现的,而不是在编译时。普通的 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。这里的思考是,有没有办法在编码时就避免这种可能会出现的问题?

另外一种常见的,特别是在查询服务中容易出现的例子如下:

1
2
3
User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

在这个场景下,由于入参都是 String 类型,不得不在方法名上面加上 ByXXX 来区分,而 findByNameAndPhone 同样也会陷入前面的入参顺序错误的问题,而且和前面的入参不同,这里参数顺序如果输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。这里的思考是,有没有办法让方法入参一目了然,避免入参错误导致的 bug ?

问题2 - 数据验证和错误处理

在前面这段数据校验代码:

1
2
3
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}

在日常编码中经常会出现,一般来说这种代码需要出现在方法的最前端,确保能够 fail-fast 。但是假设你有多个类似的接口和类似的入参,在每个方法里这段逻辑会被重复。而更严重的是如果未来我们要拓展电话号去包含手机时,很可能需要加入以下代码:

1
2
3
if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
throw new ValidationException("phone");
}

如果你有很多个地方用到了 phone 这个入参,但是有个地方忘记修改了,会造成 bug 。这是一个 DRY 原则被违背时经常会发生的问题。

如果有个新的需求,需要把入参错误的原因返回,那么这段代码就变得更加复杂:

1
2
3
4
5
if (phone == null) {
throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
throw new ValidationException("phone格式错误");
}

可以想像得到,代码里充斥着大量的类似代码块时,维护成本要有多高。

最后,在这个业务方法里,会(隐性或显性的)抛 ValidationException,所以需要外部调用方去try/catch,而业务逻辑异常和数据校验异常被混在了一起,是否是合理的?

在传统Java架构里有几个办法能够去解决一部分问题,常见的如BeanValidation注解或ValidationUtils类,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Use Bean Validation
User registerWithBeanValidation(
@NotNull @NotBlank String name,
@NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\\d{8}$") String phone,
@NotNull String address
);

// Use ValidationUtils:
public User registerWithUtils(String name, String phone, String address) {
ValidationUtils.validateName(name); // throws ValidationException
ValidationUtils.validatePhone(phone);
ValidationUtils.validateAddress(address);
...
}

但这几个传统的方法同样有问题,

BeanValidation:

  • 通常只能解决简单的校验逻辑,复杂的校验逻辑一样要写代码实现定制校验器
  • 在添加了新校验逻辑时,同样会出现在某些地方忘记添加一个注解的情况,DRY原则还是会被违背

ValidationUtils类:

  • 当大量的校验逻辑集中在一个类里之后,违背了Single Responsibility单一性原则,导致代码混乱和不可维护
  • 业务异常和校验异常还是会混杂

所以,有没有一种方法,能够一劳永逸的解决所有校验的问题以及降低后续的维护成本和异常处理成本呢?

问题3 - 业务代码的清晰度

在这段代码里:

1
2
3
4
5
6
7
8
9
10
String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
break;
}
}
SalesRep rep = salesRepRepo.findRep(areaCode);

实际上出现了另外一种常见的情况,那就是从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中再抽取部分数据用作其他的作用。这种代码通常被称作“胶水代码”,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。比如,如果SalesRepRepository包含一个findRepByPhone的方法,则上面大部分的代码都不必要了。

所以,一个常见的办法是将这段代码抽离出来,变成独立的一个或多个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static String findAreaCode(String phone) {
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (isAreaCode(prefix)) {
return prefix;
}
}
return null;
}

private static boolean isAreaCode(String prefix) {
String[] areas = new String[]{"0571", "021"};
return Arrays.asList(areas).contains(prefix);
}

然后原始代码变为:

1
2
String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

而为了复用以上的方法,可能会抽离出一个静态工具类 PhoneUtils 。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

问题4 - 可测试性

为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有 TC 覆盖(假设我们先不去测试内部业务逻辑),所以在我们这个方法里需要以下的 TC :

假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC 。

如果这时候在该方法中加入一个新的入参字段 fax ,即使 fax 和 phone 的校验逻辑完全一致,为了保证 TC 覆盖率,也一样需要 M 个新的 TC 。

而假设有 P 个方法中都用到了 phone 这个字段,这 P 个方法都需要对该字段进行测试,也就是说整体需要:

P N M

个测试用例才能完全覆盖所有数据验证的问题,在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。

在这个情况下,降低测试成本 == 提升代码质量,如何能够降低测试的成本呢?

解决方案

我们回头先重新看一下原始的 use case,并且标注其中可能重要的概念:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户的注册系统,在用户注册后能够通过用户电话号的区号对业务员发奖金。

在分析了 use case 后,发现其中地推业务员、用户本身自带 ID 属性,属于 Entity(实体),而注册系统属于 Application Service(应用服务),这几个概念已经有存在。但是发现电话号这个概念却完全被隐藏到了代码之中。我们可以问一下自己,取电话号的区号的逻辑是否属于用户(用户的区号?)?是否属于注册服务(注册的区号?)?如果都不是很贴切,那就说明这个逻辑应该属于一个独立的概念。所以这里引入我们第一个原则:

Make Implicit Concepts Explicit

将隐性的概念显性化

在这里,我们可以看到,原来电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个Value Object:

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
public class PhoneNumber {

private final String number;
public String getNumber() {
return number;
}

public PhoneNumber(String number) {
if (number == null) {
throw new ValidationException("number不能为空");
} else if (isValid(number)) {
throw new ValidationException("number格式错误");
}
this.number = number;
}

public String getAreaCode() {
for (int i = 0; i < number.length(); i++) {
String prefix = number.substring(0, i);
if (isAreaCode(prefix)) {
return prefix;
}
}
return null;
}

private static boolean isAreaCode(String prefix) {
String[] areas = new String[]{"0571", "021", "010"};
return Arrays.asList(areas).contains(prefix);
}

public static boolean isValid(String number) {
String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
return number.matches(pattern);
}

}

这里面有几个很重要的元素:

  • 通过 private final String number 确保 PhoneNumber 是一个(Immutable)Value Object。(一般来说 VO 都是 Immutable 的,这里只是重点强调一下)
  • 校验逻辑都放在了 constructor 里面,确保只要 PhoneNumber 类被创建出来后,一定是校验通过的。
  • 之前的 findAreaCode 方法变成了 PhoneNumber 类里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一个计算属性。

这样做完之后,我们发现把 PhoneNumber 显性化之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

  • Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里
    这两个概念加起来,构造成了本文标题的 Domain Primitive(DP)。

我们看一下全面使用了 DP 之后效果:

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
public class User {
UserId userId;
Name name;
PhoneNumber phone;
Address address;
RepId repId;
}

public User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address
) {
// 找到区域内的SalesRep
SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

// 最后创建用户,落盘,然后返回,这部分代码实际上也能用Builder解决
User user = new User();
user.name = name;
user.phone = phone;
user.address = address;
if (rep != null) {
user.repId = rep.repId;
}

return userRepo.saveUser(user);
}

我们可以看到在使用了 DP 之后,所有的数据验证逻辑和非业务流程的逻辑都消失了,剩下都是核心业务逻辑,可以一目了然。我们重新用上面的四个维度评估一下:

评估1 - 接口的清晰度

重构后的方法签名变成了很清晰的:

1
public User register(Name, PhoneNumber, Address)

而之前容易出现的bug,如果按照现在的写法

1
service.register(new Name("殷浩"), new Address("浙江省杭州市余杭区文三西路969号"), new PhoneNumber("0571-12345678"));

让接口 API 变得很干净,易拓展。

评估2 - 数据验证和错误处理

1
2
3
4
5
public User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address
) // no throws

如前文代码展示的,重构后的方法里,完全没有了任何数据验证的逻辑,也不会抛 ValidationException 。原因是因为 DP 的特性,只要是能够带到入参里的一定是正确的或 null(Bean Validation 或 lombok 的注解能解决 null 的问题)。所以我们把数据验证的工作量前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。

再展开来看,使用DP的另一个好处就是代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber 的校验逻辑,只需要在一个文件里修改即可,所有使用到了 PhoneNumber 的地方都会生效。

评估3 - 业务代码的清晰度

1
2
3
SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

除了在业务方法里不需要校验数据之外,原来的一段胶水代码 findAreaCode 被改为了 PhoneNumber 类的一个计算属性 getAreaCode ,让代码清晰度大大提升。而且胶水代码通常都不可复用,但是使用了 DP 后,变成了可复用、可测试的代码。我们能看到,在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。( Entity 相关的重构在后面文章会谈到,这次先忽略)

评估4 - 可测试性

当我们将 PhoneNumber 抽取出来之后,在来看测试的 TC :

首先 PhoneNumber 本身还是需要 M 个测试用例,但是由于我们只需要测试单一对象,每个用例的代码量会大大降低,维护成本降低。
每个方法里的每个参数,现在只需要覆盖为 null 的情况就可以了,其他的 case 不可能发生(因为只要不是 null 就一定是合法的)
所以,单个方法的 TC 从原来的 N * M 变成了今天的 N + M 。同样的,多个方法的 TC 数量变成了

N + M + P

这个数量一般来说要远低于原来的数量 N M P ,让测试成本极大的降低。

评估总结

进阶使用

在上文我介绍了 DP 的第一个原则:将隐性的概念显性化。在这里我将介绍 DP 的另外两个原则,用一个新的案例。

▍案例1 - 转账

假设现在要实现一个功能,让A用户可以支付 x 元给用户 B ,可能的实现如下:

1
2
3
public void pay(BigDecimal money, Long recipientId) {
BankService.transfer(money, "CNY", recipientId);
}

如果这个是境内转账,并且境内的货币永远不变,该方法貌似没啥问题,但如果有一天货币变更了(比如欧元区曾经出现的问题),或者我们需要做跨境转账,该方法是明显的 bug ,因为 money 对应的货币不一定是 CNY 。

在这个 case 里,当我们说“支付 x 元”时,除了 x 本身的数字之外,实际上是有一个隐含的概念那就是货币“元”。但是在原始的入参里,之所以只用了 BigDecimal 的原因是我们认为 CNY 货币是默认的,是一个隐含的条件,但是在我们写代码时,需要把所有隐性的条件显性化,而这些条件整体组成当前的上下文。所以 DP 的第二个原则是:

Make Implicit Context Explicit

将 隐性的 上下文 显性化

所以当我们做这个支付功能时,实际上需要的一个入参是支付金额 + 支付货币。我们可以把这两个概念组合成为一个独立的完整概念:Money。

1
2
3
4
5
6
7
8
9
@Value
public class Money {
private BigDecimal amount;
private Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
}

而原有的代码则变为:

1
2
3
public void pay(Money money, Long recipientId) {
BankService.transfer(money, recipientId);
}

通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为 Money ,我们可以避免很多当前看不出来,但未来可能会暴雷的bug。

案例2 - 跨境转账

前面的案例升级一下,假设用户可能要做跨境转账从 CNY 到 USD ,并且货币汇率随时在波动:

1
2
3
4
5
6
7
8
9
10
public void pay(Money money, Currency targetCurrency, Long recipientId) {
if (money.getCurrency().equals(targetCurrency)) {
BankService.transfer(money, recipientId);
} else {
BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
Money targetMoney = new Money(targetAmount, targetCurrency);
BankService.transfer(targetMoney, recipientId);
}
}

在这个case里,由于 targetCurrency 不一定和 money 的 Curreny 一致,需要调用一个服务去取汇率,然后做计算。最后用计算后的结果做转账。

这个case最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有2个 Currency ,2 个 Money ,1 个 BigDecimal ,总共 5 个对象。这种涉及到多个对象的业务逻辑,需要用 DP 包装掉,所以这里引出 DP 的第三个原则:

Encapsulate Multi-Object Behavior

封装 多对象 行为

在这个 case 里,可以将转换汇率的功能,封装到一个叫做 ExchangeRate 的 DP 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Value
public class ExchangeRate {
private BigDecimal rate;
private Currency from;
private Currency to;

public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
this.rate = rate;
this.from = from;
this.to = to;
}

public Money exchange(Money fromMoney) {
notNull(fromMoney);
isTrue(this.from.equals(fromMoney.getCurrency()));
BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
return new Money(targetAmount, to);
}
}

ExchangeRate汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:

1
2
3
4
5
public void pay(Money money, Currency targetCurrency, Long recipientId) {
ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
Money targetMoney = rate.exchange(money);
BankService.transfer(targetMoney, recipientId);
}

讨论和总结

▍Domain Primitive 的定义

让我们重新来定义一下 Domain Primitive :Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

DP是一个传统意义上的Value Object,拥有Immutable的特性
DP是一个完整的概念整体,拥有精准定义
DP使用业务域中的原生语言
DP可以是业务域的最小组成部分、也可以构建复杂组合
注:Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 Secure by Design。

▍使用 Domain Primitive 的三原则

让隐性的概念显性化
让隐性的上下文显性化
封装多对象行为

▍Domain Primitive 和 DDD 里 Value Object 的区别

在 DDD 中, Value Object 这个概念其实已经存在:

在 Evans 的 DDD 蓝皮书中,Value Object 更多的是一个非 Entity 的值对象
在Vernon的IDDD红皮书中,作者更多的关注了Value Object的Immutability、Equals方法、Factory方法等
Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。当然同样的要求无副作用(side-effect free)。

▍Domain Primitive 和 Data Transfer Object (DTO) 的区别

在日常开发中经常会碰到的另一个数据结构是 DTO ,比如方法的入参和出参。DP 和 DTO 的区别如下:

▍什么情况下应该用 Domain Primitive

常见的 DP 的使用场景包括:

有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
复杂的数据结构:比如 Map<String, List> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

实战 - 老应用重构的流程

在新应用中使用 DP 是比较简单的,但在老应用中使用 DP 是可以遵循以下流程按部就班的升级。在此用本文的第一个 case 为例。

▍第一步 - 创建 Domain Primitive,收集所有 DP 行为

在前文中,我们发现取电话号的区号这个是一个可以独立出来的、可以放入 PhoneNumber 这个 Class 的逻辑。类似的,在真实的项目中,以前散落在各个服务或工具类里面的代码,可以都抽出来放在 DP 里,成为 DP 自己的行为或属性。这里面的原则是:所有抽离出来的方法要做到无状态,比如原来是 static 的方法。如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP 。因为 DP 本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。

(代码参考 PhoneNumber 的代码,这里不再重复)

▍第二步 - 替换数据校验和无状态逻辑

为了保障现有方法的兼容性,在第二步不会去修改接口的签名,而是通过代码替换原有的校验逻辑和根 DP 相关的业务逻辑。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public User register(String name, String phone, String address)
throws ValidationException {
if (name == null || name.length() == 0) {
throw new ValidationException("name");
}
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}

String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
break;
}
}
SalesRep rep = salesRepRepo.findRep(areaCode);
// 其他代码...
}

通过 DP 替换代码后:

1
2
3
4
5
6
7
8
9
10
public User register(String name, String phone, String address)
throws ValidationException {

Name _name = new Name(name);
PhoneNumber _phone = new PhoneNumber(phone);
Address _address = new Address(address);

SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
// 其他代码...
}

通过 new PhoneNumber(phone) 这种代码,替代了原有的校验代码。

通过 _phone.getAreaCode() 替换了原有的无状态的业务逻辑。

▍第三步 - 创建新接口
创建新接口,将DP的代码提升到接口参数层:

1
2
3
public User register(Name name, PhoneNumber phone, Address address) {
SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
}

▍第四步 - 修改外部调用

外部调用方需要修改调用链路,比如:

1
2
3
4
service.register("殷浩", "0571-12345678", "浙江省杭州市余杭区文三西路969号");
改为:

service.register(new Name("殷浩"), new PhoneNumber("0571-12345678"), new Address("浙江省杭州市余杭区文三西路969号"));

通过以上 4 步,就能让你的代码变得更加简洁、优雅、健壮、安全。你还在等什么?今天就去尝试吧!

应用架构设计

设计原则

  • 单一性原则(Single Responsibility Principle):单一性原则要求一个对象/类应该只有一个变更的原因。但是在这个案例里,代码可能会因为任意一个外部依赖或计算逻辑的改变而改变。
  • 依赖反转原则(Dependency Inversion Principle):依赖反转原则要求在代码中依赖抽象,而不是具体的实现。在这个案例里外部依赖都是具体的实现,比如YahooForexService虽然是一个接口类,但是它对应的是依赖了Yahoo提供的具体服务,所以也算是依赖了实现。同样的KafkaTemplate、MyBatis的DAO实现都属于具体实现。
  • 开放封闭原则(Open Closed Principle):开放封闭原则指开放扩展,但是封闭修改。在这个案例里的金额计算属于可能会被修改的代码,这个时候该逻辑应该需要被包装成为不可修改的计算类,新功能通过计算类的拓展实现。

需求背景

我们先看一个简单的案例需求如下:

用户可以通过银行网页转账给另一个账号,支持跨币种转账。

同时因为监管和对账需求,需要记录本次转账活动。

拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:

1、从MySql数据库中找到转出和转入的账户,选择用 MyBatis 的 mapper 实现 DAO;2、从 Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是 http 开放接口);

3、计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限;

4、实现转入和转出操作,扣除手续费,保存数据库;

5、发送 Kafka 审计消息,以便审计和对账用;

而一个简单的代码实现如下:

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
public class TransferController {

private TransferService transferService;

public Result<Boolean> transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
return transferService.transfer(userId, targetAccountNumber, amount, "CNY");
}
}

public class TransferServiceImpl implements TransferService {

private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;

@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);

// 2. 业务参数校验
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}

// 3. 获取外部数据,并且包含一定的业务逻辑
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

// 4. 业务参数校验
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}

if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}

// 5. 计算新值,并且更新字段
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);

// 6. 更新到数据库
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);

// 7. 发送审计消息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);

return Result.success(true);
}

}

流程图

在重构之前,我们先画一张流程图,描述当前代码在做的每个步骤:
image

这是一个传统的三层分层结构:UI层、业务层、和基础设施层。上层对于下层有直接的依赖关系,导致耦合度过高。在业务层中对于下层的基础设施有强依赖,耦合度高。我们需要对这张图上的每个节点做抽象和整理,来降低对外部依赖的耦合度。

抽象数据存储层

第一步常见的操作是将Data Access层做抽象,降低系统对数据库的直接依赖。具体的方法如下:

新建Account实体对象:一个实体(Entity)是拥有ID的域对象,除了拥有数据之外,同时拥有行为。Entity和数据库储存格式无关,在设计中要以该领域的通用严谨语言(Ubiquitous Language)为依据。
新建对象储存接口类AccountRepository:Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。

具体的简单代码实现如下:

Account实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
public class Account {
private AccountId id;
private AccountNumber accountNumber;
private UserId userId;
private Money available;
private Money dailyLimit;

public void withdraw(Money money) {
// 转出
}

public void deposit(Money money) {
// 转入
}
}

和AccountRepository及MyBatis实现类:

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
public interface AccountRepository {
Account find(AccountId id);
Account find(AccountNumber accountNumber);
Account find(UserId userId);
Account save(Account account);
}

public class AccountRepositoryImpl implements AccountRepository {

@Autowired
private AccountMapper accountDAO;

@Autowired
private AccountBuilder accountBuilder;

@Override
public Account find(AccountId id) {
AccountDO accountDO = accountDAO.selectById(id.getValue());
return accountBuilder.toAccount(accountDO);
}

@Override
public Account find(AccountNumber accountNumber) {
AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
return accountBuilder.toAccount(accountDO);
}

@Override
public Account find(UserId userId) {
AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
return accountBuilder.toAccount(accountDO);
}

@Override
public Account save(Account account) {
AccountDO accountDO = accountBuilder.fromAccount(account);
if (accountDO.getId() == null) {
accountDAO.insert(accountDO);
} else {
accountDAO.update(accountDO);
}
return accountBuilder.toAccount(accountDO);
}

}

Account实体类和AccountDO数据类的对比如下:

  • Data Object数据类:AccountDO是单纯的和数据库表的映射关系,每个字段对应数据库表的一个column,这种对象叫Data Object。DO只有数据,没有行为。AccountDO的作用是对数据库做快速映射,避免直接在代码里写SQL。无论你用的是MyBatis还是Hibernate这种ORM,从数据库来的都应该先直接映射到DO上,但是代码里应该完全避免直接操作 DO。
  • Entity实体类:Account 是基于领域逻辑的实体类,它的字段和数据库储存不需要有必然的联系。Entity包含数据,同时也应该包含行为。在 Account 里,字段也不仅仅是String等基础类型,而应该尽可能用上一讲的 Domain Primitive 代替,可以避免大量的校验代码。

DAO 和 Repository 类的对比如下:

  • DAO对应的是一个特定的数据库类型的操作,相当于SQL的封装。所有操作的对象都是DO类,所有接口都可以根据数据库实现的不同而改变。比如,insert 和 update 属于数据库专属的操作。
  • Repository对应的是Entity对象读取储存的抽象,在接口层面做统一,不关注底层实现。比如,通过 save 保存一个Entity对象,但至于具体是 insert 还是 update 并不关心。Repository的具体实现类通过调用DAO来实现各种操作,通过Builder/Factory对象实现AccountDO 到 Account之间的转化

Repository和Entity

  • 通过Account对象,避免了其他业务逻辑代码和数据库的直接耦合,避免了当数据库字段变化时,大量业务逻辑也跟着变的问题。
  • 通过Repository,改变业务代码的思维方式,让业务逻辑不再面向数据库编程,而是面向领域模型编程。
  • Account属于一个完整的内存中对象,可以比较容易的做完整的测试覆盖,包含其行为。
  • Repository作为一个接口类,可以比较容易的实现Mock或Stub,可以很容易测试。
  • AccountRepositoryImpl实现类,由于其职责被单一出来,只需要关注Account到AccountDO的映射关系和Repository方法到DAO方法之间的映射关系,相对于来说更容易测试。

image

抽象第三方服务

类似对于数据库的抽象,所有第三方服务也需要通过抽象解决第三方服务不可控,入参出参强耦合的问题。在这个例子里我们抽象出 ExchangeRateService 的服务,和一个ExchangeRate的Domain Primitive类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface ExchangeRateService {
ExchangeRate getExchangeRate(Currency source, Currency target);
}

public class ExchangeRateServiceImpl implements ExchangeRateService {

@Autowired
private YahooForexService yahooForexService;

@Override
public ExchangeRate getExchangeRate(Currency source, Currency target) {
if (source.equals(target)) {
return new ExchangeRate(BigDecimal.ONE, source, target);
}
BigDecimal forex = yahooForexService.getExchangeRate(source.getValue(), target.getValue());
return new ExchangeRate(forex, source, target);
}
}

防腐层(ACL)

这种常见的设计模式叫做Anti-Corruption Layer(防腐层或ACL)。很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。

image

ACL 不仅仅只是多了一层调用,在实际开发中ACL能够提供更多强大的功能:

  • 适配器:很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到ACL内部,降低对业务代码的侵入。在这个案例里,我们通过封装了ExchangeRate和Currency对象,转化了对方的入参和出参,让入参出参更符合我们的标准。
  • 缓存:对于频繁调用且数据变更不频繁的外部依赖,通过在ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入ACL,能够降低业务代码的复杂度。
  • 兜底:如果外部依赖的稳定性较差,一个能够有效提升我们系统稳定性的策略是通过ACL起到兜底的作用,比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在ACL中,更加容易被测试和修改。
  • 易于测试:类似于之前的Repository,ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。
  • 功能开关:有些时候我们希望能在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,我们可以在ACL配置功能开关来实现,而不会对真实业务代码造成影响。同时,使用功能开关也能让我们容易的实现Monkey测试,而不需要真正物理性的关闭外部依赖。

image

抽象中间件

类似于2.2的第三方服务的抽象,对各种中间件的抽象的目的是让业务代码不再依赖中间件的实现逻辑。因为中间件通常需要有通用型,中间件的接口通常是String或Byte[] 类型的,导致序列化/反序列化逻辑通常和业务逻辑混杂在一起,造成胶水代码。通过中间件的ACL抽象,减少重复胶水代码。

在这个案例里,我们通过封装一个抽象的AuditMessageProducer和AuditMessage DP对象,实现对底层kafka实现的隔离:

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
@Value
@AllArgsConstructor
public class AuditMessage {

private UserId userId;
private AccountNumber source;
private AccountNumber target;
private Money money;
private Date date;

public String serialize() {
return userId + "," + source + "," + target + "," + money + "," + date;
}

public static AuditMessage deserialize(String value) {
// todo
return null;
}
}

public interface AuditMessageProducer {
SendResult send(AuditMessage message);
}

public class AuditMessageProducerImpl implements AuditMessageProducer {

private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";

@Autowired
private KafkaTemplate<String, String> kafkaTemplate;

@Override
public SendResult send(AuditMessage message) {
String messageBody = message.serialize();
kafkaTemplate.send(TOPIC_AUDIT_LOG, messageBody);
return SendResult.success();
}
}

image

封装业务逻辑

用Domain Primitive封装跟实体无关的无状态计算逻辑

在这个案例里使用ExchangeRate来封装汇率计算逻辑:

1
2
3
4
5
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

变为:

1
2
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);

用Entity封装单对象的有状态的行为,包括业务校验

用Account实体类封装所有Account的行为,包括业务校验如下:

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
@Data
public class Account {

private AccountId id;
private AccountNumber accountNumber;
private UserId userId;
private Money available;
private Money dailyLimit;

public Currency getCurrency() {
return this.available.getCurrency();
}

// 转入
public void deposit(Money money) {
if (!this.getCurrency().equals(money.getCurrency())) {
throw new InvalidCurrencyException();
}
this.available = this.available.add(money);
}

// 转出
public void withdraw(Money money) {
if (this.available.compareTo(money) < 0) {
throw new InsufficientFundsException();
}
if (this.dailyLimit.compareTo(money) < 0) {
throw new DailyLimitExceededException();
}
this.available = this.available.subtract(money);
}
}

原有的业务代码则可以简化为:

1
2
sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);

用Domain Service封装多对象逻辑

在这个案例里,我们发现这两个账号的转出和转入实际上是一体的,也就是说这种行为应该被封装到一个对象中去。特别是考虑到未来这个逻辑可能会产生变化:比如增加一个扣手续费的逻辑。这个时候在原有的TransferService中做并不合适,在任何一个Entity或者Domain Primitive里也不合适,需要有一个新的类去包含跨域对象的行为。这种对象叫做Domain Service。

我们创建一个AccountTransferService的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface AccountTransferService {
void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate);
}

public class AccountTransferServiceImpl implements AccountTransferService {
private ExchangeRateService exchangeRateService;

@Override
public void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate) {
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);
}
}

而原始代码则简化为一行:

1
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

image

重构后结果分析

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
public class TransferServiceImplNew implements TransferService {

private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;

@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 参数校验
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));

// 读数据
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());

// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

// 保存数据
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);

// 发送审计消息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);

return Result.success(true);
}
}

可以看出来,经过重构后的代码有以下几个特征:

  • 业务逻辑清晰,数据存储和业务逻辑完全分隔。
  • Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,但是却包含了所有核心业务逻辑,可以单独完整测试。
  • 原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。

我们可以根据新的结构重新画一张图:

image

然后通过重新编排后该图变为:

image

我们可以发现,通过对外部依赖的抽象和内部逻辑的封装重构,应用整体的依赖关系变了:

  • 最底层不再是数据库,而是Entity、Domain Primitive和Domain Service。这些对象不依赖任何外部服务和框架,而是纯内存中的数据和操作。这些对象我们打包为Domain Layer(领域层)。领域层没有任何外部依赖关系。
  • 再其次的是负责组件编排的Application Service,但是这些服务仅仅依赖了一些抽象出来的ACL类和Repository类,而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL等我们统称为Application Layer(应用层)。应用层 依赖 领域层,但不依赖具体实现。
  • 最后是ACL,Repository等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为Infrastructure Layer(基础设施层)。Web框架里的对象如Controller之类的通常也属于基础设施层。

如果今天能够重新写这段代码,考虑到最终的依赖关系,我们可能先写Domain层的业务逻辑,然后再写Application层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做Domain-Driven Design(领域驱动设计,或DDD)。所以DDD不是一个特殊的架构设计,而是所有Transction Script代码经过合理重构后一定会抵达的终点。

DDD的六边形架构

在我们传统的代码里,我们一般都很注重每个外部依赖的实现细节和规范,但是今天我们需要敢于抛弃掉原有的理念,重新审视代码结构。在上面重构的代码里,如果抛弃掉所有Repository、ACL、Producer等的具体实现细节,我们会发现每一个对外部的抽象类其实就是输入或输出,类似于计算机系统中的I/O节点。这个观点在CQRS架构中也同样适用,将所有接口分为Command(输入)和Query(输出)两种。除了I/O之外其他的内部逻辑,就是应用业务的核心逻辑。基于这个基础,Alistair Cockburn在2005年提出了Hexagonal Architecture(六边形架构),又被称之为Ports and Adapters(端口和适配器架构)。

image

在这张图中:

  • I/O的具体实现在模型的最外层
  • 每个I/O的适配器在灰色地带
  • 每个Hex的边是一个端口
  • Hex的中央是应用的核心领域模型

在Hex中,架构的组织关系第一次变成了一个二维的内外关系,而不是传统一维的上下关系。同时在Hex架构中我们第一次发现UI层、DB层、和各种中间件层实际上是没有本质上区别的,都只是数据的输入和输出,而不是在传统架构中的最上层和最下层。

除了2005年的Hex架构,2008年 Jeffery Palermo的Onion Architecture(洋葱架构)和2017年 Robert Martin的Clean Architecture(干净架构),都是极为类似的思想。除了命名不一样、切入点不一样之外,其他的整体架构都是基于一个二维的内外关系。这也说明了基于DDD的架构最终的形态都是类似的。Herberto Graca有一个很全面的图包含了绝大部分现实中的端口类,值得借鉴。
image

代码组织结构

为了有效的组织代码结构,避免下层代码依赖到上层实现的情况,在Java中我们可以通过POM Module和POM依赖来处理相互的关系。通过Spring/SpringBoot的容器来解决运行时动态注入具体实现的依赖的问题。一个简单的依赖关系图如下:
image

image

Types 模块

Types模块是保存可以对外暴露的Domain Primitives的地方。Domain Primitives因为是无状态的逻辑,可以对外暴露,所以经常被包含在对外的API接口中,需要单独成为模块。Types模块不依赖任何类库,纯 POJO 。

image

Domain 模块

Domain 模块是核心业务逻辑的集中地,包含有状态的Entity、领域服务Domain Service、以及各种外部依赖的接口类(如Repository、ACL、中间件等。Domain模块仅依赖Types模块,也是纯 POJO 。

image

Application模块

Application模块主要包含Application Service和一些相关的类。Application模块依赖Domain模块。还是不依赖任何框架,纯POJO。
image

Infrastructure模块

Infrastructure模块包含了Persistence、Messaging、External等模块。比如:Persistence模块包含数据库DAO的实现,包含Data Object、ORM Mapper、Entity到DO的转化类等。Persistence模块要依赖具体的ORM类库,比如MyBatis。如果需要用Spring-Mybatis提供的注解方案,则需要依赖Spring。

image

Web模块

Web模块包含Controller等相关代码。如果用SpringMVC则需要依赖Spring。
image

Start模块

Start模块是SpringBoot的启动类。

测试

  • Types,Domain模块都属于无外部依赖的纯POJO,基本上都可以100%的被单元测试覆盖。
  • Application模块的代码依赖外部抽象类,需要通过测试框架去Mock所有外部依赖,但仍然可以100%被单元测试。
  • Infrastructure的每个模块的代码相对独立,接口数量比较少,相对比较容易写单测。但是由于依赖了外部I/O,速度上不可能很快,但好在模块的变动不会很频繁,属于一劳永逸。
  • Web模块有两种测试方法:通过Spring的MockMVC测试,或者通过HttpClient调用接口测试。但是在测试时最好把Controller依赖的服务类都Mock掉。一般来说当你把Controller的逻辑都后置到Application Service中时,Controller的逻辑变得极为简单,很容易100%覆盖。
  • Start模块:通常应用的集成测试写在start里。当其他模块的单元测试都能100%覆盖后,集成测试用来验证整体链路的真实性。

代码的演进/变化速度

在传统架构中,代码从上到下的变化速度基本上是一致的,改个需求需要从接口、到业务逻辑、到数据库全量变更,而第三方变更可能会导致整个代码的重写。但是在DDD中不同模块的代码的演进速度是不一样的:

  • Domain层属于核心业务逻辑,属于经常被修改的地方。比如:原来不需要扣手续费,现在需要了之类的。通过Entity能够解决基于单个对象的逻辑变更,通过Domain Service解决多个对象间的业务逻辑变更。
  • Application层属于Use Case(业务用例)。业务用例一般都是描述比较大方向的需求,接口相对稳定,特别是对外的接口一般不会频繁变更。添加业务用例可以通过新增Application Service或者新增接口实现功能的扩展。
  • Infrastructure层属于最低频变更的。一般这个层的模块只有在外部依赖变更了之后才会跟着升级,而外部依赖的变更频率一般远低于业务逻辑的变更频率。
    所以在DDD架构中,能明显看出越外层的代码越稳定,越内层的代码演进越快,真正体现了领域“驱动”的核心思想。

总结

DDD不是一个什么特殊的架构,而是任何传统代码经过合理的重构之后最终一定会抵达的终点。DDD的架构能够有效的解决传统架构中的问题:

  • 高可维护性:当外部依赖变更时,内部代码只用变更跟外部对接的模块,其他业务逻辑不变。
  • 高可扩展性:做新功能时,绝大部分的代码都能复用,仅需要增加核心业务逻辑即可。
  • 高可测试性:每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖。
  • 代码结构清晰:通过POM module可以解决模块间的依赖关系, 所有外接模块都可以单独独立成Jar包被复用。当团队形成规范后,可以快速的定位到相关代码。

Repository模式

为什么要用 Repository

实体模型 vs. 贫血模型

Entity(实体)这个词在计算机领域的最初应用可能是来自于Peter Chen在1976年的“The Entity-Relationship Model - Toward a Unified View of Data”(ER模型),用来描述实体之间的关系,而ER模型后来逐渐的演变成为一个数据模型,在关系型数据库中代表了数据的储存方式。而2006年的JPA标准,通过@Entity等注解,以及Hibernate等ORM框架的实现,让很多Java开发对Entity的理解停留在了数据映射层面,忽略了Entity实体的本身行为,造成今天很多的模型仅包含了实体的数据和属性,而所有的业务逻辑都被分散在多个服务、Controller、Utils工具类中,这个就是Martin Fowler所说的的Anemic Domain Model(贫血领域模型)。如何知道你的模型是贫血的呢?可以看一下你代码中是否有以下的几个特征:

  • 有大量的XxxDO对象:这里DO虽然有时候代表了Domain Object,但实际上仅仅是数据库表结构的映射,里面没有包含(或包含了很少的)业务逻辑;
  • 服务和Controller里有大量的业务逻辑:比如校验逻辑、计算逻辑、格式转化逻辑、对象关系逻辑、数据存储逻辑等;
  • 大量的Utils工具类等。

而贫血模型的缺陷是非常明显的:

  • 无法保护模型对象的完整性和一致性:因为对象的所有属性都是公开的,只能由调用方来维护模型的一致性,而这个是没有保障的;之前曾经出现的案例就是调用方没有能维护模型数据的一致性,导致脏数据使用时出现bug,这一类的 bug还特别隐蔽,很难排查到。
  • 对象操作的可发现性极差:单纯从对象的属性上很难看出来都有哪些业务逻辑,什么时候可以被调用,以及可以赋值的边界是什么;比如说,Long类型的值是否可以是0或者负数?
  • 代码逻辑重复:比如校验逻辑、计算逻辑,都很容易出现在多个服务、多个代码块里,提升维护成本和bug出现的概率;一类常见的bug就是当贫血模型变更后,校验逻辑由于出现在多个地方,没有能跟着变,导致校验失败或失效。
  • 代码的健壮性差:比如一个数据模型的变化可能导致从上到下的所有代码的变更。
  • 强依赖底层实现:业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。

虽然贫血模型有很大的缺陷,但是在我们日常的代码中,我见过的99%的代码都是基于贫血模型,为什么呢?我总结了以下几点:

  • 数据库思维:从有了数据库的那一天起,开发人员的思考方式就逐渐从“写业务逻辑“转变为了”写数据库逻辑”,也就是我们经常说的在写CRUD代码。
  • 贫血模型“简单”:贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情
  • 脚本思维:很多常见的代码都属于“脚本”或“胶水代码”,也就是流程式代码。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。

但是可能最核心的原因在于,实际上我们在日常开发中,混淆了两个概念:

  • 数据模型(Data Model):指业务数据该如何持久化,以及数据之间的关系,也就是传统的ER模型;
  • 业务模型/领域模型(Domain Model):指业务逻辑中,相关联的数据该如何联动。

所以,解决这个问题的根本方案,就是要在代码里严格区分Data Model和Domain Model,具体的规范会在后文详细描述。在真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository。

Repository的价值

在传统的数据库驱动开发中,我们会对数据库操作做一个封装,一般叫做Data Access Object(DAO)。DAO的核心价值是封装了拼接SQL、维护数据库连接、事务等琐碎的底层逻辑,让业务开发可以专注于写代码。但是在本质上,DAO的操作还是数据库操作,DAO的某个方法还是在直接操作数据库和数据模型,只是少写了部分代码。在Uncle Bob的《代码整洁之道》一书里,作者用了一个非常形象的描述:

  • 硬件(Hardware):指创造了之后不可(或者很难)变更的东西。数据库对于开发来说,就属于”硬件“,数据库选型后基本上后面不会再变,比如:用了MySQL就很难再改为MongoDB,改造成本过高。
  • 软件(Software):指创造了之后可以随时修改的东西。对于开发来说,业务代码应该追求做”软件“,因为业务流程、规则在不停的变化,我们的代码也应该能随时变化。
  • 固件(Firmware):即那些强烈依赖了硬件的软件。我们常见的是路由器里的固件或安卓的固件等等。固件的特点是对硬件做了抽象,但仅能适配某款硬件,不能通用。所以今天不存在所谓的通用安卓固件,而是每个手机都需要有自己的固件。

从上面的描述我们能看出来,数据库在本质上属于”硬件“,DAO 在本质上属于”固件“,而我们自己的代码希望是属于”软件“。但是,固件有个非常不好的特性,那就是会传播,也就是说当一个软件强依赖了固件时,由于固件的限制,会导致软件也变得难以变更,最终让软件变得跟固件一样难以变更。

模型对象代码规范

这边要解决这个问题,首先要指定编码规约

对象类型

在讲Repository规范之前,我们需要先讲清楚3种模型的区别,Entity、Data Object (DO)和Data Transfer Object (DTO):

  • Data Object (DO、数据对象):实际上是我们在日常工作中最常见的数据模型。但是在DDD的规范里,DO应该仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。为了简单明了,DO的字段类型和名称应该和数据库物理表格的字段类型和名称一一对应,这样我们不需要去跑到数据库上去查一个字段的类型和名称。(当然,实际上也没必要一摸一样,只要你在Mapper那一层做到字段映射)
  • Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity和DO很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity的生命周期应该仅存在于内存中,不需要可序列化和可持久化。
  • DTO(传输对象):主要作为Application层的入参和出参,比如CQRS里的Command、Query、Event,以及Request、Response等都属于DTO的范畴。DTO的价值在于适配不同的业务场景的入参和出参,避免让业务对象变成一个万能大对象。

模型所在模块和转化器

于现在从一个对象变为3+个对象,对象间需要通过转化器(Converter/Mapper)来互相转化。而这三种对象在代码中所在的位置也不一样,简单总结如下:
image

这边DO结尾的文件是跟数据库一一对应的,DTO是跟前端或者其他系统协商的,只有DTO需要序列化,Entity是不固定的,实际的属性设置是根据业务来的。这边我推荐用一个mapper层,所有文件都以Mapper结尾,在Mapper层做转换,这边具体的转换用一个工具很好用,叫MapStruct。

从使用复杂度角度来看,区分了DO、Entity、DTO带来了代码量的膨胀(从1个变成了3+2+N个)。但是在实际复杂业务场景下,通过功能来区分模型带来的价值是功能性的单一和可测试、可预期,最终反而是逻辑复杂性的降低。

我自己的项目因为是简单的业务模型,所以用了通用的贫血模型,规范的项目目录结构如下所示(这边缺少了entity和防腐层的设计):

image

DDD领域层的一些设计规范

上面我主要针对同一个例子对比了OOP、ECS和DDD的3种实现,比较如下:

  • 基于继承关系的OOP代码:OOP的代码最好写,也最容易理解,所有的规则代码都写在对象里,但是当领域规则变得越来越复杂时,其结构会限制它的发展。新的规则有可能会导致代码的整体重构。
  • 基于组件化的ECS代码:ECS代码有最高的灵活性、可复用性、及性能,但极具弱化了实体类的内聚,所有的业务逻辑都写在了服务里,会导致业务的一致性无法保障,对商业系统会有较大的影响。
  • 基于领域对象 + 领域服务的DDD架构:DDD的规则其实最复杂,同时要考虑到实体类的内聚和保证不变性(Invariants),也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高。

实体类(Entity)

大多数DDD架构的核心都是实体类,实体类包含了一个领域里的状态、以及对状态的直接操作。Entity最重要的设计原则是保证实体的不变性(Invariants),也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。所以几个设计原则如下:

创建即一致
在贫血模型里,通常见到的代码是一个模型通过手动new出来之后,由调用方一个参数一个参数的赋值,这就很容易产生遗漏,导致实体状态不一致。所以DDD里实体创建的方法有两种:

constructor参数要包含所有必要属性,或者在constructor里有合理的默认值。

使用Factory模式来降低调用方复杂度

尽量避免public setter

一个最容易导致不一致性的原因是实体暴露了public的setter方法,特别是set单一参数会导致状态不一致的情况。比如,一个订单可能包含订单状态(下单、已支付、已发货、已收货)、支付单、物流单等子实体,如果一个调用方能随意去set订单状态,就有可能导致订单状态和子实体匹配不上,导致业务流程走不通的情况。所以在实体里,需要通过行为方法来修改内部状态。

通过聚合根保证主子实体的一致性

在稍微复杂一点的领域里,通常主实体会包含子实体,这时候主实体就需要起到聚合根的作用,即:

  • 子实体不能单独存在,只能通过聚合根的方法获取到。任何外部的对象都不能直接保留子实体的引用
  • 子实体没有独立的Repository,不可以单独保存和取出,必须要通过聚合根的Repository实例化
  • 子实体可以单独修改自身状态,但是多个子实体之间的状态一致性需要聚合根来保障

常见的电商域中聚合的案例如主子订单模型、商品/SKU模型、跨子订单优惠、跨店优惠模型等。很多聚合根和Repository的设计规范在我前面一篇关于Repository的文章中已经详细解释过,可以拿来参考。

不可以强依赖其他聚合根实体或领域服务

一个实体的原则是高内聚、低耦合,即一个实体类不能直接在内部直接依赖一个外部的实体或服务。这个原则和绝大多数ORM框架都有比较严重的冲突,所以是一个在开发过程中需要特别注意的。这个原则的必要原因包括:对外部对象的依赖性会直接导致实体无法被单测;以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。

所以,正确的对外部依赖的方法有两种:

  • 只保存外部实体的ID:这里我再次强烈建议使用强类型的ID对象,而不是Long型ID。强类型的ID对象不单单能自我包含验证代码,保证ID值的正确性,同时还能确保各种入参不会因为参数顺序变化而出bug。具体可以参考Domain Primitive。
  • 针对于“无副作用”的外部依赖,通过方法入参的方式传入。比如上文中的equip(Weapon,EquipmentService)方法。

如果方法对外部依赖有副作用,不能通过方法入参的方式,只能通过Domain Service解决,见下文。

任何实体的行为只能直接影响到本实体(和其子实体)

这个原则更多是一个确保代码可读性、可理解的原则,即任何实体的行为不能有“直接”的”副作用“,即直接修改其他的实体类。这么做的好处是代码读下来不会产生意外。

另一个遵守的原因是可以降低未知的变更的风险。在一个系统里一个实体对象的所有变更操作应该都是预期内的,如果一个实体能随意被外部直接修改的话,会增加代码bug的风险。

领域服务(Domain Service)

在上文讲到,领域服务其实也分很多种,在这里根据上文总结出来三种常见的:

单对象策略型

这种领域对象主要面向的是单个实体对象的变更,但涉及到多个领域对象或外部依赖的一些规则。在上文中,EquipmentService即为此类:

  • 变更的对象是Player的参数
  • 读取的是Player和Weapon的数据,可能还包括从外部读取一些数据

在这种类型下,实体应该通过方法入参的方式传入这种领域服务,然后通过Double Dispatch来反转调用领域服务的方法。

跨对象事务型

当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。

通用组件型

这种类型的领域服务更像ECS里的System,提供了组件化的行为,但本身又不直接绑死在一种实体类上。

策略对象(Domain Policy)

Policy或者Strategy设计模式是一个通用的设计模式,但是在DDD架构中会经常出现,其核心就是封装领域规则。

一个Policy是一个无状态的单例对象,通常需要至少2个方法:canApply 和 一个业务方法。其中,canApply方法用来判断一个Policy是否适用于当前的上下文,如果适用则调用方会去触发业务方法。通常,为了降低一个Policy的可测试性和复杂度,Policy不应该直接操作对象,而是通过返回计算后的值,在Domain Service里对对象进行操作。

除了本文里静态注入多个Policy以及手动排优先级之外,在日常开发中经常能见到通过Java的SPI机制或类SPI机制注册Policy,以及通过不同的Priority方案对Policy进行排序,在这里就不作太多的展开了。

领域事件介绍

领域事件是一个在领域里发生了某些事后,希望领域里其他对象能够感知到的通知机制。在上面的案例里,代码之所以会越来越复杂,其根本的原因是反应代码(比如升级)直接和上面的事件触发条件(比如收到经验)直接耦合,而且这种耦合性是隐性的。领域事件的好处就是将这种隐性的副作用“显性化”,通过一个显性的事件,将事件触发和事件处理解耦,最终起到代码更清晰、扩展性更好的目的。

所以,领域事件是在DDD里,比较推荐使用的跨实体“副作用”传播机制。

领域事件实现

和消息队列中间件不同的是,领域事件通常是立即执行的、在同一个进程内、可能是同步或异步。我们可以通过一个EventBus来实现进程内的通知机制,简单实现如下:

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
// 实现者:瑜进 2019/11/28
public class EventBus {

// 注册器
@Getter
private final EventRegistry invokerRegistry = new EventRegistry(this);

// 事件分发器
private final EventDispatcher dispatcher = new EventDispatcher(ExecutorFactory.getDirectExecutor());

// 异步事件分发器
private final EventDispatcher asyncDispatcher = new EventDispatcher(ExecutorFactory.getThreadPoolExecutor());

// 事件分发
public boolean dispatch(Event event) {
return dispatch(event, dispatcher);
}

// 异步事件分发
public boolean dispatchAsync(Event event) {
return dispatch(event, asyncDispatcher);
}

// 内部事件分发
private boolean dispatch(Event event, EventDispatcher dispatcher) {
checkEvent(event);
// 1.获取事件数组
Set<Invoker> invokers = invokerRegistry.getInvokers(event);
// 2.一个事件可以被监听N次,不关心调用结果
dispatcher.dispatch(event, invokers);
return true;
}

// 事件总线注册
public void register(Object listener) {
if (listener == null) {
throw new IllegalArgumentException("listener can not be null!");
}
invokerRegistry.register(listener);
}

private void checkEvent(Event event) {
if (event == null) {
throw new IllegalArgumentException("event");
}
if (!(event instanceof Event)) {
throw new IllegalArgumentException("Event type must by " + Event.class);
}
}
}

调用方式:

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
public class LevelUpEvent implements Event {
private Player player;
}

public class LevelUpHandler {
public void handle(Player player);
}

public class Player {
public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
EventBus.dispatch(event);
this.exp = 0;
}
}
}
@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);
assertThat(player.getLevel()).equals(2);
}

目前领域事件的缺陷和展望

从上面代码可以看出来,领域事件的很好的实施依赖EventBus、Dispatcher、Invoker这些属于框架级别的支持。同时另一个问题是因为Entity不能直接依赖外部对象,所以EventBus目前只能是一个全局的Singleton,而大家都应该知道全局Singleton对象很难被单测。这就容易导致Entity对象无法被很容易的被完整单测覆盖全。

另一种解法是侵入Entity,对每个Entity增加一个List:

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
public class Player {
List<Event> events;

public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
events.add(event); // 把event加进去
this.exp = 0;
}
}
}

@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);

for(Event event: player.getEvents()) { // 在这里显性的dispatch事件
EventBus.dispatch(event);
}

assertThat(player.getLevel()).equals(2);
}

但是能看出来这种解法不但会侵入实体本身,同时也需要比较啰嗦的显性在调用方dispatch事件,也不是一个好的解决方案。

也许未来会有一个框架能让我们既不依赖全局Singleton,也不需要显性去处理事件,但目前的方案基本都有或多或少的缺陷,大家在使用中可以注意。

总结

在真实的业务逻辑里,我们的领域模型或多或少的都有一定的“特殊性”,如果100%的要符合DDD规范可能会比较累,所以最主要的是梳理一个对象行为的影响面,然后作出设计决策,即:

  • 是仅影响单一对象还是多个对象,
  • 规则未来的拓展性、灵活性,
  • 性能要求,
  • 副作用的处理,等等

当然,很多时候一个好的设计是多种因素的取舍,需要大家有一定的积累,真正理解每个架构背后的逻辑和优缺点。一个好的架构师不是有一个正确答案,而是能从多个方案中选出一个最平衡的方案。

参考文章:

  • 阿里技术专家详解 DDD 系列 第一讲- Domain Primitive
  • 阿里技术专家详解DDD系列 第二讲 - 应用架构
  • 阿里技术专家详解DDD系列 第三讲 - Repository模式
  • 阿里技术专家详解DDD系列 第四讲 - 领域层设计规范

SkyWalking实践

发表于 2020-12-11 | 分类于 JAVA |

简介

SkyWalking是一个开源的可观察性平台,用于收集,分析,聚合和可视化来自服务和云本机基础结构的数据。SkyWalking提供了一种简便的方法来维护您的分布式系统的清晰视图,即使在整个云中也是如此。它是一种现代APM,专门为基于云的基于容器的分布式系统而设计。

为什么要使用SkyWalking

SkyWalking提供了用于在许多不同情况下观察和监视分布式系统的解决方案。首先,与传统方法一样,SkyWalking为服务提供自动仪器代理,例如Java,C#,Node.js,Go,PHP和Nginx LUA。(并呼吁提供Python和C ++ SDK贡献)。在多语言,持续部署的环境中,云本机基础架构变得越来越强大,但也越来越复杂。SkyWalking的服务网格接收器使SkyWalking能够从Istio / Envoy和Linkerd等服务网格框架接收遥测数据,从而使用户能够了解整个分布式系统。

SkyWalking为服务,服务实例,端点提供可观察性功能。服务,实例和端点这些术语在当今到处都有使用,因此值得在SkyWalking的上下文中定义它们的特定含义:

  • 服务。表示一组/一组工作负载,这些工作负载为传入请求提供相同的行为。您可以在使用乐器代理或SDK时定义服务名称。SkyWalking也可以使用您在Istio等平台中定义的名称。
  • 服务实例。服务组中的每个单独工作负载都称为实例。像pods在Kubernetes中一样,它不必是单个OS进程,但是,如果您使用仪器代理,则实例实际上是一个真正的OS进程。
  • 端点。服务中用于传入请求的路径,例如HTTP URI路径或gRPC服务类+方法签名。
    通过SkyWalking,用户可以了解服务和端点之间的拓扑关系,查看每个服务/服务实例/端点的指标并设置警报规则。

此外,可以整合

  • 其他使用SkyWalking本机代理程序和SDK以及Zipkin,Jaeger和OpenCensus进行的分布式跟踪。
  • 其他度量系统,例如Prometheus,Sleuth(Micrometer)。

架构设计

SkyWalking分为四个部分:

  • 探针:收集数据并重新格式化以符合SkyWalking要求(不同的探针支持不同的来源)。
  • 平台后端:支持数据聚合,分析并驱动从探针到UI的流程。该分析包括SkyWalking本机跟踪和度量标准,包括Istio和Envoy遥测的第三方,Zipkin跟踪格式等。您甚至可以通过使用针对本机度量标准的Observability Analysis Language和针对扩展度量标准的Meter System来定制聚合和分析。
  • 存储:存储设备通过开放/可插入的界面存储SkyWalking数据。您可以选择现有的实现,例如ElasticSearch,H2或由Sharding-Sphere管理的MySQL集群,也可以实现自己的实现。欢迎新存储实现者使用补丁!
  • UI:是一个高度可定制的基于Web的界面,允许SkyWalking最终用户可视化和管理SkyWalking数据。

image

使用

本文章基于官方源码tag:v8.3.0

源码编译

修改.mvn/jvm.config

1
2
3
4
5
6
-Dhttp.proxyHost=proxy_ip
-Dhttp.proxyPort=proxy_port
-Dhttps.proxyHost=proxy_ip
-Dhttps.proxyPort=proxy_port
-Dhttp.proxyUser=username
-Dhttp.proxyPassword=password

下载git源码

1
2
3
git clone --branch v8.3.0 https://github.com/apache/skywalking.git
git submodule init
git submodule update

执行编译

1
./mvnw clean package -DskipTests

打包后的都会在/dist目录下

下载制品

推荐直接下载成品

服务启动

进入目录/bin,windows和linux的启动脚本都在里面,可以直接启动。

执行命令

1
./dist-material/bin/startup.sh

现在服务端就启起来了,可以打开后台地址查看(默认是8080端口): http://localhost:8080,界面如下:
image
image

image

image

image

客户端需要提前准备开墙信息,默认的启动端口是8080,数据采集的端口是11800

默认的存储是用内存数据库H2来存储的,建议改掉,不然数据会跟着服务重启丢失,而且服务占用内存会很大。修改的话在目录/config下有3个文件可以改:

  • application.yml
  • log4j.xml
  • alarm-settings.yml

其中application.yml配置如下

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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
cluster:
selector: ${SW_CLUSTER:standalone}
standalone:
# Please check your ZooKeeper is 3.5+, However, it is also compatible with ZooKeeper 3.4.x. Replace the ZooKeeper 3.5+
# library the oap-libs folder with your ZooKeeper 3.4.x library.
zookeeper:
nameSpace: ${SW_NAMESPACE:""}
hostPort: ${SW_CLUSTER_ZK_HOST_PORT:localhost:2181}
# Retry Policy
baseSleepTimeMs: ${SW_CLUSTER_ZK_SLEEP_TIME:1000} # initial amount of time to wait between retries
maxRetries: ${SW_CLUSTER_ZK_MAX_RETRIES:3} # max number of times to retry
# Enable ACL
enableACL: ${SW_ZK_ENABLE_ACL:false} # disable ACL in default
schema: ${SW_ZK_SCHEMA:digest} # only support digest schema
expression: ${SW_ZK_EXPRESSION:skywalking:skywalking}
kubernetes:
namespace: ${SW_CLUSTER_K8S_NAMESPACE:default}
labelSelector: ${SW_CLUSTER_K8S_LABEL:app=collector,release=skywalking}
uidEnvName: ${SW_CLUSTER_K8S_UID:SKYWALKING_COLLECTOR_UID}
consul:
serviceName: ${SW_SERVICE_NAME:"SkyWalking_OAP_Cluster"}
# Consul cluster nodes, example: 10.0.0.1:8500,10.0.0.2:8500,10.0.0.3:8500
hostPort: ${SW_CLUSTER_CONSUL_HOST_PORT:localhost:8500}
aclToken: ${SW_CLUSTER_CONSUL_ACLTOKEN:""}
etcd:
serviceName: ${SW_SERVICE_NAME:"SkyWalking_OAP_Cluster"}
# etcd cluster nodes, example: 10.0.0.1:2379,10.0.0.2:2379,10.0.0.3:2379
hostPort: ${SW_CLUSTER_ETCD_HOST_PORT:localhost:2379}
nacos:
serviceName: ${SW_SERVICE_NAME:"SkyWalking_OAP_Cluster"}
hostPort: ${SW_CLUSTER_NACOS_HOST_PORT:localhost:8848}
# Nacos Configuration namespace
namespace: ${SW_CLUSTER_NACOS_NAMESPACE:"public"}
# Nacos auth username
username: ${SW_CLUSTER_NACOS_USERNAME:""}
password: ${SW_CLUSTER_NACOS_PASSWORD:""}
# Nacos auth accessKey
accessKey: ${SW_CLUSTER_NACOS_ACCESSKEY:""}
secretKey: ${SW_CLUSTER_NACOS_SECRETKEY:""}
core:
selector: ${SW_CORE:default}
default:
# Mixed: Receive agent data, Level 1 aggregate, Level 2 aggregate
# Receiver: Receive agent data, Level 1 aggregate
# Aggregator: Level 2 aggregate
role: ${SW_CORE_ROLE:Mixed} # Mixed/Receiver/Aggregator
restHost: ${SW_CORE_REST_HOST:0.0.0.0}
restPort: ${SW_CORE_REST_PORT:12800}
restContextPath: ${SW_CORE_REST_CONTEXT_PATH:/}
restMinThreads: ${SW_CORE_REST_JETTY_MIN_THREADS:1}
restMaxThreads: ${SW_CORE_REST_JETTY_MAX_THREADS:200}
restIdleTimeOut: ${SW_CORE_REST_JETTY_IDLE_TIMEOUT:30000}
restAcceptorPriorityDelta: ${SW_CORE_REST_JETTY_DELTA:0}
restAcceptQueueSize: ${SW_CORE_REST_JETTY_QUEUE_SIZE:0}
gRPCHost: ${SW_CORE_GRPC_HOST:0.0.0.0}
gRPCPort: ${SW_CORE_GRPC_PORT:11800}
maxConcurrentCallsPerConnection: ${SW_CORE_GRPC_MAX_CONCURRENT_CALL:0}
maxMessageSize: ${SW_CORE_GRPC_MAX_MESSAGE_SIZE:0}
gRPCThreadPoolQueueSize: ${SW_CORE_GRPC_POOL_QUEUE_SIZE:-1}
gRPCThreadPoolSize: ${SW_CORE_GRPC_THREAD_POOL_SIZE:-1}
gRPCSslEnabled: ${SW_CORE_GRPC_SSL_ENABLED:false}
gRPCSslKeyPath: ${SW_CORE_GRPC_SSL_KEY_PATH:""}
gRPCSslCertChainPath: ${SW_CORE_GRPC_SSL_CERT_CHAIN_PATH:""}
gRPCSslTrustedCAPath: ${SW_CORE_GRPC_SSL_TRUSTED_CA_PATH:""}
downsampling:
- Hour
- Day
# Set a timeout on metrics data. After the timeout has expired, the metrics data will automatically be deleted.
enableDataKeeperExecutor: ${SW_CORE_ENABLE_DATA_KEEPER_EXECUTOR:true} # Turn it off then automatically metrics data delete will be close.
dataKeeperExecutePeriod: ${SW_CORE_DATA_KEEPER_EXECUTE_PERIOD:5} # How often the data keeper executor runs periodically, unit is minute
recordDataTTL: ${SW_CORE_RECORD_DATA_TTL:3} # Unit is day
metricsDataTTL: ${SW_CORE_METRICS_DATA_TTL:7} # Unit is day
# Cache metrics data for 1 minute to reduce database queries, and if the OAP cluster changes within that minute,
# the metrics may not be accurate within that minute.
enableDatabaseSession: ${SW_CORE_ENABLE_DATABASE_SESSION:true}
topNReportPeriod: ${SW_CORE_TOPN_REPORT_PERIOD:10} # top_n record worker report cycle, unit is minute
# Extra model column are the column defined by in the codes, These columns of model are not required logically in aggregation or further query,
# and it will cause more load for memory, network of OAP and storage.
# But, being activated, user could see the name in the storage entities, which make users easier to use 3rd party tool, such as Kibana->ES, to query the data by themselves.
activeExtraModelColumns: ${SW_CORE_ACTIVE_EXTRA_MODEL_COLUMNS:false}
# The max length of service + instance names should be less than 200
serviceNameMaxLength: ${SW_SERVICE_NAME_MAX_LENGTH:70}
instanceNameMaxLength: ${SW_INSTANCE_NAME_MAX_LENGTH:70}
# The max length of service + endpoint names should be less than 240
endpointNameMaxLength: ${SW_ENDPOINT_NAME_MAX_LENGTH:150}
# Define the set of span tag keys, which should be searchable through the GraphQL.
searchableTracesTags: ${SW_SEARCHABLE_TAG_KEYS:http.method,status_code,db.type,db.instance,mq.queue,mq.topic,mq.broker}
storage:
selector: ${SW_STORAGE:h2}
elasticsearch:
nameSpace: ${SW_NAMESPACE:""}
clusterNodes: ${SW_STORAGE_ES_CLUSTER_NODES:localhost:9200}
protocol: ${SW_STORAGE_ES_HTTP_PROTOCOL:"http"}
user: ${SW_ES_USER:""}
password: ${SW_ES_PASSWORD:""}
trustStorePath: ${SW_STORAGE_ES_SSL_JKS_PATH:""}
trustStorePass: ${SW_STORAGE_ES_SSL_JKS_PASS:""}
secretsManagementFile: ${SW_ES_SECRETS_MANAGEMENT_FILE:""} # Secrets management file in the properties format includes the username, password, which are managed by 3rd party tool.
dayStep: ${SW_STORAGE_DAY_STEP:1} # Represent the number of days in the one minute/hour/day index.
indexShardsNumber: ${SW_STORAGE_ES_INDEX_SHARDS_NUMBER:1} # Shard number of new indexes
indexReplicasNumber: ${SW_STORAGE_ES_INDEX_REPLICAS_NUMBER:1} # Replicas number of new indexes
# Super data set has been defined in the codes, such as trace segments.The following 3 config would be improve es performance when storage super size data in es.
superDatasetDayStep: ${SW_SUPERDATASET_STORAGE_DAY_STEP:-1} # Represent the number of days in the super size dataset record index, the default value is the same as dayStep when the value is less than 0
superDatasetIndexShardsFactor: ${SW_STORAGE_ES_SUPER_DATASET_INDEX_SHARDS_FACTOR:5} # This factor provides more shards for the super data set, shards number = indexShardsNumber * superDatasetIndexShardsFactor. Also, this factor effects Zipkin and Jaeger traces.
superDatasetIndexReplicasNumber: ${SW_STORAGE_ES_SUPER_DATASET_INDEX_REPLICAS_NUMBER:0} # Represent the replicas number in the super size dataset record index, the default value is 0.
bulkActions: ${SW_STORAGE_ES_BULK_ACTIONS:1000} # Execute the async bulk record data every ${SW_STORAGE_ES_BULK_ACTIONS} requests
syncBulkActions: ${SW_STORAGE_ES_SYNC_BULK_ACTIONS:50000} # Execute the sync bulk metrics data every ${SW_STORAGE_ES_SYNC_BULK_ACTIONS} requests
flushInterval: ${SW_STORAGE_ES_FLUSH_INTERVAL:10} # flush the bulk every 10 seconds whatever the number of requests
concurrentRequests: ${SW_STORAGE_ES_CONCURRENT_REQUESTS:2} # the number of concurrent requests
resultWindowMaxSize: ${SW_STORAGE_ES_QUERY_MAX_WINDOW_SIZE:10000}
metadataQueryMaxSize: ${SW_STORAGE_ES_QUERY_MAX_SIZE:5000}
segmentQueryMaxSize: ${SW_STORAGE_ES_QUERY_SEGMENT_SIZE:200}
profileTaskQueryMaxSize: ${SW_STORAGE_ES_QUERY_PROFILE_TASK_SIZE:200}
advanced: ${SW_STORAGE_ES_ADVANCED:""}
elasticsearch7:
nameSpace: ${SW_NAMESPACE:""}
clusterNodes: ${SW_STORAGE_ES_CLUSTER_NODES:localhost:9200}
protocol: ${SW_STORAGE_ES_HTTP_PROTOCOL:"http"}
trustStorePath: ${SW_STORAGE_ES_SSL_JKS_PATH:""}
trustStorePass: ${SW_STORAGE_ES_SSL_JKS_PASS:""}
dayStep: ${SW_STORAGE_DAY_STEP:1} # Represent the number of days in the one minute/hour/day index.
indexShardsNumber: ${SW_STORAGE_ES_INDEX_SHARDS_NUMBER:1} # Shard number of new indexes
indexReplicasNumber: ${SW_STORAGE_ES_INDEX_REPLICAS_NUMBER:1} # Replicas number of new indexes
# Super data set has been defined in the codes, such as trace segments.The following 3 config would be improve es performance when storage super size data in es.
superDatasetDayStep: ${SW_SUPERDATASET_STORAGE_DAY_STEP:-1} # Represent the number of days in the super size dataset record index, the default value is the same as dayStep when the value is less than 0
superDatasetIndexShardsFactor: ${SW_STORAGE_ES_SUPER_DATASET_INDEX_SHARDS_FACTOR:5} # This factor provides more shards for the super data set, shards number = indexShardsNumber * superDatasetIndexShardsFactor. Also, this factor effects Zipkin and Jaeger traces.
superDatasetIndexReplicasNumber: ${SW_STORAGE_ES_SUPER_DATASET_INDEX_REPLICAS_NUMBER:0} # Represent the replicas number in the super size dataset record index, the default value is 0.
user: ${SW_ES_USER:""}
password: ${SW_ES_PASSWORD:""}
secretsManagementFile: ${SW_ES_SECRETS_MANAGEMENT_FILE:""} # Secrets management file in the properties format includes the username, password, which are managed by 3rd party tool.
bulkActions: ${SW_STORAGE_ES_BULK_ACTIONS:1000} # Execute the async bulk record data every ${SW_STORAGE_ES_BULK_ACTIONS} requests
syncBulkActions: ${SW_STORAGE_ES_SYNC_BULK_ACTIONS:50000} # Execute the sync bulk metrics data every ${SW_STORAGE_ES_SYNC_BULK_ACTIONS} requests
flushInterval: ${SW_STORAGE_ES_FLUSH_INTERVAL:10} # flush the bulk every 10 seconds whatever the number of requests
concurrentRequests: ${SW_STORAGE_ES_CONCURRENT_REQUESTS:2} # the number of concurrent requests
resultWindowMaxSize: ${SW_STORAGE_ES_QUERY_MAX_WINDOW_SIZE:10000}
metadataQueryMaxSize: ${SW_STORAGE_ES_QUERY_MAX_SIZE:5000}
segmentQueryMaxSize: ${SW_STORAGE_ES_QUERY_SEGMENT_SIZE:200}
profileTaskQueryMaxSize: ${SW_STORAGE_ES_QUERY_PROFILE_TASK_SIZE:200}
advanced: ${SW_STORAGE_ES_ADVANCED:""}
h2:
driver: ${SW_STORAGE_H2_DRIVER:org.h2.jdbcx.JdbcDataSource}
url: ${SW_STORAGE_H2_URL:jdbc:h2:mem:skywalking-oap-db}
user: ${SW_STORAGE_H2_USER:sa}
metadataQueryMaxSize: ${SW_STORAGE_H2_QUERY_MAX_SIZE:5000}
maxSizeOfArrayColumn: ${SW_STORAGE_MAX_SIZE_OF_ARRAY_COLUMN:20}
numOfSearchableValuesPerTag: ${SW_STORAGE_NUM_OF_SEARCHABLE_VALUES_PER_TAG:2}
mysql:
properties:
jdbcUrl: ${SW_JDBC_URL:"jdbc:mysql://localhost:3306/swtest"}
dataSource.user: ${SW_DATA_SOURCE_USER:root}
dataSource.password: ${SW_DATA_SOURCE_PASSWORD:root@1234}
dataSource.cachePrepStmts: ${SW_DATA_SOURCE_CACHE_PREP_STMTS:true}
dataSource.prepStmtCacheSize: ${SW_DATA_SOURCE_PREP_STMT_CACHE_SQL_SIZE:250}
dataSource.prepStmtCacheSqlLimit: ${SW_DATA_SOURCE_PREP_STMT_CACHE_SQL_LIMIT:2048}
dataSource.useServerPrepStmts: ${SW_DATA_SOURCE_USE_SERVER_PREP_STMTS:true}
metadataQueryMaxSize: ${SW_STORAGE_MYSQL_QUERY_MAX_SIZE:5000}
maxSizeOfArrayColumn: ${SW_STORAGE_MAX_SIZE_OF_ARRAY_COLUMN:20}
numOfSearchableValuesPerTag: ${SW_STORAGE_NUM_OF_SEARCHABLE_VALUES_PER_TAG:2}
tidb:
properties:
jdbcUrl: ${SW_JDBC_URL:"jdbc:mysql://localhost:4000/tidbswtest"}
dataSource.user: ${SW_DATA_SOURCE_USER:root}
dataSource.password: ${SW_DATA_SOURCE_PASSWORD:""}
dataSource.cachePrepStmts: ${SW_DATA_SOURCE_CACHE_PREP_STMTS:true}
dataSource.prepStmtCacheSize: ${SW_DATA_SOURCE_PREP_STMT_CACHE_SQL_SIZE:250}
dataSource.prepStmtCacheSqlLimit: ${SW_DATA_SOURCE_PREP_STMT_CACHE_SQL_LIMIT:2048}
dataSource.useServerPrepStmts: ${SW_DATA_SOURCE_USE_SERVER_PREP_STMTS:true}
dataSource.useAffectedRows: ${SW_DATA_SOURCE_USE_AFFECTED_ROWS:true}
metadataQueryMaxSize: ${SW_STORAGE_MYSQL_QUERY_MAX_SIZE:5000}
maxSizeOfArrayColumn: ${SW_STORAGE_MAX_SIZE_OF_ARRAY_COLUMN:20}
numOfSearchableValuesPerTag: ${SW_STORAGE_NUM_OF_SEARCHABLE_VALUES_PER_TAG:2}
influxdb:
# InfluxDB configuration
url: ${SW_STORAGE_INFLUXDB_URL:http://localhost:8086}
user: ${SW_STORAGE_INFLUXDB_USER:root}
password: ${SW_STORAGE_INFLUXDB_PASSWORD:}
database: ${SW_STORAGE_INFLUXDB_DATABASE:skywalking}
actions: ${SW_STORAGE_INFLUXDB_ACTIONS:1000} # the number of actions to collect
duration: ${SW_STORAGE_INFLUXDB_DURATION:1000} # the time to wait at most (milliseconds)
batchEnabled: ${SW_STORAGE_INFLUXDB_BATCH_ENABLED:true}
fetchTaskLogMaxSize: ${SW_STORAGE_INFLUXDB_FETCH_TASK_LOG_MAX_SIZE:5000} # the max number of fetch task log in a request

agent-analyzer:
selector: ${SW_AGENT_ANALYZER:default}
default:
sampleRate: ${SW_TRACE_SAMPLE_RATE:10000} # The sample rate precision is 1/10000. 10000 means 100% sample in default.
slowDBAccessThreshold: ${SW_SLOW_DB_THRESHOLD:default:200,mongodb:100} # The slow database access thresholds. Unit ms.
forceSampleErrorSegment: ${SW_FORCE_SAMPLE_ERROR_SEGMENT:true} # When sampling mechanism active, this config can open(true) force save some error segment. true is default.
segmentStatusAnalysisStrategy: ${SW_SEGMENT_STATUS_ANALYSIS_STRATEGY:FROM_SPAN_STATUS} # Determine the final segment status from the status of spans. Available values are `FROM_SPAN_STATUS` , `FROM_ENTRY_SPAN` and `FROM_FIRST_SPAN`. `FROM_SPAN_STATUS` represents the segment status would be error if any span is in error status. `FROM_ENTRY_SPAN` means the segment status would be determined by the status of entry spans only. `FROM_FIRST_SPAN` means the segment status would be determined by the status of the first span only.
# Nginx and Envoy agents can't get the real remote address.
# Exit spans with the component in the list would not generate the client-side instance relation metrics.
noUpstreamRealAddressAgents: ${SW_NO_UPSTREAM_REAL_ADDRESS:6000,9000}
slowTraceSegmentThreshold: ${SW_SLOW_TRACE_SEGMENT_THRESHOLD:-1} # Setting this threshold about the latency would make the slow trace segments sampled if they cost more time, even the sampling mechanism activated. The default value is `-1`, which means would not sample slow traces. Unit, millisecond.
meterAnalyzerActiveFiles: ${SW_METER_ANALYZER_ACTIVE_FILES:} # Which files could be meter analyzed, files split by ","

receiver-sharing-server:
selector: ${SW_RECEIVER_SHARING_SERVER:default}
default:
# For Jetty server
restHost: ${SW_RECEIVER_SHARING_REST_HOST:0.0.0.0}
restPort: ${SW_RECEIVER_SHARING_REST_PORT:0}
contextPath: ${SW_RECEIVER_SHARING_REST_CONTEXT_PATH:/}
restMinThreads: ${SW_RECEIVER_SHARING_JETTY_MIN_THREADS:1}
restMaxThreads: ${SW_RECEIVER_SHARING_JETTY_MAX_THREADS:200}
restIdleTimeOut: ${SW_RECEIVER_SHARING_JETTY_IDLE_TIMEOUT:30000}
restAcceptorPriorityDelta: ${SW_RECEIVER_SHARING_JETTY_DELTA:0}
restAcceptQueueSize: ${SW_RECEIVER_SHARING_JETTY_QUEUE_SIZE:0}
# For gRPC server
gRPCHost: ${SW_RECEIVER_GRPC_HOST:0.0.0.0}
gRPCPort: ${SW_RECEIVER_GRPC_PORT:0}
maxConcurrentCallsPerConnection: ${SW_RECEIVER_GRPC_MAX_CONCURRENT_CALL:0}
maxMessageSize: ${SW_RECEIVER_GRPC_MAX_MESSAGE_SIZE:0}
gRPCThreadPoolQueueSize: ${SW_RECEIVER_GRPC_POOL_QUEUE_SIZE:0}
gRPCThreadPoolSize: ${SW_RECEIVER_GRPC_THREAD_POOL_SIZE:0}
gRPCSslEnabled: ${SW_RECEIVER_GRPC_SSL_ENABLED:false}
gRPCSslKeyPath: ${SW_RECEIVER_GRPC_SSL_KEY_PATH:""}
gRPCSslCertChainPath: ${SW_RECEIVER_GRPC_SSL_CERT_CHAIN_PATH:""}
authentication: ${SW_AUTHENTICATION:""}
receiver-register:
selector: ${SW_RECEIVER_REGISTER:default}
default:

receiver-trace:
selector: ${SW_RECEIVER_TRACE:default}
default:

receiver-jvm:
selector: ${SW_RECEIVER_JVM:default}
default:

receiver-clr:
selector: ${SW_RECEIVER_CLR:default}
default:

receiver-profile:
selector: ${SW_RECEIVER_PROFILE:default}
default:

service-mesh:
selector: ${SW_SERVICE_MESH:default}
default:

envoy-metric:
selector: ${SW_ENVOY_METRIC:default}
default:
acceptMetricsService: ${SW_ENVOY_METRIC_SERVICE:true}
alsHTTPAnalysis: ${SW_ENVOY_METRIC_ALS_HTTP_ANALYSIS:""}
# `k8sServiceNameRule` allows you to customize the service name in ALS via Kubernetes metadata,
# the available variables are `pod`, `service`, f.e., you can use `${service.metadata.name}-${pod.metadata.labels.version}`
# to append the version number to the service name.
# Be careful, when using environment variables to pass this configuration, use single quotes(`''`) to avoid it being evaluated by the shell.
k8sServiceNameRule: ${K8S_SERVICE_NAME_RULE:"${service.metadata.name}"}

prometheus-fetcher:
selector: ${SW_PROMETHEUS_FETCHER:-}
default:
enabledRules: ${SW_PROMETHEUS_FETCHER_ENABLED_RULES:"self"}

kafka-fetcher:
selector: ${SW_KAFKA_FETCHER:-}
default:
bootstrapServers: ${SW_KAFKA_FETCHER_SERVERS:localhost:9092}
partitions: ${SW_KAFKA_FETCHER_PARTITIONS:3}
replicationFactor: ${SW_KAFKA_FETCHER_PARTITIONS_FACTOR:2}
enableMeterSystem: ${SW_KAFKA_FETCHER_ENABLE_METER_SYSTEM:false}
isSharding: ${SW_KAFKA_FETCHER_IS_SHARDING:false}
consumePartitions: ${SW_KAFKA_FETCHER_CONSUME_PARTITIONS:""}
kafkaHandlerThreadPoolSize: ${SW_KAFKA_HANDLER_THREAD_POOL_SIZE:-1}
kafkaHandlerThreadPoolQueueSize: ${SW_KAFKA_HANDLER_THREAD_POOL_QUEUE_SIZE:-1}

receiver-meter:
selector: ${SW_RECEIVER_METER:default}
default:

receiver-otel:
selector: ${SW_OTEL_RECEIVER:-}
default:
enabledHandlers: ${SW_OTEL_RECEIVER_ENABLED_HANDLERS:"oc"}
enabledOcRules: ${SW_OTEL_RECEIVER_ENABLED_OC_RULES:"istio-controlplane"}

receiver_zipkin:
selector: ${SW_RECEIVER_ZIPKIN:-}
default:
host: ${SW_RECEIVER_ZIPKIN_HOST:0.0.0.0}
port: ${SW_RECEIVER_ZIPKIN_PORT:9411}
contextPath: ${SW_RECEIVER_ZIPKIN_CONTEXT_PATH:/}
jettyMinThreads: ${SW_RECEIVER_ZIPKIN_JETTY_MIN_THREADS:1}
jettyMaxThreads: ${SW_RECEIVER_ZIPKIN_JETTY_MAX_THREADS:200}
jettyIdleTimeOut: ${SW_RECEIVER_ZIPKIN_JETTY_IDLE_TIMEOUT:30000}
jettyAcceptorPriorityDelta: ${SW_RECEIVER_ZIPKIN_JETTY_DELTA:0}
jettyAcceptQueueSize: ${SW_RECEIVER_ZIPKIN_QUEUE_SIZE:0}

receiver_jaeger:
selector: ${SW_RECEIVER_JAEGER:-}
default:
gRPCHost: ${SW_RECEIVER_JAEGER_HOST:0.0.0.0}
gRPCPort: ${SW_RECEIVER_JAEGER_PORT:14250}

receiver-browser:
selector: ${SW_RECEIVER_BROWSER:default}
default:
# The sample rate precision is 1/10000. 10000 means 100% sample in default.
sampleRate: ${SW_RECEIVER_BROWSER_SAMPLE_RATE:10000}

query:
selector: ${SW_QUERY:graphql}
graphql:
path: ${SW_QUERY_GRAPHQL_PATH:/graphql}

alarm:
selector: ${SW_ALARM:default}
default:

telemetry:
selector: ${SW_TELEMETRY:none}
none:
prometheus:
host: ${SW_TELEMETRY_PROMETHEUS_HOST:0.0.0.0}
port: ${SW_TELEMETRY_PROMETHEUS_PORT:1234}
sslEnabled: ${SW_TELEMETRY_PROMETHEUS_SSL_ENABLED:false}
sslKeyPath: ${SW_TELEMETRY_PROMETHEUS_SSL_KEY_PATH:""}
sslCertChainPath: ${SW_TELEMETRY_PROMETHEUS_SSL_CERT_CHAIN_PATH:""}

configuration:
selector: ${SW_CONFIGURATION:none}
none:
grpc:
host: ${SW_DCS_SERVER_HOST:""}
port: ${SW_DCS_SERVER_PORT:80}
clusterName: ${SW_DCS_CLUSTER_NAME:SkyWalking}
period: ${SW_DCS_PERIOD:20}
apollo:
apolloMeta: ${SW_CONFIG_APOLLO:http://localhost:8080}
apolloCluster: ${SW_CONFIG_APOLLO_CLUSTER:default}
apolloEnv: ${SW_CONFIG_APOLLO_ENV:""}
appId: ${SW_CONFIG_APOLLO_APP_ID:skywalking}
period: ${SW_CONFIG_APOLLO_PERIOD:5}
zookeeper:
period: ${SW_CONFIG_ZK_PERIOD:60} # Unit seconds, sync period. Default fetch every 60 seconds.
nameSpace: ${SW_CONFIG_ZK_NAMESPACE:/default}
hostPort: ${SW_CONFIG_ZK_HOST_PORT:localhost:2181}
# Retry Policy
baseSleepTimeMs: ${SW_CONFIG_ZK_BASE_SLEEP_TIME_MS:1000} # initial amount of time to wait between retries
maxRetries: ${SW_CONFIG_ZK_MAX_RETRIES:3} # max number of times to retry
etcd:
period: ${SW_CONFIG_ETCD_PERIOD:60} # Unit seconds, sync period. Default fetch every 60 seconds.
group: ${SW_CONFIG_ETCD_GROUP:skywalking}
serverAddr: ${SW_CONFIG_ETCD_SERVER_ADDR:localhost:2379}
clusterName: ${SW_CONFIG_ETCD_CLUSTER_NAME:default}
consul:
# Consul host and ports, separated by comma, e.g. 1.2.3.4:8500,2.3.4.5:8500
hostAndPorts: ${SW_CONFIG_CONSUL_HOST_AND_PORTS:1.2.3.4:8500}
# Sync period in seconds. Defaults to 60 seconds.
period: ${SW_CONFIG_CONSUL_PERIOD:60}
# Consul aclToken
aclToken: ${SW_CONFIG_CONSUL_ACL_TOKEN:""}
k8s-configmap:
period: ${SW_CONFIG_CONFIGMAP_PERIOD:60}
namespace: ${SW_CLUSTER_K8S_NAMESPACE:default}
labelSelector: ${SW_CLUSTER_K8S_LABEL:app=collector,release=skywalking}
nacos:
# Nacos Server Host
serverAddr: ${SW_CONFIG_NACOS_SERVER_ADDR:127.0.0.1}
# Nacos Server Port
port: ${SW_CONFIG_NACOS_SERVER_PORT:8848}
# Nacos Configuration Group
group: ${SW_CONFIG_NACOS_SERVER_GROUP:skywalking}
# Nacos Configuration namespace
namespace: ${SW_CONFIG_NACOS_SERVER_NAMESPACE:}
# Unit seconds, sync period. Default fetch every 60 seconds.
period: ${SW_CONFIG_NACOS_PERIOD:60}
# Nacos auth username
username: ${SW_CONFIG_NACOS_USERNAME:""}
password: ${SW_CONFIG_NACOS_PASSWORD:""}
# Nacos auth accessKey
accessKey: ${SW_CONFIG_NACOS_ACCESSKEY:""}
secretKey: ${SW_CONFIG_NACOS_SECRETKEY:""}

exporter:
selector: ${SW_EXPORTER:-}
grpc:
targetHost: ${SW_EXPORTER_GRPC_HOST:127.0.0.1}
targetPort: ${SW_EXPORTER_GRPC_PORT:9870}

health-checker:
selector: ${SW_HEALTH_CHECKER:-}
default:
checkIntervalSeconds: ${SW_HEALTH_CHECKER_INTERVAL_SECONDS:5}

插件安装

Java代理插件都是可插入的。可以optional-plugins在代理或第三方存储库下的文件夹中提供可选插件。要使用这些插件,需要将目标插件jar文件放入/plugins。

目前已有的插件有:

  • Plugin of tracing Spring annotation beans
  • tracing Oracle and Resin
  • Filter traces through specified endpoint name patterns
  • Plugin of Gson serialization lib in optional plugin folder.
  • Plugin of Zookeeper 3.4.x in optional plugin folder. The reason of being optional plugin is, many business irrelevant traces are generated, which cause extra payload to agents and backends. At the same time, those traces may be just heartbeat(s).
  • Customize enhance Trace methods based on description files, rather than write plugin or change source codes.
  • Plugin of Spring Cloud Gateway 2.1.x in optional plugin folder. Please only active this plugin when you install agent in Spring Gateway. spring-cloud-gateway-2.x-plugin and spring-webflux-5.x-plugin are both required.
  • Plugin of Spring Transaction in optional plugin folder. The reason of being optional plugin is, many local span are generated, which also spend more CPU, memory and network.
  • Plugin of Kotlin coroutine provides the tracing across coroutines automatically. As it will add local spans to all across routines scenarios, Please assess the performance impact.
  • Plugin of quartz-scheduler-2.x in the optional plugin folder. The reason for being an optional plugin is, many task scheduling systems are based on quartz-scheduler, this will cause duplicate tracing and link different sub-tasks as they share the same quartz level trigger, such as ElasticJob.
  • Plugin of spring-webflux-5.x in the optional plugin folder. Please only activate this plugin when you use webflux alone as a web container. If you are using SpringMVC 5 or Spring Gateway, you don’t need this plugin.

前端配置

前端配置在文件夹webapp的webapp.yml文件里面。

客户端接入

java应用

执行命令里面加上

1
java -javaagent:/path/to/skywalking-agent/skywalking-agent.jar -Dskywalking_config=/path/to/agent.config -jar yourApp.jar

这里另外设置了agent的配置,具体agent的配置可以参考官方文档,这里贴上我自己的配置:

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
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# The agent namespace
agent.namespace=${SW_AGENT_NAMESPACE:LOCAL}

# The service name in UI
agent.service_name=${SW_AGENT_NAME:Activiti}

# The number of sampled traces per 3 seconds
# Negative or zero means off, by default
agent.sample_n_per_3_secs=${SW_AGENT_SAMPLE:-1}

# Authentication active is based on backend setting, see application.yml for more details.
# agent.authentication = ${SW_AGENT_AUTHENTICATION:xxxx}

# The max amount of spans in a single segment.
# Through this config item, SkyWalking keep your application memory cost estimated.
# agent.span_limit_per_segment=${SW_AGENT_SPAN_LIMIT:150}

# Ignore the segments if their operation names end with these suffix.
# agent.ignore_suffix=${SW_AGENT_IGNORE_SUFFIX:.jpg,.jpeg,.js,.css,.png,.bmp,.gif,.ico,.mp3,.mp4,.html,.svg}

# If true, SkyWalking agent will save all instrumented classes files in `/debugging` folder.
# SkyWalking team may ask for these files in order to resolve compatible problem.
# agent.is_open_debugging_class = ${SW_AGENT_OPEN_DEBUG:true}

# If true, SkyWalking agent will cache all instrumented classes files to memory or disk files (decided by class cache mode),
# allow other javaagent to enhance those classes that enhanced by SkyWalking agent.
# agent.is_cache_enhanced_class = ${SW_AGENT_CACHE_CLASS:false}

# The instrumented classes cache mode: MEMORY or FILE
# MEMORY: cache class bytes to memory, if instrumented classes is too many or too large, it may take up more memory
# FILE: cache class bytes in `/class-cache` folder, automatically clean up cached class files when the application exits
# agent.class_cache_mode = ${SW_AGENT_CLASS_CACHE_MODE:MEMORY}

# The operationName max length
# Notice, in the current practice, we don't recommend the length over 190.
# agent.operation_name_threshold=${SW_AGENT_OPERATION_NAME_THRESHOLD:150}

# If true, skywalking agent will enable profile when user create a new profile task. Otherwise disable profile.
# profile.active=${SW_AGENT_PROFILE_ACTIVE:true}

# Parallel monitor segment count
# profile.max_parallel=${SW_AGENT_PROFILE_MAX_PARALLEL:5}

# Max monitor segment time(minutes), if current segment monitor time out of limit, then stop it.
# profile.duration=${SW_AGENT_PROFILE_DURATION:10}

# Max dump thread stack depth
# profile.dump_max_stack_depth=${SW_AGENT_PROFILE_DUMP_MAX_STACK_DEPTH:500}

# Snapshot transport to backend buffer size
# profile.snapshot_transport_buffer_size=${SW_AGENT_PROFILE_SNAPSHOT_TRANSPORT_BUFFER_SIZE:50}

# Backend service addresses.
collector.backend_service=${SW_AGENT_COLLECTOR_BACKEND_SERVICES:127.0.0.1:11800}

# Logging file_name
logging.file_name=${SW_LOGGING_FILE_NAME:skywalking-api.log}

# Logging level
logging.level=${SW_LOGGING_LEVEL:INFO}

# Logging dir
# logging.dir=${SW_LOGGING_DIR:""}

# Logging max_file_size, default: 300 * 1024 * 1024 = 314572800
# logging.max_file_size=${SW_LOGGING_MAX_FILE_SIZE:314572800}

# The max history log files. When rollover happened, if log files exceed this number,
# then the oldest file will be delete. Negative or zero means off, by default.
# logging.max_history_files=${SW_LOGGING_MAX_HISTORY_FILES:-1}

# Listed exceptions would not be treated as an error. Because in some codes, the exception is being used as a way of controlling business flow.
# Besides, the annotation named IgnoredException in the trace toolkit is another way to configure ignored exceptions.
# statuscheck.ignored_exceptions=${SW_STATUSCHECK_IGNORED_EXCEPTIONS:}

# The max recursive depth when checking the exception traced by the agent. Typically, we don't recommend setting this more than 10, which could cause a performance issue. Negative value and 0 would be ignored, which means all exceptions would make the span tagged in error status.
# statuscheck.max_recursive_depth=${SW_STATUSCHECK_MAX_RECURSIVE_DEPTH:1}

# Mount the specific folders of the plugins. Plugins in mounted folders would work.
plugin.mount=${SW_MOUNT_FOLDERS:plugins,activations}

# Exclude activated plugins
# plugin.exclude_plugins=${SW_EXCLUDE_PLUGINS:}

# mysql plugin configuration
plugin.mysql.trace_sql_parameters=${SW_MYSQL_TRACE_SQL_PARAMETERS:true}

# Kafka producer configuration
# plugin.kafka.bootstrap_servers=${SW_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}

# Match spring bean with regex expression for classname
# plugin.springannotation.classname_match_regex=${SW_SPRINGANNOTATION_CLASSNAME_MATCH_REGEX:}

NodeJs接入

1
2
3
4
5
6
7
8
9
import Agent from 'skywalking';

Agent.start({
serviceName: '',
serviceInstance: '',
collectorAddress: '',
authorization: '',
maxBufferSize: 1000,
});

参数包括:

Environment Variable | Description | Default
| — | — | — |
| SW_AGENT_NAME | The name of the service | your-nodejs-service |
| SW_AGENT_INSTANCE | The name of the service instance | Randomly generated |
| SW_AGENT_COLLECTOR_BACKEND_SERVICES | The backend OAP server address | 127.0.0.1:11800 |
| SW_AGENT_AUTHENTICATION | The authentication token to verify that the agent is trusted by the backend OAP, as for how to configure the backend, refer to the yaml. | not set |
| SW_AGENT_LOGGING_LEVEL | The logging level, could be one of CRITICAL, FATAL, ERROR, WARN(WARNING), INFO, DEBUG | INFO |
| SW_AGENT_MAX_BUFFER_SIZE | The maximum buffer size before sending the segment data to backend | '1000' |

前端接入

可以参考官方文档

高级功能

自己开发插件

可以参考文章,已经写得很好了。。。我不需要再表述了.

开发插件后,可以给官方贡献一下

浏览器端监控、使用标签查询、指标分析语言

参考官方文档

SkyWalking浏览器监视也提供以下数据: PV(page views,页面浏览量), UV(unique visitors,独立访客数),浏览量前 N 的页面(Top N Page Views)等。这些数据可以为产品队伍优化他们的产品提供线索。

image

image

服务性能指标监控

Skywalking还可以查看具体Service的性能指标,根据相关的性能指标可以分析系统的瓶颈所在并提出优化方案。

在服务调用拓扑图上点击相应的节点我们可以看到该服务的

  • SLA: 服务可用性(主要是通过请求成功与失败次数来计算)
  • CPM: 每分钟调用次数
  • Avg Response Time: 平均响应时间

从应用整体外部来看我们可以监测到应用在一定时间段内的

  • 服务可用性指标SLA
  • 每分钟平均响应数
  • 平均响应时间
  • 服务进程PID
  • 服务所在物理机的IP、HostName、Operation System

服务告警

通过webhook的方式让我们可以自定义我们告警信息的通知方式。诸如:邮件通知、微信通知、短信通知等。

先来看一下告警的规则配置。在alarm-settings.xml中可以配置告警规则,告警规则支持自定义。

一份告警配置由以下几部分组成:

  • service_resp_time_rule:告警规则名称 ***_rule (规则名称可以自定义但是必须以_rule结尾
  • indicator-name:指标数据名称: 定义参见http://t.cn/EGhfbmd
  • op: 操作符: > , < , = 【当然你可以自己扩展开发其他的操作符】
  • threshold:目标值:指标数据的目标数据 如sample中的1000就是服务响应时间,配合上操作符就是大于1000ms的服务响应
  • period: 告警检查周期:多久检查一次当前的指标数据是否符合告警规则
  • counts: 达到告警阈值的次数
  • silence-period:忽略相同告警信息的周期
  • message:告警信息
  • webhooks:服务告警通知服务地

Skywalking通过HttpClient的方式远程调用在配置项webhooks中定义的告警通知服务地址。

Mysql最佳实践

发表于 2020-12-08 | 分类于 数据库 |

建表规约

强制要求

  1. 表达是与否概念的字段,必须使用 is_xxx 的方式命名,数据类型是 unsigned tinyint (1 表示是, 0 表示否)。
    • 说明: 任何字段如果为非负数,必须是 unsigned。
    • 正例: 表达逻辑删除的字段名 is_deleted, 1 表示删除, 0 表示未删除。
  2. 表名、字段名必须使用小写字母或数字,禁止出现数字开头,禁止两个下划线中间只出现数字。
    • 数据库字段名的修改代价很大,因为无法进行预发布,所以字段名称需要慎重考虑。
    • 说明: MySQL 在 Windows 下不区分大小写,但在 Linux 下默认是区分大小写。因此,数据库名、表名、字段名,都不允许出现任何大写字母,避免节外生枝。
    • 正例: hap_admin, rdc_config, level3_name
    • 反例: HapAdmin, rdcConfig, level_3_name
  3. 表名不使用复数名词。
    • 说明: 表名应该仅仅表示表里面的实体内容,不应该表示实体数量,对应于 DO 类名也是单数形式,符合表达习惯。
  4. 禁用保留字,如 desc、 range、 match、 delayed 等, 请参考 MySQL 官方保留字。
  5. 主键索引名为 pk_字段名; 唯一索引名为 uk_字段名; 普通索引名则为 idx_字段名。
    • 说明: pk_ 即 primary key; uk_ 即 unique key; idx_ 即 index 的简称。
  6. 小数类型为 decimal,禁止使用 float 和 double。
    • 说明: float 和 double 在存储的时候,存在精度损失的问题,很可能在值的比较时,得到不正确的结果。如果存储的数据范围超过 decimal 的范围,建议将数据拆成整数和小数分开存储。
  7. 如果存储的字符串长度几乎相等,使用 char 定长字符串类型。
  8. varchar 是可变长字符串,不预先分配存储空间,长度不要超过5000,如果存储长度大于此值,定义字段类型为 text,独立出来一张表,用主键来对应,避免影响其它字段索引效率。
    • 说明: 该表的命名以 原表名_字段缩写 的格式命名。
  9. 表必备字段: id, create_date, last_update_date, create_by , last_update_by , object_version_number。

    • 说明: 其中 id 必为主键,类型为 unsigned bigint、单表时自增、步长为 1。
    • create_date, last_update_date 的类型均为 datetime 类型,前者现在时表示主动创建,后者过去分词表示被动更新。

      1
      2
      3
      4
      5
      column(name: "object_version_number", type: "BIGINT UNSIGNED", defaultValue: "1")
      column(name: "created_by", type: "BIGINT UNSIGNED", defaultValue: "0")
      column(name: "creation_date", type: "DATETIME", defaultValueComputed: "CURRENT_TIMESTAMP")
      column(name: "last_updated_by", type: "BIGINT UNSIGNED", defaultValue: "0")
      column(name: "last_update_date", type: "DATETIME", defaultValueComputed: "CURRENT_TIMESTAMP")
  10. 表的命名最好是加上“业务名称_表的作用”。

    • 正例: kanban_task / devops_project / website_config
  11. id类型没有特殊要求,必须使用bigint unsigned,禁止使用int,即使现在的数据量很小。id如果是数字类型的话,必须是8个字节。参见最后例子

    • 方便对接外部系统,还有可能产生很多废数据
    • 避免废弃数据对系统id的影响
    • 未来分库分表,自动生成id,一般也是8个字节
  12. 更新数据表记录时,必须同时更新记录对应的 last_update_date 字段值为当前时间,

    推荐规约

  1. 库名与应用名称尽量一致。
  2. 如果修改字段含义或对字段表示的状态追加时,需要及时更新字段注释。
  3. 字段允许适当冗余,以提高查询性能,但必须考虑数据一致。冗余字段应遵循:
    • 不是频繁修改的字段。
    • 不是 varchar 超长字段,更不能是 text 字段。
    • 正例: 商品类目名称使用频率高, 字段长度短,名称基本一成不变, 可在相关联的表中冗余存储类目名称,避免关联查询。
  4. 单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。
    • 说明: 如果预计三年后的数据量根本达不到这个级别,请不要在创建表时就分库分表。

规约参考

  1. 合适的字符存储长度,不但节约数据库表空间、节约索引存储,更重要的是提升检索速度。

    • 正例: 如下表,其中无符号值可以避免误存负数,且扩大了表示范围。

      |对象|年龄区间|类型|字节|表示范围|
      |—-|——–|—-|—-|——–|
      |人|150岁之内|unsigned tinyint|1|无符号值: 0 到 255|
      |龟|数百岁|unsigned smallint|2|无符号值: 0 到 65535|
      |恐龙化石|数千万年|unsigned int|4|无符号值: 0 到约 42.9 亿|
      |太阳|约 50 亿年|unsigned bigint|8|无符号值: 0 到约 10 的 19 次方|

索引规约

强制要求

  1. 业务上具有唯一特性的字段,即使是多个字段的组合,也必须建成唯一索引。
    • 说明:不要以为唯一索引影响了insert速度,这个速度损耗可以忽略,但提高查找速度是明显的;另外,即使在应用层做了非常完善的校验控制,只要没有唯一索引,根据墨菲定律,必然有脏数据产生。
  2. 超过三个表禁止join。需要join的字段,数据类型必须绝对一致;多表关联查询时,保证被关联的字段需要有索引。
    • 说明:即使双表join也要注意表索引、SQL性能。
  3. 在varchar字段上建立索引时,必须指定索引长度,没必要对全字段建立索引,根据实际文本区分度决定索引长度即可。
    • 说明:索引的长度与区分度是一对矛盾体,一般对字符串类型数据,长度为 20 的索引,区分度会高达90%以上,可以使用 count(distinct left(列名,索引长度))/count(*)的区分度来确定。
  4. 页面搜索严禁左模糊或者全模糊,如果需要请走搜索引擎来解决(项目前期数据量不大可以不遵循这个要求,后续改进,不过需要留痕)。
    • 说明:索引文件具有B-Tree的最左前缀匹配特性,如果左边的值未确定,那么无法使用此索引。

推荐规约

  1. 如果有order by的场景,请注意利用索引的有序性。order by 最后的字段是组合索引的一部分,并且放在索引组合顺序的最后,避免出现file_sort的情况,影响查询性能。
    • 正例: where a=? and b=? order by c; 索引: a_b_c
    • 反例: 索引中有范围查找,那么索引有序性无法利用,如: WHERE a>10 ORDER BY b; 索引a_b无法排序。
  2. 利用覆盖索引来进行查询操作, 避免回表。
    • 说明:如果一本书需要知道第 11 章是什么标题,会翻开第 11章对应的那一页吗?目录浏览一下就好,这个目录就是起到覆盖索引的作用。
    • 正例:能够建立索引的种类分为主键索引、唯一索引、普通索引三种,而覆盖索引只是一种查询的一种效果,用explain 的结果,extra 列会出现: using index。
  3. 利用延迟关联或者子查询优化超多分页场景。

    • 说明:MySQL 并不是跳过 offset 行,而是取 offset+N 行,然后返回放弃前offset行,返回N行,那当 offset 特别大的时候,效率就非常的低下,要么控制返回的总页数,要么对超过特定阈值的页数进行SQL改写。
    • 正例:先快速定位需要获取的 id 段,然后再关联:
      1
      SELECT a.* FROM 表 1 a, (select id from 表 1 where 条件 LIMIT 100000,20 ) b where a.id=b.id
  4. SQL 性能优化的目标:至少要达到 range 级别,要求是ref级别,如果可以是consts最好。

    • 说明:
      1. consts 单表中最多只有一个匹配行(主键或者唯一索引),在优化阶段即可读取到数据。
      2. ref 指的是使用普通的索引(normal index) 。
      3. range 对索引进行范围检索。
    • 反例: explain 表的结果, type=index,索引物理文件全扫描,速度非常慢,这个 index 级别比较 range 还低,与全表扫描是小巫见大巫。
  5. 建组合索引的时候,区分度最高的在最左边。
    • 正例: 如果 where a=? and b=? , a列的几乎接近于唯一值,那么只需要单建 idx_a 索引即可。
    • 说明: 存在非等号和等号混合判断条件时,在建索引时,请把等号条件的列前置。如: where a>? and b=? 那么即使 a 的区分度更高,也必须把 b 放在索引的最前列。
  6. 防止因字段类型不同造成的隐式转换,导致索引失效。

总结

  • 索引占磁盘空间,不要重复的索引,尽量短
  • 只给常用的查询条件加索引
  • 过滤性高的列建索引,取值范围固定的列不建索引
  • 唯一的记录添加唯一索引
  • 频繁更新的列不要建索引
  • 不要对索引列运算
  • 同样过滤效果下,保持索引长度最小
  • 合理利用组合索引,注意索引字段先后顺序
  • 多列组合索引,过滤性高的字段最前
  • order by 字段建立索引,避免 filesort
  • 组合索引,不同的排序顺序不能使用索引
  • <>!=无法使用索引

规约参考

  1. 创建索引时避免有如下极端误解:
    1. 宁滥勿缺。 认为一个查询就需要建一个索引。
    2. 宁缺勿滥。认为索引会消耗空间、严重拖慢更新和新增速度。
    3. 抵制惟一索引。认为业务的惟一性一律需要在应用层通过“先查后插”方式解决。

SQL 语句

强制要求

  1. 不要使用 count(列名)或 count(常量)来替代 count(\*), count(*)是 SQL92 定义的标准统计行数的语法,跟数据库无关,跟 NULL 和非 NULL 无关。
    • 说明: count(*)会统计值为 NULL 的行,而 count(列名)不会统计此列为 NULL 值的行。
  2. count(distinct col) 计算该列除 NULL 之外的不重复行数, 注意 count(distinct col1, col2) 如果其中一列全为 NULL,那么即使另一列有不同的值,也返回为 0。
  3. 当某一列的值全是 NULL 时, count(col)的返回结果为 0,但 sum(col)的返回结果为 NULL,因此使用 sum()时需注意 NPE 问题。

    • 正例: 可以使用如下方式来避免 sum 的 NPE 问题:
      1
      2
      SELECT IF(ISNULL(SUM(g)),0,SUM(g))
      FROM table;
  4. 使用 ISNULL()来判断是否为 NULL 值。

    • 说明: NULL 与任何值的直接比较都为 NULL。
      1. NULL<>NULL 的返回结果是 NULL, 而不是 false。
      2. NULL=NULL 的返回结果是 NULL, 而不是 true。
      3. NULL<>1 的返回结果是 NULL,而不是 true。
  5. 在代码中写分页查询逻辑时,若 count 为 0 应直接返回,避免执行后面的分页语句。
  6. 不得使用外键与级联,一切外键概念必须在应用层解决。
    • 说明:以学生和成绩的关系为例,学生表中的 student_id是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新, 即为级联更新。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险:外键影响数据库的插入速度。
  7. 禁止使用存储过程,存储过程难以调试和扩展,更没有移植性。
  8. 数据订正(特别是删除、 修改记录操作) 时,要先 select,避免出现误删除,确认无误才能执行更新语句。

推荐规约

  1. in 操作能避免则避免,若实在避免不了,需要仔细评估 in 后边的集合元素数量,控制在 1000 个之内。(多的话用 EXISTS 或 NOT EXISTS 代替)

规约参考

  1. 如果有全球化需要,所有的字符存储与表示,均以 utf-8 编码,注意字符统计函数的区别。

    • 说明:

      1
      2
      SELECT LENGTH("轻松工作");\\返回为 12
      SELECT CHARACTER_LENGTH("轻松工作");\\返回为 4
    • 如果需要存储表情,那么选择 utf8mb4 来进行存储,注意它与 utf-8 编码的区别。

  2. 不建议在开发代码中使用此语句 TRUNCATE TABLE
    • TRUNCATE TABLE 比 DELETE 速度快,且使用的系统和事务日志资源少,但 TRUNCATE 无事务且不触发 trigger,有可能造成事故,故不建议在开发代码中使用此语句。
    • 说明: TRUNCATE TABLE 在功能上与不带 WHERE 子句的 DELETE 语句相同。

总结

  • 能够快速缩小结果集的 WHERE 条件写在前面,如果有恒量条 件,也尽量放在前面 ,例如 where 1=1
  • 避免使用 GROUP BY、DISTINCT 等语句的使用,避免联表查 询和子查询
  • 能够使用索引的字段尽量进行有效的合理排列
  • 针对索引字段使用 >, >=, =, <, <=, IF NULL 和 BETWEEN 将会 使用索引,如果对某个索引字段进行 LIKE 查询,使用 LIKE ‘%abc%’ 不能使用索引,使用 LIKE ‘abc%’ 将能够使用索引
  • 如果在 SQL 里使用了 MySQL部分自带函数,索引将失效
  • 避免直接使用 select *,只取需要的字段,增加使用覆盖索引使用的可能
  • 对于大数据量的查询,尽量避免在 SQL 语句中使用 order by 字
    句
  • 连表查询的情况下,要确保关联条件的数据类型一致,避免嵌
    套子查询
  • 对于连续的数值,使用 between 代替 in
  • where 语句中尽量不要使用 CASE 条件
  • 当只要一行数据时使用 LIMIT 1

ORM 映射

强制要求

  1. 在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。
    • 说明:
      1. 增加查询分析器解析成本.
      2. 增减字段容易与 resultMap 配置不一致。
  2. POJO 类的布尔属性不能加 is,而数据库字段必须加 is_,要求在 resultMap 中进行
    字段与属性之间的映射。
  3. 不要用 resultClass 当返回参数,即使所有类属性名与数据库字段一一对应,也需要定义;反过来,每一个表也必然有一个与之对应。
    • 说明: 配置映射关系,使字段与 DO 类解耦,方便维护。
  4. sql.xml 配置参数使用: #{}, #param# 不要使用${} 此种方式容易出现 SQL 注入。
  5. iBATIS 自带的 queryForList(String statementName,int start,int size)不推荐使用。

    • 说明:其实现方式是在数据库取到 statementName对应的SQL语句的所有记录,再通过 subList
      取 start,size 的子集合。
    • 正例:
      1
      2
      3
      Map<String, Object> map = new HashMap<String, Object>();
      map.put("start", start);
      map.put("size", size);
  6. 不允许直接拿 HashMap 与 Hashtable 作为查询结果集的输出。

    • 说明: resultClass=”Hashtable”, 会置入字段名和属性值,但是值的类型不可控。
  7. 更新数据表记录时,必须同时更新记录对应的 gmt_modified 字段值为当前时间。

推荐规约

  1. 不要写一个大而全的数据更新接口。 传入为 POJO 类,不管是不是自己的目标更新字
    段,都进行 update table set c1=value1,c2=value2,c3=value3; 这是不对的。执行 SQL时, 不要更新无改动的字段,一是易出错; 二是效率低; 三是增加 binlog 存储。

规约参考

  1. @Transactional 事务不要滥用。事务会影响数据库的 QPS,另外使用事务的地方需要考虑各方面的回滚方案,包括缓存回滚、搜索引擎回滚、消息补偿、统计修正等。
  2. <isEqual>中的 compareValue 是与属性值对比的常量,一般是数字,表示相等时带上此条件; <isNotEmpty>表示不为空且不为 null 时执行;<isNotNull>表示不为 null 值时执行。

Git提交规范

发表于 2020-11-08 | 分类于 GIT |

设置用户

首次拉代码后,在目录下用命令行设置用户信息

1
2
3
git config core.ignorecase false
git config --global user.name "张三"
git config --global user.email san.zhang@gmail.com

Git Commit规范

规定格式如下:

1
2
3
<type>(<scope>): <subject> #issue_number

<description>

其中,type、scope、subject是必需的,description 可以省略。不管是哪一个部分,任何一行都不得超过72个字符(或100个字符)。这是为了避免自动换行影响美观。

type(必须)取值说明

type用于说明 commit 的类别:

  • feat: (feature)增加新功能
  • fix: 修补bug
  • docs: 文档(documentation), 只改动了文档相关的内容
  • style: 不影响代码含义的改动,例如去掉空格、改变缩进、增删分号
  • build: 构造工具的或者外部依赖的改动,例如webpack,npm
  • refactor: 代码重构时使用
  • revert:回滚到上一个版本,执行git revert打印的message
  • test: 添加测试或者修改现有测试
  • pref: 提高性能的改动
  • chore: 不修改src或者test的其余修改,例如构建过程或辅助工具的变动
  • merge:代码合并
  • sync:同步主线或分支的Bug
  • ci: 与CI(持续集成服务)有关的改动

scope(可选)取值说明

scope用于说明 commit影响的范围,比如数据层、控制层、视图层等等,视项目不同而不同。例如:
node-pc/common rrd-h5/activity,而we-sdk不需指定模块名。如果一次commit修改多个模块,建议拆分成多次commit,以便更好追踪和维护。后加入项目的新成员应遵循已有的 scope 约定(通过 git log可以查看某个文件的提交历史),不要自己编造。使用首字母小写的驼峰命名。除具体的模块、组件名之外,可以使用 base 表示基础结构、框架相关的改动,用 misc 表示杂项改动,用 all 表示大范围重构。

例如在Angular,可以是location,browser,compile,compile,rootScope, ngHref,ngClick,ngView等。如果你的修改影响了不止一个scope,你可以使用*代替。

subject(必须)

subject是 commit 目的的简短描述,50 个字符左右的简要说明。

中文

  • 结尾不加句号或其他标点符号。
  • 根据以上规范git commit message将是如下的格式:
    1
    2
    fix(DAO):用户查询缺少username属性 
    feat(Controller):用户查询接口开发

以上就是我们梳理的git commit规范,那么我们这样规范git commit到底有哪些好处呢?

  • 便于程序员对提交历史进行追溯,了解发生了什么情况。
  • 一旦约束了commit message,意味着我们将慎重的进行每一次提交,不能再一股脑的把各种各样的改动都放在一个git commit里面,这样一来整个代码改动的历史也将更加清晰。
  • 格式化的commit message才可以用于自动化输出Change log。

英文

首字母小写,通常是动宾结构,描述做了什么事情,动词用一般现在时,禁止出现 update code , fix bug 等无实际意义的描述,好的例子: select connector by sorting free memory (不需要形如 update about how to select connector … 的啰嗦写法), fix success tip can not show on IE8 (不需要形如 fix bug of … 的啰嗦写法)

  • 以动词开头,使用第一人称现在时,比如change,而不是changed或changes
  • 尽量使用简单句保证简洁性
  • 第一个字母小写
  • 结尾不加句号(.)
  • 通过翻译检测工具确认英文的正确性和可读性

body

body填写详细描述,主要描述改动之前的情况及修改动机,对于小的修改不作要求,但是重大需求、更新等必须添加body来作说明。

break changes

break changes指明是否产生了破坏性修改,涉及break changes的改动必须指明该项,类似版本升级、接口参数减少、接口删除、迁移等。

affect issues

affect issues指明是否影响了某个问题。例如我们使用jira时,我们在commit message中可以填写其影响的JIRA_ID,若要开启该功能需要先打通jira与gitlab。

使用Commitizen工具

安装commitizen

1
npm install -g commitizen

安装完成后,使用

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
(base) [@dinghuangMacPro:test (master)]$git add .
(base) [@dinghuangMacPro:test (master)]$git cz
#会出现下面的,按照提示写入
(base) [@dinghuangMacPro:test (master)]$ git cz
cz-cli@4.2.2, cz-conventional-changelog@3.3.0

? Select the type of change that you're committing: (Use arrow keys)
❯ feat: A new feature
fix: A bug fix
docs: Documentation only changes
style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
refactor: A code change that neither fixes a bug nor adds a feature
perf: A code change that improves performance
test: Adding missing tests or correcting existing tests
(Move up and down to reveal more choices)

#选择类型后
? Select the type of change that you're committing: feat: A new feature
? What is the scope of this change (e.g. component or file name): (press enter to skip) add aa.txt
? Write a short, imperative tense description of the change (max 82 chars):
(4) 新增文件
? Provide a longer description of the change: (press enter to skip)
长描述:这是我第一次的提交
? Are there any breaking changes? No
? Does this change affect any open issues? Yes
? Add issue references (e.g. "fix #123", "re #123".):
feat #123

#查看生成的git commit信息
(base) [@dinghuangMacPro:test (master)]$ git log
commit 00d63b2c54c502bb75725e5a461d6ce9499910bc (HEAD -> master)
Author: 丁煌 <dinghuang123@gmail.com>
Date: Wed Nov 25 13:39:13 2020 +0800

feat(add aa.txt): 新增文件

长描述:这是我第一次的提交

feat #123

自动生成Change log

1
2
npm install -g conventional-changelog-cli
conventional-changelog -p angular -i CHANGELOG.md -s

这个插件是根据当前分支提交的所有commit信息来生成对应的changelog,一般是在开发完成后,封代码后去进行生成。如果自动生成的有问题,可以根据自己的需求进行修改。

分支规范

分支基本原则

基本原则:master为保护分支,不直接在master上进行代码修改和提交。

开发日常需求或者项目时,从master分支上checkout一个feature分支进行开发或者bugfix分支进行bug修复,功能测试完毕并且项目发布上线后,将feature分支合并到主干master,并且打Tag发布,最后删除开发分支。

分支命名规范

例如:

  • 分支版本命名规则:分支类型 分支发布时间 分支功能。比如:feature_20200401_#issueNum
    • 分支类型包括:feature、bugfix、refactor三种类型,即新功能开发、bug修复和代码重构
    • 时间使用年月日进行命名,不足2位补0
    • 分支功能命名使用snake case命名法,即下划线命名。
  • Tag包括3位版本,前缀使用v。比如v1.2.31。Tag命名规范:
    • 新功能开发使用第2位版本号,bug修复使用第3位版本号
    • 核心基础库或者Node中间价可以在大版本发布请使用灰度版本号,在版本后面加上后缀,用中划线分隔。alpha或者belta后面加上次数,即第几次alpha(具体可以查看语义化版本):
      • v2.0.0-alpha-1
      • v2.0.0-belta-1

SpringBoot2.X Security Oauth2 JWT

发表于 2020-10-20 | 分类于 java |

简介

官网地址

官方文档

Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。

Spring Security当前支持与所有以下技术的身份验证集

  • HTTP BASIC authentication headers (an IETF RFC-based standard)
  • HTTP Digest authentication headers (an IETF RFC-based standard)
  • HTTP X.509 client certificate exchange (an IETF RFC-based standard)
  • LDAP (a very common approach to cross-platform authentication needs, especially in large environments)
  • Form-based authentication (for simple user interface needs)
  • OpenID authentication
  • Authentication based on pre-established request headers (such as Computer Associates Siteminder)
  • JA-SIG Central Authentication Service (otherwise known as CAS, which is a popular open source single sign-on system)
  • Transparent authentication context propagation for Remote Method Invocation (RMI) and HttpInvoker (a Spring remoting protocol)
  • Automatic “remember-me” authentication (so you can tick a box to avoid re-authentication for a predetermined period of time)
  • Anonymous authentication (allowing every unauthenticated call to automatically assume a particular security identity)
  • Run-as authentication (which is useful if one call should proceed with a different security identity)
  • Java Authentication and Authorization Service (JAAS)
  • JEE container autentication (so you can still use Container - Managed Authentication if desired)
  • Kerberos
  • Java Open Source Single Sign On (JOSSO) *
  • OpenNMS Network Management Platform *
  • AppFuse *
  • AndroMDA *
  • Mule ESB *
  • Direct Web Request (DWR) *
  • Grails *
  • Tapestry *
  • JTrac *
  • Jasypt *
  • Roller *
  • Elastic Path *
  • Atlassian Crowd *
  • Your own authentication systems (see below)

涉及以下系统组件:
客户端:它可以是任何平台上的任何Web服务使用者。简而言之,它可以是另一个WebService,UI应用程序或Mobile平台,它们希望通过应用程序以安全的方式读写数据。
授权服务器:验证用户凭据,并颁发令牌。它根据不同的授予类型发行令牌。下面列出了最常见的OAuth 2.0授权类型:

  • Authorization Code
  • Implicit
  • Password
  • Client Credentials
  • Device Code
  • Refresh Token

名词解释可以看SpringOauth2官方文档

架构设计

image

名词解释

– WebSecurityConfigurerAdapter 是我们安全实施的关键。它提供HttpSecurity配置以配置cors,csrf,会话管理以及受保护资源的规则。我们还可以扩展和定制包含以下元素的默认配置。

– UserDetailsService 接口有一种方法可以按用户名加载User并返回UserDetailsSpring Security可以用于认证和验证的对象。

– UserDetails 包含用于构建身份验证对象的必要信息(例如:用户名,密码,授权机构)。

– UsernamePasswordAuthenticationToken 从登录请求中获取{用户名,密码},AuthenticationManager将使用它来认证登录帐户。

– AuthenticationManager 有一个DaoAuthenticationProvider(与帮助UserDetailsService&PasswordEncoder)来验证UsernamePasswordAuthenticationToken对象。如果成功,则AuthenticationManager返回完全填充的身份验证对象(包括授予的权限)。

– OncePerRequestFilter 对我们API的每个请求执行一次。它提供了一种doFilterInternal()方法,我们将实现解析和验证JWT,加载用户详细信息(使用UserDetailsService),检查授权(使用UsernamePasswordAuthenticationToken)。

– AuthenticationEntryPoint 当客户端未经身份验证访问受保护的资源时,将捕获未经授权的错误并返回401。

认证流程

image

实践

引入maven包

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.springframework.security.oauth2</groupId>
<artifactId>spring-security-oauth2-parent</artifactId>
<version>1.0.0.BUILD-SNAPSHOT</version>
<packaging>pom</packaging>

<modules>
<module>auth-server</module>
<module>client-app</module>
<module>resource-server</module>
</modules>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.4.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.2.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.1.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.2.1.RELEASE</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

核心代码:

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
package org.springframework.security.oauth.config;


import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
public class CustomerAuthenticationEntryPointConfiguration implements AuthenticationEntryPoint {


@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
//处理异常消息通知
httpServletResponse.getWriter().write("暂无权限访问");
}
}

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
package org.springframework.security.oauth.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
public class CustomerAuthenticationProviderConfiguration implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//这里做自定义的用户校验,可以对用户进行继承扩展
User user = new User((String) authentication.getPrincipal(), (String) authentication.getCredentials(), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
//这里可以通过继承AbstractAuthenticationToken进行扩展
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), authentication.getCredentials());
usernamePasswordAuthenticationToken.setDetails(user);
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
return usernamePasswordAuthenticationToken;
}

@Override
public boolean supports(Class<?> aClass) {
return false;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package org.springframework.security.oauth.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
public class CustomerAuthenticationManager implements AuthenticationManager {

@Autowired
private CustomerAuthenticationProviderConfiguration customerAuthenticationProviderConfiguration;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
return customerAuthenticationProviderConfiguration.authenticate(authentication);
}
}
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
package org.springframework.security.oauth.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth.properties.Oauth2ClientProperties;
import org.springframework.security.oauth.properties.Oauth2Properties;
import org.springframework.security.oauth2.config.annotation.builders.InMemoryClientDetailsServiceBuilder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
@EnableAuthorizationServer
public class CustomerAuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

@Autowired
private TokenStore tokenStore;
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
private CustomerAuthenticationManager customerAuthenticationManager;
@Autowired
private Oauth2Properties oauth2Properties;
@Autowired
private CustomerWebResponseExceptionTranslator customerWebResponseExceptionTranslator;

@Override
public void configure(AuthorizationServerEndpointsConfigurer authorizationServerEndpointsConfigurer) {
authorizationServerEndpointsConfigurer.tokenEnhancer(jwtAccessTokenConverter)
.tokenStore(tokenStore)
.authenticationManager(customerAuthenticationManager)
.exceptionTranslator(customerWebResponseExceptionTranslator);
}

@Override
public void configure(AuthorizationServerSecurityConfigurer authorizationServerSecurityConfigurer) throws Exception {
authorizationServerSecurityConfigurer.tokenKeyAccess("permitAll()")
.allowFormAuthenticationForClients()
.checkTokenAccess("isAuthenticated");
}

@Override
public void configure(ClientDetailsServiceConfigurer clientDetailsServiceConfigurer) throws Exception {
InMemoryClientDetailsServiceBuilder inMemoryClientDetailsServiceBuilder = clientDetailsServiceConfigurer.inMemory();
for (Oauth2ClientProperties oauth2ClientProperties : oauth2Properties.getClients()) {
inMemoryClientDetailsServiceBuilder.withClient(oauth2ClientProperties.getClientId())
.secret(bCryptPasswordEncoder().encode(oauth2ClientProperties.getClientSecret()))
.accessTokenValiditySeconds(oauth2ClientProperties.getAccessTokenValiditySeconds())
.refreshTokenValiditySeconds(oauth2ClientProperties.getRefreshTokenValiditySeconds())
.authorizedGrantTypes("refresh_token", "password", "authorization_code")
.scopes(oauth2ClientProperties.getScopes());
}
}

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
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
package org.springframework.security.oauth.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth.filter.CustomerOauth2AuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
@EnableWebSecurity
public class CustomerSecurityConfiguration extends WebSecurityConfigurerAdapter {

@Autowired
private CustomerAuthenticationManager customerAuthenticationManager;
@Autowired
private CustomerAuthenticationEntryPointConfiguration customerAuthenticationEntryPointConfiguration;
@Autowired
private CustomerOauth2AuthenticationProcessingFilter customerOauth2AuthenticationProcessingFilter;

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception{
httpSecurity.authorizeRequests().anyRequest().authenticated().and().addFilterBefore(customerOauth2AuthenticationProcessingFilter, AnonymousAuthenticationFilter.class)
.csrf().disable().exceptionHandling().authenticationEntryPoint(customerAuthenticationEntryPointConfiguration);
}

@Override
public AuthenticationManager authenticationManager(){
return customerAuthenticationManager;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package org.springframework.security.oauth.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
public class CustomerWebResponseExceptionTranslator implements WebResponseExceptionTranslator<OAuth2Exception> {
@Override
public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
//自定义认证失败信息
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package org.springframework.security.oauth.config.token;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.AuthenticationKeyGenerator;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
public class CustomerAuthenticationKeyGeneratorConfiguration implements AuthenticationKeyGenerator {


@Override
public String extractKey(OAuth2Authentication authentication) {
//自定义token生成,可以用来实现ip控制登录用户只能有一个,顶掉登录
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package org.springframework.security.oauth.config.token;

import io.jsonwebtoken.JwtHandlerAdapter;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
public class CustomerJwtAccessTokenConverterConfiguration extends JwtAccessTokenConverter {

@Override
public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication){
//可以实现jwt的token放一些用户信息
return super.enhance(oAuth2AccessToken,oAuth2Authentication);
}
}
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
package org.springframework.security.oauth.config.token;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth.properties.Oauth2Properties;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Configuration
public class CustomerJwtStoreConfiguration {

@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Autowired
private Oauth2Properties oauth2Properties;
@Autowired
private CustomerAuthenticationKeyGeneratorConfiguration customerAuthenticationKeyGeneratorConfiguration;
@Autowired
private CustomerJwtAccessTokenConverterConfiguration customerJwtAccessTokenConverterConfiguration;

@Bean
public TokenStore redisTokenStore() {
RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
redisTokenStore.setAuthenticationKeyGenerator(customerAuthenticationKeyGeneratorConfiguration);
return redisTokenStore;
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
customerJwtAccessTokenConverterConfiguration.setSigningKey(oauth2Properties.getJwtSigningKey());
return customerJwtAccessTokenConverterConfiguration;
}
}
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
package org.springframework.security.oauth.filter;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth.config.CustomerAuthenticationProviderConfiguration;
import org.springframework.security.oauth.properties.Oauth2Properties;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;


/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@Component
public class CustomerOauth2AuthenticationProcessingFilter implements Filter, InitializingBean {

@Autowired
private Oauth2Properties oauth2Properties;
@Autowired
private CustomerAuthenticationProviderConfiguration customerAuthenticationProviderConfiguration;

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
//白名单过滤
AntPathMatcher antPathMatcher = new AntPathMatcher();
for (String path : oauth2Properties.getPermitUrl().split(",")) {
if (antPathMatcher.match(path, httpServletRequest.getPathInfo())) {
filterChain.doFilter(servletRequest, servletResponse);
}
}
//自定义验证
Authentication authentication = getFromRequest(httpServletRequest);
if (authentication == null) {
SecurityContextHolder.clearContext();
} else {
SecurityContextHolder.getContext().setAuthentication(customerAuthenticationProviderConfiguration.authenticate(authentication));
}
filterChain.doFilter(servletRequest, servletResponse);
}

private Authentication getFromRequest(HttpServletRequest httpServletRequest) {
//todo 这里可以自己去实现
return null;
}

@Override
public void afterPropertiesSet() throws Exception {

}
}
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
package org.springframework.security.oauth.properties;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
public class Oauth2ClientProperties {

private String clientId;
private String clientSecret;
private String scopes;
private Integer accessTokenValiditySeconds;
private Integer refreshTokenValiditySeconds;


public String getClientId() {
return clientId;
}

public void setClientId(String clientId) {
this.clientId = clientId;
}

public String getClientSecret() {
return clientSecret;
}

public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}

public String getScopes() {
return scopes;
}

public void setScopes(String scopes) {
this.scopes = scopes;
}

public Integer getAccessTokenValiditySeconds() {
return accessTokenValiditySeconds;
}

public void setAccessTokenValiditySeconds(Integer accessTokenValiditySeconds) {
this.accessTokenValiditySeconds = accessTokenValiditySeconds;
}

public Integer getRefreshTokenValiditySeconds() {
return refreshTokenValiditySeconds;
}

public void setRefreshTokenValiditySeconds(Integer refreshTokenValiditySeconds) {
this.refreshTokenValiditySeconds = refreshTokenValiditySeconds;
}
}
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
package org.springframework.security.oauth.properties;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* @author dinghuang123@gmail.com
* @since 2020/10/20
*/
@ConfigurationProperties(prefix = "security.oauth2")
public class Oauth2Properties {

private String jwtSigningKey;
private String permitUrl;

private Oauth2ClientProperties[] clients = {};

public String getJwtSigningKey() {
return jwtSigningKey;
}

public void setJwtSigningKey(String jwtSigningKey) {
this.jwtSigningKey = jwtSigningKey;
}

public String getPermitUrl() {
return permitUrl;
}

public void setPermitUrl(String permitUrl) {
this.permitUrl = permitUrl;
}

public Oauth2ClientProperties[] getClients() {
return clients;
}

public void setClients(Oauth2ClientProperties[] clients) {
this.clients = clients;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server:
port: 8090
spring:
redis:
host: 127.0.0.1
port: 6379
password:
security:
oauth2:
jwtSigningKey: admin
permitUrl: /oauth/**
clients[0]:
clientId: client
clientSecret: client
scopes: all
accessTokenValiditySeconds: 864000
refreshTokenValiditySeconds: 864000
logging:
level:
root: WARN
org.springframework.web: INFO
org.springframework.security: INFO
org.springframework.security.oauth2: INFO

验证

1
curl -H "Content-Type: application/json" -X POST "http://localhost:8090/oauth/token?client_id=client&client_secret=client&grant_type=pwssword&username=admin&password&admin"

代码地址

结合网关

架构设计

image

12…4
强壮的病猫

强壮的病猫

学习、生活、闲谈、足球

33 日志
14 分类
14 标签
GitHub E-Mail Google
© 2017 — 2023 强壮的病猫