本文用于记录搭建基于Spring Cloud的微服务项目的架构骨架。基础构建工具使用Gradle/KotlinSDL,Java版本为11,微服务框架Spring Cloud。项目构建只考虑统一语言、统一框架的前提下构建,因此所有的微服务代码都置于同一个git仓库中,因此需要启用多模块的Gradle项目。

  1. 构建工具
  2. 项目基本结构
  3. 服务基础组件
  4. 第三方starter
  5. 代码规范
  6. Git工作流
  7. 基于Gitlab的CI和CD

构建工具

项目构建工具采用Gradle 6.5/KotlinSDL,Java 11。构建脚本分为三个文件:

  1. settings.gradle.kts
  2. build.gradle.kts
  3. 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可以很方便的编写自定义任务。

  1. 首先考虑的是插件声明以及版本的导入:
//插件声明但不导入,后面按需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分支/标签名称的匹配情况。

  1. 所有子项目的配置

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-clientbase-starter-dbbase-starter-jobbase-starter-mqbase-starter-redisbase-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,该类主要功能为:

  1. 扩展RowBounds,提供pageSizepageNooffsetlimit的转换。API仅支持page参数,不支持offset模式。
  2. 提供了排序相关的参数,可以扩展API层面排序的功能。
  3. 提供了回传总记录数的字段,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模式的抽象等。

  1. 对链路跟踪的支持,提供了一个JobServiceScheduledTaskRegistrar进行额外的包装,把链路跟踪信息加到每一个Job的运行时。
  2. 由于大多数Job都是对记录的依次处理,因此提供了统一个Job抽象,包含SimpleBatchJobBatchJob

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用于更新扫描时间戳,防止因无法处理导致的死循环。

  1. 以上两种模式执行时,对每条记录的处理都加入了是否退出的判断,用于Job需要关闭的时候自动退出Job实现优雅停机。
Web组件:base-starter-web

对于web组件的定制主要基于Spring MVC的扩展点进行扩展。包含的定制内容包括Exception的定制、额外的注解支持、返回结果的统一维护等。

  1. 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}`这样的消息模板。
}
  1. 额外注解的支持:提供了诸如@IpAddressClientInfo等项目定制需求的定制。权限控制也在这里完成。
  2. 返回结果的统一维护,定制统一个返回值结构,兼容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
}
其他组件

其他组件包括cachemq等,在此不做详细描述。

第三方starter

这里描述一下对接流程:

  1. 完成对第三方API的签名验证工具类,添加测试。
  2. 完成通用HttpClient的代码结构。
  3. 批量完成对DTO的导入,可以通过SublimeText工具从原始文档进行文本处理转换。不建议挨个字段复制粘贴。
  4. 对接每一个API,提供最原始的XXRequest->XXResponse的方法对接。
  5. 完成第三方服务层Service,把错误码转换成合理的Exception(这里主要指BaseException)。
  6. 编写一定量的测试。

完成对接后,业务项目依赖第三方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工具完成。

  1. 分支master对应开发环境。
  2. 标签vx.y.z对应测试环境。
持续集成

Gradle任务需要提供一个专门针对gitlab-ci的任务,命名为buildInCI。其需要根据CI环境进行定制,并且完成比开发多一些的任务:

  1. 普通build任务
  2. 运行测试任务
  3. 运行PMD任务进行代码校验
  4. flyway刷SQL任务,需要附带启动一个数据库实例(参考Gitlab-ci的Service功能)
持续部署

持续集成测试后,应当允许特定分支/标签的代码自动部署到开发测试环境。这里定义master分支自动部署到开发分支,标签版本vx.y.z自动部署到测试分支。

  1. 开发环境的部署,不刷flyway,数据库Schema由开发者自行执行,不限制版本,可以随意部署最新的master版本。
  2. 测试环境的部署,需要刷flyway,数据库Schema通过版本管理进行维护,因此测试环境版本只能升不能降,自动部署需要拒绝版本号降低的情况。由于持续集成测试已经检验了SQL的正确性,因此这里可以自动化部署。但是需要注意的是测试环境数据库不能把Schema变更的权限开放给开发者,否则可能导致数据库Schema异常部署失败。