如遇图片无法加载请使用代理访问

前言

在上个月的25号,我成功搭建了Nexus,在接下来的一个月中,我做了这些事情:

  1. 封装各个starter的jar包,上传至Nexus
  2. 拆分单体项目为多个微服务
  3. 引入ProtoBuf,多个微服务之间采用grpc通信
  4. rancher创建项目,构建测试环境、生产环境
  5. 测试环境回归测试,生产环境回归测试

至此,我成功将单体服务拆分,共耗时一个月的时间,代码逻辑没有变化,拆分工作比较费心,但万事开头难,后面的开发会更加简单和高效,下面,我将详细论述这些操作的难点和细节,偏简单口语点,勿对号入座直接照搬。目前暂未考虑开源,仅提供一些思路,取之精华,弃之糟粕


starter

leopold-spring-boot-starter

我的项目都是基于SpringBoot的,因此一定需要一个starter来封装有关SpringBoot的内容,而且微服务之间的通讯基本是grpc,所以也会涵盖grpc的内容

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
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
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<!-- 基于 SpringBoot 2.7.9 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.9</version>
<relativePath/>
</parent>

<groupId>com.leopold</groupId>
<artifactId>leopold-spring-boot-starter</artifactId>
<version>1.0.3-SNAPSHOT</version>

<properties>
<java.version>17</java.version>
</properties>

<dependencies>

<!-- grpc 相关,第三方仓库非Spring推荐,酌情使用,受SpringBoot版本影响 -->
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-server-spring-boot-starter</artifactId>
<version>2.14.0.RELEASE</version>
</dependency>

<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-client-spring-boot-starter</artifactId>
<version>2.14.0.RELEASE</version>
</dependency>

<!--
Spring Boot 属性迁移库可以帮助我们完成应用程序属性的版本升级和数据迁移等工作。
当我们在应用程序中升级了某些属性或删除了一些属性时,旧的配置文件可能会因为新的属性名或属性值不匹配而无法使用,
这时候就需要使用 Spring Boot 属性迁移库来自动进行属性迁移。这个库是在运行时使用的,只有在程序启动的时候才会被加载,
并且它不会影响应用程序的正常运行,因此被设置为 runtime 范围的依赖
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>

<!--
Spring Boot Starter Web 是一个可扩展的依赖项集合,包括 Spring MVC、Tomcat 和 Spring Websocket 等,
可以帮助我们快速搭建 Web 应用程序。通过引入这个依赖,我们可以使用 Spring Boot 预定义的基本配置来创建和运行 Web 应用程序,
无需繁琐地手动配置和管理环境。同时,该依赖还提供了一些常用的功能,如错误处理、日志记录等,使得我们可以更加专注于业务逻辑的实现
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--
spring-boot-configuration-processor通常被用于开发 Spring Boot 应用程序时,特别是当使用注解方式定义配置类时非常有用。
在这种情况下,我们可以使用 @ConfigurationProperties 注解来指定应用程序的配置属性,而这个依赖项则可以根据这些注解自动生成配置
元数据,以便在开发过程中更加方便地使用和管理应用程序的配置。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<!--
spring-boot-starter-test包含了一些常用的测试库和工具,例如 JUnit、Mockito 和 Hamcrest 等。它们可以帮助开发人员编写各种
类型的测试,如单元测试、集成测试等。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

<!--
spring-boot-starter-aop 为 Spring Boot 应用程序添加面向切面编程(AOP)支持
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!--
spring-boot-starter-thymeleaf 为 Spring Boot 应用程序添加 Thymeleaf 模板引擎支持。Thymeleaf 是一种流行的 Java 模板
引擎,它可以将 HTML 模板与数据模型结合起来生成最终的 HTML 页面。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<!--
Actuator 是 Spring Boot 的一个子项目,它提供了一组用于监控和管理应用程序的工具和接口。
通过使用这个依赖项,开发人员可以轻松地将 Actuator 集成到 Spring Boot 应用程序中,并使用其提供的各种功能,如获取应用程序信息、
健康检查、配置查看、日志输出等。此外,这个依赖项还包含了一些 Actuator 相关的库和工具,例如 Actuator 的端点和自定义指标等。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<!--
junit 让开发者能够方便地编写单元测试用例,并运行这些测试以验证代码的正确性
-->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
</dependency>

<!--
引入我们自己的公共包
-->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-common</artifactId>
<version>1.0.6-SNAPSHOT</version>
</dependency>
</dependencies>

<!-- BUILD START -->
<build>
<finalName>${project.artifactId}</finalName>
<resources>
<resource>
<directory>src\main\resources\lib</directory>
<targetPath>BOOT-INF\lib</targetPath>
<includes>
<include>**/*.jar</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>
<!-- BUILD END -->

</project>

不难发现,基本上都是一些基础的SpringBoot依赖和Grpc依赖,这里的leopold-common是我平常爱用的工具类,因此单独封装的一个jar包,因为目前都是Java项目,所以基本上都会用得到,就直接引在这个starter里了,所以他的scope没有配置

此外,我会编写一些配置类,来作为通用配置,包含:

  1. MVC相关所涉及的序列化、RFC配置、MVC配置以及Filter
  2. 一些自定义注解,用于MVC中AOP方面的一些操作

可以看到,基本上都是MVC相关的配置,对于GRPC的Fiter等一系列配置,在这里并没有设置,原因是我的大部分微服务端口不会对外发布,节点通讯依靠wireguard,除了考虑qos的影响外目前还不用考虑其他因素,如果需要对外发布,则应该配置grpc相关的filter更为妥当


leopold-jpa-starter

在原先的单体服务中,是用到了jpa相关内容,所以我需要单独封装一个starter,它的作用就是,只要引了这个jar包,就能使用jpa相关的功能

抽象

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
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
<?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.7.9</version>
<relativePath/>
</parent>
<groupId>com.leopold</groupId>
<artifactId>leopold-jpa-starter</artifactId>
<version>1.0.4-SNAPSHOT</version>
<name>leopold-jpa-starter</name>
<description>leopold-jpa-starter</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>

<!-- leopold spring boot starter -->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-spring-boot-starter</artifactId>
<version>1.0.3-SNAPSHOT</version>
<scope>provided</scope>
</dependency>

<!-- JPA START -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>5.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>5.0.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- JPA END -->

</dependencies>

<!-- BUILD START -->
<build>
<finalName>${project.artifactId}</finalName>
<resources>
<resource>
<directory>src\main\resources\lib</directory>
<targetPath>BOOT-INF\lib</targetPath>
<includes>
<include>**/*.jar</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
<!-- BUILD END -->

