工程经验 - 构建一个 SpringBoot 应用的良好实践

今天看到一篇文章提到:

“make it run, make it fast, make it beautiful.

最近在做副业的尝试,有个深刻的体会,技术可能是商业里面最不重要的。

从零把产品做出来,推广给用户,用户只会关注你的产品是否好用,能否解决他们的问题.

他们既不会关注你是用C++/Java还是Javascript 写的,也不会关注你代码写得是否优雅,与其执着于技术选型,不如先把产品干出来让用户试用。 - ramsayleung”

这段话的观点我挺认可。我认为,快速实现、快速迭代工程,是我接下来的无论是在公司工作或者个人项目都应该遵循的原则。本篇文章分享我对构建一个 SpringBoot 应用使用到的工程技术和实践,作为快速构建一个可靠的 SpringBoot 应用的参考。

一、包管理约定

1.1 工具

Gradle or Maven?Maven

1.2 隐式指定

版本配置

定义一个父 POM,并在其中做 dependencyManagement、pluginManagement 的版本管理

  • 对于 Spring 系依赖:使用 SpringBoot Parent 依赖作为父 POM 的父依赖 or 引入 spring-boot-dependencies 依赖统一管理生态的依赖,涉及到 Spring 系的依赖使用隐式指定的版本

  • 对于应用的三方依赖:使用 dependencyManagement、pluginManagement 的版本管理

约定,在子模块中引入的依赖统一隐式指定(从父 POM)获取

执行配置

在 pluginManagement 标签中定义插件的版本、executions、configuration,对于子模块来说,使用插件只需引入插件的 artifactId 和 groupId

二、构建插件约定

2.1 maven-enforcer-plugin

用途:校验检查编译环境 & JDK 版本

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <executions>
      <execution>
          <id>enforce-java</id>
          <goals>
              <goal>enforce</goal>
          </goals>
          <configuration>
              <rules>
                  <requireJavaVersion>
                      <message>This build requires at least Java ${java.version},
                          update your JVM, and
                          run the build again
                      </message>
                      <version>${java.version}</version>
                  </requireJavaVersion>
              </rules>
          </configuration>
      </execution>
  </executions>
</plugin>

2.2 springboot-maven-plugin

用途:将所有依赖、配置、classes 打入到一个 jar(Fat Jar),启动服务 java -jar ,一切从简

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>repackage</id>
            <goals>
                <goal>repackage</goal>
            </goals>
        </execution>
        <execution>
            <!-- Spring Boot Actuator displays build-related information
              if a META-INF/build-info.properties file is present -->
            <goals>
                <goal>build-info</goal>
            </goals>
            <configuration>

                <additionalProperties>
                    <encoding.source>${project.build.sourceEncoding}</encoding.source>
                    <encoding.reporting>${project.reporting.outputEncoding}</encoding.reporting>
                    <java.source>${java.version}</java.source>
                    <java.target>${java.version}</java.target>
                </additionalProperties>
            </configuration>
        </execution>
    </executions>
</plugin>

2.3 git-commit-id-maven-plugin

用途:你可能曾经遇到 bug,怀疑线上服务部署的代码到底有没有更新过?这个在打包的时候,会输出一个 git.properties,用于跟踪服务部署时的 git 分支、commitId

<plugin>
    <groupId>io.github.git-commit-id</groupId>
    <artifactId>git-commit-id-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>get-the-git-infos</id>
            <goals>
                <goal>revision</goal>
            </goals>
            <phase>initialize</phase>
        </execution>
    </executions>
    <configuration>
        <generateGitPropertiesFile>true</generateGitPropertiesFile>
        <generateGitPropertiesFilename>${project.build.outputDirectory}/git.properties
        </generateGitPropertiesFilename>
        <dateFormat>yyyy-MM-dd HH:mm:ss</dateFormat>
        <useNativeGit>true</useNativeGit>
        <includeOnlyProperties>
            <includeOnlyProperty>^git.branch$</includeOnlyProperty>
            <includeOnlyProperty>^git.build.(time|version)$</includeOnlyProperty>
            <includeOnlyProperty>^git.commit.id.(abbrev|full)$</includeOnlyProperty>
            <includeOnlyProperty>^git.commit.message.short$</includeOnlyProperty>
        </includeOnlyProperties>
        <commitIdGenerationMode>full</commitIdGenerationMode>
    </configuration>
</plugin>

2.4 maven-compiler-plugin

用途:用于编译 Java

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.13.0</version>
    <configuration>
        <source>21</source>
        <target>21</target>
        <release>21</release>
        <annotationProcessorPaths>
            <path>
                <groupId>org.mapstruct</groupId>
                <artifactId>mapstruct-processor</artifactId>
                <version>${mapstruct.version}</version>
            </path>
            <path>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok.version}</version>
            </path>
            <path>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok-mapstruct-binding</artifactId>
                <version>${lombok-mapstruct-binding.version}</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

2.5 spotless-maven-plugin

用途:用于工程代码格式统一化

<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>2.43.0</version> <!-- Use the latest version -->
    <executions>
        <execution>
            <goals>
                <goal>apply</goal>
            </goals>
            <phase>validate</phase>
        </execution>
    </executions>
    <configuration>
        <java>
            <googleJavaFormat>
                <style>AOSP</style>
                <reflowLongStrings>true</reflowLongStrings>
            </googleJavaFormat>
        </java>
    </configuration>
