第 30 章 · 走出 GoF
GoF 的 23 个模式收在 1994 年那本书里。之后,人们在 Web 服务、数据库、界面框架里又反复看到另一些结构,有的出自 Martin Fowler 的《企业应用架构模式》(Patterns of Enterprise Application Architecture),有的是框架约定俗成的叫法。它们不在 23 个之内,却比其中几个更常见:打开一个 Web 项目,你未必找得到解释器,但通常找得到中间件。
这一章挑六种导览:依赖注入、仓储、中间件管道、事件总线与消息代理、空对象、MVC 与 MVVM。每节只讲它是什么、解决什么麻烦、和前面哪几章是同一套零件,讲到能认出来、知道去哪里深入为止。前面几章留给本章的话,在对应小节接上。最后一节说一个贯穿全书的看法:模式是重构的目标,不是起点。
图左边是本书讲过的 GoF 模式,右边是本章的模式(另有值对象),连线上写着右边借用了左边的哪一点。
30.1 依赖注入
Section titled “30.1 依赖注入”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) 正是从构造函数拿到策略的。
30.2 仓储
Section titled “30.2 仓储”下单、退款、报表三处代码里各写着一条 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 章享元的内在状态一样:不可变。
30.3 中间件管道
Section titled “30.3 中间件管道”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 章”拆分阶段“做到底就是它,那一章把管道留给了本章,也说了它和责任链的区别:管道的每一段总把结果交下去,能截断的才是责任链。
30.4 事件总线与消息代理
Section titled “30.4 事件总线与消息代理”第 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 dequefrom collections.abc import Callablefrom 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 积分 199interface 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 章)。
30.5 空对象
Section titled “30.5 空对象”第 17 章的 Checkout 总要拿到一个 DiscountStrategy。可用户也可能没用券:策略变成 None,于是订单预估、结算、订单详情里的“优惠说明”三处都要写 if strategy is None。哪一处漏了,只有不用券的用户会碰上 'NoneType' object has no attribute 'discount',而测试数据里人人都带着券。
空对象(Null Object)的做法是给“没有”也造一个对象,它实现同样的接口,做的是“什么都不做”:
from dataclasses import dataclassfrom typing import Protocol
@dataclassclass 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 是一个空对象,那一章也把空对象留给了本章。它同时提醒了别用的场合:特殊值本身就是错误时,别用空对象吞掉它。查不到用户时返回一个”空用户”,后面给他发短信静默跳过,排查“短信为什么没发”时找不到是在哪一步断的。没有券是正常情况,可以用空对象;不该没有的东西,该尽早报错。
30.6 MVC 与 MVVM
Section titled “30.6 MVC 与 MVVM”模型-视图-控制器(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(模型、模板、视图),它的“视图”相当于这里的控制器,大致如此。
30.7 模式是重构的目标
Section titled “30.7 模式是重构的目标”《重构》第 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 章也讲了反方向:模式不再需要时,把它重构掉,换回更简单的结构,同样是一次好的重构。
30.8 小结
Section titled “30.8 小结”- 依赖注入:对象不自己拿依赖,由组合根从构造函数递进来,是 6.8 “在入口创建一次”的机制化;容器替你写组合根。
- 仓储把一类对象的存取收进业务一方定义的接口,像外观、像适配器;只转发 ORM 的仓储不值得写。
- 中间件管道是装饰器加上能截断的责任链,按列表从后往前包成洋葱;顺序就是行为。
- 第 18 章的
EventBus是同步、同进程的;消息代理是异步、持久、至少一次投递的。换成代理解决了慢,带来了重复、乱序和“写库与发消息不在一个事务里”,重复靠按订单号做幂等。 - 空对象是“减 0”的策略,特殊值本身是错误时别用;MVC 和 MVVM 是观察者、策略、组合的组合。这些都是需求变了以后重构的目的地,不是起点。