</project>

可以看到,我引入了上面一章节封装的leopold-spring-boot-stater,但作用域scopeprovided,因为这个starer并不需要作为服务启动,而是作为jar包引入,对于Spring相关的依赖,在其他项目中应该提供,而不是这个jar包提供,所以打包的时候也不会打入 leopold-spring-boot-stater,而是仅打包跟jpa相关的内容,包体积更小,更内聚

在本starter中,我封装了这些内容:

  1. SnowId(后续会用etcd替换)
  2. BaseJpaEntity,包含创建时间、创建人、修改时间等基础元素,所以让其他entity继承此类即可,因此把它封装在了此starter里
  3. jpa bean相关配置
  4. jpa exception相关配置
  5. jpa 通用crud操作

可以看到,内容都是和jpa相关的,这里我简单分享下代码,酌情参考

BaseJpaEntity.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;

import javax.persistence.Column;
import javax.persistence.MappedSuperclass;
import javax.persistence.Transient;
import java.io.Serial;
import java.io.Serializable;

@Getter
@Setter
@MappedSuperclass
public abstract class BaseJpaEntity implements Serializable {

@Serial
@Transient
private static final long serialVersionUID = 7054150882445633369L;

@Column(insertable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Integer isDeleted = 0;

@Column(updatable = false)
@CreatedBy
private Long creator;

@LastModifiedBy
private Long updator;

@CreatedDate
@Column(updatable = false)
private Long createDate;

@LastModifiedDate
private Long updateDate;
}

每张表继承此类,就可以含有这些基础元素,如果表不需要这些,就不用继承

接下来,我要分享一个自己内聚的一个通用查询,它包含了crud的操作,不同于jpa驼峰查询,在引入了grpc的概念后,驼峰命名方法虽然快速,但对外暴露的grpc接口错综复杂,且没有共性。细看proto定义的方法,各种根据字段的查询揉成一团,极度类似面向数据编程,因此我封装了一个简单的crud,用于处理各种因字段不同、字段多少的问题

IBaseService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.leopold.jpa.dto.SmartDTO;
import com.leopold.jpa.dto.SmartDeleteDTO;
import com.leopold.jpa.dto.SmartSaveDTO;
import org.springframework.data.domain.Page;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

import java.util.List;

public interface IBaseService<T, C extends JpaSpecificationExecutor<T>> {
void initRepository(C repository);

Page<T> smartFind(SmartDTO smartDTO);

long smartCount(SmartDTO smartDTO);

void smartDelete(SmartDeleteDTO smartDeleteDTO);

List<T> smartSave(SmartSaveDTO smartSaveDTO, Class<T> clazz);
}

这个接口描述了我们常用的crud操作,以及一个init方法,用于传递jpa的repository对象

这里的各种DTO可能你会一头雾水,别急,我们来看proto中是如何定义的

查询一般分为如下类型:

