BLOG · #Engineering

模块化与迁移:划出能活下来的边界

不管你规划与否,模块边界最终都会长成组织结构的样子。真正有用的判据不是分层也不是文件类型,而是“隐藏那些可能会变的决策”,而它同时也是让迁移不必重写的前提。

English version: Modularity and migration: cutting boundaries that survive。本文是交易类 App 架构系列第五篇。

一个长期存活的应用有一个设计文档从不提及的性质:它必须一边改变形状,一边持续交付。没有人可以停下来一年。这个约束,比任何技术约束都更能决定哪种架构能活下来。

两个很老的想法在这里承担了大部分工作,第三个则告诉你该怎么移动。

你的架构会长成组织的样子

Conway 的观察是:系统的模块边界,最终会映射生产它的组织的沟通边界。这不是一句警告,而是一个足够可靠、可以据以设计的预测。

务实的读法是:如果八个团队做同一个应用,你大概会得到八个模块。唯一的问题是,这八个是不是你选的。与人们实际协作方式吻合的边界会很便宜,一个团队不必和别人协调就能交付;横切多个团队的边界则会永久昂贵,因为每一次改动都要开一次会。

所以模块化不只是代码组织练习,它是一个关于谁可以不问谁就交付的决定。这也是为什么它最终体现在构建时间、评审时长,以及有多少次发布被一个不相干的团队卡住。

在“可能会变的决策”处下刀

判据来自 Parnas,1972 年,至今仍是关于模块化最有用的一句话:一个模块应当隐藏一个可能会变的决策。

请注意它没有说什么。它没有说按技术分层来分组,模型放一块、视图放一块。那种排布是大多数团队的第一反应,而它恰好把目标搞反了:一次典型的改动是一个功能,而一个功能会碰到模型、视图和服务,于是每次改动都要碰每个模块。你造出的边界只增加了仪式,什么也没有隔离。

检验方法很简单:拿出你最近十次有意义的改动,看每一次碰了几个模块。 如果大多数只碰一个,你的边界与现实吻合;如果大多数碰了四个,说明你的模块是按文件类型划的,而不是按改动划的。修法是改为沿功能和易变决策来切:行情协议、图表引擎、认证流程、持久化选型。

核心的依赖应该是被交给它的

有了模块之后,问题是箭头朝哪边。依赖倒置给出答案:核心声明它需要的接口,比如一个网络传输、一个存储、一个时钟、一个日志器,由外部世界提供实现。核心不依赖任何具体实现。

由此得到三件事,这也是这个模式在大型系统里反复出现的原因:

  • 核心可以被隔离测试,因为每一个协作者都能换成假的,包括时钟。这正是你如何在不真的等待的情况下测试一次超时。
  • 基础设施可以被替换而不触碰业务规则。当且仅当核心里没有任何地方点了 HTTP 客户端的名,它才真的是一个实现细节。
  • 那份接口清单就是真实的依赖文档。 一个由十几个被注入能力初始化的核心,会明确告诉你它需要外界提供什么,这往往比 wiki 里那张架构图更诚实。

对应的坏味道是一个会自己往外伸手的核心:自己构造 HTTP 客户端、自己读系统时钟、自己调用单例日志器。它能跑,同时悄悄把业务规则焊死在基础设施上。你会在试图测试它的那天发现这件事。

注册表买到解耦,代价是安全性

当模块之间必须在没有编译期依赖的情况下互相找到对方时,团队会选择注册表:模块在启动时注册实现,使用方按接口查找。

好处是真实的:模块可以独立构建和交付,谁也不需要在编译期引用兄弟模块。

代价同样真实:一个缺失的绑定不再是编译错误,而是运行时崩溃,发生在某个人某个周二才走到的代码路径上。你用一个保证换来了灵活性。

如果你接受这笔交易,就要有意识地把风险还掉:把接口放进双方都依赖的小型契约模块;并且在调试构建里于启动时校验整份注册表,让缺失的绑定立刻大声失败,而不是深埋在某个用户流程里。灵活的装配没问题,无声的灵活装配才有问题。

迁移的方式是让新的在旧的里面长

大型应用迟早需要一门它起步时没有的技术。本能反应是重写,而重写之所以失败,原因是结构性的,不是态度问题:你瞄准的是一个移动的靶子。你重建的同时,现有产品还在不断增加功能,于是你必须追平一个仍在生长的东西,而在追平之前,你什么也交付不了。

绞杀榕的做法把它倒了过来:让新系统在旧系统内部生长,一次一屏,直到旧的可以被移除。具体需要四样东西:

  1. 一条接缝,通常是路由或导航,让同一个目的地可以由任一实现来承接。
  2. 按屏追平,而不是按应用追平。 一屏是一个可交付单元,一整个应用不是。
  3. 两个运行时共存,并明确接受上一篇里列出的那些成本。
  4. 一个熔断开关。 新实现在线上出问题时,路由要能退回去。没有它,增量迁移的风险画像和重写是一样的,只是摊薄了。

代价是有一段时间(往往是几年)要同时维护两套做法。这是“永远不需要一个切换日”所付的价钱;对一个停机或路线图停滞不可接受的产品,这笔交易是划算的。这也是为什么大型应用从外面看总显得“不一致”:你看到的是一次正在进行中的迁移,而那正是一个健康的长寿系统该有的样子。

不要过早买入这些

上面的一切,定价对应的是协调问题。如果你没有协调问题,它们就是开销。

对一到三个开发者来说,模块边界主要是在增加仪式:更多构建配置、更多间接层、更多要翻的地方。一个注册表严格劣于直接调用那个东西。诚实的默认选项是一份代码库,内部结构清晰,但不设强制边界。

等到某条边界开始硌人时再切模块:同一个区域反复出现合并冲突、构建时间已经能被感知、没人说得清归属、你想替换某个依赖却换不掉。这些都是测量结果。“我们可能会长大”不是。

昂贵的失败不是少了一个模块,而是一个小团队在维护一个大团队的架构,为一个并不存在的组织支付协调成本。

参考


上一篇:跨平台的边界 · 下一篇:客户端安全模型