单体框架迁移到模块化,最核心的难点从来不是技术选型,而是如何在不中断业务的前提下,安全地把一个“大泥球”拆成可以独立演进的小单元。很多团队一上来就讨论是用OSGi还是用Spring Modulith,或者直接跳到微服务,这其实把顺序搞反了。迁移的起点不是选工具,而是先搞清楚现有代码里哪些是真正的“模块”,哪些只是因为历史原因被塞进同一个目录下的代码堆。没有这个认知,任何迁移都会变成一场昂贵的重命名运动。

真正的模块化不是把代码从一个巨大的war包挪到十个小的jar包里,而是让每个模块拥有明确的边界、清晰的接口和独立的变更节奏。你去看那些迁移失败的项目,几乎都有一个共同特征:他们把物理拆分当成了逻辑拆分。把代码分到不同文件夹或者不同仓库,但模块之间仍然通过几十个互相依赖的Service类直接耦合,这种拆分除了让调试路径变长之外,没有带来任何架构收益。

先画模块边界,再动代码

在动手写任何一行迁移代码之前,最值得花时间做的事情是绘制一张“模块依赖图”。这张图不是给架构师看的PPT,而是真正从代码里提取出来的。你需要一个静态分析工具,比如JDepend、ArchUnit或者Structure101,把现有代码里包与包之间的依赖关系全部拉出来。这时候你通常会看到两种典型问题:一种是“循环依赖”,A依赖B,B又依赖A,这在单体里非常普遍;另一种是“上帝包”,几乎所有模块都依赖一个叫common或者util的包,这个包里有日期工具、有业务常量、甚至还有DAO的基类。

处理循环依赖的第一步不是拆,而是“解环”。最有效的方法是用依赖倒置原则,在A和B之间抽出一个接口层C,让A和B都依赖C。这个过程不需要新建任何服务或者部署单元,只是在现有代码里移动接口和类。很多人低估了这一步的价值,实际上当你把循环依赖全部解开之后,模块的边界自然就浮现出来了。那些只能单向依赖的包集群,就是未来模块的雏形。

用“内部API”替代直接依赖

模块化最容易被忽视的一步,是为每个模块定义对外的API。在单体里,任何public类都可以被任何其他类调用,这导致了事实上的无边界。迁移到模块化框架,不管是Spring Modulith还是Java Platform Module System,核心变化就是:模块内部可以有任意复杂的实现,但模块之间只能通过显式声明的API通信。

实际操作上,你不需要一步到位引入模块系统。可以先在现有项目里建立包级别的约定,比如每个模块建一个api子包,里面只放接口、DTO和异常类。然后通过ArchUnit写测试用例,强制要求模块A不能直接访问模块B的internal包。这个测试一旦集成到CI流水线里,就相当于用最轻量的方式建立了一道“架构护栏”。我见过不少团队用这种方式运行了半年以上,等到模块边界真正稳定了,才考虑引入更重的模块化框架。

数据库拆分比代码拆分难十倍

代码层面的模块化只是整个迁移工作量的三分之一,真正的硬仗在数据库。单体应用通常共享一个巨大的数据库,表之间通过外键和联表查询紧密耦合。当你把代码拆成模块之后,如果所有模块仍然直连同一个数据库的所有表,这种模块化就是自欺欺人。

数据库拆分的策略通常分三个阶段推进。第一阶段是“逻辑隔离”,通过代码规范禁止模块A直接访问模块B的表,所有数据访问必须通过模块B提供的服务接口。这不需要改任何数据库结构,但需要严格的代码审查和架构测试来保障。第二阶段是“物理隔离但同库”,在同一个数据库实例里为不同模块建立不同的schema,利用数据库本身的权限控制来强制隔离。第三阶段才是“独立数据库”,当某个模块的访问量或者数据量需要独立扩展时,再把它迁移到独立的数据库实例。

这里有一个关键经验:不要一开始就拆数据库。先让代码模块化稳定运行至少两到三个迭代周期,确认模块之间的API设计是合理的,再去动数据库。因为API一旦需要修改,代价远低于数据库迁移的回滚成本。

事务边界需要重新设计

单体应用里,一个Service方法可以轻松地操作多张表,并且用一个数据库事务保证一致性。模块化之后,跨模块的操作不能再依赖本地事务。这是迁移过程中最容易引发生产事故的地方。

