跳转到内容

第 30 章 · 走出 GoF

GoF 的 23 个模式收在 1994 年那本书里。之后,人们在 Web 服务、数据库、界面框架里又反复看到另一些结构,有的出自 Martin Fowler 的《企业应用架构模式》(Patterns of Enterprise Application Architecture),有的是框架约定俗成的叫法。它们不在 23 个之内,却比其中几个更常见:打开一个 Web 项目,你未必找得到解释器,但通常找得到中间件。

这一章挑六种导览:依赖注入、仓储、中间件管道、事件总线与消息代理、空对象、MVC 与 MVVM。每节只讲它是什么、解决什么麻烦、和前面哪几章是同一套零件,讲到能认出来、知道去哪里深入为止。前面几章留给本章的话,在对应小节接上。最后一节说一个贯穿全书的看法:模式是重构的目标,不是起点。

GoF 之外的六种模式各自建立在本书哪几章之上

图左边是本书讲过的 GoF 模式,右边是本章的模式(另有值对象),连线上写着右边借用了左边的哪一点。

6.8 末尾给了单例的替代办法:在入口创建一次 Config,作为参数传给 send_sms,并说“这就是依赖注入(Dependency Injection)的雏形”。依赖注入把这件事当成规矩:一个对象要用的东西(它的依赖)不自己去拿,既不调 Config.instance(),也不在内部新建,而是由外面递进来。最常见的递法是构造函数注入(constructor injection):依赖写在构造函数的参数里,对象造好时依赖就齐了。所有新建对象的代码集中在一处,叫组合根(composition root),通常就在 main 旁边:

from __future__ import annotations
class Config: # 第 6 章的 Config,去掉了 _instance 和 instance()
def __init__(self, values: dict[str, str]) -> None:
self._values = values
@classmethod
def from_values(cls, values: dict[str, str]) -> Config:
return cls(values)
def get(self, key: str) -> str:
return self._values[key]
class SmsSender:
def __init__(self, config: Config) -> None: # 依赖从构造函数进来
self._limit = int(config.get("sms_max_length"))
def send(self, user: str, text: str) -> None:
if len(text) > self._limit:
print(f"短信超过 {self._limit} 字,未发送")
return
print(f"短信发给 {user}:{text}")
class OrderService:
def __init__(self, config: Config, sms: SmsSender) -> None:
self._currency = config.get("currency")
self._sms = sms
def place_order(self, user: str, price: int) -> None:
print(f"{user} 下单:{price} {self._currency}")
self._sms.send(user, f"订单已提交,{price} 元")
def build_app(config: Config) -> OrderService: # 组合根:全程序只在这里新建对象
return OrderService(config, SmsSender(config))
if __name__ == "__main__":
# 真实项目里是 Config.from_values(fetch_remote_config()),只在入口调一次
app = build_app(Config.from_values({"currency": "CNY", "sms_max_length": "30"}))
app.place_order("小明", 199)
# 测试里:另造一份,不碰任何全局状态,用完也不用还原
test_app = build_app(Config.from_values({"currency": "CNY", "sms_max_length": "10"}))
test_app.place_order("小红", 299)
# 输出:
# 小明 下单:199 CNY
# 短信发给 小明:订单已提交,199 元
# 小红 下单:299 CNY
# 短信超过 10 字,未发送
class Config { // 第 6 章的 Config,去掉了 _instance 和 instance()
private constructor(private readonly values: Record<string, string>) {}
static fromValues(values: Record<string, string>): Config {
return new Config(values);
}
get(key: string): string {
return this.values[key];
}
}
class SmsSender {
private readonly limit: number;
constructor(config: Config) { // 依赖从构造函数进来
this.limit = Number(config.get("sms_max_length"));
}
send(user: string, text: string): void {
if (text.length > this.limit) {
console.log(`短信超过 ${this.limit} 字,未发送`);
return;
}
console.log(`短信发给 ${user}:${text}`);
}
}
class OrderService {
private readonly currency: string;
constructor(config: Config, private readonly sms: SmsSender) {
this.currency = config.get("currency");
}
placeOrder(user: string, price: number): void {
console.log(`${user} 下单:${price} ${this.currency}`);
this.sms.send(user, `订单已提交,${price} 元`);
}
}
function buildApp(config: Config): OrderService { // 组合根:全程序只在这里新建对象
return new OrderService(config, new SmsSender(config));
}
// 真实项目里是 Config.fromValues(fetchRemoteConfig()),只在入口调一次
const app = buildApp(Config.fromValues({ currency: "CNY", sms_max_length: "30" }));
app.placeOrder("小明", 199);
// 测试里:另造一份,不碰任何全局状态,用完也不用还原
const testApp = buildApp(Config.fromValues({ currency: "CNY", sms_max_length: "10" }));
testApp.placeOrder("小红", 299);
// 输出:
// 小明 下单:199 CNY
// 短信发给 小明:订单已提交,199 元
// 小红 下单:299 CNY
// 短信超过 10 字,未发送

