跳转到内容

第 14 章 · 桥接(Bridge)

一句话:两个各自会变的维度不要用继承乘在一起,拆成两套类,一边手里拿着另一边的对象,用的时候再组合。 解决的痛:消息类型 × 发送渠道写成子类,3 × 3 是 9 个类,各加一种变 16 个;同一段渠道代码复制在好几个类里,改规则时漏一个副本就出事。

你在多练网的消息组写后端。系统要给用户和值班同事发三种消息:

  • 普通通知:标题原样,发一次就算。
  • 紧急告警:发给值班的人,标题前加 [紧急],发送失败最多试 3 次。
  • 营销活动:标题前加 [活动],正文末尾要带退订说明。

每种消息都可能走三个渠道:App 推送、短信、邮件。渠道各有各的发送细节和签名:App 推送在标题前写“多练 ·”;短信没有标题栏,标题并进正文,开头是【多练】;邮件在落款写”—— 多练”。

消息类型 × 发送渠道:九种组合

每格是一种组合。直觉写法就是给每格写一个类。

UrgentSmsMessage 就是“用短信发的紧急告警”,类型规则和短信细节写在一起。按类型分三个文件,每个文件三个类:

# 3 种消息 × 3 个渠道 = 9 个类,这里列出 4 个,其余 5 个照同样的样子写:
# NoticeSmsMessage、NoticeEmailMessage、UrgentAppMessage、UrgentEmailMessage、MarketingAppMessage
# notice.py:普通通知
class NoticeAppMessage: # 普通通知 × App 推送
def send(self, to: str, title: str, body: str) -> None:
self._deliver(to, title, body) # 普通通知:标题原样,不重试
def _deliver(self, to: str, title: str, body: str) -> bool: # App 推送的发送细节
print(f"[App 推送] {to}:多练 · {title} | {body}")
return True # 模拟:总是成功
# urgent.py:紧急告警
class UrgentSmsMessage: # 紧急告警 × 短信
def send(self, to: str, title: str, body: str) -> None:
title = f"[紧急] {title}" # 紧急告警:标题加前缀,失败最多试 3 次
for _ in range(3):
if self._deliver(to, title, body):
return
def _deliver(self, to: str, title: str, body: str) -> bool: # 短信的发送细节:没有标题栏,签名放最前
print(f"[短信] {to}:【多练】{title}:{body}")
return True
# marketing.py:营销活动(增长组维护)
class MarketingSmsMessage: # 营销活动 × 短信
def send(self, to: str, title: str, body: str) -> None:
title = f"[活动] {title}" # 营销活动:标题加前缀,正文带退订说明,不重试
self._deliver(to, title, f"{body}(可在设置中退订)")
def _deliver(self, to: str, title: str, body: str) -> bool: # 和 UrgentSmsMessage 里的一模一样
print(f"[短信] {to}:【多练】{title}:{body}")
return True
class MarketingEmailMessage: # 营销活动 × 邮件
def send(self, to: str, title: str, body: str) -> None:
title = f"[活动] {title}" # 和 MarketingSmsMessage 里的一模一样
self._deliver(to, title, f"{body}(可在设置中退订)")
def _deliver(self, to: str, title: str, body: str) -> bool: # 邮件的发送细节:签名放落款
print(f"[邮件] {to}:主题「{title}」 {body} —— 多练")
return True
if __name__ == "__main__":
NoticeAppMessage().send("user_42", "课程更新", "第 14 章已上线")
UrgentSmsMessage().send("138****0000", "支付服务不可用", "支付失败率 35%")
MarketingSmsMessage().send("139****1111", "国庆五折", "全部课程五折")
MarketingEmailMessage().send("[email protected]", "国庆五折", "全部课程五折")
# 输出:
# [App 推送] user_42:多练 · 课程更新 | 第 14 章已上线
# [短信] 138****0000:【多练】[紧急] 支付服务不可用:支付失败率 35%
# [短信] 139****1111:【多练】[活动] 国庆五折:全部课程五折(可在设置中退订)
# [邮件] [email protected]:主题「[活动] 国庆五折」 全部课程五折(可在设置中退订) —— 多练