  1. 外层查询相关:分页查询、按照字段排序
  2. 时间查询相关:时间范围查询,时间范围段查询
  3. 字段查询相关:等值查询、模糊查询、忽略大小写查询
  4. 字段查询连接符:and、or

BaseService.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
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
import cn.hutool.json.JSONUtil;
import com.leopold.jpa.dto.*;
import com.leopold.jpa.enums.EqEnum;
import com.leopold.jpa.enums.OrderEnum;
import com.leopold.jpa.enums.TimeEnum;
import com.leopold.jpa.service.IBaseService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.util.ObjectUtils;

import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;


public class BaseService<T, C extends JpaSpecificationExecutor<T> & JpaRepository<T, Long>> implements IBaseService<T, C> {

private C repository;

public static final int MAX_LIMIT = 10000;

@Override
public void initRepository(C repository) {
this.repository = repository;
}

@Override
public Page<T> smartFind(SmartDTO smartDTO) {
// TODO
return null;
}

@Override
public long smartCount(SmartDTO smartDTO) {
// TODO
return 0;
}

@Override
public void smartDelete(SmartDeleteDTO smartDeleteDTO) throws CustomException {
// TODO
}

@Override
public List<T> smartSave(SmartSaveDTO smartSaveDTO, Class<T> clazz) {
// TODO
return null;
}

public C getRepository() {
return repository;
}

public void setRepository(C repository) {
this.repository = repository;
}
}

因为jpa查询的关系,表都是简单而且单调的,不会涉及多表联查,多表联查也不适用于jpa查询

对应的proto定义如下:

1
2
3
4
5
6
message SmartReq {
QuerySetting querySetting = 99;
repeated TimeSetting timeSetting = 98;
repeated EqSetting eqSetting = 97;
bool eqOr = 96;
}

可以看到,它对应了上面的四个查询类型所需要的参数SmartDTO

QuerySetting用于外层的分页查询,所以需要定义分页page和每页个数limit。排序一般按照某个字段排序、默认ASC,而且我们要用到protobuf中,枚举默认值是第一个的特性,即便我们没有set排序的类型,它依旧默认ASC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
message QuerySetting {
int32 page = 1;
int32 limit = 2;
repeated OrderBy orderBy = 3;
}

message OrderBy {
string field = 1;
OrderEnum orderEnum = 2;
}

enum OrderEnum {
ASC = 0;
DESC = 1;
}

TimeSetting用于时间查询,包含开始时间、结束时间、时间查询字段和时间查询范围类型,注意,TimeSetting这里我默认repeated、也就是可以是多个。默认是and连接符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
message TimeSetting {
TimeEnum timeEnum = 1;
int64 startTime = 2;
int64 endTime = 3;
string key = 4;
}

enum TimeEnum {
LT = 0;
GT = 1;
BETWEEN = 2;
LTE = 3;
GTE = 4;
}

EqSetting用于等值查询,包含查询的字段、字段值、字段的类型、是否模糊查询、是否忽略大小写,bool类型也是默认false的,在微服务调用时可不set此字段,通常我们都是查询字符串类型的字段,所以我把查询字段类型的枚举中,第一个设为STRING,保持默认值的特性。EqSetting也是 repeated的,因此可以多个等值查询,因为有or的查询,所以单独设定了这个等值查询的连接符,也就是eqOr字段,bool类型,默认false,就是默认and连接符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
message EqSetting {
string key = 1;
string val = 2;
EqEnum eqEnum = 3;
bool isLike = 4;
bool isLower = 5;
}

enum EqEnum {
STRING = 0;
LONG = 1;
INTEGER = 2;
BOOL = 3;
}

接下来,我将详细描述Service层查询的具体实现思路:

jpa的分页查询我使用的是JpaSpecificationExecutorPage<T> findAll(@Nullable Specification<T> spec, Pageable pageable);方法,所以我们先构造所需要的Specification

因为查询的类型不同,我们需要区分是时间查询还是等值查询,所以我们先构造一个初始化的查询init,等同于where 1=1 and 的效果

1
2
3
public Specification<T> init() {
return (root, criteriaQuery, criteriaBuilder) -> criteriaBuilder.and();
}

实现初始化一个Specification

1
2
3
4
public Specification<T> spec(SmartDTO smartDTO) {
Specification<T> findSpecification = init();
return findSpecification;
}

接下来,只需要按需判断,哪个类型的查询有值,我们就按照哪个查询的原则,比如等值查询:

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
if (!ObjectUtils.isEmpty(smartDTO.getEqSettingDTOList())) {
findSpecification = findSpecification.and(eq(smartDTO.getEqSettingDTOList(), smartDTO.getEqOr()));
}

...

public Specification<T> eq(List<EqSettingDTO> eqSettingDTOList, Boolean eqOr) {
return (root, criteriaQuery, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
for (EqSettingDTO eqSettingDTO : eqSettingDTOList) {
String v = eqSettingDTO.getVal();
String k = eqSettingDTO.getKey();
Boolean isLike = eqSettingDTO.getIsLike();
EqEnum eqEnum = eqSettingDTO.getEqEnum();
Boolean isLower = eqSettingDTO.getIsLower();
switch (eqEnum) {
case BOOL ->
predicates.add(criteriaBuilder.equal(root.get(k).as(Boolean.class), Boolean.parseBoolean(v)));
case LONG -> predicates.add(criteriaBuilder.equal(root.get(k).as(Long.class), Long.parseLong(v)));
case INTEGER ->
predicates.add(criteriaBuilder.equal(root.get(k).as(Integer.class), Integer.parseInt(v)));
case STRING -> predicates.add(isLike ? criteriaBuilder.like(
isLower ? criteriaBuilder.lower(root.get(k).as(String.class)) : root.get(k).as(String.class)
, String.format("%%%s%%", v)) :
criteriaBuilder.equal(
isLower ? criteriaBuilder.lower(root.get(k).as(String.class)) : root.get(k).as(String.class)
, v));
default -> throw new IllegalArgumentException("Invalid EqEnum value: " + eqEnum);
}
}
return ObjectUtils.isEmpty(predicates) ? null :
eqOr ? criteriaBuilder.or(predicates.toArray(new Predicate[0])) : criteriaBuilder.and(predicates.toArray(new Predicate[0]))
;
};
}

时间查询:

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
if (!ObjectUtils.isEmpty(smartDTO.getTimeSettingDTOList())) {
findSpecification = findSpecification.and(time(smartDTO.getTimeSettingDTOList()));
}

...

public Specification<T> time(List<TimeSettingDTO> timeSettingList) {
return (root, criteriaQuery, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
for (TimeSettingDTO timeSetting : timeSettingList) {
String key = timeSetting.getKey();
long startTime = timeSetting.getStartTime();
long endTime = timeSetting.getEndTime();
TimeEnum timeEnum = timeSetting.getTimeEnum();
switch (timeEnum) {
case BETWEEN -> predicates.add(criteriaBuilder.between(root.get(key), startTime, endTime));
case LT -> predicates.add(criteriaBuilder.lt(root.get(key), startTime));
case GT -> predicates.add(criteriaBuilder.gt(root.get(key), startTime));
case LTE -> predicates.add(criteriaBuilder.lessThanOrEqualTo(root.get(key), startTime));
case GTE -> predicates.add(criteriaBuilder.greaterThanOrEqualTo(root.get(key), startTime));
default -> throw new IllegalArgumentException("Invalid timeEnum value: " + timeEnum);
}
}
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
};
}

最终拼接的spec方法如下:

1
2
3
4
5
6
7
8
9
10
public Specification<T> spec(SmartDTO smartDTO) {
Specification<T> findSpecification = init();
if (!ObjectUtils.isEmpty(smartDTO.getEqSettingDTOList())) {
findSpecification = findSpecification.and(eq(smartDTO.getEqSettingDTOList(), smartDTO.getEqOr()));
}
if (!ObjectUtils.isEmpty(smartDTO.getTimeSettingDTOList())) {
findSpecification = findSpecification.and(time(smartDTO.getTimeSettingDTOList()));
}
return findSpecification;
}

对应的 smartFind 方法里:

1
2
3
4
5
6
@Override
public Page<T> smartFind(SmartDTO smartDTO) {
QuerySettingDTO querySettingDTO = smartDTO.getQuerySettingDTO();
Specification<T> findSpecification = spec(smartDTO);
...
}

拼接好了Specification,我们需要拼接分页字段,返回findAll所需要的Pageable对象,我们首先构建一个List,用于存储排序字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public List<Sort> sort(List<OrderByDTO> orderByDTOList) {
List<Sort> sortList = new ArrayList<>();
if (!ObjectUtils.isEmpty(orderByDTOList)) {
for (OrderByDTO orderByDTO : orderByDTOList) {
String field = orderByDTO.getField();
OrderEnum orderEnum = orderByDTO.getOrderEnum();
switch (orderEnum) {
case ASC -> sortList.add(Sort.by(Sort.Direction.ASC, field));
case DESC -> sortList.add(Sort.by(Sort.Direction.DESC, field));
default -> throw new IllegalArgumentException("Invalid orderEnum value: " + orderEnum);
}
}
}
return sortList;
}

对应的 smartFind 方法里:

1
2
3
4
5
6
7
@Override
public Page<T> smartFind(SmartDTO smartDTO) {
QuerySettingDTO querySettingDTO = smartDTO.getQuerySettingDTO();
Specification<T> findSpecification = spec(smartDTO);
List<Sort> sortList = sort(smartDTO.getQuerySettingDTO().getOrderByDTOList());
...
}

接下来构建Pageable对象,这里我们默认只查前10000行,如果不传page,但传了limit,就等同于jpa的top

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public Pageable getPage(int page, int limit, List<Sort> sortList) {
PageRequest pageRequest = null;
if (page == 0 && limit == 0) {
page = 1;
limit = MAX_LIMIT;
}
if (limit > 0) {
if (page > 0) {
pageRequest = PageRequest.of(page - 1, limit);
} else {
pageRequest = PageRequest.ofSize(limit);
}
}
if (null != pageRequest && !ObjectUtils.isEmpty(sortList)) {
for (Sort orders : sortList) {
pageRequest = pageRequest.withSort(orders);
}
}
return pageRequest;
}

对应的 smartFind 方法里:

1
2
3
4
5
6
7
8
9
@Override
public Page<T> smartFind(SmartDTO smartDTO) {
QuerySettingDTO querySettingDTO = smartDTO.getQuerySettingDTO();
List<Sort> sortList = sort(smartDTO.getQuerySettingDTO().getOrderByDTOList());
Specification<T> findSpecification = spec(smartDTO);
int limit = querySettingDTO.getLimit();
int page = querySettingDTO.getPage();
return this.repository.findAll(findSpecification, getPage(page, limit, sortList));
}

至此,smartFind方法已经实现了,这个方法很简单,但它构造了我们很多公共的方法,后续只要有拼接 where或者order bylimit等,都可以共用,比如接下来,我们着手 smartCount方法:

1
2
3
4
@Override
public long smartCount(SmartDTO smartDTO) {
return this.repository.count(spec(smartDTO));
}

你会看到,查询总数的方法不会涉及排序和分页、所以只需要构造一个specification即可,而且可共用spec()这个逻辑


接下来我们封装smartSave方法

proto定义如下:

1
2
3
message SmartSaveReq {
repeated string entity = 1;
}

也许你会奇怪,为什么是一个string类型,并且可以传递多个,因为每个实体的字段实在是不同,所以这里并不会考虑protobuf字段定义所带来的字节数降低的优点,而是序列化成字符串传递,虽然会增大一个消息的字节数,但在封装上省下不少时间

对应的smartSave实现如下:

1
2
3
4
5
6
7
8
9
10
@Override
public List<T> smartSave(SmartSaveDTO smartSaveDTO, Class<T> clazz) {
List<String> entityList = smartSaveDTO.getEntityList();
if (!ObjectUtils.isEmpty(entityList)) {
List<T> collectList = entityList.stream().map(entity -> JSONUtil.toBean(entity, clazz)).toList();
return this.repository.saveAll(collectList);
} else {
throw new CustomException("保存对象为空,禁止保存");
}
}

因为是string类型,所以我们在传递的过程中需要序列化为json str,再反序列化为泛型对象


smartDelete的定义和smartFind大同小异,只不过不用排序和分页

proto定义如下:

1
2
3
4
5
message SmartDeleteReq {
repeated EqSetting eqSetting = 97;
bool eqOr = 96;
repeated string id = 1;
}

这里的 EqSettingsmartFind里的 EqSetting是一样的,eqOr用于and()还是or()判断,id用于主键删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public void smartDelete(SmartDeleteDTO smartDeleteDTO) throws CustomException {
List<String> idList = smartDeleteDTO.getIdList();
if (!ObjectUtils.isEmpty(idList)) {
this.repository.deleteAllById(idList.stream().map(Long::parseLong).collect(Collectors.toList()));
} else {
Specification<T> findSpecification = init();
List<EqSettingDTO> eqSettingDTOList = smartDeleteDTO.getEqSettingDTOList();
Specification<T> eqList = eq(eqSettingDTOList, smartDeleteDTO.getEqOr());
if (ObjectUtils.isEmpty(eqList)) {
throw new CustomException("删除条件为空,禁止删除");
}
findSpecification = findSpecification.and(eqList);
List<T> repositoryDeleteAll = this.repository.findAll(findSpecification);
this.repository.deleteAll(repositoryDeleteAll);
}
}

P.S. 请使用 CrudRepository下的删除方法,因为在实体里,我们的删除是假删除,执行删除操作其实是update了is_delete这个字段,所以我会在实体上添加如下注解:

1
2
3
4
5
6
...
@SQLDelete(sql = "update xxxEntity set is_deleted = 1 where id = ?")
@Where(clause = "is_deleted != 1")
public class xxxEntity extends BaseJpaEntity {
...
}

如果使用其他类的删除方法,这个注解就失效了

服务端

接口代码如下:

1
2
public interface IResourceService extends IBaseService<ResourceEntity, ResourceRepository> {
}

实现类代码如下:

1
2
3
4
5
6
7
8
9
public class ResourceService extends BaseService<ResourceEntity, ResourceRepository> implements IResourceService {
@Resource
private ResourceRepository resourceRepository;

@PostConstruct
public void initService() {
super.initRepository(resourceRepository);
}
}

grpc代码如下:

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
@Override
public void smartCount(SmartReq request, StreamObserver<ResourceSmartCountResp> responseObserver) {
try {
ResourceSmartCountResp.Builder builder = ResourceSmartCountResp.newBuilder();
builder.setCount(resourceService.smartCount(SmartFactory.buildSmartDTO(request)));
responseObserver.onNext(builder.build());
responseObserver.onCompleted();
} catch (Exception e) {
log.error(e.getMessage(), e);
responseObserver.onError(Status.INTERNAL.withDescription(e.getMessage()).asRuntimeException());
}
}

@Override
public void smartFind(SmartReq request, StreamObserver<ResourceSmartFindResp> responseObserver) {
try {
log.debug("smartFind request -> {}", request);
long queryStart = System.currentTimeMillis();
ResourceSmartFindResp.Builder builder = ResourceSmartFindResp.newBuilder();
Page<ResourceEntity> resourceEntities = resourceService.smartFind(SmartFactory.buildSmartDTO(request));
List<Resource> resources = resourceFactory.buildResourceProtoList(resourceEntities.getContent());
builder.addAllResource(resources);
builder.setCount(resourceEntities.getTotalElements());
responseObserver.onNext(builder.build());
responseObserver.onCompleted();
log.debug("smartFind response -> {}", resources);
log.debug("smartFind cost {}ms", System.currentTimeMillis() - queryStart);
} catch (Exception e) {
log.error(e.getMessage(), e);
responseObserver.onError(Status.INTERNAL.withDescription(e.getMessage()).asRuntimeException());
}
}

@Override
@Transactional(rollbackFor = Exception.class)
public void smartSave(SmartSaveReqProto.SmartSaveReq request, StreamObserver<SmartSaveRespProto.SmartSaveResp> responseObserver) {
try {
List<ResourceEntity> resourceEntityList = resourceService.smartSave(SmartSaveFactory.buildSmartSaveDTO(request), ResourceEntity.class);
responseObserver.onNext(smartSaveFactory.buildSmartSaveResp(resourceEntityList));
responseObserver.onCompleted();
} catch (Exception e) {
log.error(e.getMessage(), e);
responseObserver.onError(Status.INTERNAL.withDescription(e.getMessage()).asRuntimeException());
}
}

@Override
@Transactional(rollbackFor = Exception.class)
public void smartDelete(SmartDeleteReqProto.SmartDeleteReq request, StreamObserver<SmartDeleteRespProto.SmartDeleteResp> responseObserver) {
try {
resourceService.smartDelete(SmartDeleteFactory.buildSmartDeleteDTO(request));
responseObserver.onNext(SmartDeleteRespProto.SmartDeleteResp.newBuilder().build());
responseObserver.onCompleted();
} catch (Exception e) {
log.error(e.getMessage(), e);
responseObserver.onError(Status.INTERNAL.withDescription(e.getMessage()).asRuntimeException());
}
}

客户端

下面我将测试crud,伪码如下

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
@Test
public void smartFind() throws Exception {
...
SmartReqProto.SmartReq.Builder builder = SmartReqProto.SmartReq.newBuilder();
builder.setQuerySetting(QuerySettingProto.QuerySetting.newBuilder()
.setPage(1)
.setLimit(5)
.addOrderBy(QuerySettingProto.OrderBy.newBuilder()
.setField("subscribeTime")
.setOrderEnum(QuerySettingProto.OrderEnum.DESC)
.build())
.build());
builder.addEqSetting(QuerySettingProto.EqSetting.newBuilder()
.setKey("isSubscribe")
.setVal("true")
.setEqEnum(QuerySettingProto.EqEnum.BOOL)
.build())
;

// 查询最新订阅的前5个会员
// SQL查询:select * from xx where is_subscribe = 1 order by subscribe_time desc limit 5
// 原JPA驼峰查询:List<xxxEntity> findTop5ByIsSubscribeOrderBySubscribeTimeDesc(Boolean isSubscribe);
SmartFindResp smartFindResp = stub.smartFind(builder.build());
...
}


@Test
public void smartCount() throws Exception {
SmartReqProto.SmartReq.Builder builder = SmartReqProto.SmartReq.newBuilder();
builder.addEqSetting(QuerySettingProto.EqSetting.newBuilder()
.setKey("isSubscribe")
.setVal("true")
.setEqEnum(QuerySettingProto.EqEnum.BOOL)
.build())
;

// 查询订阅会员数
// SQL查询:select count(*) from xx where is_subscribe = 1
// 原JPA驼峰查询:long countByIsSubscribe(Boolean isSubscribe);
SmartCountResp smartCountResp = stub.smartCount(builder.build());
long count = smartCountResp.getCount();

System.out.println("count: " + count);
}

@Test
public void smartSave() throws Exception {
SmartSaveReqProto.SmartSaveReq.Builder builder = SmartSaveReq.newBuilder();
builder.addAllEntity(List.of(
JSONUtil.toJsonStr(Map.of(
"password", "456",
"name", "1"
)), JSONUtil.toJsonStr(Map.of(
"password", "456",
"name", "2"
)), JSONUtil.toJsonStr(Map.of(
"password", "456",
"name", "3"
))
));
SmartSaveResp smartSaveResp = stub.smartSave(builder.build());
ProtocolStringList entity = smartSaveResp.getEntityList();

System.out.println("entity: " + entity);
}

@Test
public void smartDelete() throws Exception {
SmartDeleteReq.Builder builder = SmartDeleteReq.newBuilder();
builder.addEqSetting(QuerySettingProto.EqSetting.newBuilder().build());
builder.addAllId(List.of(
"1660565474116767744",
"1660565474120962048",
"1660565474120962049"
));

SmartDeleteResp smartDeleteResp = stub.smartDelete(builder.build());
long count = smartDeleteResp.getCount();

System.out.println("count: " + count);
}

leopold-redis-starter

这个starter主要用于redis 的连接以及一些基础的redis操作

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
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
<?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.7.9</version>
<relativePath/>
</parent>
<groupId>com.leopold</groupId>
<artifactId>leopold-redis-starter</artifactId>
<version>1.0.2-SNAPSHOT</version>
<name>leopold-redis-starter</name>
<description>leopold-redis-starter</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>

<!-- leopold spring boot starter -->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-spring-boot-starter</artifactId>
<version>1.0.3-SNAPSHOT</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.11.0</version>
<type>jar</type>
<scope>compile</scope>
</dependency>

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

</dependencies>

<!-- BUILD START -->
<build>
<finalName>${project.artifactId}</finalName>
<resources>
<resource>
<directory>src\main\resources\lib</directory>
<targetPath>BOOT-INF\lib</targetPath>
<includes>
<include>**/*.jar</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>
<!-- BUILD END -->
</project>

关于redis的配置,我已经写过了,详细可以查看这篇文章 Rancher部署Redis HA


leopold-security-starter

这个starter用于配置token相关,常用于web项目

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
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
<?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 http://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.7.9</version>
<relativePath/>
</parent>

<groupId>com.leopold</groupId>
<artifactId>leopold-security-starter</artifactId>
<version>1.0.3-SNAPSHOT</version>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>

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

<!-- leopold spring boot starter -->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-spring-boot-starter</artifactId>
<version>1.0.3-SNAPSHOT</version>
<scope>provided</scope>
</dependency>

<!-- leopold redis starter -->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-redis-starter</artifactId>
<version>1.0.2-SNAPSHOT</version>
<scope>provided</scope>
</dependency>

<!-- leopold jpa starter -->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-jpa-starter</artifactId>
<version>1.0.4-SNAPSHOT</version>
<scope>provided</scope>
</dependency>

</dependencies>

<!-- BUILD START -->
<build>
<finalName>${project.artifactId}</finalName>
<resources>
<resource>
<directory>src\main\resources\lib</directory>
<targetPath>BOOT-INF\lib</targetPath>
<includes>
<include>**/*.jar</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>
<!-- BUILD END -->

</project>

你会好奇,为什么security的starter会引入jpa的starter,其实是因为在设计用户表和密码的校验中,是需要涉及到UserEntity实体的,也就需要jpa操作实体,后续这个starter会适配单点登录、Oauth2,所以它会涉及到用户表的概念。

在这个starter中,我做了如下操作:

  • JWT配置、跨域配置、白名单配置
  • 角色配置、密码校验配置

这里的代码比较复杂,我自定义的也比较灵活,就不贴代码了


leopold-websocket-starter

这个starer用于处理websocket连接,由于目前websocket连接需要token验证,所以也包含了leopold-security-starter

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
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 http://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.7.9</version>
<relativePath/>
</parent>

<groupId>com.leopold</groupId>
<artifactId>leopold-websocket-starter</artifactId>
<version>1.0.2-SNAPSHOT</version>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>

<!-- leopold spring boot starter -->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-spring-boot-starter</artifactId>
<version>1.0.3-SNAPSHOT</version>
<scope>provided</scope>
</dependency>

<!-- leopold security starter -->
<dependency>
<groupId>com.leopold</groupId>
<artifactId>leopold-security-starter</artifactId>
<version>1.0.3-SNAPSHOT</version>
<scope>provided</scope>
</dependency>

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

</dependencies>

<!-- BUILD START -->
<build>
<finalName>${project.artifactId}</finalName>
<resources>
<resource>
<directory>src\main\resources\lib</directory>
<targetPath>BOOT-INF\lib</targetPath>
<includes>
<include>**/*.jar</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>
<!-- BUILD END -->

</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
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
package com.leopold.websocket;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;

import javax.websocket.*;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import javax.websocket.server.ServerEndpointConfig;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

/**
*
* Message Center Based On Websocket <br/>
*
* <p>1. We use <b>RESTFul API</b> to ctrl message center</p>
* <p>2. We don't choose NIO (Even though Tomcat Version > 6.0 is NIO), because :</p>
* <p>2.1 Our members are below to 1,000. (we should think about new NIO such
* as Netty if our online user up to 1,000)</p>
* <p>2.2 Our SpringBoot system is based on Tomcat. It's hard to change container and
* we don't have time to get Regression Test</p>
* <p>2.3 Tomcat NIO is a typical singleton thread model, but Netty is Reactor model
* which is used on high concurrency.</p><br/>
*
* <p> WARNING: </p>
* If you always catch {@link java.io.EOFException}, you should add connect time out
* on nginx such as {@code proxy_connect_timeout 3600s;} P.S. Netty is a better choice
* maybe I will recode this class :)<br/>
*
* @author leopold
* @version 1.0
*/
@ServerEndpoint(value = "/websocket/{userId}", subprotocols = {"xxx"}, configurator = WebSocketServer.CustomWebSocketConfigurator.class)
@Component
@Slf4j
public class WebSocketServer {

private static TokenUtil tokenUtil;

@Autowired
public void setTokenUtil(TokenUtil tokenUtil) {
WebSocketServer.tokenUtil = tokenUtil;
}

/**
* Our online member number. We used {@link java.util.concurrent.atomic}
* to synchronize integer so that we could get correct number.
*/
private static final AtomicInteger ONLINE_COUNT = new AtomicInteger(0);

/**
* Our online clients hashmap. We used {@link ConcurrentHashMap} to
* save sessions. The key is userId which is based on business system
* and the value is {@link Session}
*/
private static final ConcurrentHashMap<String, Session> CLIENTS_MAP = new ConcurrentHashMap<>();

/**
* Our send message task pool. We used {@link ExecutorService} to ctrl
* send thread pool. We used {@link Executors#newFixedThreadPool(int)}
* to create a max limit thread in pool. Normally we create a (cpu core
* num +1) in task pool.
* <br/>
* P.S. This pool maybe fill up your system memory when your thread task is doing
* slowly. If your task is not important, and it could be tolerated, you
* should use a bounded queue instead of it.
*/
private static final ExecutorService TASK_POOL = Executors.newFixedThreadPool(3);

/**
* Our userId which is from business system
*/
private volatile String userId;

public static class CustomWebSocketConfigurator extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {
super.modifyHandshake(config, request, response);

// get sec-websocket-protocol
List<String> tokenList = request.getHeaders().get("sec-websocket-protocol");
if (tokenList != null && tokenList.size() > 0) {
String token = tokenList.get(0);
config.getUserProperties().put("token", token.split(",")[0].trim());
}
}
}

/**
* When client create a new websocket connection, we catch the code
* and put userId and session into {@link WebSocketServer#CLIENTS_MAP}
*
* @param userId userId
* @param session user session
*/
@OnOpen
public void onOpen(@PathParam("userId") String userId, Session session) throws Exception {
Map<String, Object> userProperties = session.getUserProperties();
Object tokenO = userProperties.get("token");
if (ObjectUtils.isEmpty(tokenO)) {
throw new Exception("token not found");
}
String token = tokenO.toString();
if (!tokenUtil.check(token, userId)) {
throw new Exception("auth failed");
}
this.userId = userId;
CLIENTS_MAP.put(userId, session);
log.info("A new webSocket connected, userId -> [{}], all connected num -> [{}]", userId,
ONLINE_COUNT.incrementAndGet());
}

/**
* When client closed a websocket connection, we catch the code
* and remove session from {@link WebSocketServer#CLIENTS_MAP} by
* userId. This remove method does nothing if the key is not in
* the map.
*/
@OnClose
public void onClose() {
if (!ObjectUtils.isEmpty(userId)) {
CLIENTS_MAP.remove(userId);
log.info("A webSocket closed, userId -> [{}], current connected num -> [{}]", userId,
ONLINE_COUNT.decrementAndGet());
}
}

/**
* When client send a message to service, then we catch the code.
* I did nothing because our business system didn't have to save it.
*
* @param message client send message
* @param session client session
*/
@OnMessage
public void onMessage(@PathParam("userId") String userId, String message, Session session) throws IOException {
// HEART CHECK
if (!ObjectUtils.isEmpty(message) && WebSocketMessageTypeEnum.HEART.name().equals(message)) {
sendMessage(message, session);
} else {
log.info("get message from client -> [{}]", message);
}
}

/**
*
* When the websocket goes errors, then we catch the code. For
* some reasons, we always catch {@link java.io.EOFException}
* because the http connections goes max connecting time.
*
*/
@OnError
public void onError(Session session, Throwable error) {
if (error instanceof java.io.EOFException) {
log.warn("ws normally disconnect with max connect timeout, session id -> [{}]", session.getId());
} else {
log.error("ws error -> {} session id -> [{}]", error.getMessage(), session.getId(), error);
}
try {
session.close();
} catch (IOException e) {
log.error("ws close error -> {} session id -> [{}]", error.getMessage(), session.getId(), error);
}
}

/**
* Broadcast message on all online user
* @param message message body
*/
public static void broadcastMessage(String message) {
TASK_POOL.submit(() -> {
for (Session session : CLIENTS_MAP.values()) {
try {
sendMessage(message, session);
} catch (IOException e) {
log.error("broadcast message error -> {}", e.getMessage(), e);
}
}
});
}

/**
* Send message to user by userId
* @param message message body
* @param userId userId
*/
public static void sendMessage(String message, String userId) {
for (String clientUserId : CLIENTS_MAP.keySet()) {
if (userId.equals(clientUserId)) {
TASK_POOL.submit(() -> {
try {
sendMessage(message, CLIENTS_MAP.get(clientUserId));
} catch (IOException e) {
log.error("send message error, userId -> [{}], message -> [{}], because -> [{}]",
userId, message, e.getMessage(), e);
}
});
break;
}
}
}


/**
* Send message to user by userIdList
* @param message message body
* @param userIdList userIdList
*/
public static void sendMessage(String message, List<String> userIdList) {
TASK_POOL.submit(() -> {
for (String clientUserId : CLIENTS_MAP.keySet()) {
if (userIdList.contains(clientUserId)) {
try {
sendMessage(message, CLIENTS_MAP.get(clientUserId));
} catch (IOException e) {
log.error("send message error, userId -> [{}], message -> [{}], because -> [{}]",
clientUserId, message, e.getMessage(), e);
}
}
}
});
}


// ------------------------------------------------------------------- private method


/**
* Core send message method
* @param message message body
* @param session user session
* @throws IOException session error normally
*/
private static void sendMessage(String message, Session session) throws IOException {
// Check session is not closed
if (session.isOpen()) {
// Not async sending message
session.getBasicRemote().sendText(message);
} else {
log.warn("session [{}] is closed, cannot send message", session.getId());
}
}
}