两次 build_app 各拿一份 Config,第二份短信上限 10 字。没有 set_instance,也就没有“用完要还原”:6.8 第 2 条说的测试 A 忘了还原、害得测试 B 一起跑才失败,在这里不会发生,因为根本没有共享的实例。6.8 第 1 条那位财务同事,看构造函数就知道 SmsSender 要用配置。

6.8 说直接传参的代价是“要一层层往下传”。构造函数注入把这笔账挪了地方:OrderService 调 self._sms.send 时不用再转手配置,因为 SmsSender 造好时已经拿到了;代价集中到组合根,类多到几十个,build_app 就有几十行,谁依赖谁全写在这里。依赖注入容器(DI container)替你写这几十行:你登记“要 Config 时给这一份”,它看每个构造函数要什么,递归地造出来。

  • FastAPI:路由函数的参数写成 db: Session = Depends(get_db),每次请求时框架先调 get_db,把结果传进来;同一次请求里几处都依赖 get_db,默认只调一次;get_db 写成带 yield 的生成器,请求结束后还会执行 yield 之后的清理代码。测试时用 app.dependency_overrides[get_db] = fake_db 换掉,大致如此。这就是 6.8 说的“替你做传递的活”。
  • NestJS、Angular:类加上 @Injectable(),构造函数参数写明类型,容器按类型找到实现并注入,默认整个应用共用一份。Nest 知道参数类型,靠的是 TS 装饰器和编译时生成的类型元数据;Angular 也可以在字段初始化时调 inject(SomeService),大致如此。

容器的代价是:一个类拿到的是哪个实现,要去翻登记处;漏登记一项,常在启动或首次用到时才报“找不到某某的提供者”。几个类的小程序,手写组合根就够了。

和本书的关系:容器里“整个应用共用一份”,就是 29.3 说的“在入口创建一次”,单例(第 6 章)由组合根保证,不由类自己保证;容器本身是一个按类型造对象的大工厂(第 3、4 章);注入进来的东西常常就是一个策略,17.4 的 Checkout(order, strategy) 正是从构造函数拿到策略的。

下单、退款、报表三处代码里各写着一条 SELECT ... FROM orders WHERE order_id = %s,再把查出来的行拼成 Order。表里加一列,三处都要改;测这三个函数,都得先起一个数据库。

仓储(Repository,也译作仓库)把“按业务对象存取”收进一个接口,调用方像用一个集合那样用它:

class OrderRepository(Protocol):
def get(self, order_id: str) -> Order | None: ...
def add(self, order: Order) -> None: ...

生产环境用 SqlOrderRepository 拼 SQL、把行翻译成 Order;测试用 InMemoryOrderRepository,背后是一个字典。下单代码只调 repo.get("P1001"),不知道背后是哪一个。表结构变了,只改 SqlOrderRepository 一个类。Fowler 的《企业应用架构模式》和 Eric Evans 的《领域驱动设计》(Domain-Driven Design)都收录了它。