TS 版一样:

// 3 种消息 × 3 个渠道 = 9 个类,这里列出 4 个,其余 5 个照同样的样子写:
// NoticeSmsMessage、NoticeEmailMessage、UrgentAppMessage、UrgentEmailMessage、MarketingAppMessage
// notice.ts:普通通知
class NoticeAppMessage { // 普通通知 × App 推送
send(to: string, title: string, body: string): void {
this.deliver(to, title, body); // 普通通知:标题原样,不重试
}
private deliver(to: string, title: string, body: string): boolean { // App 推送的发送细节
console.log(`[App 推送] ${to}:多练 · ${title} | ${body}`);
return true; // 模拟:总是成功
}
}
// urgent.ts:紧急告警
class UrgentSmsMessage { // 紧急告警 × 短信
send(to: string, title: string, body: string): void {
title = `[紧急] ${title}`; // 紧急告警:标题加前缀,失败最多试 3 次
for (let i = 0; i < 3; i++) {
if (this.deliver(to, title, body)) return;
}
}
private deliver(to: string, title: string, body: string): boolean { // 短信的发送细节:没有标题栏,签名放最前
console.log(`[短信] ${to}:【多练】${title}:${body}`);
return true;
}
}
// marketing.ts:营销活动(增长组维护)
class MarketingSmsMessage { // 营销活动 × 短信
send(to: string, title: string, body: string): void {
title = `[活动] ${title}`; // 营销活动:标题加前缀,正文带退订说明,不重试
this.deliver(to, title, `${body}(可在设置中退订)`);
}
private deliver(to: string, title: string, body: string): boolean { // 和 UrgentSmsMessage 里的一模一样
console.log(`[短信] ${to}:【多练】${title}:${body}`);
return true;
}
}
class MarketingEmailMessage { // 营销活动 × 邮件
send(to: string, title: string, body: string): void {
title = `[活动] ${title}`; // 和 MarketingSmsMessage 里的一模一样
this.deliver(to, title, `${body}(可在设置中退订)`);
}
private deliver(to: string, title: string, body: string): boolean { // 邮件的发送细节:签名放落款
console.log(`[邮件] ${to}:主题「${title}」 ${body} —— 多练`);
return true;
}
}
new NoticeAppMessage().send("user_42", "课程更新", "第 14 章已上线");
new UrgentSmsMessage().send("138****0000", "支付服务不可用", "支付失败率 35%");
new MarketingSmsMessage().send("139****1111", "国庆五折", "全部课程五折");
new MarketingEmailMessage().send("[email protected]", "国庆五折", "全部课程五折");
// 输出:
// [App 推送] user_42:多练 · 课程更新 | 第 14 章已上线
// [短信] 138****0000:【多练】[紧急] 支付服务不可用:支付失败率 35%
// [短信] 139****1111:【多练】[活动] 国庆五折:全部课程五折(可在设置中退订)
// [邮件] [email protected]:主题「[活动] 国庆五折」 全部课程五折(可在设置中退订) —— 多练

每个类一目了然。大多数人第一版都这么写。

新需求是:加一种“验证码”消息(标题是“登录验证码”这样,正文写明有效期,失败重试一次);加一个“企业微信”渠道,给内部同事发。

要改的地方:

  1. 验证码:新建 verify_code.py,写 VerifyCodeAppMessage、VerifyCodeSmsMessage、VerifyCodeEmailMessage。_deliver 从别的类里抄渠道代码,验证码的规则也抄三遍。
  2. 企业微信:在四个文件里各加一个类,NoticeWeComMessage、UrgentWeComMessage、MarketingWeComMessage、VerifyCodeWeComMessage。企业微信的发送代码一模一样,写四遍。
  3. 类从 9 个变成 16 个,每条渠道规则的副本也跟着变多,短信代码从 3 份变 4 份。再加一个渠道是 20 个类。

