Spring Cloud 微服务项目构建
本文用于记录搭建基于Spring Cloud的微服务项目的架构骨架。基础构建工具使用Gradle/KotlinSDL,Java版本为11,微服务框架Spring Cloud。项目构建只考虑统一语言、统一框架的前提下构建,因此所有的微服务代码都置于同一个git仓库中,因此需要启用多模块的Gradle项目。
构建工具⌗
项目构建工具采用Gradle 6.5/KotlinSDL,Java 11。构建脚本分为三个文件:
- settings.gradle.kts
- build.gradle.kts
- gradle.properties
settings.gradle.kts⌗
该文件主要负责插件管理和项目导入,样例代码如下:
// 插件管理
pluginManagement {
// 替换官方源,毕竟离得近下载的更快
repositories {
maven(url = "https://maven.aliyun.com/nexus/content/groups/public/") {
// 修复阿里云maven仓库实际是http协议的问题。
isAllowInsecureProtocol = true
}
// 插件官方源
gradlePluginPortal()
}
// 该配置中把插件的版本统一放置到gradle.properties文件管理
val map = mapOf(
"org.springframework.boot" to "springBootVersion",
"io.spring.dependency-management" to "springDependencyVersion",
)
resolutionStrategy {
eachPlugin {
map.get(requested.id.id)
?.let { useVersion(gradle.rootProject.extra[it] as String) }
}
}
}
//项目导入
include(
// 基础公用代码
"base-dto",
// 基础starters,多个
"base-starter",
// 第三方starters,多个
"third-starter-xxx",
// 通用服务
"config-war",
"gateway-war",
// 项目模块
"user-dto",
"user-client",
"user-war"
// 其他项目模块
)
build.gradle.kts⌗
该文件主要负责Gradle构建相关的所有内容,不同于Maven,Gradle可以很方便的编写自定义任务。
- 首先考虑的是插件声明以及版本的导入:
//插件声明但不导入,后面按需apply。
plugins {
id("org.springframework.boot") apply false
id("io.spring.dependency-management") apply false
}
//从gradle.properties导入版本信息,这些变量可以用于settings.gradle.kts。
val springCloudVersion: String by project
val springBootVersion: String by project
val springVersion: String by project
val lombokVersion: String by project
//版本解析,同时自动从GitLab-ci的环境变量读取、或者从本地git目录读取commitid。
val rawVersion: String by project
val revision = System.getenv("CI_COMMIT_SHORT_SHA") ?: runCmd("git rev-parse --short HEAD")
allprojects {
version = rawVersion + (if (isRelease()) "-RELEASE" else "-SNAPSHOT")
task("printVersion") { doLast { println(version) } }
task("printRevision") { doLast { println(revision) } }
}
函数isRelease
可以用于判定版本的有效性,包含格式以及和git分支/标签名称的匹配情况。
- 所有子项目的配置
subprojects {
//统一激活子项目插件,此处设置可以排除rootProject作为一个java项目。
apply(plugin = "java")
apply(plugin = "java-library")
apply(plugin = "idea")
apply(plugin = "io.spring.dependency-management")
//设置基础JDK版本和编码
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
sourceCompatibility = JavaVersion.VERSION_11.toString()
targetCompatibility = JavaVersion.VERSION_11.toString()
}
//项目的Maven仓库
repositories {
maven(url = "https://maven.aliyun.com/nexus/content/groups/public/") {
isAllowInsecureProtocol = true
}
maven(url = "https://repo.spring.io/milestone")
mavenCentral()
}
//针对Spring项目的版本管理,可以导入所有Spring管理的库版本号。
configure<DependencyManagementExtension> {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion")
mavenBom("org.springframework.boot:spring-boot-dependencies:$springBootVersion")
mavenBom("org.springframework:spring-framework-bom:$springVersion")
}
}
// 剩下子项目配置
}
gradle.properties⌗
该文件主要配置版本信息以及一些编译参数
group=your.company
rawVersion=1.0.0
org.gradle.jvmargs=-Xms128m -Xmx1024m -XX:+CMSClassUnloadingEnabled
springVersion=5.2.6.RELEASE
springBootVersion=2.3.0.RELEASE
springCloudVersion=2020.0.0-M2
springDependencyVersion=1.0.9.RELEASE
lombokVersion=1.18.12
项目基本结构⌗
Gradle灵活的SDL可以让我们方便的对项目模块根据名称进行归类。 如前面settings.gradle.kts
文件中加载的模块那样,我对项目的模块通过名称进行了划分。每个模块的名字都以字母单词和横杠-
作为分隔符组成,大概包括几类:base-**
、base-starter-**
、third-starter-**
、*-dto
、*-client
、*-war
。
全局公共类库:base-dto
⌗
全局公共类库用于放置所有模块都依赖的公共类。一些公用定义类、工具类等均可放在这里。
全局starter公共类库:base-starter
⌗
starter公共类库用于放置所有模块都需要加载的starter配置。
基础starters:base-starter-**
⌗
基础类starter用于简单包装spring-boot提供的starters,或者增加自己的工具层。一般属于那些存在一定的定制需求,并且不是每个项目都需要的模块。例如Job配置模块、Cache配置模块、消息配置模块、数据库配置模块、RPC配置模块等。某些简单的项目,例如验证码服务只需要Cache即可。因此每个实际的业务项目可能需要依赖的starters是不同的。项目架构上,应当尽量控制依赖的数量和质量,为后续项目的变更处理减少障碍(项目的变更原因可能是JDK升级、框架升级或者bug导致的升级)。Gradle脚本可以自动为基础starters添加通用的依赖(base-dto和bast-starter),这两个模块是开发starter必备的,同时也可以减少通用依赖分散在每个starter项目中,从而减轻开发和管理成本。
Gradle脚本配置示例如下:
subprojects {
//其他配置
if (project.name.startsWith("base-starter-")) {
dependencies {
api(project(":base-dto"))
api(project(":base-starter"))
}
}
//其他配置
}
第三方starters: third-starter-**
⌗
第三方starter用于对接第三方服务。通常第三方服务都提供了http的api,因此可以统一手工对接API。采用的方式可以是RestTemplate或者Feign等,后者可以和Spring Cloud一起统一管理。有一些特例,例如aliyun的API很多,并且提供了较高质量的SDK,则可以采用直接依赖SDK的方式。大多数第三方服务的sdk均比较垃圾,不建议直接导入依赖。否则可能加重后续升级变更的风险,同时会破坏项目的代码规范或者配置规范等。第三方服务模块没有一定依赖的模块,因此依赖部分自定义。
项目的模块的划分:**-dto
、**-client
、**-war
⌗
项目模块可以划分成dto、client、war三个模块,对于想要更进一步划分的情况,可以对war进行dao、service、controller的拆分。这里我仅考虑分成三个模块。其中dto主要负责编写数据传输对象、以及一些通用接口,一般这些内容是client和war同时需要用到的。war模块是业务项目的主要模块,负责从dao到controller层的所有代码,模块内部需要进行逻辑分层,以便后续根据业务需求进行拆分。client模块则提供了统一的客户端调用代码。我用的是Spring Cloud Feign,因此该模块主要用Feign编写一些客户端接口。client和war不能相互引用,他们的关系是调用方和实现方。一般client被其他项目的war引用,可以直接提供java实例来进行远程调用。Client层的抽象可以方便后续进行RPC的变更,而不用影响业务模块。
Gradle脚本配置示例如下:
subprojects {
//其他配置
if (project.name.endsWith("-dto")) {
dependencies {
api(project(":base-dto"))
}
}
if (project.name.endsWith("-client")) {
dependencies {
api(project(":" + project.name.replace("-client$", "-dto")))
implementation(project(":base-starter-client"))
}
}
if (project.name.endsWith("-war")) {
dependencies {
api(project(":" + project.name.replace("-war$", "-dto")))
//如果统一采用的是webflux,则可以更改此依赖。
implementation(project(":base-starter-web"))
//war需要依赖的运行时代码
runtimeOnly("org.springframework.cloud:spring-cloud-starter-sleuth")
runtimeOnly("org.springframework.boot:spring-boot-starter-actuator")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
runtimeOnly("org.springframework.cloud:spring-cloud-starter-consul-discovery")
}
//其他war配置
}
//其他配置
}
对于war模块,其还需要提供额外的插件,以及一些更进一步的通用配置,可以在其他war配置
中配置如下代码
subprojects {
//其他配置
if (project.name.endsWith("-war")) {
apply(plugin = "org.springframework.boot")
//针对数据库项目的定制
if (isSupportDatabase()) {
apply(plugin = "org.flywaydb.flyway")
tasks.withType<org.flywaydb.gradle.task.AbstractFlywayTask> {
//flyway定制
}
}
tasks.withType<BootRun> {
//对bootRun任务的定制
}
}
//其他配置
服务基础组件⌗
服务基础组件包括base-starter-client
、base-starter-db
、base-starter-job
、base-starter-mq
、base-starter-redis
、base-starter-web
等。其中client用于微服务间调用相关组件。其余模块则主要是数据库、Job、Web相关构件。
微服务调用组件:base-starter-client
⌗
其直接依赖org.springframework.cloud:spring-cloud-starter-openfeign
,主要做一些调用相关的定制。 例如,内部调用增加安全校验;对返回结果做一些处理;负载均衡定制等。
数据库组件:base-starter-db
⌗
数据库组件主要用于定制数据库相关的配置。例如:分页插件、flyway定制、内存数据库定制等。我这边对内存数据库采用Profile管理,提供了BasicFlywayAutoConfiguration
用于配置local模式下启动内存数据库的功能,方便开发时使用。实际部署环境则因为Profile不是local的,所以不会加载该类。依赖关系上,对flyway的依赖应当排除在打包阶段。
分页插件的逻辑很简单,业务开发人员应当尽可能低的改动代码。这里我没有选择第三方库,而是自己实现了MyBatis插件。Mapper层的编写对开发人员无感,即如果要求查询分页,直接提供一个RowBounds
的参数即可。由分页插件完成对RowBounds
的重新配置。具体的,提供一个继承RowBounds
的类PageableBounds
,该类主要功能为:
- 扩展RowBounds,提供
pageSize
、pageNo
到offset
、limit
的转换。API仅支持page
参数,不支持offset
模式。 - 提供了排序相关的参数,可以扩展API层面排序的功能。
- 提供了回传总记录数的字段,mapper调用后把总记录数写入该字段,在Spring MVC层可以通过
PageableBounds
的引用直接得到total值。而不需要在返回的List
中获取。一般的业务开发过程中可能对List
加以改造,因此有可能破坏调用返回的List
结果。
分页插件在Spring MVC层可以增加注解,用于方便的定制可排序字段、修改默认分页大小等功能,并且可以配合Swagger提供合适的API文档。
其他的定制还包括SQL模板类、TypeHandler
定制等。
除此以外,可以对项目中的SQL脚本进行严格的版本控制,其通过扩展Gradle编译任务来实现。
SQL代码需要在编译过程中自动测试运行。
Job组件:base-starter-job
⌗
简单的Job组件,直接基于org.springframework.scheduling.TaskScheduler
。我为job模块定制按Profile加载的方案,即BasicJobAutoConfiguration
配置只有在ActiveProfiles中包含了job
的Profile才可以加载。这样做的好处是,同一个war项目既包括web组件又包括job组件。并且可以根据Profile是否包含job,提供两种运行方案。部署的模式为一个xxx-war
可以提供多个xxx-server
实例和一个xxx-job
实例运行。绝大部分Job和web依赖相同的代码逻辑,因此简单的Job方案是合理的。
具体的功能上,对于Job需要附加一些额外的组件:对链路跟踪的支持、对优雅停机的支持、对Job模式的抽象等。
- 对链路跟踪的支持,提供了一个
JobService
对ScheduledTaskRegistrar
进行额外的包装,把链路跟踪信息加到每一个Job的运行时。 - 由于大多数Job都是对记录的依次处理,因此提供了统一个Job抽象,包含
SimpleBatchJob
和BatchJob
。
public interface SimpleBatchJob<E> {
List<E> getRecords(Pageable pageable);
void handle(String jobName, E record);
default boolean exitOnceRecordFailed() {
return false;
}
}
public interface BatchJob<E> extends SimpleBatchJob<E> {
void updateNextScanTime(E record);
}
SimpleBatchJob
用于对Job按分页从第1页到第N页进行处理;BatchJob
则是对Job每次进行未处理的记录查询,每次查出来的是最优先的需要进行批处理的记录,updateNextScanTime
用于更新扫描时间戳,防止因无法处理导致的死循环。
- 以上两种模式执行时,对每条记录的处理都加入了是否退出的判断,用于Job需要关闭的时候自动退出Job实现优雅停机。
Web组件:base-starter-web
⌗
对于web组件的定制主要基于Spring MVC的扩展点进行扩展。包含的定制内容包括Exception的定制、额外的注解支持、返回结果的统一维护等。
- Exception的定制,Java提供的Exception是基于类的,并且每个Exception结构各异,很不合适转换成统一的错误结构返回给前端。因此定制了统一个错误类
BaseException
:
public BaseException(@Nullable Throwable ex, @NonNull Enum<?> code, Object... args){}
该类接收一个起因Throwable
,一个错误枚举值code
以及错误转换的参数列表。其中Enum
类用于代替原来通过类的方式定义错误,而是用一个枚举值代表一个错误码。并且对于枚举值的错误码进行了规范。枚举值直接代表默认的错误码消息模板,例如:HTTP_ARGUMENTS_INVALID_
可以转换成http arguments invalid {0}
;而HTTP_ARGUMENTS_INVALID__
可以转换成http arguments invalid {0} {1}
。通过下划线_
来分隔单词,如果单词为空字符串则表示该位置需要填入一个参数。这种结构可以简化代码编写,并且直观的提供了错误码需要几个参数的表示。
//表示该枚举类下面错误码默认的日志级别
@ErrorLevel(ErrorLogLevel.WARN)
public enum BaseErrorCode {
//直接定义该错误码的日志级别
@ErrorLevel(ErrorLogLevel.INFO)
HTTP_ARGUMENTS_INVALID_, //该错误码按照`_`做分隔,得到`http arguments invalid {0}`这样的消息模板。
}
- 额外注解的支持:提供了诸如
@IpAddress
、ClientInfo
等项目定制需求的定制。权限控制也在这里完成。 - 返回结果的统一维护,定制统一个返回值结构,兼容Spring MVC默认的返回结构。其中
result
字段用于返回正常的结果。
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<E> {
private String traceId; //链路跟踪ID
private Date timestamp; //服务端时间戳
private E result;
private PageValue pageable; //分页模式下的分页参数
private String message; //错误消息
@JsonIgnore
private String exception;
@JsonIgnore
private String error;
private List<FieldError> errors; //字段错误消息
@JsonIgnore
private String trace;
private Integer status; //http status code
}
其他组件⌗
其他组件包括cache
、mq
等,在此不做详细描述。
第三方starter⌗
这里描述一下对接流程:
- 完成对第三方API的签名验证工具类,添加测试。
- 完成通用HttpClient的代码结构。
- 批量完成对DTO的导入,可以通过SublimeText工具从原始文档进行文本处理转换。不建议挨个字段复制粘贴。
- 对接每一个API,提供最原始的XXRequest->XXResponse的方法对接。
- 完成第三方服务层Service,把错误码转换成合理的Exception(这里主要指BaseException)。
- 编写一定量的测试。
完成对接后,业务项目依赖第三方starter,通过第三方服务层Service进行调用。
代码规范⌗
采用PMD进行代码格式校验,可以方便的对代码进行格式规范。例如:字段、方法名字必须用camelCase,枚举值必须SNAKE_CASE;对if的嵌套层数限制。主要目的是规范代码、限制容易出错的书写方式(使得代码更易读、更安全)。
Git工作流⌗
项目版本定义为1.2.3的格式,其中第一位数字是大版本号,用于重大版本升级;第二位数字是次版本号,用于每周一次的发布升级;第三位数字是HotFix版本号,用于修复线上问题时更新。
Git项目主分支为master,每周一进行版本cut,切出最新版本分支x.x-release,同时升级master分支版本到x.x+1.0。版本分支x.x-release主要用于本周问题修复。
SQL的版本跟随项目版本走,对于版本x.y.z的项目其SQL脚本必须是db/migration/Vx_y_z__sql_name.sql的格式,这样可以限定每个版本的SQL位置。每次进行版本升级之前,需要固定化当前版本的SQL,我提供了sqlFreeze的Gradle任务用来完成该功能。该任务会计算当前版本SQL的md5值,保存在特定的文本文件中,每次build的时候会对文本文件的md5值和历史版本SQL进行一一检查。如果发现不一致则编译失败,以此保证正常的开发不会因为误修改历史SQL文件而通过编译,合并进入主分支。
分支分成两类,master和x.x-release,分别用于最新代码分支和准备上线/已上线分支。 标签用于发布版本,格式为vx.y.z。
CI和CD⌗
持续集成和持续部署基于上述Git工作流采用Gitlab-ci工具完成。
- 分支master对应开发环境。
- 标签vx.y.z对应测试环境。
持续集成⌗
Gradle任务需要提供一个专门针对gitlab-ci的任务,命名为buildInCI
。其需要根据CI环境进行定制,并且完成比开发多一些的任务:
- 普通build任务
- 运行测试任务
- 运行PMD任务进行代码校验
- flyway刷SQL任务,需要附带启动一个数据库实例(参考Gitlab-ci的Service功能)
持续部署⌗
持续集成测试后,应当允许特定分支/标签的代码自动部署到开发测试环境。这里定义master分支自动部署到开发分支,标签版本vx.y.z自动部署到测试分支。
- 开发环境的部署,不刷flyway,数据库Schema由开发者自行执行,不限制版本,可以随意部署最新的master版本。
- 测试环境的部署,需要刷flyway,数据库Schema通过版本管理进行维护,因此测试环境版本只能升不能降,自动部署需要拒绝版本号降低的情况。由于持续集成测试已经检验了SQL的正确性,因此这里可以自动化部署。但是需要注意的是测试环境数据库不能把Schema变更的权限开放给开发者,否则可能导致数据库Schema异常部署失败。