它和前面两章是同一套零件。像外观(第 11 章),它把取连接、拼 SQL、转换结果这一串步骤收到几个方法后面;不同的是外观收的是一段流程,仓储收的是“一类对象从哪里来”。像适配器(第 9 章),接口由业务一方来定,9.4 的 Tracker 是“我们自己定义的接口,调用处只认它”,OrderRepository 也是;实现负责翻译;连用途都一样,InMemoryOrderRepository 就是 9.4 测试里的 FakeTracker。

要不要自己包一层,看 29.8 那条:只转发、不翻译的包装是写多了的信号。在 Django 的 Order.objects 外面包一个每个方法只转调一行的仓储,读代码的人多跳一层,什么也没换来;包了以后测试能不起数据库,才值得。

《重构》第 33 章”将值对象改为引用对象“里相关的设计模式一节说,管理引用对象的仓库要到本书第 30 章讲。那一章的情形是:同一个客户在程序里只能有一份,改了它,别处都看得到,于是得有一个地方按编号交出这一份,这个地方就是仓储。与之相对的是值对象(Value Object):金额、地址这类没有编号、按值比较、造好就不改的小对象,随手新建、随手比较,用不着仓储。值对象能被随处共享,前提和第 15 章享元的内在状态一样:不可变。

23.5 区分了两种责任链:纯粹的链只有一个处理者真正处理;管道(pipeline)里每个处理者都做一点事再交给下一个,Web 框架的中间件(middleware)更接近后者,只是每一层也能截断。

回到 23.6 的 Django 模拟。logging_middleware 在调 get_response 之前打印“日志:→ /refunds”,之后打印“日志:← 200”。请求从外往里依次穿过日志、鉴权、计时,到达视图;响应再从里往外穿回来,每一层都有“之前”和“之后”两次机会。画出来像一层层的洋葱,所以常叫洋葱模型(onion model)。Koa 把一层写成 async (ctx, next) => { 之前; await next(); 之后; },ASP.NET Core 的中间件也是这个形状,大致如此。

它是两个 GoF 模式的组合,23.8 已经点过:单看一层,它和被包的下一层接口相同,正常情况下一定往里传,这是装饰器(第 10 章);可以不调 get_response 直接返回,这是责任链(第 23 章)。“管道”这个名字强调的是拼法:一张有序的列表,Django 是 settings.MIDDLEWARE,Express 是 app.use 的注册顺序;Django 由框架从后往前一层层包好,就是 23.6 的 load_middleware。

所以顺序就是行为。23.6 里鉴权排在计时前面,被拒的请求连计时都没进,响应头里也就没有耗时;把鉴权挪到日志前面,被拒的请求就不会留下日志,查“为什么这个用户一直 403”时日志里一条都找不到。调整中间件顺序要当成改业务逻辑来审。

管道不只在 Web 框架里:一批数据依次清洗、换算、写入,Unix 命令用 | 串起来,都是这个形状。《重构》第 11 章”拆分阶段“做到底就是它,那一章把管道留给了本章,也说了它和责任链的区别:管道的每一段总把结果交下去,能截断的才是责任链。

第 18 章有三处把话留给了本章:18.4 说“要让慢的订阅者不拖住回调,得把事件放进消息队列,由别的进程异步处理”;18.9 的表里,发布订阅(Publish/Subscribe)“中间多了一个消息代理”;18.11 说“要异步就得引入队列”。先把两样东西分清。

第 18 章的 EventBus 是进程内的:订阅者列表在发布方进程的内存里,publish 在当前线程里一个个调,全部跑完才返回。消息代理(message broker)是一个独立运行的服务,RabbitMQ、Kafka、Redis Streams 都属于这一类:发布方把消息交给它,它存下来就算发布成功;订阅方是别的进程,常在别的机器上,自己去取。

进程内 EventBus(第 18 章) 消息代理
publish 何时返回 所有订阅者跑完 通常收下消息就返回
订阅者慢 发布方跟着等(18.4 的埋点卡 3 秒) 消息在队列里排着,发布方不等
订阅者没启动或挂了 这次通知错过就没了;发布方进程崩溃,内存里没发完的也没了 消息留在代理里,订阅方起来后接着处理
订阅者失败 总线接住异常,记一行日志 不确认就重投,重投多次仍失败的可转入死信队列(dead-letter queue)
同一条收到几次 每个订阅者一次 至少一次(at-least-once),可能收到两次
顺序 登记顺序 只在一个队列或分区里大致有序,重投会打乱

