一个通知路由服务的演化:从最小核心到遗留系统迁移
设计文章里的最终架构图,常常让人以为好的设计是一开始就画出来的。实际的项目更像是:先解决一个具体问题,然后需求一个接一个到来,每一次都要决定改哪里、加什么、什么先不做。本文用一个可运行的告警通知路由服务,走一遍三次变化请求,再走一遍从旧系统迁移过来的过程。
这是设计专题的收尾篇,会用到前面各篇的方法:关注点分离与可测试性 的端口与时钟注入、函数式设计 的纯函数核心、API 与 DSL 的规则文本、DDD 的防腐层。案例是虚构的,代码在 JDK 21.0.5 与 JUnit Platform 1.13.4 上实测。
一、先说结论
- 新设计从可执行的需求开始:先把「支付的 P1 告警要打电话」写成测试,再写能通过测试的最小模型。
- 每一步只解决已经出现的问题:第一版没有渠道接口,没有规则引擎;三次变化到来时,都是通过新增类型完成的,核心模型没有被修改。
- 把规则交给业务人员维护是一个成本决定:用「自动化阶梯」判断当前处在哪一级、上一级要付出什么,而不是默认越自动越好。
- 遗留系统迁移先画目标,再盘点现状:新旧逻辑同时处理 10,000 条历史告警,第一轮只有 9,269 条一致,差异暴露了三条没有写进文档的旧行为。
- 差异不能默认按新系统处理:每一条都要和使用方确认是保留还是下线,并记录下来,这一步决定了切换能否被接受。
二、新设计线:从一个问题长出一个服务
2.1 问题与可执行需求
起点是一次事故复盘:支付服务的 P1 告警只发到了邮件组,值班人员 20 分钟后才看到。复盘结论是需要一个按团队和级别路由告警的服务。
在写任何类之前,先把需求写成测试:
@Test void 支付P1打电话并发群消息() { assertEquals(List.of(PHONE, CHAT), router().route(alert("payment", Severity.P1))); }
@Test void 支付P3只发群消息() { assertEquals(List.of(CHAT), router().route(alert("payment", Severity.P3))); }
@Test void 没有匹配的路由就不通知() { assertEquals(List.of(), router().route(alert("search", Severity.P1))); }这三个测试同时确定了接口的形状:输入一条告警,输出要通知的目标列表。它们也限定了第一版的范围,发送、重试、配置都不在里面。
2.2 最小核心模型
enum Severity { P1, P2, P3 }
record Alert(String service, String team, Severity severity, String fingerprint, Instant at) {}
record Target(String channel, String address) {}
record Route(String name, Predicate<Alert> when, List<Target> to) {}
final class Router {
private final List<Route> routes;
Router(List<Route> routes) { this.routes = List.copyOf(routes); }
List<Target> route(Alert a) {
LinkedHashSet<Target> out = new LinkedHashSet<>();
for (Route r : routes) if (r.when().test(a)) out.addAll(r.to());
return List.copyOf(out);
}
}Router.route 是一个纯函数:不发送、不读配置、不看当前时间。规则直接写在代码里,发送用一段脚本调用现有的电话和群机器人接口。
为什么这时候够用:只有支付一个团队在用,规则一共两条,改规则的是写代码的人。这时候做渠道抽象、规则配置中心,都是在为没有出现的需求付成本。
2.3 变化一:渠道开始增加
两周后,搜索团队接入,希望用群消息;支付团队希望 P1 同时打电话。发送逻辑不能再是一段脚本了。
新增的是一个端口和一个编排类:
interface Channel { String name(); void send(Target t, Alert a); }
final class Dispatcher {
List<Target> dispatch(Alert a) {
Instant now = clock.instant();
List<Target> sent = new ArrayList<>();
for (Target t : router.route(a)) {
if (rules.stream().anyMatch(r -> r.suppress(a, t, now))) continue; // 变化二加入
Channel c = channels.get(t.channel());
if (c == null) throw new IllegalStateException("未注册的渠道:" + t.channel());
c.send(t, a);
sent.add(t);
}
return sent;
}
}Router 一行没改。新增一个渠道,就是新增一个 Channel 实现并注册进去。
这一版留下了一个问题,测试把它记录了下来:路由规则里写了一个没有注册的渠道,要到真正发送时才报错。
@Test void 渠道没注册在发送时报错() { … assertThrows(IllegalStateException.class, () -> d.dispatch(…)); }当时规则只由开发者写,代码评审能兜住,所以先接受。到变化三,规则交给非开发者之后,这个问题就必须解决。
2.4 变化二:夜里被 P3 吵醒
接入一个月后,值班人员反馈两个问题:同一个告警每分钟重复触发,群里刷屏;凌晨的 P3 告警把人吵醒,但它们第二天处理也不迟。
新增一个抑制策略接口和两个实现:
interface Suppression { boolean suppress(Alert a, Target t, Instant now); }
final class QuietHours implements Suppression { // 夜间静默,只作用于不严重的告警
public boolean suppress(Alert a, Target t, Instant now) {
if (a.severity().compareTo(atMost) < 0) return false;
LocalTime local = now.atZone(zone).toLocalTime();
return !local.isBefore(from) || local.isBefore(to); // 22:00—08:00 跨午夜
}
}
final class Dedup implements Suppression { … } // 同一告警、同一目标 10 分钟内只发一次时间通过 Clock 注入,now 作为参数传进策略,所以跨午夜的边界可以精确测试:
@Test void 静默区间边界() {
assertTrue(q.suppress(a, CHAT, Instant.parse("2026-09-21T14:00:00Z"))); // 上海 22:00 开始静默
assertTrue(q.suppress(a, CHAT, Instant.parse("2026-09-21T23:59:00Z"))); // 07:59
assertFalse(q.suppress(a, CHAT, Instant.parse("2026-09-22T00:00:00Z"))); // 08:00 恢复
assertFalse(q.suppress(a, CHAT, Instant.parse("2026-09-21T13:59:00Z"))); // 21:59
}QuietHours 是纯函数,Dedup 需要记住上次发送的时间,是有状态的,它属于外壳的一部分。这个区别在 函数式核心、命令式外壳 里讨论过。Router 和 Channel 都没有改。
2.5 变化三:每周都要改规则
服务推广到六个团队之后,路由规则每周都要改:换值班人、加服务、调整级别。每次都要找开发者改代码、走发布,值班负责人等一两天。
在决定怎么做之前,先看这件事处在哪一级:
| 级别 | 做法 | 谁来改 | 适合的情况 |
|---|---|---|---|
| 1 | 人工处理 | 值班人员手动转发 | 极少发生,不值得写代码 |
| 2 | 开发者改代码 | 开发者,走发布 | 规则少、变化少,或者变化需要新的能力 |
| 3 | 开发者改配置 | 开发者,不用发布 | 变化频繁,但仍需要懂系统的人把关 |
| 4 | 业务人员通过领域接口配置 | 值班负责人 | 变化频繁,修改者懂业务、不懂代码,且有校验和预览兜底 |
这把尺子只用来分析,不是说越高越好。每上一级,都要额外实现校验、预览、权限、审计和回滚。规则一年改两次,停在第 2 级最便宜;这里每周都在改,而且等待已经影响到值班,值得上到第 4 级。
实现方式是一种外部 DSL,把规则写成值班负责人能读懂的文本:
# 值班负责人维护
route 支付高优先级: team=payment severity<=P2 -> phone:oncall-payment, chat:payment-alerts
route 支付全部: team=payment -> chat:payment-alerts解析器 RuleText.parse 把文本转换成同样的 Route 对象。核心模型没有改,只是多了一种构造它的方式。两个测试确保这一步没有引入新的行为:
@Test void 文本规则与代码规则等价() {
Router fromText = new Router(RuleText.parse(RULES, Set.of("phone", "chat")));
for (Severity s : Severity.values()) for (String team : List.of("payment", "search"))
assertEquals(router().route(alert(team, s)), fromText.route(alert(team, s)));
}
@Test void 规则写错在加载时报出全部错误() {
// 第 1 行条件无法识别:severity<=P5;第 2 行渠道不存在:pager:y
}第二个测试解决了变化一留下的问题:未注册的渠道现在在加载规则时就会被拒绝,而且一次列出所有错误和行号。改规则的人不懂代码,这类错误必须在保存时就告诉他,而不是等到某次告警发不出去。
2.6 测试结果
RouterTest ✔
├─ 支付P1打电话并发群消息() ✔
├─ 十分钟内同一告警只发一次() ✔
├─ 渠道没注册在发送时报错() ✔
├─ 新渠道只需注册实现() ✔
├─ 夜间静默P3但不静默P1() ✔
├─ 支付P3只发群消息() ✔
├─ 静默区间边界() ✔
├─ 文本规则与代码规则等价() ✔
├─ 规则写错在加载时报出全部错误() ✔
└─ 没有匹配的路由就不通知() ✔
Test run finished after 62 ms10 个测试不依赖网络、数据库或真实时间,每次改动后都可以全部重跑。三次变化中,Alert、Route、Router 都没有修改。这不是因为一开始就预见了这些变化,而是因为核心只做了一件事:根据告警算出目标。
三、遗留系统线:从旧逻辑迁移过来
现实中更常见的情况是,已经有一个旧的告警服务在运行,规则、格式解析、发送逻辑写在一个几百行的方法里:
final class LegacyAlertService {
List<Target> handle(Map<String, String> p) {
String team = p.get("team"), sev = p.get("severity");
int hour = Instant.parse(p.get("at")).atZone(ZoneId.of("Asia/Shanghai")).getHour();
if ("payment".equalsIgnoreCase(team)) {
if (sev.equals("P1") || sev.equals("critical")) {
// 电话 + 群消息;夜里还给负责人发邮件
if (hour >= 22 || hour < 8) out.add(new Target("email", "payment-lead"));
} else if (sev.equals("P2")) { … } else { … }
} else if ("search".equals(team)) { … }
return out;
}
}这时候不能从零开始按上一节的顺序走,而是要从两头往中间推。
3.1 先画目标设计
目标就是上一节的结构:纯函数的 Router,可替换的 Channel,可组合的 Suppression,由值班负责人维护的规则文本。先画出目标,才知道迁移是在逼近什么,否则很容易变成在旧代码上一处一处打补丁。
3.2 盘点现有接口
接着从外部把旧系统的能力还原出来,不是读它的实现,而是找所有调用它的地方:
| 入口 | 调用方 | 迁移时要注意 |
|---|---|---|
| HTTP Webhook | 监控系统推送告警 | 报文格式由监控系统决定,不能要求它改 |
| 定时重发任务 | 每 5 分钟重发未确认的 P1 | 依赖旧服务的内部表 |
| 管理后台「发送测试通知」按钮 | 值班负责人 | 需要在新系统里有对应功能 |
这张表决定了新系统的边界:监控系统的报文格式通过一层翻译进入新模型,也就是 DDD 篇 里的防腐层。
3.3 影子比对:让差异自己出现
旧系统的行为很少有完整的文档。最可靠的办法是让新旧两套逻辑同时处理相同的输入,比较输出,新系统的结果只记录、不发送。实验用 10,000 条合成的历史告警(3 个团队,时间分布在一周内,少量团队名大小写不一致和旧的 critical 写法):
for (Map<String, String> p : history) {
List<Target> old = legacy.handle(p);
Optional<Alert> a = translator.translate(p); // 防腐层
List<Target> neu = a.map(router::route).orElse(null);
// 比较两组目标,按差异原因分类计数
}== 第一次影子比对(严格翻译):一致 9269 / 10000
316 新系统无法识别 severity=critical
6 旧系统多发 [chat, email, phone](team=Payment)
22 旧系统多发 [chat, phone](team=Payment)
67 旧系统多发 [chat](team=Payment)
320 旧系统多发 [email](team=payment)
== 第二次(翻译层兼容大小写与 critical):一致 9617 / 10000
7 旧系统多发 [email](team=Payment)
376 旧系统多发 [email](team=payment)
== 第三次(夜间邮件确认下线,登记为有意差异):一致 10000 / 10000第一轮暴露了三条旧行为,它们都不在任何文档里:
| 差异 | 旧行为 | 决定 | 改在哪里 |
|---|---|---|---|
critical 写法 | 当作 P1 | 保留,部分老监控项还在用 | 防腐层把 critical 翻译成 P1 |
| 团队名大小写 | Payment 与 payment 视为同一个团队 | 保留 | 防腐层统一转成小写 |
| 夜间邮件 | 夜里的 P1 额外给支付负责人发邮件 | 与支付团队确认后下线,因为电话已经能叫醒值班人 | 登记为有意差异 |
前两条是格式问题,放进防腐层,核心规则不需要知道它们。第三条是业务规则,不能由开发者单方面决定,要拿着差异报告去问使用方。
3.4 按团队切换,随时可以回退
比对全部一致或全部有解释之后,再开始切换:
- 按团队逐个切换:用开关控制某个团队的告警由新系统发送,先切影响最小的团队。
- 保留影子比对:切换后旧系统继续运行但不发送,比对结果仍然每天产出,发现新的差异立即回退。
- 关注三个指标:新旧差异条数、发送失败率、告警从产生到送达的延迟。切换前先记录旧系统的基线,切换后与基线比较。
- 回退就是关掉开关:新系统不改旧系统的数据,回退时不需要数据迁移。
3.5 差异报告也是沟通材料
影子比对的产出不只是给开发者看的。每一条「旧系统多发」或「新系统多发」都对应一个问题:这个行为是有意为之,还是一个一直没人发现的 bug?这些问题只能由使用方回答。
把差异报告带到评审会上,逐条确认、记录决定,团队对新系统的信任就是在这个过程里建立的。跳过这一步直接切换,下一次出事时,没人能说清楚这是新系统的 bug,还是旧行为被有意去掉了。
四、复盘:每一步为什么是够的
| 步骤 | 触发原因 | 当时没做什么 | 为什么可以不做 |
|---|---|---|---|
| 最小核心 | 一次事故 | 渠道接口、配置中心 | 只有一个团队、两条规则 |
| 新增渠道 | 第二个团队接入 | 规则校验 | 规则只由开发者写,评审能兜住 |
| 去重与静默 | 值班反馈被打扰 | 通用的规则引擎 | 两个明确的策略就能解决 |
| 规则文本 | 每周改规则,等待影响值班 | 图形化编辑器、审批流 | 文本加校验已经能让值班负责人自己改 |
| 遗留迁移 | 旧服务难以修改 | 一次性替换 | 影子比对和按团队切换,风险可控、可回退 |
每一步的设计都没有超过当时的需求,但每一步都让下一步更容易:纯函数的 Router 让规则文本可以做等价测试,防腐层让旧格式的兼容不进入核心,抑制策略接口让新增一种静默规则只需要一个类。
五、常见误区
- 「好的设计是一开始就想清楚的」:案例中的每个抽象都是在需求出现后才加的;一开始想清楚的只有核心:根据告警算出目标。
- 「预留扩展点总没错」:第一版如果做了规则引擎,它的形状多半和第三次变化的真实需求对不上,还要花力气维护。
- 「自动化级别越高越好」:每上一级都要付出校验、预览、权限、审计的成本,要和变化频率一起算。
- 「迁移就是照着旧代码重写」:旧代码里有 bug、有过时的规则,也有没写进文档的重要行为,影子比对才能把它们分开。
- 「差异由开发者判断」:格式差异可以由开发者处理,业务行为的差异必须由使用方确认。
小结
这个案例想说明的是设计的过程,而不是最终的类图:从一个具体问题和几个测试开始,只做当时需要的最小核心;每次变化到来时,先判断它落在哪个边界里,尽量通过新增而不是修改来完成。迁移遗留系统时,先画出目标,再从外部还原现状,用影子比对让差异自己出现,并和使用方一起逐条决定。
模型和规范要通过沟通才能成为团队的共识,原则之间发生冲突时,取舍靠的是经验。经验可以有意识地积累:遇到一段难改的代码,问自己「如果重写这一段,会怎么设计」;评审真实的变更时,看它碰到了几个边界;读一些规模小、边界清楚的开源项目,看它们怎样在不破坏核心的前提下增加功能。
配套实验
- codesphere-labs/design/notification-routing:10 个测试、三轮影子比对与
Router依赖方向检查(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料
- Kent Beck,《Test-Driven Development: By Example》
- Michael Feathers,《Working Effectively with Legacy Code》第 13 章(特征测试)
- Martin Fowler,StranglerFigApplication
- GitHub Scientist:在生产环境中并行运行新旧代码并比较结果的库