第 3 处的代价上个月就见识过。短信服务商通知:签名必须和备案名一致,【多练】要改成【多练网】,下月起不合规的短信直接拦截。产品顺带要求 App 推送和邮件的签名也统一成“多练网”。签名在每个类的 _deliver 里,9 处要改。同事改了 notice.py 和 urgent.py 里的 6 个类:

print(f"[短信] {to}:【多练网】{title}:{body}") # 原来是【多练】

marketing.py 归增长组,放在另一个目录,他没搜到。把 before.py 前两个类的签名照此改成“多练网”、两个营销类不动,运行结果:

[App 推送] user_42:多练网 · 课程更新 | 第 14 章已上线
[短信] 138****0000:【多练网】[紧急] 支付服务不可用:支付失败率 35%
[短信] 139****1111:【多练】[活动] 国庆五折:全部课程五折(可在设置中退订)
[邮件] [email protected]:主题「[活动] 国庆五折」 全部课程五折(可在设置中退订) —— 多练

测试只发了通知和告警,都对。这个月 1 日起,营销短信全被拦下,国庆活动的短信一条也没发出去。

问题的形状是:类型和渠道用继承乘在一起,类的数量是两边的乘积;同一条渠道规则复制在每种类型的类里,改一次要找遍所有副本。

朴素写法:16 个类和要改的位置

黄色的 ① 是验证码一行,② 是企业微信一列。红色的 ③ 是签名事故:通知、告警两行的 6 个类改了,边框加粗的营销三个类漏了。

把两个维度拆成两套类。渠道只管“怎么送到”,包括自己的签名,接口是 deliver(to, title, body);消息只管“发什么、发几次”,手里拿着一个渠道对象。组合在创建时决定:UrgentMessage(SmsChannel())。

from typing import Protocol
# 实现部分:渠道只管"怎么送到",包括各自的签名格式
class Channel(Protocol):
def deliver(self, to: str, title: str, body: str) -> bool: ...
class AppChannel:
def deliver(self, to: str, title: str, body: str) -> bool:
print(f"[App 推送] {to}:多练网 · {title} | {body}")
return True # 模拟:总是成功
class SmsChannel:
def __init__(self, fail_times: int = 0) -> None:
self.fail_times = fail_times # 模拟:前几次网关超时
def deliver(self, to: str, title: str, body: str) -> bool:
if self.fail_times > 0:
self.fail_times -= 1
print(f"[短信] {to}:网关超时")
return False
print(f"[短信] {to}:【多练网】{title}:{body}")
return True
class EmailChannel:
def deliver(self, to: str, title: str, body: str) -> bool:
print(f"[邮件] {to}:主题「{title}」 {body} —— 多练网")
return True
class WeComChannel: # 新渠道:企业微信
def deliver(self, to: str, title: str, body: str) -> bool:
print(f"[企业微信] {to}:{title} | {body}(多练网)")
return True
# 抽象部分:消息管"发什么、发几次",手里拿着一个渠道;基类给出默认规则,子类覆盖
class Message:
max_attempts = 1
def __init__(self, channel: Channel) -> None:
self.channel = channel # 这就是"桥"
def send(self, to: str, title: str, body: str) -> None:
for _ in range(self.max_attempts):
if self.channel.deliver(to, self.format_title(title), self.format_body(body)):
return
def format_title(self, title: str) -> str:
return title
def format_body(self, body: str) -> str:
return body
class NoticeMessage(Message):
pass
class UrgentMessage(Message):
max_attempts = 3
def format_title(self, title: str) -> str:
return f"[紧急] {title}"
class MarketingMessage(Message):
def format_title(self, title: str) -> str:
return f"[活动] {title}"
def format_body(self, body: str) -> str:
return f"{body}(可在设置中退订)"
class VerifyCodeMessage(Message): # 新类型:验证码
max_attempts = 2
def format_title(self, title: str) -> str:
return f"{title}验证码"
def format_body(self, body: str) -> str:
return f"{body},5 分钟内有效,请勿告诉他人"
if __name__ == "__main__":
NoticeMessage(AppChannel()).send("user_42", "课程更新", "第 14 章已上线")
UrgentMessage(SmsChannel(fail_times=1)).send("138****0000", "支付服务不可用", "支付失败率 35%")
MarketingMessage(SmsChannel()).send("139****1111", "国庆五折", "全部课程五折")
MarketingMessage(EmailChannel()).send("[email protected]", "国庆五折", "全部课程五折")
VerifyCodeMessage(SmsChannel()).send("139****1111", "登录", "382916")
UrgentMessage(WeComChannel()).send("zhangsan", "支付服务不可用", "支付失败率 35%")
# 输出:
# [App 推送] user_42:多练网 · 课程更新 | 第 14 章已上线
# [短信] 138****0000:网关超时
# [短信] 138****0000:【多练网】[紧急] 支付服务不可用:支付失败率 35%
# [短信] 139****1111:【多练网】[活动] 国庆五折:全部课程五折(可在设置中退订)
# [邮件] [email protected]:主题「[活动] 国庆五折」 全部课程五折(可在设置中退订) —— 多练网
# [短信] 139****1111:【多练网】登录验证码:382916,5 分钟内有效,请勿告诉他人
# [企业微信] zhangsan:[紧急] 支付服务不可用 | 支付失败率 35%(多练网)