18.9 的判别问题仍然最好用:publish 返回时,订阅者跑完了没有。下面用一个列表模拟代理,不连真实的服务,对比两种发法:

from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
@dataclass(frozen=True)
class PaymentSucceeded:
order_id: str
user_id: str
amount: int
Handler = Callable[[PaymentSucceeded], None]
class EventBus: # 第 18 章的做法:同一个进程,publish 返回时订阅者都跑完了(省去了异常处理)
def __init__(self) -> None:
self._handlers: list[Handler] = []
def subscribe(self, handler: Handler) -> None:
self._handlers.append(handler)
def publish(self, event: PaymentSucceeded) -> None:
for handler in self._handlers:
handler(event)
class FakeBroker: # 模拟消息代理:publish 只入队;消费者稍后来取,确认之前消息不删
def __init__(self) -> None:
self.queue: deque[PaymentSucceeded] = deque()
def publish(self, event: PaymentSucceeded) -> None:
self.queue.append(event)
def consume_one(self, handler: Handler, ack_lost: bool = False) -> None:
event = self.queue[0]
handler(event)
if ack_lost: # 处理完了,确认却没送到代理:代理只能当没处理过
print(f"确认丢失,{event.order_id} 留在队列里等重投")
return
self.queue.popleft() # 收到确认才删
class Points:
def __init__(self) -> None:
self.balance: dict[str, int] = {}
self.done: set[str] = set() # 处理过的订单号;真实系统里要存进数据库
def handle(self, event: PaymentSucceeded) -> None:
if event.order_id in self.done: # 幂等:同一个订单只加一次
print(f"积分:{event.order_id} 已处理过,跳过")
return
self.done.add(event.order_id)
self.balance[event.user_id] = self.balance.get(event.user_id, 0) + event.amount
print(f"积分:{event.user_id} 加 {event.amount} 分")
if __name__ == "__main__":
paid = PaymentSucceeded("P1001", "u42", 199)
print("== 进程内 EventBus ==")
bus = EventBus()
bus.subscribe(Points().handle)
bus.publish(paid)
print("publish 返回")
print("== 消息代理(模拟) ==")
points = Points()
broker = FakeBroker()
broker.publish(paid)
print(f"publish 返回,队列里 {len(broker.queue)} 条,积分还没加")
broker.consume_one(points.handle, ack_lost=True) # 另一个进程稍后来取
broker.consume_one(points.handle) # 重投
print(f"队列里 {len(broker.queue)} 条,u42 积分 {points.balance['u42']}")
# 输出:
# == 进程内 EventBus ==
# 积分:u42 加 199 分
# publish 返回
# == 消息代理(模拟) ==
# publish 返回,队列里 1 条,积分还没加
# 积分:u42 加 199 分
# 确认丢失,P1001 留在队列里等重投
# 积分:P1001 已处理过,跳过
# 队列里 0 条,u42 积分 199
interface PaymentSucceeded {
readonly orderId: string;
readonly userId: string;
readonly amount: number;
}
type Handler = (event: PaymentSucceeded) => void;
class EventBus { // 第 18 章的做法:同一个进程,publish 返回时订阅者都跑完了(省去了异常处理)
private handlers: Handler[] = [];
subscribe(handler: Handler): void {
this.handlers.push(handler);
}
publish(event: PaymentSucceeded): void {
for (const handler of this.handlers) handler(event);
}
}
class FakeBroker { // 模拟消息代理:publish 只入队;消费者稍后来取,确认之前消息不删
readonly queue: PaymentSucceeded[] = [];
publish(event: PaymentSucceeded): void {
this.queue.push(event);
}
consumeOne(handler: Handler, ackLost = false): void {
const event = this.queue[0];
handler(event);
if (ackLost) { // 处理完了,确认却没送到代理:代理只能当没处理过
console.log(`确认丢失,${event.orderId} 留在队列里等重投`);
return;
}
this.queue.shift(); // 收到确认才删
}
}
class Points {
readonly balance = new Map<string, number>();
private readonly done = new Set<string>(); // 处理过的订单号;真实系统里要存进数据库
handle(event: PaymentSucceeded): void {
if (this.done.has(event.orderId)) { // 幂等:同一个订单只加一次
console.log(`积分:${event.orderId} 已处理过,跳过`);
return;
}
this.done.add(event.orderId);
this.balance.set(event.userId, (this.balance.get(event.userId) ?? 0) + event.amount);
console.log(`积分:${event.userId} 加 ${event.amount} 分`);
}
}
const paid: PaymentSucceeded = { orderId: "P1001", userId: "u42", amount: 199 };
console.log("== 进程内 EventBus ==");
const bus = new EventBus();
const busPoints = new Points();
bus.subscribe((e) => busPoints.handle(e));
bus.publish(paid);
console.log("publish 返回");
console.log("== 消息代理(模拟) ==");
const points = new Points();
const broker = new FakeBroker();
broker.publish(paid);
console.log(`publish 返回,队列里 ${broker.queue.length} 条,积分还没加`);
broker.consumeOne((e) => points.handle(e), true); // 另一个进程稍后来取
broker.consumeOne((e) => points.handle(e)); // 重投
console.log(`队列里 ${broker.queue.length} 条,u42 积分 ${points.balance.get("u42")}`);
// 输出:
// == 进程内 EventBus ==
// 积分:u42 加 199 分
// publish 返回
// == 消息代理(模拟) ==
// publish 返回,队列里 1 条,积分还没加
// 积分:u42 加 199 分
// 确认丢失,P1001 留在队列里等重投
// 积分:P1001 已处理过,跳过
// 队列里 0 条,u42 积分 199

