第 28 章 · 怎么选模式
前面 23 个模式章一章讲一个模式,都是从场景出发:先有一段直觉写法,再来一条新需求,看它在哪里吃力。真实项目里顺序也是这样,没有人先挑好一个模式再去找地方用它,而是先遇到“加一种渠道要改三处”这样的麻烦,再回头找办法。这一章把顺序倒过来查:手里有一个问题,该翻哪一章;两个模式看着像,怎么分;读框架源码时,怎么认出它用的是哪一个;以及什么时候干脆不用。
28.1 从问题出发
Section titled “28.1 从问题出发”下面三张表按第 1 章的三类排列。左列是你在代码里会碰到的麻烦,用的是各章 N.1、N.3 里的说法;右列是那一章的场景,翻过去就能对上。
创建对象
| 你遇到的问题 | 先看 | 章 | 理由(本书的场景) |
|---|---|---|---|
按类型 new 对象的 if 抄在好几个调用点,加一种类型每处都要改 |
工厂方法(Factory Method) | 3 | 注册和重置密码两处都按渠道选 Sender;改成子类决定创建哪个,加微信只加一个子类 |
几个对象必须配套,各处各自 if 出来,配错了也不报错 |
抽象工厂(Abstract Factory) | 4 | 支付、退款、验签三件套必须出自同一个渠道;换渠道只在入口换一次 gateway |
| 构造函数参数越加越多,靠位置对应,传错位置不报错 | 建造者(Builder) | 5 | 一封邮件有收件人、抄送、附件等八项;按名字一步步填,build() 统一校验 |
| 创建一次很贵的对象被每个调用点各造一份 | 单例(Singleton) | 6 | 三个模块都要配置,只该读一次;先读 6.8 的四种代价 |
| 要一个和现成对象差不多的新对象,逐字段复制总漏字段 | 原型(Prototype) | 7 | 复制图表时嵌套的样式只复制了引用,改副本把原件改坏;由对象自己 clone() |
组合结构
| 你遇到的问题 | 先看 | 章 | 理由(本书的场景) |
|---|---|---|---|
| 第三方接口和你要的对不上,转换代码散在每个调用处 | 适配器(Adapter) | 9 | 顺丰、中通的物流 SDK 各不相同;翻译集中在一层,页面只认自己定义的接口 |
| 想给对象叠加压缩、加密这类功能,按组合写子类,数量成倍长 | 装饰器(Decorator) | 10 | 租户 A 要压缩加加密,租户 B 只要加密;在组装处一层套一层 |
| 同一套“按顺序调几个子系统、失败回滚”的流程抄在几个入口 | 外观(Facade) | 11 | 下单要调库存、优惠、支付、通知,三个入口各抄一遍 |
| 权限、懒加载、审计这些把关的事散在调用处 | 代理(Proxy) | 12 | 私密相册漏判一次就泄露;替身和真对象接口相同,调用方不知道它在 |
| 单个和一组要一样对待,组里还能再套组 | 组合(Composite) | 13 | 年货礼盒里有套装,套装里有单品;每个函数不再各写一遍递归 |
| 两个维度各自会变,子类数是两边相乘 | 桥接(Bridge) | 14 | 消息类型 × 发送渠道,3 × 3 是 9 个类 |
| 大量对象各带一份完全相同的数据 | 享元(Flyweight) | 15 | 上万张商品卡片各抄一份分类图标;每种只存一份,大家共用 |
协作行为
| 你遇到的问题 | 先看 | 章 | 理由(本书的场景) |
|---|---|---|---|
一族可互换的算法写成一串 if,好几处都要选 |
策略(Strategy) | 17 | 满减、折扣、新人券在预估和结算两处必须算出同一个数 |
| 一件事之后要做的事越来越多,全写在一个函数里 | 观察者(Observer) | 18 | 支付回调后扣库存、加积分、发短信、埋点,一件抛异常,平台一重试,做完的又做一遍 |
| 操作要撤销、排队、定时、记日志 | 命令(Command) | 19 | 批量改价的每个按钮回调各记一遍旧值 |
| 几段流程只有中间一两步不同,各抄了一份 | 模板方法(Template Method) | 20 | CSV 和 JSON 到货清单都是五步,只有解析不同 |
每个方法里都有 if status == …,加一个状态要翻遍 |
状态(State) | 21 | 订单的五个操作在六种状态下结果各不相同 |
| 翻页、游标写在每个调用方的循环里 | 迭代器(Iterator) | 22 | 对账和导出两个脚本各写一遍分页 |
谁来处理写成一串 if,请求要依次过几道关 |
责任链(Chain of Responsibility) | 23 | 退款按金额由客服、主管、财务审批 |
| 几个对象你调我、我调你,加一个要改好几个类 | 中介者(Mediator) | 24 | 结算页的地址、运费、优惠券、总价互相联动 |
| 要把对象恢复成之前的样子,字段由外面一个个抄出去 | 备忘录(Memento) | 25 | 商品编辑器加了字段,恢复代码漏写一行 |
| 节点类型不常变,报表和操作不断加 | 访问者(Visitor) | 26 | 商品树要算税、导 CSV、查缺货,每加一张报表改每个节点类 |
| 规则要让运营自己写成一行文字 | 解释器(Interpreter) | 27 | 专区筛选规则如 price < 100 and tag = 新品 |
表里一行对一个模式,可实际问题常常同时像好几行。这时用各章“容易混淆的模式”一节里那个当场就能回答的问题去分:
分叉处的判别问题大多取自各章的判别法,比如“谁来换”出自 17.9 和 21.8,“去掉这一层会怎样”出自 10.9 和 12.8,“中间那个对象有没有业务规则”出自 18.9 和 24.8。
28.2 模式之间的关系
Section titled “28.2 模式之间的关系”23 个模式不是 23 个孤岛。有的是另一个的组成部分,有的常搭配使用,有的长得像、意图不同。下面两张图分别画前两种关系和第三种。
图例:实线箭头是组成,读作“箭尾的模式里有箭头的模式”或“用箭头的模式来实现”;虚线是常一起用,线上写着怎么搭。
每个框是一组容易混在一起的模式,连线上是区分它们的那个问题,都摘自各章的“容易混淆的模式”一节;同一个模式会出现在几个框里。
最值得记住的是下面七条。
工厂方法是模板方法的一步。第 3 章的 Notifier.notify() 是一段固定流程,其中 create_sender() 由子类决定创建哪个 Sender。3.5 说创建者的主要工作是那段流程,20.9 用“子类填的那一步是不是在创建对象”来分:是,这一步叫工厂方法,整个 notify() 仍是模板方法。
抽象工厂常由工厂方法组成。第 4 章 PaymentGateway 的 create_refund() 单独看就是一个工厂方法(4.5),抽象工厂是把几个必须一起变的工厂方法装进同一个对象。它的某个创建方法内部还可以用建造者去组装一个复杂产品(5.8)。
组合、迭代器、访问者围着同一棵树。第 13 章的组合定义树的形状和每个节点怎么回答问题;第 22 章的迭代器把节点一个个交出来,22.9 说“组合搭好树,迭代器走树”;第 26 章的树仍是组合,只是按类型分开的操作从节点上搬进了访问者(26.8)。第 27 章的表达式树本身就是组合,操作多了也可以改成访问者(27.8)。
命令加备忘录做撤销。第 19 章的 ChangePrice 两者都用了:命令记住“做了什么”、按顺序撤销,执行前存下的快照负责“恢复成什么样”,19.9 说它就是一个简化的备忘录。25.8 给出搭配的规矩:能算出反向操作的用反向操作,算不出的再存快照。
观察者常用来实现中介者。24.8 说:组件发“我变了”的事件,中介者订阅所有组件,规则仍写在中介者里,它仍是中介者。判别看中间那个对象有没有业务规则:第 18 章的 EventBus 只管转发,第 24 章的 changed 写着“地址变了先算运费、再刷券”。
装饰器、代理、适配器长得像,意图不同。三者都是一个对象包着另一个。9.8 和 12.8 先分出适配器:它换接口,另两个不换。剩下两个用 10.9 和 12.8 的删除测试:“去掉它,功能少了”是装饰器,“去掉它,功能一样,只是访问没人把关了”是代理。结构完全一样时,名字是在告诉读代码的人这一层是来加功能还是来把关的,12.8 的 CachingProxy 就落在两者中间。责任链也是同接口、持有下一个,区别在一层可以就此停下,不往下传(23.8)。
策略、状态、模板方法、桥接都在“换掉一部分”。17.9 给了三个问题:“谁来换”分开策略和状态,外面挑好交进来的是策略,当前状态做完事自己切到下一个的是状态;“换算法要不要新建子类”分开策略和模板方法;“被持有的接口有几个方法,持有它的一方有没有自己的子类”分开策略和桥接,第 14 章的 Channel 是一整套发送能力,消息类型自己还有一棵子类树。
28.3 在框架和库里认出模式
Section titled “28.3 在框架和库里认出模式”下表只收本书真实例子里讲过的机制。框架的实际实现比书里的简化版复杂,对应关系是就主要结构而言,书中注明“大致如此”的地方这里同样适用。
| 框架/库 | 机制 | 对应模式 | 本书章节 |
|---|---|---|---|
| Django | 中间件:拿到 get_response,可以不调它直接返回 |
责任链 | 23.6 |
| Django | 信号:post_save.connect 订阅,send 依次调用 |
观察者 | 18.7 |
| Django | PASSWORD_HASHERS:哈希器类列表,各自实现 encode、verify |
策略 | 17.7 |
| Django | SimpleLazyObject:request.user 第一次被读时才查用户 |
代理 | 12.6 |
| Django | 迁移的 Operation:database_forwards 和 database_backwards |
命令 | 19.7 |
Python io |
BufferedWriter、gzip.GzipFile 包在二进制文件外面 |
装饰器 | 10.7 |
Python io |
TextIOWrapper 把读写字节换成读写字符串 |
适配器 | 9.6、10.7 |
Python ast |
NodeVisitor 按节点类名找 visit_Call |
访问者 | 26.6 |
Python logging |
Logger 把记录交给各个 Handler |
桥接 | 14.6 |
Python logging |
getLogger(name) 同名总返回同一个对象 |
单例(按名字的注册表) | 6.7 |
Python unittest |
TestCase.run() 按顺序调 setUp、测试方法、tearDown |
模板方法 | 20.7 |
| Express | app.use 注册中间件,不调 next()、直接回响应,后面的中间件就不跑 |
责任链 | 23.6 |
| Node.js | EventEmitter 的 on 和 emit,同步调完所有监听器 |
观察者 | 18.7 |
| Node.js | stream.Transform:子类实现 _transform,_flush 是钩子 |
模板方法 | 20.7 |
| JavaScript | Proxy:用 trap 拦截读写属性的语言机制 |
拦截机制:可写代理,也可写装饰器 | 12.6 |
| Vue 3 | reactive 返回一个 Proxy |
结构是代理,意图更接近装饰器 | 12.6 |
| Redux | action 是可序列化的数据,交给 dispatch;DevTools 能记录、重放 |
命令 | 19.7 |
| Redux | 应用在 store.ts 里创建一次并导出的 store(createStore 本身不是单例) |
单例(模块级) | 6.7 |
| React | 协调器和渲染器之间的 host config | 桥接 | 14.6 |
| Passport.js | passport.use 按名字登记 strategy |
策略 | 17.7 |
| Babel | 插件的 visitor 对象,键是节点类型 |
访问者 | 26.6 |
表里有三处值得多看一眼。第一,同一个库常常同时用几个模式:io 的三层里中间一层是装饰器、最外一层是适配器;logging 既有桥接,也有按名字的注册表。第二,名字不可全信:Vue 的 reactive 用的是 Proxy,但 12.6 按“去掉它会怎样”判断,它更像在加功能;JavaScript 的 Object.create 也叫“原型”,7.6 说明了它和原型模式同名不同义。第三,Django 和 Express 的中间件介于装饰器和责任链之间:日志、计时每次都往下传,鉴权可以截断(23.8)。
28.4 读源码时怎么认
Section titled “28.4 读源码时怎么认”读一个陌生项目时,下面几条线索能帮你很快定位到该翻的那一章:
- 看名字。
create_*、make_*、*Factory先查第 3、4 章;一串返回自身的方法,最后调build()或toSQL()才拿结果,查第 5 章;instance()、getInstance(),或者模块顶层建好一个对象让大家导入,查第 6 章;clone()、__deepcopy__查第 7 章。名字只是起点,确认要看结构。 - 看一个类手里持有什么。持有一个和自己接口相同的对象、方法里转调它,查第 10、12 章,再做一次删除测试分清是加功能还是把关;持有一组同接口的对象、把调用转给每个再汇总,查第 13 章;持有“下一个”且有时不调它,查第 23 章;持有一个接口对象,接口只有一个方法,查第 17 章。
- 看方法名里的回调约定。
accept、visit_*查第 26 章;subscribe、on、connect、emit查第 18 章;execute配undo查第 19 章;__iter__、__next__、yield、for await查第 22 章。 - 看谁调谁。基类里调用一个自己没写实现、等子类填的方法,比如
_transform、setUp,这是第 20 章的好莱坞原则;状态对象的方法里把上下文切到另一个状态对象,查第 21 章。
28.5 先别急着用模式
Section titled “28.5 先别急着用模式”每章的“什么时候别用”各说各的场景,放在一起看,反复出现的是三条:
- 只有一种实现。第 3 章只发邮件、第 20 章只有 CSV、第 14 章只有一个维度在变、第 17 章只有两种算法而且不会再加:这时模式只是让读代码的人在几个类之间跳,才看清一个
if就能说完的事。 - 为了“以后可能”。3.8 说提前写好的基类,等第二种渠道真来了常常不合用,得先拆掉;4.7 说只接一个渠道时不知道一套该有哪几件;14.7 说只有短信时提前拆出的接口,企业微信来了照样要改。接口按想象设计,往往和真实的第二种对不上。
- 包一层只是转发。9.7 说接口只差一个方法名时直接改名;11.7 说子系统只有一个类时外观没东西可收;19.8 说
execute()里只有一行调用、也不撤销的命令类只是多了一层。同类的还有规模太小:卡片几百张用不着享元(15.7),两三个状态用不着状态类(21.7),一两个处理者用不着链(23.7)。
所以更稳的做法是:先写直白代码,等需求第二次朝同一个方向变的时候,再重构到模式。本书每章从 N.2 走到 N.4 就是这条路:直觉写法没毛病,是新需求让它暴露出“要改三处”,那时你已经见过两种真实的变化,知道该把哪一块拆出去。怎样用一连串小步、每步都能跑测试地走过去,见《重构》(iduolian.com/refactoring/),其中第 60 章”从重构到设计模式“专门讲哪些重构手法做到底就是一个模式。
28.6 小结
Section titled “28.6 小结”- 选模式从问题出发:先看变的是哪一块(对象怎么来、怎么组合、怎么协作),再用 28.1 的表缩到一两章。
- 两个模式像时,用各章“容易混淆的模式”里的那个问题当场分:谁来换、去掉它会怎样、中间有没有业务规则、靠反向操作还是快照。
- 模式之间常是组成或搭配关系:工厂方法是模板方法的一步,抽象工厂由工厂方法组成,组合、迭代器、访问者围着同一棵树,命令和备忘录一起做撤销。
- 在框架里认模式看结构不看名字,同一个库常同时用几个模式。
- 只有一种实现、为了以后可能、包一层只是转发时,先别用;第二次变化来了再重构到模式。