Maven 工程规范:真正的承重墙

random-pic-api

规范这种东西,到底有用没用

上一篇文章聊了研发底座的整体架构和一些让我头疼的设计问题。今天深入第一个优化方向:工程与项目规范。

先说实话——规范这个东西,技术人员天然反感。觉得这是管理岗搞出来的条条框框,约束创造力。但我在这个项目中待了一段时间后,反而成了规范最坚定的推动者。为什么?因为一个反直觉的事实:在人员流动性大、技术水平参差不齐的项目中,规范的约束力比框架的技术能力更重要。

框架再牛,也挡不住有人用一百种写法实现同一个功能。Spring Security 配置得好好的,有人非要在 Controller 里手写一个 Token 校验。响应体统一封装好了,有人就是要自己 new 一个 HashMap 返回。日志规范定了,但每个人的埋点格式都不一样。

这不是技术问题,这是没有规范或者规范没有执行的问题。

产品经理圈子里有句老话,叫”把用户当傻子”。意思是让用户减少学习成本、容易上手,是产品设计的核心追求。研发底座本质上也是一个产品——它的用户是研发人员。你不能假设每个使用者技术实力都很强、都有好的编码习惯。你会这么想,是因为你在架构组,你周围的人能力都不差。但业务团队的现实是:有人刚毕业,有人从其他语言转过来,有人忙着赶需求没空研究框架文档。

所以他们需要规范。不是管他们,而是帮他们——告诉你目录怎么建、依赖怎么管、代码怎么写、包怎么打。你照做就行,不用思考。

第一步,从项目脚手架开始。

为什么不能直接复制示例项目

框架的现状是提供了一个叫 xxx-manage-service 的示例项目给业务方看。但有些团队图省事,直接把这个示例项目复制一份开始做业务开发。这听起来没什么,实际上后患无穷。

首先,你要删掉原来的 .git 目录,重新 git init。然后在 GitLab 上创建一个新的业务仓库关联起来。接着修改项目名称——根目录名、pom.xml 的 artifactId、application.yml 里的应用名,还有一些藏在代码里的引用。最后还要把示例代码删掉。删完之后呢?那些示例代码中演示的推荐写法你也看不到了。于是要么再拉一份示例项目下来对照,要么凭记忆写。

有人说”用原来的示例项目建多个分支来管理不同业务”,这更不现实。不同业务项目的演进路径完全不同,它们的代码应该在不同的仓库里,而不是同一个仓库的不同分支。

更合理的做法是提供一个 Maven Archetype。它是一个项目模板,你在命令行里跑一条命令,输入 groupId、artifactId、项目名,它自动给你生成一个标准化的项目骨架:

1
2
3
4
5
6
mvn archetype:generate \
-DarchetypeGroupId=com.xxx \
-DarchetypeArtifactId=project-archetype \
-DarchetypeVersion=3.1.0 \
-DgroupId=com.xxx.business \
-DartifactId=my-business-service

生成的骨架包含了所有约定的目录结构、基础配置文件和必要的代码注释。不是空壳,而是一个”可以直接开始写业务代码”的状态。

有人可能会说,Spring Initializr 也能做这个事,甚至可以直接二开 start.spring.io。理论上可行,但目前阶段性价比不高。Spring Initializr 的二次开发需要维护一个 starter 索引服务,和内部的 starter 体系对接,而且对于非 Spring Boot 标准场景的支持需要额外开发。现阶段用 Maven Archetype 是最轻量的方案,一条命令就能用,不需要额外部署服务。

目录结构不是想放哪就放哪

框架的目录结构不能只有 src/main/javasrc/test/java。这是 Maven 的默认约定,没问题,但它不完整。我要求的固化结构至少还要包含这些:

1
2
3
4
5
6
7
8
9
10
11
12
project-root/
├── docs/ # 文档跟着代码走
│ ├── architecture.md # 架构说明
│ └── api-guide.md # 接口说明
├── db/ # 数据库脚本跟着版本走
│ ├── init/ # 初始化脚本
│ └── update/ # 变更脚本(按版本号命名)
├── branch.md # 分支说明,杜绝"口口相传"
├── src/
│ ├── main/java/...
│ └── test/java/...
└── pom.xml

为什么 docs/ 很重要?因为这个项目型的团队里,文档往往被放在 Wiki 或者 Confluence 里。但那些地方的文档和代码版本是脱节的——代码升到了 3.1.0,Wiki 上可能还是 2.1.0 的说明。把文档放在代码仓库里,跟着版本一起走,至少保证每个版本的代码都有对应版本的文档。

为什么 branch.md 很重要?因为一个大型项目可能会有很多分支——feature、hotfix、release、个人开发分支。新来的人面对二十几个分支,完全不知道哪个是干什么的。老员工口头告诉他的信息,可能过两天就忘了。一份写在仓库里的分支说明文档,成本极低,但避免了大量的沟通浪费。

为什么 db/ 很重要?因为数据库结构的变更应该跟着代码版本走。你今天改了一个表结构,你的 SQL 脚本放在哪?如果放在你本地电脑上,那其他人怎么知道这个变更?如果放在 Wiki 上,那和代码版本的对应关系怎么保证?放在 db/update/ 下,按版本号命名——V2.1.0__add_user_phone_column.sql——清清楚楚。

