4、演进第一步:模块化拆分——从包结构到多模块Maven项目

说实话,很多团队在谈微服务时,一上来就想着拆成几十个独立服务。我见过不少这样的案例——最后都陷入了“分布式泥潭”。

我个人习惯是:先别急着拆服务,先把代码拆清楚。这一步叫模块化拆分。说白了,就是在同一个代码仓库里,把原来的一锅粥,分成几个独立的模块。

为什么非要先做模块化?

你想想看,一个典型的单体应用,包结构长什么样?

com.example.project
├── controller
├── service
├── dao
├── model
├── utils
└── config

这种按“技术层次”划分的方式,在项目初期确实够用。但一旦业务复杂起来,问题就来了:

  • 订单相关的代码散落在 controller、service、dao 各个包里
  • 用户相关的代码也散落在同样的包里
  • 两个业务之间互相调用,没有明确的边界

我在项目中遇到过最夸张的情况:一个修改订单状态的方法,竟然被六个不同的业务模块直接调用。改一个地方,六个地方跟着出问题。

核心观点:模块化拆分的本质,是把“技术分层”变成“业务分区”。

第一步:重新组织包结构

嗯,这里要注意。不要一上来就搞 Maven 多模块。先做包级别的拆分,这是成本最低的尝试。

我建议的做法是:

  1. 先梳理出核心业务域。比如电商系统:订单、商品、用户、支付、库存
  2. 每个业务域独立成一个顶级包
  3. 每个包内部再按技术层次组织

改造后的结构大概是这样:

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 内的直接调用,变成了模块间的调用。怎么处理?

我推荐两种方式:

  1. 直接方法调用——如果两个模块在同一个进程内,直接 new 或者 Spring 注入就行
  2. 接口抽象——在 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 个模块

满足这些条件,模块化拆分就算完成了。下一步,才是考虑要不要拆成独立的微服务。

总结一下:模块化拆分是微服务演进的第一步,也是最安全的一步。它不改变运行时架构,只改变代码组织方式。但这一步走稳了,后面的拆分就会顺畅很多。

下一章,我们会聊聊如何从模块化走向真正的微服务拆分。到时候,你会感谢今天做的这些准备工作。