看两段输出里“publish 返回”的位置:总线那段它在积分之后,消息代理那段它在积分之前。现在回答第 18 章留下的问题。

慢。支付回调不再直接调埋点,只把 PaymentSucceeded 交给消息代理就回 200。埋点卡 3 秒,卡住的是埋点自己的消费进程,回调不受影响,平台不会因为等太久而重试。

重复。18.4 说“即使那样,重试仍可能发生(200 回了,网络却断在半路)”。代理只是把重复的来源变多了:平台重试会发出第二条消息;消费者处理完、确认没送到,代理也会再投一次,就是上面的 ack_lost。代理承诺的是至少一次,所以积分要按订单号做幂等,正如 18.4 所说。上面的 done 放在内存里只为演示,消费进程一重启就忘了;真实系统要把“P1001 已加过积分”和加积分本身写进同一个数据库事务,或者给积分流水表的订单号加唯一约束,让第二次插入直接失败。

顺序。18.8 说订阅者之间的先后靠登记顺序保证很脆弱。换成代理只会更脆:积分和短信在两个进程里各取各的,谁先跑完没人保证。那里给的办法照样适用:积分处理完再发一条“积分已到账”,由短信去订阅它。

代理还带来一个总线没有的新问题:把订单改成“已支付”写进数据库,和把消息交给代理,是两个系统里的两次写入。写库成功、发消息前进程崩溃,这条消息就永远没发出去,积分少加一次,没有任何报错。常见做法是发件箱(transactional outbox):把要发的事件和订单状态写进同一个数据库事务,再由一个后台任务把它转发给代理,大致如此。

几个常见代理的说法不同,机制相近,大致如此。RabbitMQ 里消息先到交换机(exchange),再路由到队列,消费者处理完发确认(ack),连接断开时没确认的消息会重投。Kafka 把消息追加到主题(topic)分区的日志里,消费后并不删除,每个消费者组自己记读到了哪里(offset);处理了却没来得及提交位置就挂了,重启后从旧位置再读一遍,同样是至少一次。Redis Streams 用 XADD 追加、XREADGROUP 按消费者组读、XACK 确认,没确认的留在待处理列表里。有的代理宣传“恰好一次”,通常限于它自己的读写链路之内;结果写到你自己的数据库时,仍然要靠幂等。