TS 版一样:

// 实现部分:渠道只管"怎么送到",包括各自的签名格式
interface Channel {
deliver(to: string, title: string, body: string): boolean;
}
class AppChannel implements Channel {
deliver(to: string, title: string, body: string): boolean {
console.log(`[App 推送] ${to}:多练网 · ${title} | ${body}`);
return true; // 模拟:总是成功
}
}
class SmsChannel implements Channel {
constructor(private failTimes = 0) {} // 模拟:前几次网关超时
deliver(to: string, title: string, body: string): boolean {
if (this.failTimes > 0) {
this.failTimes--;
console.log(`[短信] ${to}:网关超时`);
return false;
}
console.log(`[短信] ${to}:【多练网】${title}:${body}`);
return true;
}
}
class EmailChannel implements Channel {
deliver(to: string, title: string, body: string): boolean {
console.log(`[邮件] ${to}:主题「${title}」 ${body} —— 多练网`);
return true;
}
}
class WeComChannel implements Channel { // 新渠道:企业微信
deliver(to: string, title: string, body: string): boolean {
console.log(`[企业微信] ${to}:${title} | ${body}(多练网)`);
return true;
}
}
// 抽象部分:消息管"发什么、发几次",手里拿着一个渠道;基类给出默认规则,子类覆盖
class Message {
protected maxAttempts = 1;
constructor(protected readonly channel: Channel) {} // 这就是"桥"
send(to: string, title: string, body: string): void {
for (let i = 0; i < this.maxAttempts; i++) {
if (this.channel.deliver(to, this.formatTitle(title), this.formatBody(body))) return;
}
}
protected formatTitle(title: string): string {
return title;
}
protected formatBody(body: string): string {
return body;
}
}
class NoticeMessage extends Message {}
class UrgentMessage extends Message {
protected maxAttempts = 3;
protected formatTitle(title: string): string {
return `[紧急] ${title}`;
}
}
class MarketingMessage extends Message {
protected formatTitle(title: string): string {
return `[活动] ${title}`;
}
protected formatBody(body: string): string {
return `${body}(可在设置中退订)`;
}
}
class VerifyCodeMessage extends Message { // 新类型:验证码
protected maxAttempts = 2;
protected formatTitle(title: string): string {
return `${title}验证码`;
}
protected formatBody(body: string): string {
return `${body},5 分钟内有效,请勿告诉他人`;
}
}
new NoticeMessage(new AppChannel()).send("user_42", "课程更新", "第 14 章已上线");
new UrgentMessage(new SmsChannel(1)).send("138****0000", "支付服务不可用", "支付失败率 35%");
new MarketingMessage(new SmsChannel()).send("139****1111", "国庆五折", "全部课程五折");
new MarketingMessage(new EmailChannel()).send("[email protected]", "国庆五折", "全部课程五折");
new VerifyCodeMessage(new SmsChannel()).send("139****1111", "登录", "382916");
new UrgentMessage(new WeComChannel()).send("zhangsan", "支付服务不可用", "支付失败率 35%");
// 输出:
// [App 推送] user_42:多练网 · 课程更新 | 第 14 章已上线
// [短信] 138****0000:网关超时
// [短信] 138****0000:【多练网】[紧急] 支付服务不可用:支付失败率 35%
// [短信] 139****1111:【多练网】[活动] 国庆五折:全部课程五折(可在设置中退订)
// [邮件] [email protected]:主题「[活动] 国庆五折」 全部课程五折(可在设置中退订) —— 多练网
// [短信] 139****1111:【多练网】登录验证码:382916,5 分钟内有效,请勿告诉他人
// [企业微信] zhangsan:[紧急] 支付服务不可用 | 支付失败率 35%(多练网)