</plugin>

如上配置,在 mvn validate 的时候会出发 Google Android 风格的格式化。为什么在 mvn validate ?因为每次 mvn compile 时都会经过 mvn validate ,这样可以检查格式化后的代码是否能正常通过编码。

此插件有两个 goal:检查和格式化。可以配合 pre-commit hook,在每次 git 代码提交时检查风格,如果不通过,让提醒用户其执行 mvn validate。pre-commit 内容如下:

(base) jar4ever@L-MBP example-service % cat .git/hooks/pre-commit
#!/bin/sh
echo "正在运行 spotless:check..."
mvn com.diffplug.spotless:spotless-maven-plugin:2.43.0:check > /dev/null 2>&1
if [ $? -ne 0 ]; then
  echo "Spotless 检查失败,提交已中止。"
  echo "请运行以下命令修复格式问题:"
  echo "  mvn validate"
  exit 1
else
  echo "Spotless 检查通过,继续提交。"
fi

由于依赖 pre-commit,此文件不会提交到 git,因此最好是提供一个脚本,一键替换用户的 pre-commit 文件( .git/hooks/pre-commit

三、Profile 构建管理约定

3.1 服务环境定义

针对大多数情况,我将 profile 拆分为三个环境,profile 同样要求在父 pom 中统一定义

<profiles>
    <!-- 本地环境 -->
    <profile>
        <id>local</id>
        <properties>
            <profiles.active>local</profiles.active>
        </properties>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
    </profile>

    <!-- 开发环境 -->
    <profile>
        <id>dev</id>
        <properties>
            <profiles.active>dev</profiles.active>
        </properties>
    </profile>

    <!-- 生产环境 -->
    <profile>
        <id>prod</id>
        <properties>
            <profiles.active>prod</profiles.active>
        </properties>
    </profile>
</profiles>

<build>
  <resources>
      <resource>
          <directory>src/main/resources</directory>
          <filtering>true</filtering>
          <excludes>
              <exclude>application-*.yml</exclude>
          </excludes>
      </resource>
      <resource>
          <directory>src/main/resources</directory>
          <filtering>true</filtering>
          <includes>
              <include>application-${profiles.active}.yml</include>
          </includes>
      </resource>
  </resources>
</build>

3.2 指定环境命令

(base) jalr4ever@L-MBP resources % tree
.
├── application-dev.yml
├── application-local.yml
├── application-prod.yml
├── application.yml
├── db
│   └── migration
│       └── V1__init_db.sql
├── logback.xml
└── mapper
    ├── address
    │   └── AddressMapper.xml
    └── cargo
        └── CargoDeliveryAddressMapper.xml

如上例子,我们可以在 resource 目录下定义不同 profile 的配置,maven 构建打包的时候,可以通过 -P 参数指定 profile,从而完成多环境的指定构建,例如使用命令:

  • 本地环境:mvn clean package(默认)

  • 开发环境:mvn clean package -P dev

  • 生产环境:mvn clean package -P prod

四、环境变量管理约定

4.1 写入方式

直接看实例,统一走 yml 配置

(base) jalr4ever@L-MBP resources % tree
.
├── application-dev.yml
├── application-local.yml
├── application-prod.yml
├── application.yml
├── db
│   └── migration
│       └── V1__init_db.sql
├── logback.xml
└── mapper
    ├── address
    │   └── AddressMapper.xml
    └── cargo
        └── CargoDeliveryAddressMapper.xml

application.yml: 包含基础配置,每个 profile 都需要的配置

# 系统配置
server:
  port: 9898

# Spring配置
spring:
  application:
    name: mindflow-scaffold
  profiles:
    active: @profiles.active@
  aop:
    proxy-target-class: true

application-dev.yml or application-prod.yml:包含研发环境 or 生产环境的配置,例如其中的一段

application:
  env:
    cache:
      host: ${BASE_CACHE_HOST}
      port: ${BASE_CACHE_PORT}
      auth: ${BASE_CACHE_AUTH}
      db: ${BASE_CACHE_DB}

服务部署的时候,运维注入 ENV,ENV 会注入到 application.yml

4.2 读取方式

统一走 SpringBoot 的方案读取,使用 @Value or @ConfigurationProperties

五、日志管理约定

使用 logback + slf4j 方案进行工程管理,参考之前发布过的文章(工程经验 - 服务日志打印方案)

六、实体转换约定

之前我们写的是 builder.build() or 实例化以及 setXXX() 代码,而 mapstruct 要求我们写 mapping DSL,写 mapping 体验更好么?不会。但 IDEA 提供 mapstruct 插件,可以更好、更快地完成这一过程的 DSL 生成

我们这里使用 mapstruct 以及 IDEA 中的 mapstruct 插件进行实体的转换,此插件在编译

七、sql 管理约定

使用 flyway 进行数据库的 sql 管理

-- 前 4 位产品版本号,最后一位作为 flyway 的自增版本
V1.0.0.0.0__description.sql

八、三方库配置约定

在工程我们会使用 lettuce、mq、druid 进行三方服务的资源池化,要求:能在代码中使用 @Bean 配置就在代码中做,在 yml 尽量只注入环境变量相关的内容

*脚手架

TODO 根据以上的说法,提供一个引入 mq、cache、database 的脚手架,用于快速地构建一个 Web 应用。