编译

在封装好了starter后,我们需要把jar包上传至Nexus,流程如下:

  1. 代码上传至Git仓库
  2. 触发WebHook,通知Jenkins
  3. Jenkins执行 mvm deploy,打成jar包,并上传至Nexus
  4. 其他项目引用jar包

下面我简单贴一些配置仅供参考

Jenkins Pipeline

image-20230526140335699

image-20230526140525197

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
pipeline {

agent any

environment {
JAVA_HOME = "你的JAVA_HOME"
PATH = "${JAVA_HOME}/bin:${PATH}"
NEXUS_REPO = "my-maven::default::你的Nexus_repo地址"
}

stages {

stage('初始化代码环境') {
steps {
deleteDir()

dir("${workspace}@tmp") {
deleteDir()
}
dir("${workspace}@script") {
deleteDir()
}
}
}

stage('拉取Git仓库代码') {
steps {
...
}
}

stage('打包至Nexus') {
steps {
sh 'mvn -Dmaven.compiler.executable=${JAVA_HOME}/bin/javac clean deploy -DskipTests -DaltDeploymentRepository=${NEXUS_REPO}'
}
}
}
}

如果你想relead每个jar,让tag作为当前release版本的version,可以使用 mvn versions:set -DnewVersion=${tag} -DartifactId=${ARTIFACT_ID}更改。

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
...
<mirrors>
<mirror>
<id>nexus-aliyun</id>
<mirrorOf>central</mirrorOf>
<name>Nexus aliyun</name>
<url>http://maven.aliyun.com/nexus/content/groups/public</url>
</mirror>
<mirror>
<id>my-maven</id>
<!--mirrorof配置为 * 时,所有的请求都走这个mirror的url,
mirrorof配置是某个repositoryid时,若构建找不到,则会到maven默认中央仓库去获取的。-->
<mirrorOf>*</mirrorOf>
<name>my-mirror</name>
<url>http://xxx/repository/xxx-group/</url>
</mirror>
</mirrors>
...
<profiles>
<profile>
<id>nexus</id>
<repositories>
<repository>
<id>my-maven</id>
<name>Nexus</name>
<url>http://xxx/repository/xxx-group/</url>
<releases><enabled>true</enabled></releases>
<!-- 允许SNAPSHOTS 拉取和更新 -->
<snapshots>
<enabled>true</enabled>
<updatePolicy>always</updatePolicy>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>nexus</activeProfile> <!-- 默认激活的配置项,与profile的id一致 -->
</activeProfiles>