这些看似”谁都知道”的约定,其实执行得很差。原因不是大家不想做,而是没人把这些要求明确地写下来并强制检查

Maven 依赖管理的几个坑

scope 和 optional,你真的分得清吗

这是我最常被问到的问题之一。Maven 有两个看起来很像的东西——<scope>provided</scope><optional>true</optional>。它们的区别用一个场景就能讲清楚。

以 Lombok 为例。Lombok 只在编译期有用,运行时完全没用。那应该怎么配?

第一步考虑:要不要打入部署包?不要。所以 scope=provided。provided 的语义是”编译和测试时可用,运行时由容器提供,不打入部署包”。

第二步考虑:如果我的项目被其他项目依赖,Lombok 要不要传递过去?不要。但这里有一个细节——provided 本身就不会传递依赖。所以用了 provided,也就不需要 optional。

那 optional 用在什么场景?经典例子是 Spring Boot 的 Starter。一个 Starter 可能支持多种日志框架——Logback、Log4j2,但它不会强制你全部引入。做法是在父 POM 中声明所有可选日志框架的依赖,每个都标 optional=true,然后业务方按需显式引入自己想用的那一个。

简单总结:你关注的是打包控制,用 scope;你关注的是依赖传递性,用 optional 对于 Lombok、注解处理器这类编译期工具,一律用 provided,既不打入部署包,也不向下游传递。

另一个常见争议是 spring-boot-starter-parent vs spring-boot-dependencies。两者都可以管理 Spring Boot 依赖的版本,但有细微差别。

spring-boot-starter-parent 除了管理依赖版本,还带了一堆 Maven 插件的默认配置——编译插件、打包插件、资源过滤规则。小型项目或者个人项目用它就够了,简单省事。

但它不适合作为企业级框架的父 POM。为什么?因为框架的父 POM 需要放自己的东西——内部的仓库配置、内部的插件配置、内部的依赖版本管理。如果你直接继承 spring-boot-starter-parent,那你的父 POM 就只能有一个爹。而你真正需要的是 Spring Boot 的 BOM(Bill of Materials),也就是依赖版本对齐的能力,不是它那些插件默认配置。

所以推荐的做法是:框架的父 POM 用 spring-boot-dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 框架自己的 BOM -->
<dependency>
<groupId>com.xxx</groupId>
<artifactId>framework-dependencies</artifactId>
<version>${framework.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

这样既用了 Spring Boot 的版本对齐,又保留了框架自己父 POM 的灵活空间。

为什么需要一个独立的 dependencies 模块

看一个具体问题。框架的 common-parent 中同时定义了子模块的依赖,比如 user-api-starter 做为一个依赖项出现在了 parent 的 dependencyManagement 中:

1
2
3
4
5
<dependency>
<groupId>com.xxx</groupId>
<artifactId>user-api-starter</artifactId>
<version>${project.version}</version>
</dependency>

这让父子模块耦合在了一起。推荐的做法是创建一个独立的 framework-dependencies 模块,把所有 starter 组件的依赖管理集中在这里。parent 只管构建配置,dependencies 只管版本管理。职责分离,边界清晰。

构建产物怎么打包

当前主流做法是 Spring Boot 的 fat jar——一个 jar 包包含所有依赖和资源。但我更推荐使用 maven-assembly-plugin 做分层打包:

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── bin/
│ └── launcher # 通用启动脚本
├── config/ # 环境配置(外置,方便修改)
│ ├── application.yml
│ ├── application-prod.yml
│ ├── application-dev.yml
│ └── build-info.properties
├── app.jar # 主 jar(只有业务代码)
└── lib/ # 第三方依赖
├── spring-boot-3.x.jar
├── mybatis-plus-3.x.jar
└── ...

这个结构有几个好处。首先,配置外置——你要改个数据库地址,不需要解压 jar 包,直接改 config 目录下的配置文件就行。其次,依赖和业务代码分离——部署时如果只改了业务代码,只传 app.jar 就行,lib 目录不需要动。这点在 Docker 场景下特别有用,配合 Docker 的分层缓存,只变更业务代码时构建速度能快不少。

有人会说 fat jar 更简单,一个文件传上去就行。这没错。但简单是有代价的——每次改一行代码,你都要重新传一个 100MB 的 jar。企业内网的带宽可能不是问题,但构建和部署的时间就是问题。而且分层打包后,启动脚本(bin/launcher)也标准化了,不需要每个项目自己写一套启动命令。

这还没完

工程规范这件事,我上面说的只是冰山一角。编码规范、测试规范、版本发布规范,每一项都值得单独展开。这些在后续文章里会陆续写到,特别是代码质量工具链(Checkstyle + PMD + SonarQube)我们在第六篇会详细讲。

但有一件事我想先强调:**规范的意义不在于”写得有多全”,而在于”执行得有多到位”**。你花三个月写了一本三百页的编码规范,没人看,等于没写。你只定了三条规则,但 CI 管道自动检查,不通过就不给合入——这才叫规范。

下一篇我们聊一个更”血淋淋”的话题——统一响应体 Response 的重构。这玩意涉及面太广了,几乎每个接口都在用,改它不是技术问题,是怎么保证不出线上事故的问题。