从一个通知系统再谈设计模式
1. 边界如何演化出来
很多人第一次学设计模式,是从名字和类图开始的。回到业务代码里,在实际应用时又容易陷入另一种误区:这里能不能套一个 Strategy,那里是否应该补一个 Factory,这种封装像不像 Decorator。
模式不是可以提前搭好的框架,它更像一系列被反复验证过的边界经验:某类变化总在同一个位置导致代码混乱,人们才慢慢发现,哪些职责该分开,哪些协作关系更稳,哪些抽象层值得长期留下。本文只围绕一个通知系统,看这些复杂度首先集中在哪里,什么时候值得单独抽象出边界。模式名会出现,但只是这些判断的简称。
通知系统很适合讲这件事,因为它几乎涵盖了常见的复杂度压力。最早它可能就是这样:
function completeOrder(order) {
markPaid(order)
sendEmail(order.user.email, "支付成功")
}再往后,渠道变多,触发点变多,第三方SDK采用不同的接口规范,模板、限流、审计都纳入进来,发送动作又从同步调用变成排队任务,这些复杂度一步步推动了新边界的产生。
2. 从一个最小通知系统开始
先从这段最朴素的代码继续看。它做了两件事:订单支付成功后,系统更新状态,然后给用户发一封邮件。
需求单一、调用点少、团队也清楚它只会发邮件时,直接写就是最简单的方案。第一版系统的目标是把业务跑通。
设计模式之所以值得被讨论,往往是因为某类变化开始反复集中在同一个位置上,而非仅仅因为代码“不优雅”。
一是渠道开始分化。邮件之外,还有短信、站内信、Webhook、企业微信,不同渠道要的参数、错误处理机制和成本都不一样。你很快会发现,原来那句 sendEmail() 并非单纯“多写几个分支”就能长期支撑的,因为每个分支背后都会衍生出自己的细节。
二是触发点和后续动作一起变多。订单支付、注册成功、审批通过、风控告警都要发通知;发通知之外,优惠券、审计、用户画像计算、合作方回调也会随之增加。主流程最初只是“做完这件事以后顺便发一条通知”,后来就会变成“做完这件事以后,很多下游业务都想顺便接入”。
三是系统开始接入外部服务。系统内部期望的抽象是 channel.send(),供应商SDK却要求 mobile、templateCode、params、errcode,而且不止一家。字段名不同还只是最表面的麻烦,更深一层的问题是,业务代码一旦直接依赖这些外部接口规范,内部边界就会被外部接口污染。
四是通知不再总在当前请求上下文中立刻完成。它可能要排队、延迟、重试、取消,还要知道自己现在是待发、失败还是已完成。一旦这一步发生,问题就从“怎么发”走向“这次发送动作能不能脱离当前请求异步执行一段时间”。
真实项目未必按这个顺序来,但判断方式是一致的。
还有一些压力,要等前面的边界先确立才会显现。比如稳定接口上要不要继续叠加横切关注点,动作有了独立生命周期之后该怎么管理它的阶段,这些放到后面再讲。
3. 当渠道开始分化:Strategy
通知系统最先变得复杂的地方,通常是“怎么发”。一开始只有邮件时,直接调用 sendEmail() 没问题。等短信、站内信、Webhook也进来,最直观的改法当然是加分支:邮件走这一段,短信走另一段,站内信再来一段。
前期这样写没问题。麻烦很快会从“if/else 变长”演变成“发送方式本身成为独立变化点”。你会在订单通知里写一套分支,在营销通知里再写一套,在系统告警里又写一套;每次新增一个渠道,改动都会落在多个调用点上。变化的重心已经从“调用方在发什么消息”转成了“具体渠道各自怎么发”。
更合适的边界,是把“怎么发”统一到同一个接口,让调用方只关心自己持有的是一个可发送的渠道。至于为什么选中了它,那是更高一层的决策。
文中的代码都接近TypeScript,但会省略类型标注、错误处理和工程装配细节,只用来表达边界关系。
interface NotificationChannel {
send(user, message)
}
class EmailChannel implements NotificationChannel {
send(user, message) {
emailClient.send({
to: user.email,
subject: message.title,
body: message.body,
})
}
}
class SmsChannel implements NotificationChannel {
send(user, message) {
smsClient.send({
phone: user.phone,
body: message.summary,
})
}
}
channel.send(user, message)重点是系统承认发送方式会变,而且这种变化值得单独划分出一个边界。调用方只依赖 channel.send() 这个稳定的行为。新增一个渠道时,主要工作变成新增一个实现,不再是回去把一长串调用点都改一遍。
Strategy 在通知系统里回答的是:渠道已经选好以后,这次怎么发。谁来选渠道、失败后怎么重试、供应商接口如何接进来,都放在别处;全部放进 Strategy,边界又会变得模糊。
很多团队第一次往这一步走时,通常会先把那串渠道分支提取到一个独立函数里。这是好事,因为它让变化的位置先从主流程里显现出来。只有当这个函数继续膨胀,而且不同渠道的差异已经超出几行参数拼装时,Strategy 才开始比“一个大函数 + 分支”更划算。
容易混进去的是路由决策。比如高优先级消息先试短信,营销消息优先站内信,或者用户明确关闭某个渠道。这些判断当然和渠道有关,但它们关心的是“走哪条路”。把路由和发送实现绑定在同一个策略对象里,短期会觉得简单,长期则会把请求层和渠道层重新耦合在一起。
本文示例先假设路由已经由上游完成,并写进 request.channel。如果路由规则继续增长,可以把它收进 channelRouter.select(context) 这样的边界:依赖消息类型、优先级、用户偏好这类业务意图,而且希望请求一旦提交就稳定下来的规则,更适合在生成请求时决定;依赖执行时可用性、熔断、故障转移的规则,则可以由 dispatcher 在执行时调用路由器。无论放在哪,路由回答的都只是“选哪个渠道”,具体渠道实现仍然只回答“怎么发”。
如果系统长期只有一种渠道,或者只是某一个调用点偶尔分两支,普通分支和一个小函数往往更合适。Strategy 真正有价值,是同一类“发送方式替换”反复出现以后,它让变化不再继续污染调用方。第一条边界因此很朴素:当“怎么发”开始稳定分化时,用 channel.send() 收住它。
4. 当后续动作越来越多:Observer
渠道边界收住之后,通知系统常见的下一步麻烦,是事情发生以后,越来越多的下游模块和业务需要响应它。最早的 completeOrder() 可能只做两件事:改状态,发邮件。后来通知、优惠券、审计、用户画像计算、Webhook、积分都来了,主流程代码会变得越来越臃肿:订单完成以后,继续调用很多服务。
需要解耦的是“事件发生后谁响应”。发布者只负责说“发生了什么”,响应者各自决定“我要做什么”。这就是 Observer 在业务代码里最常见的价值。
本节先把 dispatcher.dispatch() 当作通知子系统的统一执行入口,不展开内部编排,只看 Observer 怎么把响应者从主流程中解耦出来。
const publisher = {
observers: [],
subscribe(observer) {
this.observers.push(observer)
},
publish(event) {
for (const observer of this.observers) {
observer.handle(event)
}
},
}
publisher.subscribe({
handle(event) {
if (event.type !== "order_paid") return
dispatcher.dispatch({
id: `order_paid:${event.order.id}`,
userId: event.order.user.id,
templateId: "order_paid",
variables: { orderId: event.order.id },
channel: "email",
})
},
})
function completeOrder(order) {
markPaid(order)
publisher.publish({ type: "order_paid", order })
}这段代码里,completeOrder() 不再直接知道通知、优惠券、审计和回调。它只发布 order_paid。通知逻辑是其中一个观察者;以后要加审计、积分,也是继续注册新的观察者,而不是回头修改主流程。
通知观察者的职责也因此变轻了:它不再自己查用户、渲染模板、取渠道,只判断是否响应这个事件,然后把一次通知请求交给 dispatcher.dispatch()。这样第7节里worker取到任务后继续调同一个入口,也会更顺。
Observer 在这里回答“谁来响应事件”,不管“这条通知具体怎么发”。它和上一节的 Strategy 并不冲突:前者在事件层面分离职责,后者在渠道层面分离实现。
也别把它想成“必须上消息系统”。同步、异步、进程内、跨进程,都不是最先要解决的事;先判断发布者要不要继续直接依赖所有响应者。
当然,这个边界也有成本。事件链会降低直观性,读主流程时不一定一眼看见所有后续动作;异常怎么传、响应顺序怎么管、某个观察者失败要不要影响主流程,都要团队提前说清楚。如果后续动作很少,或者本来就是主流程不可分割的一部分,直接调用仍然可能更清楚。
常见误用,是把主流程关键路径也放进观察者里。如果某个动作的成败决定业务是否完成,比如扣库存、记账、落核心状态,那它更像主流程的一部分。Observer 适合的是与主流程共享触发时机,但不该继续共享一个调用者的后续动作。
通知已经从主流程里脱钩,但仍然像一个被立即消费的事件。它随后会走向另一个维度:通知本身要变成能独立存在的任务。在那之前,通知系统先会遇到更直接的运行时压力:外部接口不统一,调用方也开始手写越来越多的编排。
5. 外部适配与内部编排:Adapter和Facade
接入外部SDK:Adapter
有了 channel.send() 这个内部接口以后,接第三方供应商时,问题才会具体暴露。系统内部期望表达的是:给这个用户发这条消息。但短信供应商可能要求 mobile、templateCode、params;邮件供应商可能叫 to、subject、html;错误码的表达方式也各不相同。字段名不规范只是表面现象;业务代码一旦直接依赖这些字段,内部边界就会被外部接口规范污染。
更合适的边界,是在外面放一个适配层,把供应商接口适配成系统已经认可的 channel.send()。这就是 Adapter。
class SmsChannelAdapter implements NotificationChannel {
send(user, message) {
const result = smsSdk.deliver({
mobile: user.phone,
text: message.summary,
})
if (result.errcode !== 0) {
throw new ChannelSendError({
kind: mapSmsError(result.errcode),
providerRequestId: result.requestId,
})
}
}
}如果供应商只接受模板ID和变量,适配层负责模板标识映射和参数转换,实际渲染也可能发生在供应商侧;本文为了主线统一,仍假设内部先渲染,再把渲染后的消息交给渠道。
Adapter 只处理外部接口规范到内部接口的转换。换供应商时,变化主要落在适配层;渠道选择、排队、限流仍留在别处。
也不要默认所有供应商从此完全一致。有的支持模板变量,有的支持批量,有的能返回request id。Adapter 把常用路径收进内部接口,但排障和审计需要的信息仍要保留。
收住稳定编排:Facade
接口适配做完以后,调用方也未必足够简洁。一次通知发送常常还要渲染模板、查用户偏好、检查限流、取得渠道、记审计、落结果。每个步骤都合理,但每个业务流程都自行编排一遍,系统会越来越杂乱。
这时需要抽象出一个更贴近业务意图的入口。调用方要的只是“把这条通知派出去”。当一条固定编排开始反复出现,Facade 才有价值。本文把这个入口统一叫作 dispatcher.dispatch()。
const dispatcher = {
dispatch(request) {
let result
try {
const user = users.get(request.userId)
const message = templates.render(request.templateId, request.variables)
rateLimiter.assertAllowed(user, request.channel)
const channel = channels.get(request.channel)
channel.send(user, message)
result = { ok: true }
} catch (error) {
result = { ok: false, error }
}
audit.recordSafely(request.id, result)
return result
},
}dispatcher 承担一条稳定编排:拿用户、渲染消息、检查是否允许发送、取得已选渠道,再调用 channel.send() 并记录结果。调用方从亲自协调一串子系统,退回到提交一个通知请求。
Adapter 解决外部接口适配,Facade 解决内部编排。外部SDK的字段名和错误处理差异被隔离在边界之外,用 Adapter 隔离;多个调用点都在手写同一条通知流程,用 dispatcher.dispatch() 收住。dispatcher 也只该对应这条稳定用例:路由留在路由边界,具体发送留在渠道边界,异步任务的生命周期留给队列和后面的 Command。如果 dispatcher.dispatch() 已经开始照顾互不相干的分支,就拆成更窄的入口。
通知系统此时已经分出 channel.send() 和 dispatcher.dispatch() 两层。更自然的问题是:日志、重试、限流这些能力,还该不该继续写回每个渠道里?
6. 当稳定接口之上开始叠加能力:Decorator
同样叫限流、日志、审计,放在哪里取决于这些能力要作用于哪一层的动作。用户是否允许发送、业务配额、一次通知最终结果,属于 dispatcher.dispatch() 这条请求级语义;供应商并发保护、单次调用的快速重试、每次尝试日志,属于 channel.send() 这条调用级语义,更适合做成渠道装饰器。
能力还要看会持续多久。只在当前进程、当前调用里包一层的附加行为,仍属于 Decorator 的适用范围;一旦要跨进程、持久化并延迟执行,比如延迟后再试、worker接续执行,就该交给队列和 Command。
第5节里的 rateLimiter.assertAllowed(...) 和 audit.recordSafely(...),其实已经是请求级处理的具体体现;本节的 Decorator 只讨论围绕单次 channel.send() 包上的快速重试、尝试日志和供应商并发保护。
很多系统在这一步开始重新变重。邮件要打日志,短信要重试,Webhook要限流,站内信要审计。最直接的做法,是把这些逻辑继续写回每个渠道实现里。于是 EmailChannel 不再只是“发邮件”,而同时耦合了日志、重试、限流、审计;SmsChannel 里又复制一遍类似逻辑。
这类压力和前面几节都不一样。核心发送动作已经有了稳定接口,麻烦来自“这条稳定动作上要不要按场景叠加一些额外能力”。变化不在 channel.send() 的核心语义上,而在它周围那一圈附加动作上。
这种时候,Decorator 比继续改原对象更合适。因为它明确了一点:核心发送行为应该继续保持单纯,横切关注点则按需要通过装饰器包装在外层。重试是重试,日志是日志,限流是限流,它们不该和“邮件怎么发”混杂成一个职责。
class RetryingChannel implements NotificationChannel {
constructor(inner, maxAttempts) {
this.inner = inner
this.maxAttempts = maxAttempts
}
send(user, message) {
let lastError
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
this.inner.send(user, message)
return
} catch (error) {
lastError = error
}
}
throw lastError
}
}
channel = new RetryingChannel(baseChannel, 3)这个重试装饰器只适合少量、进程内、针对瞬时错误的快速重试。真实系统还要识别哪些错误可重试,配合退避和幂等机制;如果重试需要跨进程、持久化并延迟执行,就不该继续放在 channel.send() 外面,而应该交给任务队列和后面的 Command 生命周期处理。
这样一来,基础渠道继续只管自己怎么发;重试、日志、限流这类能力,在稳定接口外面按场景叠加。好处不在“多套几层”,而在横切关注点有了独立去处,渠道实现不再不断臃肿。
最值得留意的是顺序。不同封装顺序,记录粒度会不同:
// 记录整次发送结果
channel = new LoggingChannel(new RetryingChannel(baseChannel, 3))
// 记录每次尝试(含重试)
channel = new RetryingChannel(new LoggingChannel(baseChannel), 3)
Decorator 的好处是把这些组合关系放到明面上。
当然,它也会让调用链变长;层数一多,排查问题时你得先弄明白眼前这个 channel 到底包了哪些能力。所以 Decorator 适合横切关注点跨多个渠道重复出现、组合关系又按场景变化的情况。要是所有渠道永远固定加同一层日志和审计,更上层统一收口可能更简单。
Decorator 也很容易和 Proxy 混。结构上看,二者都像“对同接口进行包装”;区别在目的:Decorator 是让对象多做一点,比如重试、日志、限流,Proxy 的重点则是控制访问,比如权限、远程调用、延迟初始化。本文里,通知系统真正主线需要的是前者,所以 Proxy 只记这句区分就够了,不再展开。
7. 当通知变成任务:Command
dispatcher.dispatch() 到目前为止还有一个隐含前提:调用方提交的是当前这次执行的 request,系统也在同一个请求上下文中完成这次执行。这个前提一旦被打破,通知就从一次函数调用变成一个任务:调用方先生成任务对象,真正发送发生在worker、延迟任务或重试流程里。
关键变化发生在队列之前:动作先成为了可以脱离当前请求继续存在的独立实体。它不仅是这次调用栈里的一步,还是一个带 id、带类型、可序列化的数据对象;只有先完成这一步,系统才知道后面到底在保存什么、重试什么、审计什么。
队列是 Command 的一种消费方式,不是它存在的原因。同一个任务对象也可以被持久化、被审计、被回放;这些能力都来自“动作先成为独立实体”,队列只是其中一种后续安排。
观察者也会从“直接执行”变成“生成任务并入队”:
publisher.subscribe({
handle(event) {
if (event.type !== "order_paid") return
queue.enqueue({
id: `order_paid:${event.order.id}`,
type: "send_notification",
userId: event.order.user.id,
templateId: "order_paid",
variables: { orderId: event.order.id },
channel: "email",
})
},
})Observer 决定的仍然是谁来响应 order_paid;send_notification 则是系统接下来要执行的动作。queue.enqueue() 只表达职责转交:观察者把后续动作交给任务生命周期。若任务需要可靠跨进程传播,真实系统仍要处理业务提交、任务记录和投递之间的一致性,比如发件箱模式;worker侧通常也要按至少一次投递来设计幂等。
Command 在通知系统里最有用的地方,是把一次通知动作写成可保存、可排队、可审计的任务对象。它带着 type、id、userId、模板和变量这类可序列化信息,能脱离当前请求上下文继续存在。
const nextTask = queue.next()
dispatcher.dispatch(nextTask)
执行这件事仍然留在 dispatcher:worker取出任务后交给 dispatcher.dispatch(),仓库、模板引擎、渠道选择器这些运行期依赖也留在这边,而不是写进任务对象。
8. 当任务状态开始决定行为:State
可一旦通知真的变成任务,另一种压力也会随之出现:任务到了不同阶段,允许的动作不一样。pending 可以发,也可以取消;failed 可以重试,也可以放弃;sent 不该再发;cancelled 也不该再重试。最开始,这类规则通常散落在大量的 if status === ... 分支中。状态一多,分支就会开始互相交织。
State 更合适:把“当前阶段允许什么动作”收进状态对象本身。任务不再携带一个状态值,到处让别人判断;当前状态自己知道它允许什么,以及下一步会转到哪里。
这里用两个名字:status 是可持久化的字符串标识,state 是带方法的运行时对象,由前者映射而来。
const PendingState = {
dispatch(task, dispatcher) {
const result = dispatcher.dispatch(task)
transitionTo(task, result.ok ? "sent" : "failed")
},
retry() {
throw new Error("pending task does not retry")
},
cancel(task) {
transitionTo(task, "cancelled")
},
}
const FailedState = {
dispatch() {
throw new Error("failed task should retry")
},
retry(task, dispatcher) {
const result = dispatcher.dispatch(task)
transitionTo(task, result.ok ? "sent" : "failed")
},
cancel(task) {
transitionTo(task, "cancelled")
},
}
// sent 和 cancelled 都是终态,所有操作均抛出 "terminal state"
const TerminalState = {
dispatch() {
throw new Error("terminal state")
},
retry() {
throw new Error("terminal state")
},
cancel() {
throw new Error("terminal state")
},
}
const states = {
pending: PendingState,
failed: FailedState,
sent: TerminalState,
cancelled: TerminalState,
}
task.state = states[task.status]
function transitionTo(task, status) {
const nextState = states[status]
if (!nextState) {
throw new Error("unknown task status")
}
task.status = status
task.state = nextState
}终态只做禁止操作,不再细分内部差异。持久化层只保存 status: "sent" 这样的标识;任务加载成领域对象后,再映射成带方法的 state 对象。关键是认识到规则已经从“到处判断当前状态”转成了“当前状态自己决定允许什么动作,以及下一步转去哪”。
transitionTo 只表达领域里的状态迁移,并不代表状态已经被可靠持久化。多worker抢占、原子状态更新、外部发送已经成功但本地状态还没写回的不一致时间窗口,属于幂等键和任务基础设施要解决的问题,不是 State 模式本身提供的能力。
State 和前面的 Strategy 在形式上都像“一个接口后面挂多个实现”,但关心的变化不同。Strategy 问“这次选哪种发送方式”,State 问“这个任务现在处于什么阶段,因此还能做什么”。如果任务状态只有两三个,而且规则仍然集中,一个清楚的 switch 可能更合适。
9. 一些没有单独展开的模式
通知系统这条线主要引出的是 Strategy、Observer、Adapter、Facade、Decorator、Command、State。它们都在运行路径上承担了一段变化:发送方式、事件响应、外部接口、执行编排、横切关注点、动作生命周期和任务状态。
创建类模式回答的是装配期问题,不在这条运行路径上。只是集中选择具体产品时,简单工厂或工厂函数通常够用;由基类定义创建方法、子类决定产品时,才更接近 Factory Method。生产环境和沙箱环境要成套切换渠道时,Abstract Factory 的重点是防止混搭。Builder 只在必填、可选、默认值和校验交织在一起时才值得存在。
Template Method 也只放在附注里:当多种任务runner的骨架已经稳定,比如“取任务 -> 校验 -> dispatcher.dispatch() -> 记结果”顺序不变,只是少数步骤不同,才考虑用继承固定流程。若步骤需要自由组合,组合、函数拆分,甚至前面的 Strategy,往往更清楚。这些模式并非不重要,只是这条线没有把它们作为主要问题展开。
10. Singleton,以及什么时候该克制
通知系统演化出这么多边界以后,很多团队都会在某个时刻产生同一个念头:层层透传依赖过于繁琐,不如放一个全局对象。比如全局 providerClients、全局 dispatcher、全局 auditLog。表面上,代码会立刻变短;实际上,边界也很容易在这里发生退化。
Singleton 难用,风险点通常不在“系统里只有一个实例”,而在“任何地方都能直接获取它”。只创建一次,是生命周期问题;全局访问,是依赖边界问题。这两件事经常被绑在一起,所以它才像一个方便却容易产生技术债的捷径。
// 不推荐的方向:谁都能直接拿全局入口
channel.send = (user, message) => {
GlobalClients.get().sms.send({
phone: user.phone,
body: message.summary,
})
}
// 更明确的方向:启动时创建一次,使用时显式传入
const providerClients = createProviderClients(config)
const smsChannel = createSmsChannel(providerClients)
const dispatcher = createDispatcher({ channels: [smsChannel], audit, templates })
这段对比里,“更明确”的版本并没有多创建几份client。它只是把责任说清楚了:对象可以在启动时只建一次,但业务代码不该随处擅自获取。这样依赖是可见的,测试能替换,生命周期也能解释。模块缓存、启动装配、框架scope都可以承担“只创建一次”;该警惕的是业务代码因此获得了一个到处都能直接获取的隐藏入口。
一旦全局访问点成了习惯,前面建立起来的边界很容易一起退化:Adapter 关住的外部SDK会被绕开,dispatcher 收住的编排会被绕过,Command 分开的任务数据和执行依赖又会被混在一起。Singleton 在本文里承担的,更像一个刹车角色:提醒你别为了省掉装配成本,把依赖藏回一个方便入口。
前面分散的提醒,可以收成一组克制条件:
- 只是第一次出现分支,先提炼函数,别急着抽
Strategy。 - 只是参数开始变长,先试参数对象,别为了
.build()去刻意构造一个Builder。 - 只是外部字段名不规范,先做局部转换,别一开始就铺一层大而全的适配体系。
- 只有两三个状态,而且迁移规则仍然集中,清楚的
switch往往比一套State更合适。 - 如果新边界让依赖更隐藏、测试更难替换、生命周期更难界定,就先保持克制。
一条通知穿过系统时,边界会按运行压力依次显现出来:Observer 把通知从主流程中解耦出来;需要排队和重试时,Command 让动作能脱离当前请求继续存在,State 约束它在不同阶段能做什么;真正执行时,dispatcher.dispatch() 收住稳定编排,channel.send() 收住具体发送,必要的重试或日志放在渠道外层,第三方SDK则留给 Adapter 去做适配。模式名只是这些边界的简称。
最后只留一个判断习惯:先看变化落在哪里,再看边界值不值得抽象出来,再看代价。复杂度能留在函数里,就先别升级成对象;能作为局部依赖传入,就先别提升为全局入口。
