28.5 遗留系统集成 (绞杀者 / 防腐层)
一句话
集成遗留系统只有两条正当路径:防腐层(ACL) 把它的模型挡在边界外,绞杀者(Strangler) 一点点把它替换掉;两条都不做,新系统会慢慢长成旧系统的形状,并且再也没人敢动旧系统。
两条路径的分工
| 路径 | 做什么 | 何时用 | 代价 |
|---|---|---|---|
| 防腐层(Anti-Corruption Layer) | 在边界做模型转换,旧系统的概念不外泄 | 旧系统长期存在、不能替换 | 适配器要维护;转换逻辑会变厚 |
| 绞杀者(Strangler Fig) | 新实现逐步接管流量,旧系统萎缩直至下线 | 旧系统可被替代,且有明确边界 | 双跑期成本 + 路由切换 |
| 大爆炸重写 | 一次性替换 | 几乎从不推荐 | 风险与工期不可控,历史上成功率低 |
两者可以叠加:先用 ACL 隔离,再用绞杀者按功能切片迁移。
代码:ACL 的形态
// 旧系统:字段名诡异、语义含糊、错误码是数字
type legacyCustomer struct {
CUST_NO string `json:"cust_no"`
STAT int `json:"stat"` // 0=正常 1=冻结 9=注销(无文档)
AMT_UNIT int `json:"amt_unit"` // 1=元 2=分(历史遗留)
}
// 防腐层:把旧模型翻译成领域模型,旧概念不外泄
func (a *legacyAdapter) GetCustomer(ctx context.Context, id string) (Customer, error) {
raw, err := a.client.Fetch(ctx, id)
if err != nil { return Customer{}, translateErr(err) } // 错误码也翻译
if raw.AMT_UNIT == 2 { /* 分 → 元 */ }
return Customer{ID: raw.CUST_NO, Frozen: raw.STAT == 1}, nil
}
实测:ACL 到底挡住了什么(静态扫描)
把 “领域层代码” 当字符串做旧字段泄漏扫描:
| 写法 | 命中的旧字段 | 泄漏 |
|---|---|---|
直接访问旧模型(raw['STAT']、raw['AMT_UNIT']) | STAT、AMT_UNIT | 2 处 |
经 ACL 转成领域模型(c.frozen) | 无 | 0 处 |
这不是 “代码风格”,而是可自动检查的边界:把 “领域层不许出现旧字段名” 写成 lint 规则,ACL 就真的挡住了;没有这条规则,三个月后又会有人为了 “临时取个数” 直接读旧表。
# 可复现:在领域层目录里搜旧字段名,命中即失败
grep -rn "CUST_NO\|AMT_UNIT\|STAT" ./domain/ && exit 1 || echo "ACL 边界完好"
反模式
| 反模式 | 后果 |
|---|---|
| 让新系统直接读旧库 | 旧模型渗透进新代码,ACL 名存实亡 |
| ACL 里写业务规则 | 转换层变成第二套业务逻辑,无人敢改 |
| 绞杀者没有路由开关 | 出问题无法瞬间切回旧实现 |
| 双跑期不做数据对账 | 新旧结果不一致,切换时才发现 |
| 一次性重写 | 与业务节奏冲突,通常烂尾 |
| 不记录旧系统的“隐含规则” | 迁移后才发现 “这个字段其实兼作状态机” |
| ACL 只做字段改名,不做语义转换 | 旧系统的 “单位/枚举” 含义照样漏进领域层 | | 迁移后不删旧适配器 | 两套入口并存,新代码可能绕过 ACL |
三条铁律
🔑 旧系统的模型不许穿过边界;ACL 是唯一入口。
🔑 绞杀者必须有可一键回切的路由开关,并做过对账。
🔑 先补文档再迁移:旧系统的隐含规则不写下来,迁移就是猜。
自检
Q1 为什么 “直接读旧库” 是伪集成?
▸ 答案:因为它把旧系统的数据模型变成了新系统的编译期依赖:旧表改字段、改枚举、加触发器,新系统全部受影响;而且 “旧库里的隐含规则”(哪个字段兼作状态机)会悄悄渗进新逻辑。ACL 的价值就是把这种耦合挡在一个适配器里。
Q2 绞杀者模式的第一步应该做什么?
▸ 答案:先加路由开关与双跑对账,再迁第一个功能切片:让 “新实现” 与 “旧实现” 可并行、可对比、可回切。没有这层基础设施,迁移就是 “一次性切换”,风险与重写无异。
Q3 ACL 里为什么不能写业务规则?
▸ 答案:因为 ACL 的职责是翻译(字段映射、错误码转换、单位换算),一旦它开始 “判断该不该冻结、该不该退款”,就变成了第二个业务层:既没有领域模型的测试覆盖,又卡在所有调用的必经之路上。业务规则必须回到领域层。
Q4 什么时候该放弃绞杀、直接重写?
▸ 答案:当旧系统没有可切分的边界(一个巨大存储过程包办一切)、且业务允许长期停机或双轨运行时。即便如此,也应先做 “数据模型梳理 + 影子读” 验证。绝大多数 “直接重写” 失败在 “隐性规则没摸清”,不是技术。
Q5 双跑期怎么对账才有意义?
▸ 答案:按业务终态对账(订单状态、金额、数量),而不是逐字段比对:逐字段会被格式差异淹没。频率按业务风险定(资金类每小时,内容类每天),差异必须有 owner 与处理时限。
Q6 迁移完成后,旧系统还要保留多久?
▸ 答案:至少保留到 一个完整业务周期(含月结/季报/审计)之后,且确认没有 “只读调用” 残留。下线前要抓一次真实流量做空跑验证;直接关停是按 “代码里搜不到引用” 判断的,而引用常常在配置、脚本、报表里。
Q7 为什么 “领域层不许出现旧字段名” 这条 lint 比架构图有用?
▸ 答案:因为架构图不参与构建,而 lint 在 CI 里阻断合并:它把 “边界” 变成可执行规则,违规无法悄悄进主干。代价是要先冻结存量违规(白名单),再逐步清零,否则第一次接入就会因为历史代码太多而放弃(见 27.3 模块边界与依赖规则)。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| 上线 | 新系统金额比旧系统大 100 倍 | 旧库 amt_unit:1=元、2=分 | 停写、修数 |
| T+1h | 定位到新代码直接读了旧表字段 | 无 ACL,旧模型泄漏 | 加适配层 |
| T+1d | 修完数据,但报表仍用旧口径 | 两套口径并存 | 统一口径 + 对账 |
| 之后 | 加 lint:领域层禁出现旧字段名 | 边界可检查 | 逐模块迁移 |
根因:绕过 ACL 直接读旧表,且未处理 “单位” 语义。代价:金额错账 + 一天停写。根治:ACL 强制入口 + 领域层 lint + 双跑对账。
关联
- 前置:28.3 服务契约设计、28.2 ESB
- 并列:27.5 单体内部的演进
- 下游:58.2 技术债管理、58.4 演进路线图