模块化单体:不拆服务也能管好边界
橙猫守夜
7 分钟阅读
判断先说:多数团队在纠结"该不该拆微服务"之前,真正缺的不是服务拆分,而是模块边界。一个依赖单向、接口稳定的单体,能支撑到相当可观的流量与团队规模。过早拆服务,往往只是把"边界没管好"升级成"分布式边界没管好",成本更高。
模块的边界是什么
边界不是目录名,而是编译器和测试都能约束的契约。一个模块对外只暴露少量稳定接口,其余声明为内部实现;模块之间只允许单向依赖,不允许循环引用。
按技术分层(controller / service / dao)切分,几乎不产生业务边界——同一个订单的增删改查散落在三层,改一处要在三层间跳。按功能域切分(orders / billing / inventory)才让"一个能力的变动收敛在一个模块内"。
com.shop.orders
├─ api // 对外接口与 DTO,唯一允许被依赖的部分
├─ domain // 内部领域逻辑
└─ internal // 内部实现,外部禁止依赖
边界粒度:别切太碎也别切太粗
模块数量不是越多越好。一个只有两三个类、却被十几个地方依赖的"模块",本质是个工具包,不该和领域模块混为一谈。经验上,模块的划分应对应"可独立理解、可独立变更"的业务能力;当一个模块大到几百个类、内部又分多个子域时,再在模块内按子域细分包,而不是一上来就铺几十个平级模块。
判断模块切得合不合理的朴素标准:新人能否在不读全站代码的前提下,只靠 api 包和模块文档就完成一次跨模块调用?如果必须翻遍内部实现才敢调用,说明边界没站住。
用可执行规则钉死边界
靠口头约定,三个月就会腐化。把规则写成测试,在 CI 里跑,才能持续有效。ArchUnit 能直接断言依赖方向:
@AnalyzeClasses(packages = "com.shop")
class BoundaryTest {
@ArchTest
static final ArchRule 依赖只能指向api =
classes().that().resideInAPackage("..orders..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..orders..", "..billing.api..", "java..");
}
这条规则禁止 orders 模块直接触及 billing 的内部实现。有人图省事跨层调用,构建立即失败。
Java 9 的 module-info.java 还能在编译期做强封装:未 export 的包,即便类是 public,外部模块也不可见。这比包名约定硬一档:
module shop.orders {
exports com.shop.orders.api;
requires shop.billing.api;
}
模块间靠防腐层通信
两个模块交互时,调用方只依赖被调方的 api 包,通过稳定 DTO 通信,不共享数据库表,不共享内部实体。若被调方模型变动频繁,调用方加一层适配器,把对方数据结构翻译成自己的领域模型,挡住"内部改动外溢"。
防腐层听起来像额外成本,但在模块演进速度不一致时极有价值:支付模块半年一变,营销模块一周一变,营销依赖支付时若直接消费支付内部模型,支付一改营销就崩。适配器把这种耦合收敛在一处,改动可见、可测。
用指标监控边界健康
定期统计:哪个模块被依赖最多(枢纽模块,改动影响面大,需最稳的接口);哪个模块依赖了最多模块(疑似职责过宽)。循环依赖一旦出现应作为阻塞项处理。下表对比两类拆分在边界成本上的差异:
| 维度 |
模块化单体 |
拆成微服务 |
| 跨模块调用成本 |
进程内方法调用 |
网络 RPC + 序列化 |
| 边界约束手段 |
编译器 + 单测 |
网关 + 契约测试 |
| 数据一致性 |
单库事务 |
分布式事务或最终一致 |
| 部署粒度 |
整体发布 |
独立发布 |
| 排障复杂度 |
单进程内栈 |
跨服务链路追踪 |
数据层也要有边界
模块共享同一数据库时,必须用 schema 或表前缀隔离,禁止 orders 模块直接 SELECT billing 的表。若将来拆库,这层隔离能省掉大量梳理工作。理想情况下每个模块只通过自己的仓储访问自己的数据。
共享库变成"公共模块"垃圾桶是常见退化:把各类工具塞进 common,结果所有模块都依赖它,一改全站重建。common 只放真正无业务上下文的底层工具(如日期格式化、加密工具)。
单测的模块视角
测试应围绕模块的公共行为写,而非围绕内部方法。内部实现重构时,只要 api 行为不变,测试不应大面积失效。把测试视为模块契约的一部分,而非实现细节的镜像。模块级集成测试也应只通过 api 入口驱动,确保内部重组不会破坏对外承诺。
何时才值得拆服务
出现下列信号之一再考虑独立成服务:该模块对独立扩容有强诉求(计算密集、流量曲线与主站不同);团队规模大到单一代码库合并冲突频繁、构建时间不可接受;某模块对可用性、合规的要求与主系统明显割裂。
没有这些信号时,模块化的单体在开发效率、调试便利、事务一致性上都优于微服务。
渐进式模块化:从混乱单体到清晰边界
很多团队不是从零开始,而是面对一个已经耦合的单体。想一步到位切出干净模块不现实,更可行的路径是渐进:先把高频变动、又最容易引发连带故障的那一块,在目录和包层面圈出来,加一条 ArchUnit 规则禁止它反向依赖其他业务包;跑通几周后,再把下一块圈出来。每步都保留可运行、可回退,比一次性大重构安全。模块边界不是设计出来的蓝图,而是沿业务变更热点慢慢长出来的。
当某个模块被独立部署的需求真正出现——扩容曲线不同、团队边界清晰——再把它连同 api 包一起抽成服务。因为此前依赖已是单向、接口已稳定,抽离的代价主要是把进程内调用换成 RPC,并补齐重试、超时、可观测,业务逻辑几乎不动。反过来,一开始就拆服务,等发现拆错再合回去,成本远大于渐进模块化。
踩坑清单
循环依赖伪装成模块:A 调 B、B 又调 A,说明职责没切清。解法通常是抽出共享内核,或重新划分职责边界。
只在目录上分模块,依赖却四通八达:没有单向约束,拆不拆没区别。边界的价值在于"改一处不必读懂全部"。
把内部类误标为 api 暴露出去:短期方便,长期变成无法修改的契约。api 包应保持小而稳,内部重构不波及它。
边界清晰后,将来真要拆服务,迁移成本反而低:接口已稳定、依赖已单向,抽出去只是把进程内调用换成 RPC。
浏览 0
点赞 0
评论 0