对于必须保证强一致性的跨模块操作,有两种实用方案。方案一是把协调逻辑放在一个更高层次的“编排模块”里,利用数据库的XA分布式事务,但XA的性能开销很大,只适合低频操作。方案二是采用最终一致性,通过事件驱动的方式让各个模块各自处理自己的本地事务,然后通过消息队列或者事件表来保证最终一致。实际落地时,大多数业务场景其实可以接受最终一致性,只是产品经理和业务方需要被明确告知这个变化。

一个更务实的做法是:在迁移的过渡期,允许部分跨模块操作仍然走原来的单体事务路径,但把这些操作标记为“待重构”,设定明确的技术债务偿还时间。这比强行在迁移过程中同时改造事务逻辑要安全得多。

渐进式迁移的具体步骤

第一步,选定一个业务上相对独立、依赖关系清晰的模块作为“种子模块”。判断标准很简单:这个模块被其他模块依赖,但它自身不依赖或者很少依赖其他模块。比如用户认证模块、字典配置模块通常是好的起点。这些模块在现有代码里往往已经比较内聚,拆分的风险最小。

第二步,在现有代码仓库里为种子模块建立独立的包结构,把它的接口和实现分离。这个阶段不改变任何部署方式,只是调整代码组织。同时编写架构测试,确保其他模块只能通过接口访问这个模块。

第三步,把种子模块的代码迁移到独立的Maven或Gradle子模块。这一步开始涉及构建工具的改动,但部署单元仍然可以是一个整体的应用。子模块之间通过jar包依赖,而不是远程调用。这是很多人忽略的一个关键点:模块化不等于分布式,你完全可以在同一个JVM进程里运行多个模块,只是它们在构建层面是独立的。

第四步,当多个模块在构建层面独立之后,引入一个轻量级的模块化框架来管理模块之间的依赖和生命周期。Spring Modulith是目前比较务实的选择,它不需要额外的容器,只是在Spring Boot之上增加了模块可见性控制和模块间事件传递的能力。如果你用的是Java 9及以上版本,也可以直接使用JPMS,但它的模块声明语法和反射限制会让老项目的迁移成本陡增,通常不建议作为第一步。

第五步,当模块边界稳定、接口成熟之后,再评估哪些模块需要独立部署。这时候你会发现,因为模块之间的依赖已经通过接口解耦,把某个模块拆成独立服务的技术难度大大降低。而且这时候你做出的微服务拆分决策,是基于真实的业务边界和调用数据,而不是拍脑袋的猜测。

依赖管理是持续性的工作

迁移完成之后,最大的挑战是防止架构退化。模块之间的依赖关系会随着需求迭代而慢慢腐化,今天加一个直接引用,明天加一个工具类调用,半年之后你会发现之前辛辛苦苦建立的边界又模糊了。

有效的做法是把架构测试固化到CI流水线里。用ArchUnit编写模块依赖规则,比如“模块A不能依赖模块B的内部类”、“模块C只能被模块D和模块E依赖”等等。每次代码提交都会自动运行这些测试,违反规则就构建失败。这种自动化护栏比任何架构文档都管用,因为它不会过期。

另外,定期运行依赖分析工具生成模块依赖图,和团队一起评审。如果发现某个模块的依赖方向出现了反转,或者出现了新的循环依赖,要在当个迭代里修复,不要积累。架构债务和技术债务一样,越早还代价越小。

团队结构也要跟着模块走

康威定律在这个场景里体现得淋漓尽致。如果你的代码拆成了模块,但团队仍然是一个大组共同维护所有模块,那模块边界迟早会被打破。理想情况下,每个模块应该有一个明确的负责人或者一个小团队,他们对模块的内部实现有完全的控制权,但对外提供的API需要和其他团队协商。

这不意味着一定要把团队拆散,至少在迁移初期,可以保持团队结构不变,但明确每个模块的“代码所有权”。谁有权合并这个模块的Pull Request,谁对这个模块的架构质量负责,这些规则要写清楚。当模块真正独立部署之后,团队拆分就是水到渠成的事情。

从单体迁移到模块化框架,本质上是在做两件事:一是让代码的物理结构反映业务逻辑的真实边界,二是建立一套机制让这些边界不会随着时间推移而模糊。技术工具会变,但这两个原则是不变的。把这两件事做好了,不管未来你是继续演进到微服务,还是保持模块化单体,架构都能持续支撑业务的增长。