桥接版:Message 和 Channel 两套类,用一条“桥”连起来

两套类之间只有一条线。标红的两个新类各只写自己那一边。

一次紧急告警走短信:消息决定标题和重试,渠道负责送达

标题和重试次数由 UrgentMessage 定,SmsChannel 只管加签名、发出去;第一次超时,消息再调它一次。

对照 14.3:

  • 第 1 处,验证码:加一个 VerifyCodeMessage,配哪个渠道都能用。
  • 第 2 处,企业微信:加一个 WeComChannel,四种消息都能用。
  • 第 3 处,类的数量:从乘积变成相加,4 种类型加 4 个渠道是 8 个类。签名只在 4 个渠道类里各写一次,营销消息的类里根本没有签名,也就漏不掉。

也有不省事的地方。第一,按用户设置挑渠道的那段代码,仍得登记 WeComChannel。第二,拆之前要定好每个决定归哪一边:重试看消息重不重要,归消息;签名是渠道的规矩,归渠道。放错边就出事:嫌短信网关老超时,把“试 3 次”写进 SmsChannel,走 App 的紧急告警就不再重试,营销短信反倒试 3 次,网关只是回包慢时,用户会收到三条一样的广告。第三,有的规则同时取决于两边,比如营销短信要写“回复 TD 退订”,邮件要放退订链接,这时 Channel 接口得加一个方法,四个渠道类都要补上。

GoF 对桥接的定义是:将抽象部分与它的实现部分分离,使它们都可以独立地变化。

这里的“抽象”和“实现”不是抽象类和实现类:抽象部分是调用方直接用的那层(消息),实现部分是底下干活的那层(渠道)。两层各有继承体系,中间的对象引用就是“桥”。

桥接的结构:Abstraction 持有 Implementor,两边各自派生

对照案例图:Message 是 Abstraction,Channel 是 Implementor。

角色 在本章案例里是谁 职责
抽象(Abstraction) Message 定义调用方用的接口(send),持有一个实现部分的对象
扩充抽象(RefinedAbstraction) UrgentMessage、MarketingMessage 等 在抽象上加各自的规则:标题、重试、退订
实现者(Implementor) Channel 实现部分的接口,通常比抽象部分的接口更底层(deliver)
具体实现者(ConcreteImplementor) SmsChannel、WeComChannel 等 对接一个具体的平台

常见场景:

  • 跨平台:UI 控件(按钮、窗口)× 操作系统,控件逻辑只写一份,画图交给各平台。
  • 驱动:业务接口 × 数据库,代码面对同一套接口,底下换 MySQL 或 PostgreSQL 的驱动。
  • 渲染:图形(圆、矩形)× 渲染后端(SVG、Canvas),14.9 的练习就是这一类。

桥接就是第 1 章“组合优于继承”用在两个维度上,1.5 的 Report(period=Daily(), exporter=PdfExporter()) 就是一座小桥。

例一:logging 的 Logger 和 Handler(Python)

Section titled “例一:logging 的 Logger 和 Handler(Python)”

Python 标准库的 logging 分两层。Logger 是代码里打日志的那一层:按文档的说法,它判断级别,组装出一条 LogRecord,过一遍过滤器,再交给挂在它上面的每个 Handler(还会顺着父 Logger 往上传)。Handler 有自己的级别、过滤器和 Formatter,决定写成什么样、写到哪里:StreamHandler 写控制台,FileHandler 写文件,logging.handlers 里还有发网络、发邮件的。简化版:

import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol, TextIO
DEBUG, INFO = 10, 20 # 和 logging.DEBUG、logging.INFO 的数值相同
@dataclass
class LogRecord: # 真实的 logging.LogRecord 还带时间、文件名、行号、线程等
name: str
levelname: str
msg: str
class Handler(Protocol): # 实现部分,对应 logging.Handler
def emit(self, record: LogRecord) -> None: ...
class StreamHandler: # 对应 logging.StreamHandler:写到一个流(控制台);FileHandler 是它的子类
def __init__(self, stream: TextIO) -> None:
self.stream = stream
def emit(self, record: LogRecord) -> None:
self.stream.write(f"{record.levelname}:{record.name}:{record.msg}\n")
class BufferingHandler: # 对应 logging.handlers.BufferingHandler:先攒在内存里
def __init__(self) -> None:
self.buffer: list[LogRecord] = []
def emit(self, record: LogRecord) -> None:
self.buffer.append(record)
class Logger: # 抽象部分,对应 logging.Logger:管级别、过滤、组装记录,不管写到哪
def __init__(self, name: str, level: int) -> None:
self.name, self.level = name, level
self.filters: list[Callable[[LogRecord], bool]] = []
self.handlers: list[Handler] = []
def log(self, level: int, msg: str, *args: object) -> None:
if level < self.level:
return # 级别不够,连记录都不组装
# 简化:这里当场拼好消息;真实的 LogRecord 把 msg 和 args 分开存,到 getMessage() 才拼
record = LogRecord(self.name, "DEBUG" if level == DEBUG else "INFO", msg % args)
if all(f(record) for f in self.filters):
for h in self.handlers:
h.emit(record) # 真实的 Logger 调 handler.handle():先过 Handler 自己的过滤,再加锁调 emit()
def info(self, msg: str, *args: object) -> None:
self.log(INFO, msg, *args)
if __name__ == "__main__":
log = Logger("pay", INFO)
log.filters.append(lambda r: "password" not in r.msg)
memory = BufferingHandler()
log.handlers += [StreamHandler(sys.stdout), memory] # 换、加 Handler,Logger 一行不改
log.log(DEBUG, "连接池大小 %d", 8)
log.info("订单 %s 支付成功", "A1001")
log.info("password=%s", "123456")
print(f"内存里攒了 {len(memory.buffer)} 条:{memory.buffer[0].msg}")
# 输出:
# INFO:pay:订单 A1001 支付成功
# 内存里攒了 1 条:订单 A1001 支付成功

Logger 对应 Message,Handler 对应 Channel。debug 那条和带密码那条都在 Logger 这层被拦下,Handler 根本没见过。

例二:React 的协调器和渲染器(TypeScript)

Section titled “例二:React 的协调器和渲染器(TypeScript)”

React 大致也是这么分的。协调器(Reconciler)调组件拿到元素树,对比新旧两棵树,决定建哪些节点、挂到哪里;真正建节点的是渲染器:react-dom 建 DOM 节点,react-native 建原生视图。两者之间约定的一组方法,react-reconciler 包叫它 host config,里面有 createInstance、appendChild 等。简化版只留建节点和挂孩子,同一棵元素树交给两个渲染器:

// 元素树:真实 React 里是 JSX 编译出来的对象,带 props、key 等
type El = string | { type: string; children: El[] };
// 实现部分:对应 react-reconciler 的 host config,每个渲染器(react-dom、react-native)各写一份
// 真实的方法更多、参数更多:createInstance、createTextInstance、appendInitialChild、commitUpdate……
interface Renderer<I> {
createInstance(type: string): I;
createTextInstance(text: string): I;
appendChild(parent: I, child: I): void;
}
// 抽象部分:对应协调器(Reconciler),只决定"建什么、挂到哪",不知道最后画在哪里
// 真实的协调器还要对比新旧两棵树,只把变了的部分交给渲染器
function render<I>(el: El, r: Renderer<I>): I {
if (typeof el === "string") return r.createTextInstance(el);
const instance = r.createInstance(el.type);
for (const child of el.children) r.appendChild(instance, render(child, r));
return instance;
}
class HtmlNode { // 假装是浏览器的 DOM 节点
readonly children: HtmlNode[] = [];
constructor(readonly tag: string | null, readonly text = "") {}
toString(): string {
return this.tag === null ? this.text : `<${this.tag}>${this.children.join("")}</${this.tag}>`;
}
}
const domRenderer: Renderer<HtmlNode> = { // 像 react-dom:变成 DOM 节点
createInstance: (type) => new HtmlNode(type),
createTextInstance: (text) => new HtmlNode(null, text),
appendChild: (parent, child) => { parent.children.push(child); },
};
const textRenderer: Renderer<string[]> = { // 像终端里的渲染器:变成缩进的文本行
createInstance: (type) => [type],
createTextInstance: (text) => [text],
appendChild: (parent, child) => { parent.push(...child.map((line) => ` ${line}`)); },
};
const toc: El = { type: "ul", children: [
{ type: "li", children: ["第 14 章 桥接"] },
{ type: "li", children: ["第 15 章 享元"] },
] };
console.log(render(toc, domRenderer).toString());
console.log(render(toc, textRenderer).join("\n"));
// 输出:
// <ul><li>第 14 章 桥接</li><li>第 15 章 享元</li></ul>
// ul
// li
// 第 14 章 桥接
// li
// 第 15 章 享元

render 是抽象部分,Renderer 是实现者接口,两个渲染器是具体实现者。render 里没有一行和 HTML 有关。

  • 只有一个维度在变。消息只走短信,类型一直在加,普通继承或第 17 章的策略就够了。硬拆出只有一个实现的 Channel 接口,读代码的人要多跳一层才看到短信怎么发。
  • 两个维度不是“用”而是“是”。“VIP 用户”是用户的一种,不是用户手里的零件。一个等级枚举字段加一张折扣表就够;拆成 User 持有 Tier 对象,每加一条按等级区分的规则,都要在 Tier 接口和每个等级类里各加一个方法。
  • 项目早期,第二个维度还没出现。只有短信时提前拆桥,接口按短信设计;企业微信真来了,多半发现 deliver(to, title, body) 不合用(它要卡片、按钮),接口照样得改,消息类连带要改。等第二个渠道来了再拆,接口按两个真实的样子设计。
模式 意图 区别
适配器(Adapter) 让一个接口对不上的类按调用方要的接口工作 适配器是事后补救:两边都写好了,接不上,中间加一层转换。桥接不为对接而转换:抽象和实现两侧的接口都是事先设计的,见第 9 章
策略(Strategy) 把一族可互换的算法各自封装 结构几乎一样,都持有一个接口对象;但策略换的是一个算法(怎么算运费),桥接的实现部分是一整套平台能力,而且抽象部分自己也有一棵继承树。见第 17 章

图表 Chart 有折线图 LineChart 和柱状图 BarChart,渲染后端 Renderer 有 SvgRenderer(输出 SVG 字符串)和 AsciiRenderer(输出字符画)。用桥接拆开,同一组数据 [3, 5, 2, 4],两种图各用两种后端画一次。Python 和 TS 各写一版。

提示:先想 Renderer 要提供哪几个最基本的动作(开始画布、画一根柱子、画一串点),图表只用这些动作;字符画里折线只画点也行。SVG 的纵轴朝下,要翻过来。

  • 桥接把两个各自会变的维度拆成两套类,一边持有另一边的对象,组合在创建时决定。
  • 类的数量从乘积变成相加;一条渠道规则只写在一个渠道类里,不会散在每种消息里等着漏改。
  • 拆之前要定好每个决定归哪一边;同时取决于两边的规则要扩展实现者接口,所有实现都得跟着改。
  • 和适配器的分别:桥接是事先设计的拆分,适配器是事后的补救。和策略的分别:策略换一个算法,桥接换一整套底层能力。