还要分清两种投法:一条消息只给一个消费者,几个进程分担,这是工作队列(work queue);每个订阅方各收一份,才是发布订阅。积分和埋点都要收到“支付成功”,要的是后者:RabbitMQ 里每个订阅方一个队列,Kafka 里每个订阅方一个消费者组(consumer group)。队列里的消息也是请求对象,19.5 说的“后台任务队列里的一条任务”就是命令模式(第 19 章)。

第 17 章的 Checkout 总要拿到一个 DiscountStrategy。可用户也可能没用券:策略变成 None,于是订单预估、结算、订单详情里的“优惠说明”三处都要写 if strategy is None。哪一处漏了,只有不用券的用户会碰上 'NoneType' object has no attribute 'discount',而测试数据里人人都带着券。

空对象(Null Object)的做法是给“没有”也造一个对象,它实现同样的接口,做的是“什么都不做”:

from dataclasses import dataclass
from typing import Protocol
@dataclass
class Order:
order_id: str
amount: int
class DiscountStrategy(Protocol):
def discount(self, order: Order) -> int: ...
def describe(self) -> str: ...
class FullReduction: # 满减:满 threshold 减 off,第 17 章的满减多加了 describe
def __init__(self, threshold: int, off: int) -> None:
self.threshold, self.off = threshold, off
def discount(self, order: Order) -> int:
return self.off if order.amount >= self.threshold else 0
def describe(self) -> str:
return f"满 {self.threshold} 减 {self.off}"
class NoDiscount: # 空对象:没用券时也给一个策略,什么都不减
def discount(self, order: Order) -> int:
return 0
def describe(self) -> str:
return "未使用优惠"
def pick_strategy(coupon_type: str | None) -> DiscountStrategy:
if coupon_type is None: # 全程序只在这里判断一次"没有券"
return NoDiscount()
return FullReduction(200, 30)
def checkout(order: Order, strategy: DiscountStrategy) -> None: # 不用再判断 None
print(f"{order.order_id}:{strategy.describe()},应付 {order.amount - strategy.discount(order)} 元")
if __name__ == "__main__":
checkout(Order("A1", 259), pick_strategy("full_reduction"))
checkout(Order("A2", 259), pick_strategy(None))
# 输出:
# A1:满 200 减 30,应付 229 元
# A2:未使用优惠,应付 259 元
interface Order {
orderId: string;
amount: number;
}
interface DiscountStrategy {
discount(order: Order): number;
describe(): string;
}
class FullReduction implements DiscountStrategy { // 满减:满 threshold 减 off,第 17 章的满减多加了 describe
constructor(private readonly threshold: number, private readonly off: number) {}
discount(order: Order): number {
return order.amount >= this.threshold ? this.off : 0;
}
describe(): string {
return `满 ${this.threshold} 减 ${this.off}`;
}
}
class NoDiscount implements DiscountStrategy { // 空对象:没用券时也给一个策略,什么都不减
discount(_order: Order): number {
return 0;
}
describe(): string {
return "未使用优惠";
}
}
function pickStrategy(couponType: string | null): DiscountStrategy {
if (couponType === null) return new NoDiscount(); // 全程序只在这里判断一次"没有券"
return new FullReduction(200, 30);
}
function checkout(order: Order, strategy: DiscountStrategy): void { // 不用再判断 null
console.log(`${order.orderId}:${strategy.describe()},应付 ${order.amount - strategy.discount(order)} 元`);
}
checkout({ orderId: "A1", amount: 259 }, pickStrategy("full_reduction"));
checkout({ orderId: "A2", amount: 259 }, pickStrategy(null));
// 输出:
// A1:满 200 减 30,应付 229 元
// A2:未使用优惠,应付 259 元

“没有券”只在 pick_strategy 里判断一次,checkout 和以后加的每个调用处都不用管。NoDiscount 本身就是第 17 章的一个策略,只是它的算法是“减 0”;第 21 章的状态类对不允许的操作是抛错,只与它共用“接口一致、默认统一”的形状。

