4、演进第一步:模块化拆分——从包结构到多模块Maven项目
说实话,很多团队在谈微服务时,一上来就想着拆成几十个独立服务。我见过不少这样的案例——最后都陷入了“分布式泥潭”。
我个人习惯是:先别急着拆服务,先把代码拆清楚。这一步叫模块化拆分。说白了,就是在同一个代码仓库里,把原来的一锅粥,分成几个独立的模块。
为什么非要先做模块化?
你想想看,一个典型的单体应用,包结构长什么样?
com.example.project
├── controller
├── service
├── dao
├── model
├── utils
└── config
这种按“技术层次”划分的方式,在项目初期确实够用。但一旦业务复杂起来,问题就来了:
- 订单相关的代码散落在 controller、service、dao 各个包里
- 用户相关的代码也散落在同样的包里
- 两个业务之间互相调用,没有明确的边界
我在项目中遇到过最夸张的情况:一个修改订单状态的方法,竟然被六个不同的业务模块直接调用。改一个地方,六个地方跟着出问题。
核心观点:模块化拆分的本质,是把“技术分层”变成“业务分区”。
第一步:重新组织包结构
嗯,这里要注意。不要一上来就搞 Maven 多模块。先做包级别的拆分,这是成本最低的尝试。
我建议的做法是:
- 先梳理出核心业务域。比如电商系统:订单、商品、用户、支付、库存
- 每个业务域独立成一个顶级包
- 每个包内部再按技术层次组织
改造后的结构大概是这样:
com.example.project
├── order
│ ├── controller
│ ├── service
│ ├── dao
│ └── model
├── product
│ ├── controller
│ ├── service
│ ├── dao
│ └── model
├── user
│ ├── controller
│ ├── service
│ ├── dao
│ └── model
└── common
├── utils
└── config
这一步做完,你会发现:
- 代码的可读性明显提升
- 业务边界开始清晰
- 团队分工变得容易——每人负责一个包
小技巧:包名用单数形式(order 而不是 orders),这是 Java 社区的惯例。我见过有人用复数,结果 IDE 自动补全时总是不太对劲。
第二步:引入 Maven 多模块
包结构稳定后,就可以考虑升级到 Maven 多模块了。为什么要这么做?
原因很简单:包级别的拆分只是逻辑上的,编译时还是一个大 jar。而 Maven 多模块可以做到:
- 每个模块独立编译、测试
- 模块之间有明确的依赖关系
- 可以单独发布某个模块的版本
来看一个典型的父 POM 配置:
<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ecommerce-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>ecommerce-common</module>
<module>ecommerce-order</module>
<module>ecommerce-product</module>
<module>ecommerce-user</module>
<module>ecommerce-web</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.7.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
每个子模块的 POM 只需要声明自己的依赖:
<!-- ecommerce-order/pom.xml -->
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>ecommerce-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>ecommerce-order</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>ecommerce-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>ecommerce-product</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
避坑指南:我曾经犯过一个错误——把所有模块的版本号都写死。结果升级一个模块时,其他模块的依赖全乱了。后来统一用 ${project.version} 管理,再也没出过问题。
第三步:定义模块间的依赖规则
模块化拆分最怕什么?依赖混乱。我见过一个项目,common 模块依赖了 order 模块,order 又依赖了 common——循环依赖,编译都过不去。
我总结了一套依赖规则,你可以直接拿来用:
| 模块 | 可以依赖 | 不能依赖 |
|---|---|---|
| common | 外部依赖(如 Spring、Guava) | 任何业务模块 |
| order | common、product(只读) | user、web |
| product | common | order、user、web |
| user | common | order、product、web |
| web | 所有业务模块 | 无 |
为什么要这样设计?
- common 是最底层,放工具类、公共配置,谁都不能依赖它
- 业务模块之间,尽量单向依赖。比如 order 可以读 product 的数据,但 product 不能反过来依赖 order
- web 是入口层,负责组装和路由,可以依赖所有业务模块
关键原则:依赖方向必须是从上到下,不能出现循环。如果两个模块需要互相调用,说明它们应该合并成一个模块。
第四步:处理模块间的调用
模块化之后,原来同一个 JVM 内的直接调用,变成了模块间的调用。怎么处理?
我推荐两种方式:
- 直接方法调用——如果两个模块在同一个进程内,直接 new 或者 Spring 注入就行
- 接口抽象——在 common 模块里定义接口,业务模块实现。这样调用方只依赖接口,不依赖实现
举个例子:
// common 模块中定义接口
public interface ProductService {
Product getProductById(Long id);
boolean checkStock(Long productId, Integer quantity);
}
// product 模块中实现
@Service
public class ProductServiceImpl implements ProductService {
@Override
public Product getProductById(Long id) {
// 从数据库查询
}
@Override
public boolean checkStock(Long productId, Integer quantity) {
// 检查库存
}
}
// order 模块中调用
@Service
public class OrderServiceImpl {
@Autowired
private ProductService productService;
public Order createOrder(Long productId, Integer quantity) {
if (!productService.checkStock(productId, quantity)) {
throw new RuntimeException("库存不足");
}
// 创建订单
}
}
这样做的好处是:
- 模块之间通过接口解耦
- 未来拆成独立服务时,只需要把接口调用改成 RPC 调用
- 单元测试时可以轻松 mock
个人经验:接口定义要放在 common 模块里,而不是业务模块里。否则会出现 common 依赖业务模块的尴尬情况。我早期就踩过这个坑,后来重构了一次才改过来。
模块化后的构建与测试
Maven 多模块项目构建起来很简单:
# 编译所有模块
mvn clean compile
# 只编译某个模块及其依赖
mvn compile -pl ecommerce-order -am
# 运行所有模块的测试
mvn test
# 打包所有模块
mvn package
这里有个小技巧:-am 参数表示“also make”,会自动编译依赖的模块。我平时开发时,经常用这个参数只编译我改动的模块,能省不少时间。
什么时候可以停止?
模块化拆分不是越细越好。我见过有人把 common 模块拆成了 common-util、common-config、common-dto……结果项目结构比原来还乱。
我的判断标准很简单:
- 每个模块的代码量在 2000-5000 行之间
- 模块间的依赖关系不超过 3 层
- 一个开发人员能独立负责 1-2 个模块
满足这些条件,模块化拆分就算完成了。下一步,才是考虑要不要拆成独立的微服务。
总结一下:模块化拆分是微服务演进的第一步,也是最安全的一步。它不改变运行时架构,只改变代码组织方式。但这一步走稳了,后面的拆分就会顺畅很多。
下一章,我们会聊聊如何从模块化走向真正的微服务拆分。到时候,你会感谢今天做的这些准备工作。