在需要编译和拉取的主机上添加到maven的settings.xml配置文件中即可


服务拆分

在拆服务之前,我想先简单描述一下,原先的单体服务都包含了哪些功能

  1. 后台管理系统(登录、表的crud)
  2. 微信公众号(接收消息、处理消息、表的crud)
  3. 爬虫服务(线程)

所以我暂时将单体服务抽象为4个微服务

  1. api:应用层服务。它是所有暴露的api应用层入口,具有security校验能力,用于组织其他微服务执行的结果
  2. service:实体服务。它是所有表crud的入口
  3. wx:微信公众号服务。它是微信对接消息的服务,所有跟微信通信的消息都会发送至这个微服务
  4. spider:爬虫服务。当收到爬虫任务时,需要执行的爬虫服务

这样分的好处很明显:

  1. 假如爬虫服务崩溃,它不会影响微信公众号服务的响应。爬虫需求大,可暂时提升pod数量,增大爬虫效率
  2. 服务之间的边界明显,高内聚了核心的功能,低耦合互不影响。

它的缺点也是有的:

  1. 如何处理事务
  2. 如何保证幂等性
  3. 实体过多,各种重复的封装操作几乎都在每个微服务重新上演
  4. 没有写单体服务来的那么爽快

是的,上升了微服务,得到了稳定与隔离,一定会失去一些,而且目前的稳定是极其脆弱的,那该如何解决呢?

  1. 如何处理事务、保证幂等性:我更倾向于使用锁来处理,分布式的服务我们通常使用redis锁,但k3s中,我更想使用etcd锁来与pod紧密结合,哪怕回滚,也要一步一步回滚、多余的数据要进行删除和补回
  2. 实体错综复杂:使用文档型的数据库MongoDB、使用DDD聚合实体,分清聚合根,使每个实体都不再面向数据驱动,不受前端的影响,聚合自己的服务。这个过程是极易犯错的,很有可能聚合的实体越来越变得有局限性,所以刚开始一定要想清楚,所以才会使用文档型数据库,弥补行数据库的死板
  3. 没有写单体服务来的那么爽快:这个的确无法避免,随着人数的增多,它会越来越要求后端人员的前瞻能力,每个人的聚合能力不同,就会导致各个服务面目全非,甚至过度区分微服务,导致devops和编码时间大大增加,在时间效率上反而远远不足于单体服务,这是一把双刃剑,小公司很难驾驭、老板有时候也不会有前瞻性,也不肯愿意多支付微服务带来的高额费用。大公司不愿意改型,

其他

在这个月里,我基本上思维都是以代码的模式,基本没有devops思维的跳跃,起初是有点不适应,且枯燥无味的,因为crud很无聊,核心逻辑也没有变动,所以经常摆烂地写-,-

在下个月中,我将加入devops的思维,并分步,与编程的思维结合,主要有:

  1. MongoDB HA的搭建
  2. java重构service服务,将实体数据写入MongoDB中
  3. 搭建go环境
  4. go重构爬虫服务,优化爬虫性能

这一部分大概也需要一个月的时间,届时,这个服务将逐渐由数据驱动逐步转型为领域驱动设计,目前主要是先将数据底层转型,再从DDD切入,我们下个月再见~