写微服务项目,我这两年从单体一路折腾过来,工具链换过好几轮,最近半年把主力构建工具从Maven切到了Gradle,配合Spring Boot重新搭了一套多模块微服务骨架。这篇博文就把整个搭建过程完整拆开来讲:模块怎么划、Gradle怎么配、服务之间怎么通信、构建加速和踩坑点怎么处理,全部是能直接拿到项目里用的实操内容。适合正在学微服务、或者准备把单体项目改造成多模块结构的Java开发者,新手可以照着一步步来,老手可以直接翻到第5章看问题排查。
先说清楚这套东西是什么:用Gradle管理一个包含多个Spring Boot服务的工程,每个业务能力独立成一个子模块,顶层通过统一配置控制依赖版本和构建行为。它能解决的主要问题是代码复用混乱、依赖版本冲突、模块之间耦合太深,以及传统单工程里"改一处全家重启"的痛点。下面我从设计思路开始讲,全程带代码,不整虚的。
1. 整体设计:先想清楚再敲键盘
1.1 多模块和微服务不是一回事
很多初学者容易把"多模块工程"和"微服务"直接画等号,这是个很大的误区。多模块是构建层面的组织方式,一个Gradle工程里塞了多个子项目,它们共享一套构建配置,最终编译成多个Jar包;而微服务是运行层面的架构方式,强调的是独立进程、独立部署、独立生命周期。两者可以互相配合,但不等价。
我见过一些团队把几十个服务塞进一个Gradle多模块工程里,理由是"方便统一管理"。结果模块之间依赖越来越深,编译一次要跑全量构建,部署还是要一个个拆出去,完全没有享受到微服务的独立部署红利,反而背上了巨型工程的构建负担。所以做这套实战之前,首先要确定你的边界:哪些模块适合放在同一个Gradle工程里,哪些服务应该彻底独立。
我的判断标准很简单,同一份代码仓库、同一次版本发布、共享同一套研发流程的模块,适合放进同一个多模块工程;凡是需要独立发布、独立扩缩容、由不同团队维护的,建议直接拆仓库。微服务不等于把所有内容物理塞进一个工程里,多模块工程更适合承载"基础组件加多个业务领域服务"这样的中等规模集群。
1.2 为什么这个项目选Gradle而不是Maven
这个问题几乎每次分享都会被问到。我的回答是:Gradle在构建性能、脚本灵活性和依赖缓存上确实比Maven更适合中大型多模块项目。Maven的优势是稳定、生态成熟、大家都会用,但它的XML配置冗长,一个稍微复杂点的多模块项目,pom文件复制粘贴一屏都放不下;Gradle用Groovy或Kotlin DSL,循环、条件判断、自定义任务是内置能力,灵活性高出一截。
性能差距在项目变大后非常明显。Gradle的增量构建机制基于task输入输出快照,只有文件内容真正变了才会重新执行;Maven的增量能力相对粗糙,经常出现改了一个模块却触发大范围插件重跑的情况。我实际测试过,同一个包含6个子模块的工程,冷启动构建Gradle比Maven快大约30%到40%,热构建(只改单模块)差距更大,能到好几倍,这对日常开发体验影响极大。
当然Gradle也不是没有代价,它默认不带中央仓库的完整镜像,国内网络环境下下载依赖会让人抓狂;构建脚本的灵活也意味着更容易写出一坨"只可意会不可言传"的DSL。这些坑在后面都会提到,提前打好预防针就好。
1.3 模块边界:三个维度确定该怎么拆
动手创建模块之前,我习惯先用三个维度过一遍业务:部署维度、复用维度和变更频率维度。部署维度决定哪些组件必须独立成服务,比如用户服务、订单服务如果后续要单独扩容,就应该各自独立;复用维度决定哪些代码应该下沉成公共模块,比如统一返回体、异常处理、工具类,这类代码如果复制到每个服务里,后期改动就是全量复制,明显不合理;变更频率维度决定哪些模块应该依赖稳定,比如API接口定义模块应该尽量少变,一旦频繁变动,消费方就得跟着折腾。
在这个实战中,我把工程分成了五个模块,结构如下:
common:基础公共代码,包括统一响应体、统一异常、工具类,被所有模块依赖,但不依赖任何具体业务。api:各服务对外暴露的Feign接口定义,以及DTO传输对象。这个模块让服务之间可以脱敏依赖。user-service:用户领域服务,负责用户相关业务以及用户数据接口的实际返回。order-service:订单领域服务,负责订单业务,通过api模块调用用户服务。bootstrap:拆出来的启动模块,集中管理启动类和配置文件。
这里有个在设计时容易被低估的点:bootstrap这个模块。把启动类放在顶层或者某个业务模块里,短期内没啥问题,但只要涉及多个服务需要独立配置、独立端口,杂乱的启动类会让整个工程越来越难管理。拆出独立的启动模块,每个子服务对应一个启动类,配置文件通过profile区分,看起来多了一层,实际上部署和排查轻松很多。
2. 环境准备与Gradle核心配置
2.1 JDK和Gradle版本怎么选才不打架
从源头说,Gradle对JDK版本有自己支持的矩阵,不是随便配个最新JDK就能跑。以当前环境为例,Spring Boot 3.x要求Java 17起步,我用的是JDK 17;Gradle则需要选择7.6.1以上版本,旧版Gradle跑在JDK 17上要么直接报错,要么某些插件行为异常。推荐做法是Gradle 8.x配JDK 17或21,Spring Boot 3.2.x配合使用非常稳定。
安装这块,Linux和macOS用户我推荐用SDKMAN管理JDK和Gradle版本,一行命令切换,不用把环境变量改来改去。Windows用户建议直接下载Gradle的zip包解压,然后配置GRADLE_HOME和PATH环境变量。很多人卡在环境变量上,记住两条:一是PATH里要追加%GRADLE_HOME%\bin,二是配置完要重开终端,然后运行gradle -v验证。
提示:JDK 17安装后建议顺手配置
JAVA_HOME。Gradle在构建时会优先去找JAVA_HOME,如果你本机装了多个JDK版本,不把JAVA_HOME指清楚,构建时很可能莫名其妙报"Unsupported class file major version"。
2.2 国内开发者必做的镜像加速配置
Gradle默认依赖中心是Maven Central和Gradle Plugin Portal,在国内网络环境下下载慢、失败率高是常态。这个不做镜像配置,后面每一步都会很痛苦。我的经验是把镜像配到三个地方:全局初始化脚本、项目的仓库配置、插件仓库配置。
全局脚本可以直接加快所有项目的下载速度。在用户目录下创建~/.gradle/init.gradle(Windows用户是C:\Users\你的用户名\.gradle\init.gradle),内容如下:
allprojects { repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://maven.aliyun.com/repository/spring' } mavenLocal() mavenCentral() } buildscript { repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } } } }需要说明的是,init.gradle这种全局镜像配置适合个人开发机,如果项目里有多人协作,更推荐把镜像写进项目自己的build.gradle里,这样大家拉代码后配置自动生效,不用各自折腾。项目里的配置我会在下一节给出。
国内镜像源除了阿里云,腾讯云也提供了maven镜像,地址是https://mirrors.cloud.tencent.com/nexus/repository/maven-public/。我自己实测阿里云源的稳定性和同步速度都更胜一筹,正常情况下作为主选没有问题。如果你的团队有私服(比如Nexus),建议把私服地址也配到仓库列表里,顺序放在阿里云之前。
2.3 根工程配置:Settings和构建脚本的分工
创建好工程目录后,第一件事是写settings.gradle。Gradle从8.x开始默认使用.gradle后缀,老版本用的是.gradle的同名文件也没问题。多模块工程的settings.gradle核心内容是仓库地址、插件仓库、模块声明,以及可选的点版本管理配置:
pluginManagement { repositories { maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://maven.aliyun.com/repository/public' } gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/spring' } mavenLocal() mavenCentral() } } rootProject.name = 'microservice-demo' include 'common' include 'api' include 'user-service' include 'order-service' include 'bootstrap'这里两个细节值得展开:dependencyResolutionManagement里的FAIL_ON_PROJECT_REPOS模式很关键,它强制所有模块的依赖仓库统一归根工程管理,防止哪个子模块自己写个仓库地址导致构建行为分裂;pluginManagement则统一管插件下载源。这些细节决定了你的构建环境是否可复现,团队协作时尤其重要。
根目录下的build.gradle不直接引用Spring Boot插件,而是用apply false把插件下载到本地,由各子模块按需启用:
plugins { id 'java' id 'org.springframework.boot' version '3.2.4' apply false id 'io.spring.dependency-management' version '1.1.4' apply false } allprojects { group = 'com.example' version = '1.0.0-SNAPSHOT' } subprojects { apply plugin: 'java' apply plugin: 'io.spring.dependency-management' java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } configurations { compileOnly { extendsFrom annotationProcessor } } dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } tasks.named('test') { useJUnitPlatform() } }注意根build.gradle这里已经统一给所有子模块注入了Lombok依赖和注解处理配置,这是多模块工程最容易出问题的点之一。很多人每个模块重复写Lombok依赖,漏掉了注解处理配置,导致编译通过但运行时报NoClassDefFoundError,后面第5章我会专门讲。
2.4 用Version Catalog管理依赖版本,告别版本地狱
多模块项目最怕什么?依赖版本不统一。团队里不同人引了不同版本的Jackson或MyBatis-Plus,编译时大家都好,运行时就各种诡异。Gradle从7.0开始推荐使用Version Catalog来集中管理版本号,这个机制比Maven的dependencyManagement更直观,也比手工写一堆ext变量更规范。
在gradle/libs.versions.toml文件里定义版本和依赖:
[versions] spring-boot = "3.2.4" spring-cloud = "2023.0.1" spring-cloud-alibaba = "2023.0.1.0" mybatis-plus = "3.5.5" mysql-connector = "8.3.0" lombok = "1.18.32" [libraries] spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } spring-cloud-starter-openfeign = { module = "org.springframework.cloud:spring-cloud-starter-openfeign" } spring-cloud-starter-alibaba-nacos-discovery = { module = "com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-discovery" } spring-cloud-starter-alibaba-nacos-config = { module = "com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-config" } mybatis-plus-spring-boot3-starter = { module = "com.baomidou:mybatis-plus-spring-boot3-starter", version.ref = "mybatis-plus" } mysql-connector-j = { module = "com.mysql:mysql-connector-j", version.ref = "mysql-connector" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } [bundles] spring-boot-web = ["spring-boot-starter-web", "spring-boot-starter-actuator"]子模块里再也不需要写带版本号的依赖坐标,比如user-service的依赖段落就变成:
dependencies { implementation project(':common') implementation project(':api') implementation libs.spring.boot.web implementation libs.spring.cloud.starter.alibaba.nacos.discovery implementation libs.mybatis.plus.spring.boot3.starter runtimeOnly libs.mysql.connector.j testImplementation libs.spring.boot.starter.test }这种写法的好处是升级版本只需要改toml文件,全局生效,不会再出现某个子模块用老版本、另一个模块用新版本的问题。顺便提一句,如果你从Maven迁移过来,Maven的properties功能就是Version Catalog要替代的东西,但Version Catalog不只是版本变量,它还能定义依赖组合(bundle),对常见starter集合的复用非常方便。
3. 核心模块落地:从公共代码到启动模块
3.1 common模块:统一响应、异常处理和工具类
任何微服务工程,公共模块先打底。我设计的common模块不依赖任何Spring Boot业务组件,只依赖基础库和Lombok,这样其他模块引用它的时候不会把一堆无用的starter带过来,构建速度和依赖纯净度都好很多。
先定义统一响应体,这里有个设计细节:泛型加上success、code、message、data四个字段,比直接用Map返回多了类型安全保障,也会让Swagger接口文档自动生成时结构更加明确。
@Data public class Result<T> implements Serializable { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(int code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }异常处理这块,我强烈建议在common模块里放一个全局异常处理器。不放在业务服务里,是因为每个服务都要用,写在一个地方然后让所有服务引入,能保证错误结构一致。常见的处理思路是:@RestControllerAdvice捕获业务异常、参数校验异常、兜底异常,分别转成统一响应体返回。处理的时候注意日志要打完整异常栈,线上排查问题全靠它。
工具类方面,我一般放三类:JSON工具封装(基于Jackson,不选Fastjson的原因是它历史漏洞偏多)、Bean拷贝工具封装(基于Spring的BeanUtils,包一层可以方便替换)、以及一些日期和字符串处理的静态方法。工具类的设计原则是不能太厚,不要什么方法都往里塞,否则又会变成一个上帝类,后期没人敢碰。
3.2 业务服务模块:User-Service和Order-Service的落地姿势
业务模块是整个工程的核心。以user-service为例,它的build.gradle需要依赖common、api模块,以及数据库、MyBatis-Plus等数据访问组件。注意这里有个容易搞反的依赖方向:业务模块依赖api模块,但api模块不能依赖业务模块,否则就形成循环依赖,这是多模块工程的大忌。
user-service内部按经典的controller-service-mapper来分包:
com.example.user ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ └── UserMapper.java └── entity └── User.java启动类放在user-service里还是bootstrap里?我的做法是工程前期放在自己模块内,方便快速启动调试;等集群服务多了,统一迁到bootstrap。最终我推荐拆出bootstrap模块的原因之一,是你把启动类放在user-service里的话,bootstrap就没有存在的意义了。后面第3.4节会统一说明启动模块改造。
Service层的接口和实现拆分这里,我提一个建议:不要每个Service都强制拆接口。如果这个服务只有一种实现、不打算做多态替换,直接写一个具体的类反而更清晰。实践中有太多人为了"架构规范"写了接口和impl两个文件,结果接口永远只有一个实现,形成无意义的间接层。这个工程里我保留了接口,是为了和前端暴露的内部Feign接口对应,逻辑上更严谨,但如果你的服务内部逻辑不复杂,完全可以直接写实现类。
3.3 api模块:服务间调用的接口契约
服务间调用最怕的是各自对着数据库查资源、或者A服务直接依赖B服务的Mapper实现。高耦合的典型表现是B服务表结构一变,A服务的SQL全挂。引入api模块就是为了在服务之间建立"契约":被调方实现这个接口,调用方只依赖接口定义。
在api模块里定义一个Feign客户端接口,比如user-service提供给外部的用户查询能力:
@FeignClient(name = "user-service", contextId = "userApiClient") public interface UserApiClient { @GetMapping("/api/user/info/{id}") Result<UserInfoDTO> getUserInfo(@PathVariable("id") Long id); }对应的DTOUserInfoDTO也放在api模块里,只包含传输需要的字段,不暴露内部实体结构。这样即使user-service内部的用户表加了一个deleted字段、或者把密码相关字段做了改造,只要接口和DTO不变,对调用方来说就是无感升级。
DTO放哪需要刻意控制。很多团队会把DTO、VO、BO分层搞得特别细,每个模块都要建一堆类,实际效果往往是类爆炸。我建议对外传输统一用api模块的DTO,对内逻辑处理可以用实体对象,能合并就不强行拆分,避免为分层而分层。
3.4 bootstrap启动模块:启动类和配置文件的集中管理
当服务数量超过两三个,启动类分散在各业务模块里,部署时就得记住"哪个类在哪个模块",多环境配置文件也容易搞得互相覆盖,非常不直观。我的方案是把所有启动类放到bootstrap模块,同时把多环境配置文件也集中在这里。
bootstrap的build.gradle逻辑:对每个业务模块依赖,同时启用Spring Boot插件。这里有一个关键点:哪个模块的build.gradle应用了org.springframework.boot插件,哪个模块就会被构建成可执行Jar。所以我把这插件只加在bootstrap模块上,其他业务模块不应用,这样打出来的包就天然是"可运行的服务Jar + 依赖的普通Jar",运行和部署逻辑非常清晰。
plugins { id 'java' id 'org.springframework.boot' id 'io.spring.dependency-management' } dependencies { implementation project(':user-service') implementation project(':order-service') // 其他依赖 }启动类示例:
@SpringBootApplication @EnableDiscoveryClient public class UserApplication { public static void main(String[] args) { SpringApplication.run(UserApplication.class, args); } }配置文件集中到bootstrap模块的src/main/resources下,通过文件名区分服务和环境,比如:
application.yml:公共配置application-user.yml:用户服务配置application-order.yml:订单服务配置application-user-dev.yml:用户服务开发环境
启动时通过--spring.profiles.active=user,dev组合激活。这种设计一开始看起来比传统单模块多了点复杂度,但服务一多,它的好处就出来了:找配置不用翻半天目录,部署脚本里只要指定profile,剩下的全部统一。
4. 微服务化落地:注册发现、远程调用和监控集成
4.1 Nacos服务注册与配置:服务发现怎么做
微服务之间要通信,第一步是让对方能找到彼此。user-service和order-service都注册到注册中心,调用方根据服务名去发现地址,而不是在配置文件里写死IP。这里我选的是Nacos,相比Eureka和Consul,Nacos同时提供注册中心和配置中心,在国内生态里用得最广,和Spring Cloud Alibaba集成也最顺畅。
引入依赖后,在application.yml里配置服务名和注册中心地址:
spring: application: name: user-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: dev启动类上加上@EnableDiscoveryClient注解,Spring Cloud新版里其实可以省略,但显式标注更明确,团队里有人用旧版框架时也不会踩坑。启动服务后,在Nacos控制台能看到两个服务都上线了,这时就可以验证服务间的调用。
配置中心的使用建议:把每个服务的非敏感配置放到Nacos的配置列表中,比如线程池参数、开关类配置、限流阈值。这些配置变更频率高,放在Nacos里可以动态刷新,不用改代码重启。敏感配置(数据库密码、秘钥等)建议用Nacos的加密插件,或者直接用环境变量注入,不要明文放在配置文件里提交到代码仓库。
4.2 OpenFeign远程调用:服务间通信的正确打开方式
服务注册好之后,接下来是实现服务之间的调用。同步调用我首选OpenFeign,它能直接基于接口定义生成HTTP客户端,天然契合前面api模块的接口设计。底层走HTTP,虽然性能不如直接HTTP Client,但胜在开发效率高、代码直观,配合负载均衡组件可以自动做服务实例间的负载均衡。
调用方只需要把api模块中的UserApiClient注进来,像调用本地方法一样调用即可:
@Service @RequiredArgsConstructor public class OrderServiceImpl implements OrderService { private final UserApiClient userApiClient; @Override public OrderVO createOrder(OrderCreateRequest request) { Result<UserInfoDTO> userResult = userApiClient.getUserInfo(request.getUserId()); if (userResult.getCode() != 200) { throw new BizException(userResult.getCode(), userResult.getMessage()); } // 创建订单业务逻辑 return orderVO; } }用OpenFeign有几个细节必须处理。第一个是超时配置,默认连接超时只有10秒,内部调用通常要压到2到3秒,避免拖垮对端服务。第二个是错误处理,Feign在HTTP非2xx状态时会抛异常,要统一捕获转成业务异常,不能让它直接暴露给前端。第三个是路径动态变量、参数对象序列化等边界情况,建议在api模块里针对每个接口写一个冒烟测试,确保接口调用方和被调方序列化规则一致。
feign: client: config: default: connectTimeout: 3000 readTimeout: 50004.3 Actuator与Micrometer监控:微服务可观测的基础
服务上线之后,怎么知道它还活着、指标正常不正常?Spring Boot Actuator是自带的监控端点方案,Micrometer则是统一的指标收集门面。新版Spring Boot 3生成端点时,默认只暴露health,其他敏感端点需要手动开启,这也是网上很多"Actuator漏洞"问题的根源——生产环境把env、metrics、beans这些端点直接暴露出来了。
我推荐的生产环境暴露策略是只开health、info、prometheus三个端点,并且加上认证或网络隔离。配合Micrometer注册表可以输出Prometheus格式指标,明确暴露哪些、可以有哪些敏感信息先要自己想清楚。
看一个build.gradle里的依赖配置:
implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus'对应的application.yml配置:
management: endpoints: web: exposure: include: health,info,prometheus endpoint: health: show-details: never这块在微服务体系里属于锦上添花但必须做的部分。没有监控的服务就像开夜车没开大灯,系统哪天挂了大概率是运维先发现然后通知你,而不是你自己提前发现。Actuator加Prometheus加Grafana是一条成熟的监控链路,先把基础端点配好,后面接入告警就顺理成章。
5. 构建加速与常见问题排查
5.1 Gradle构建慢?先别骂Gradle
每次做完一个多模块工程,总会有人反馈"Gradle构建好慢",但其实八成不是Gradle的问题。先说构建加速的几个有效手段,我实际用下来提升非常明显。
第一个是开启构建缓存并配置远程缓存。Gradle构建缓存默认是本地开启的,同一台机器重复构建直接命中;团队场景配置远程缓存后,A机器构建过的结果B机器可以直接复用,效果从几分钟缩到几秒。配置远程缓存需要额外搭服务,小团队可以先不开,本地缓存也够用。
第二个是善用并行构建和按需配置。在gradle.properties里加上:
org.gradle.daemon=true org.gradle.parallel=true org.gradle.caching=true org.gradle.configureondemand=true其中daemon是常驻进程,避免每次构建都启动JVM;parallel让子模块能并行构建;configureondemand只配置和当前任务相关的模块,对大型多模块工程很有效。我遇到过有些项目不开并行,构建一次要跑四五分钟,开了之后直接降到两分钟左右。
第三个是JDK和Gradle自身的问题。如果你还在用Java 8跑Gradle 8.x,那你会遇到大量不兼容警告甚至报错;如果条件允许,建议直接用JDK 17或21,配合最新稳定版Gradle,整体启动和编译速度都有明显提升。这不是玄学,是Gradle官方在每个版本里都对较新JDK做了专门的优化。
5.2 依赖冲突和Maven和Gradle双修的坑
多模块项目依赖冲突是最常见的问题,常见症状是运行时报NoClassDefFoundError、ClassNotFoundException、或者NoSuchMethodError。Gradle的依赖解析策略默认是取最高版本,这看起来很美好,但真实世界里往往存在传递依赖和直接依赖打架的情况。排查时先看依赖报告:
gradle :user-service:dependencies --configuration runtimeClasspath这个命令会列出所有依赖树,你可以快速定位某个jar来自哪个链路,也能看到版本冲突后最终用了哪个版本。如果看到冲突,合理做法是直接在需要的模块里显式声明依赖版本,覆盖掉传递依赖的不确定版本。
还有一个我踩过很多次的坑:工程里同时存在Maven和Gradle两套构建脚本(比如项目从Maven迁移到Gradle),旧同事往往习惯手动往本地Maven仓库mvn install,新构建脚本配置了mavenLocal(),于是把本地仓库里的过期版本依赖给解析进来,导致发布到服务器后运行的是旧代码。处理方案是:迁移期间把mavenLocal()排在仓库列表最后,环境稳定后直接去掉,避免本地仓库污染。
5.3 Lombok与注解处理器在多模块下的配置陷阱
细心的读者应该注意到,我在根build.gradle里用configurations { compileOnly { extendsFrom annotationProcessor } },这个配置对Lombok来说至关重要。Lombok在编译期通过注解处理器生成getter、setter、builder等方法,如果你只在依赖里加了compileOnly但没把Lombok加到annotationProcessor配置里,编译时IDE可能没问题,因为IDE工具默认会补上注解处理;但一旦通过Gradle命令行或者CI构建,就会报找不到getter/setter方法,特别典型的是cannot find symbol错误。
多模块环境下,这个问题经常出在传递依赖上:common模块依赖了Lombok,业务模块通过implementation project(':common')间接依赖了它,但Lombok的注解处理器不会顺着传递依赖跑到业务模块里去。这就是为什么我建议在根subprojects里统一配好Lombok依赖和注解处理,而不是每个模块各自做,一旦有人忘了配就得白白排查半天。
另外,使用了MyBatis-Plus的项目要注意,它的分页插件和MyBatis注解在某些版本里和Lombok搭配时,编译期会出现奇怪的NoSuchMethodError。遇到这种情况先逐项检查版本兼容性,一般不是Lombok本身的问题,而是版本组合太新或太旧。借助gradle dependencies报告能快速定位。
5.4 Deprecated GFeatures警告和版本兼容处理
构建日志里出现过一段英文警告,大意是"Deprecated Gradle features were used in this build, making it incompatible with Gradle X.0",很多新手看到之后不知所措,其实它的意思是当前构建脚本中用了某个将在未来版本被移除的旧API或旧配置方式,当前版本还能跑,但升级Gradle之后可能就编译不过了。
处理这个警告的方法很直接:gradle build --warning-mode all跑一次,详细日志会精确告诉你哪个脚本的第几行用了废弃特性。常见来源包括旧式依赖语法、不再推荐的自定义任务写法、某些插件对老Gradle API的调用。拿到具体去改就行,改完之后警告消失,未来升级Gradle就会顺畅很多。
如果这个警告来自第三方插件,你又没办法改插件的源码,一个临时方案是锁定Gradle版本不升级,等插件更新了再统一处理。别为了消警告硬上高版本Gradle,有时候第三方插件还没适配,升级会引入新的兼容问题,破坏比收益大。
5.5 多模块工程下MyBatis-Plus和Mapper扫描的边界
最后提一个框架集成上常见的坑:MyBatis-Plus扫描Mapper时,默认只扫描启动类所在包及其子包。多模块工程里,如果启动类在bootstrap模块,而Mapper在user-service模块的com.example.user.mapper包下,直接把@MapperScan("com.example.user.mapper")写在bootstrap的启动类上是扫描不到user-service里那些Mapper的。
我自己踩过之后总结的解决方案有两种。第一种是在每个业务模块自己的配置类上用@MapperScan标注,这样各模块自行管理扫描路径,启动类不需要关心业务模块内部的Mapper位置;第二种是在bootstrap的启动类上同时写多个扫描路径,比如@MapperScan({"com.example.user.mapper", "com.example.order.mapper"}),这样能一次扫描多个包。第一种方式我更推荐,因为它让模块自治,新增业务服务时不用去改启动类。
关于MyBatis-Plus配置,还有一点值得注意:多模块里公共配置(比如逻辑删除、乐观锁插件)放common模块后,业务模块要确保MyBatis-Plus的starter确实被引入,否则插件配置写好了也不生效。建议在业务模块的配置类上写上这些插件,同时加上@Configuration注解,集成在Spring启动流程里。
写在最后的几个建议
这套工程从无到有搭建下来,绕过的坑确实不算少。我最想提醒的是,不要为了“多模块”而强行拆分,也不要为了“微服务”而把所有东西全部服务化。项目规模小、团队也不大的时候,单体加模块化就是最优解;等业务确实出现独立部署、独立扩展的需求时,再按本文的思路逐步演进。技术选型永远是为业务服务的,不管是Gradle还是Maven、多模块还是微服务,把问题解决得干净利落才是王道。
最后分享一个小技巧:在每个模块的构建脚本里加上下面这段,当模块越来越多时,可以快速打印每个子模块的依赖坐标,排查模块间依赖关系十分好用:
subprojects { task printDependencies { doLast { println "${project.path} -> ${configurations.runtimeClasspath.collect { it.name }}" } } }我用下来已经习惯每次接手新工程先跑一遍,对整个依赖全景心里有数之后再动手改代码。希望你也能少走点弯路。