English version: Modularity and migration: cutting boundaries that survive。本文是交易类 App 架构系列第五篇。
一个长期存活的应用有一个设计文档从不提及的性质:它必须一边改变形状,一边持续交付。没有人可以停下来一年。这个约束,比任何技术约束都更能决定哪种架构能活下来。
两个很老的想法在这里承担了大部分工作,第三个则告诉你该怎么移动。
你的架构会长成组织的样子
Conway 的观察是:系统的模块边界,最终会映射生产它的组织的沟通边界。这不是一句警告,而是一个足够可靠、可以据以设计的预测。
务实的读法是:如果八个团队做同一个应用,你大概会得到八个模块。唯一的问题是,这八个是不是你选的。与人们实际协作方式吻合的边界会很便宜,一个团队不必和别人协调就能交付;横切多个团队的边界则会永久昂贵,因为每一次改动都要开一次会。
所以模块化不只是代码组织练习,它是一个关于谁可以不问谁就交付的决定。这也是为什么它最终体现在构建时间、评审时长,以及有多少次发布被一个不相干的团队卡住。
在“可能会变的决策”处下刀
判据来自 Parnas,1972 年,至今仍是关于模块化最有用的一句话:一个模块应当隐藏一个可能会变的决策。
请注意它没有说什么。它没有说按技术分层来分组,模型放一块、视图放一块。那种排布是大多数团队的第一反应,而它恰好把目标搞反了:一次典型的改动是一个功能,而一个功能会碰到模型、视图和服务,于是每次改动都要碰每个模块。你造出的边界只增加了仪式,什么也没有隔离。
检验方法很简单:拿出你最近十次有意义的改动,看每一次碰了几个模块。 如果大多数只碰一个,你的边界与现实吻合;如果大多数碰了四个,说明你的模块是按文件类型划的,而不是按改动划的。修法是改为沿功能和易变决策来切:行情协议、图表引擎、认证流程、持久化选型。
核心的依赖应该是被交给它的
有了模块之后,问题是箭头朝哪边。依赖倒置给出答案:核心声明它需要的接口,比如一个网络传输、一个存储、一个时钟、一个日志器,由外部世界提供实现。核心不依赖任何具体实现。
由此得到三件事,这也是这个模式在大型系统里反复出现的原因:
- 核心可以被隔离测试,因为每一个协作者都能换成假的,包括时钟。这正是你如何在不真的等待的情况下测试一次超时。
- 基础设施可以被替换而不触碰业务规则。当且仅当核心里没有任何地方点了 HTTP 客户端的名,它才真的是一个实现细节。
- 那份接口清单就是真实的依赖文档。 一个由十几个被注入能力初始化的核心,会明确告诉你它需要外界提供什么,这往往比 wiki 里那张架构图更诚实。
对应的坏味道是一个会自己往外伸手的核心:自己构造 HTTP 客户端、自己读系统时钟、自己调用单例日志器。它能跑,同时悄悄把业务规则焊死在基础设施上。你会在试图测试它的那天发现这件事。
注册表买到解耦,代价是安全性
当模块之间必须在没有编译期依赖的情况下互相找到对方时,团队会选择注册表:模块在启动时注册实现,使用方按接口查找。
好处是真实的:模块可以独立构建和交付,谁也不需要在编译期引用兄弟模块。
代价同样真实:一个缺失的绑定不再是编译错误,而是运行时崩溃,发生在某个人某个周二才走到的代码路径上。你用一个保证换来了灵活性。
如果你接受这笔交易,就要有意识地把风险还掉:把接口放进双方都依赖的小型契约模块;并且在调试构建里于启动时校验整份注册表,让缺失的绑定立刻大声失败,而不是深埋在某个用户流程里。灵活的装配没问题,无声的灵活装配才有问题。
迁移的方式是让新的在旧的里面长
大型应用迟早需要一门它起步时没有的技术。本能反应是重写,而重写之所以失败,原因是结构性的,不是态度问题:你瞄准的是一个移动的靶子。你重建的同时,现有产品还在不断增加功能,于是你必须追平一个仍在生长的东西,而在追平之前,你什么也交付不了。
绞杀榕的做法把它倒了过来:让新系统在旧系统内部生长,一次一屏,直到旧的可以被移除。具体需要四样东西:
- 一条接缝,通常是路由或导航,让同一个目的地可以由任一实现来承接。
- 按屏追平,而不是按应用追平。 一屏是一个可交付单元,一整个应用不是。
- 两个运行时共存,并明确接受上一篇里列出的那些成本。
- 一个熔断开关。 新实现在线上出问题时,路由要能退回去。没有它,增量迁移的风险画像和重写是一样的,只是摊薄了。
代价是有一段时间(往往是几年)要同时维护两套做法。这是“永远不需要一个切换日”所付的价钱;对一个停机或路线图停滞不可接受的产品,这笔交易是划算的。这也是为什么大型应用从外面看总显得“不一致”:你看到的是一次正在进行中的迁移,而那正是一个健康的长寿系统该有的样子。
不要过早买入这些
上面的一切,定价对应的是协调问题。如果你没有协调问题,它们就是开销。
对一到三个开发者来说,模块边界主要是在增加仪式:更多构建配置、更多间接层、更多要翻的地方。一个注册表严格劣于直接调用那个东西。诚实的默认选项是一份代码库,内部结构清晰,但不设强制边界。
等到某条边界开始硌人时再切模块:同一个区域反复出现合并冲突、构建时间已经能被感知、没人说得清归属、你想替换某个依赖却换不掉。这些都是测量结果。“我们可能会长大”不是。
昂贵的失败不是少了一个模块,而是一个小团队在维护一个大团队的架构,为一个并不存在的组织支付协调成本。
参考
- M. E. Conway,“How Do Committees Invent?”,Datamation,1968——系统结构会复刻构建它的组织的沟通结构。
- D. L. Parnas,“On the Criteria To Be Used in Decomposing Systems into Modules”,CACM 15(12),1972——模块应当隐藏一个可能变化的决策。
- R. C. Martin,“The Clean Architecture”,2012——依赖规则:源码依赖只能向内、指向策略层。
- A. Cockburn,“Hexagonal Architecture”,2005——端口与适配器:核心声明它需要什么,外部来供给。
- M. Fowler,“Strangler Fig Application”,2004——让新系统沿着旧系统的边缘生长,直到旧系统可以被移除。
- M. Fowler,“Inversion of Control Containers and the Dependency Injection pattern”,2004——依赖注入与服务定位器的对比,以及各自在可测试性上的代价。