《重构》第 39 章”引入特例“就是走到空对象的那条路,它的 UnknownCustomer 是一个空对象,那一章也把空对象留给了本章。它同时提醒了别用的场合:特殊值本身就是错误时,别用空对象吞掉它。查不到用户时返回一个”空用户”,后面给他发短信静默跳过,排查“短信为什么没发”时找不到是在哪一步断的。没有券是正常情况,可以用空对象;不该没有的东西,该尽早报错。

模型-视图-控制器(Model-View-Controller,MVC)出自 1970 年代末 Smalltalk 的界面开发,GoF 原书第 1 章正是用它引出设计模式的:它不是一个模式,而是几个模式的组合。

角色 管什么 用到的 GoF 模式
模型(Model) 数据和业务规则;变了就发通知 观察者里的主题(第 18 章)
视图(View) 把模型画出来;订阅模型,收到通知就重画 观察者里的订阅者(第 18 章);视图里套视图是组合(第 13 章)
控制器(Controller) 把用户的点击、输入翻译成对模型的操作 视图持有一个可换的控制器,是策略(第 17 章)

数据流是一个圈:用户点“加入购物车”,控制器调模型的 add_item;模型改完数据发出通知;购物车角标和价格合计这两个视图各自重画。视图从不直接改数据,模型也不知道有几个视图在看它,这正是第 18 章观察者换来的那一点。

模型-视图-视图模型(Model-View-ViewModel,MVVM)大约在 2005 年由微软为 WPF 提出,今天的 Vue、Angular 都接近它,大致如此。它把控制器换成视图模型(ViewModel):一个专为某个界面准备好状态和操作的对象,比如“购物车合计”“能不能点结算”。视图和视图模型之间靠数据绑定(data binding)自动同步:输入框改了,视图模型的字段跟着变;字段变了,界面跟着刷新。绑定底下还是观察者,Vue 3 用 Proxy 拦截读写来知道谁该刷新,12.6 的例二模拟过这一点。

Web 后端也常说 MVC,含义不同:Rails、Spring MVC 里控制器取数据、交给模板画出一页 HTML 就结束了,视图不订阅模型,没有观察者那一半。Django 自称 MTV(模型、模板、视图),它的“视图”相当于这里的控制器,大致如此。

《重构》第 60 章”从重构到设计模式“列了一张表:哪些重构手法做到底,得到的就是哪个模式。其中有几行指向本章:引入特例(第 39 章)走到空对象(30.5),拆分阶段(第 11 章)走到管道(30.3),将值对象改为引用对象(第 33 章)走到仓储(30.2),引入参数对象这类手法得到的值对象也在 30.2 讲过。表的其余各行指向本书的第 3、9、11、14、15、17、19、20、21、22、23 章。

28.5 说过:先写直白代码,第二次变化来了再重构到模式。本章补两点。第一天就套上容器、仓储和消息代理,换来的是只有一个实现的接口、只转发的包装、只有一个订阅者的队列,都是 28.5 说的“只有一种实现”。触发的信号要具体:第二个订阅者要求不能被第一个拖慢,或者测试因为要起数据库而跑不动。那时再动手。第 60 章也讲了反方向:模式不再需要时,把它重构掉,换回更简单的结构,同样是一次好的重构。

  • 依赖注入:对象不自己拿依赖,由组合根从构造函数递进来,是 6.8 “在入口创建一次”的机制化;容器替你写组合根。
  • 仓储把一类对象的存取收进业务一方定义的接口,像外观、像适配器;只转发 ORM 的仓储不值得写。
  • 中间件管道是装饰器加上能截断的责任链,按列表从后往前包成洋葱;顺序就是行为。
  • 第 18 章的 EventBus 是同步、同进程的;消息代理是异步、持久、至少一次投递的。换成代理解决了慢,带来了重复、乱序和“写库与发消息不在一个事务里”,重复靠按订单号做幂等。
  • 空对象是“减 0”的策略,特殊值本身是错误时别用;MVC 和 MVVM 是观察者、策略、组合的组合。这些都是需求变了以后重构的目的地,不是起点。