跳转到内容

第 28 章 · 怎么选模式

前面 23 个模式章一章讲一个模式,都是从场景出发:先有一段直觉写法,再来一条新需求,看它在哪里吃力。真实项目里顺序也是这样,没有人先挑好一个模式再去找地方用它,而是先遇到“加一种渠道要改三处”这样的麻烦,再回头找办法。这一章把顺序倒过来查:手里有一个问题,该翻哪一章;两个模式看着像,怎么分;读框架源码时,怎么认出它用的是哪一个;以及什么时候干脆不用。

下面三张表按第 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。

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 是一整套发送能力,消息类型自己还有一棵子类树。

下表只收本书真实例子里讲过的机制。框架的实际实现比书里的简化版复杂,对应关系是就主要结构而言,书中注明“大致如此”的地方这里同样适用。

框架/库 机制 对应模式 本书章节
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)。

读一个陌生项目时,下面几条线索能帮你很快定位到该翻的那一章:

  1. 看名字。create_*、make_*、*Factory 先查第 3、4 章;一串返回自身的方法,最后调 build() 或 toSQL() 才拿结果,查第 5 章;instance()、getInstance(),或者模块顶层建好一个对象让大家导入,查第 6 章;clone()、__deepcopy__ 查第 7 章。名字只是起点,确认要看结构。
  2. 看一个类手里持有什么。持有一个和自己接口相同的对象、方法里转调它,查第 10、12 章,再做一次删除测试分清是加功能还是把关;持有一组同接口的对象、把调用转给每个再汇总,查第 13 章;持有“下一个”且有时不调它,查第 23 章;持有一个接口对象,接口只有一个方法,查第 17 章。
  3. 看方法名里的回调约定。accept、visit_* 查第 26 章;subscribe、on、connect、emit 查第 18 章;execute 配 undo 查第 19 章;__iter__、__next__、yield、for await 查第 22 章。
  4. 看谁调谁。基类里调用一个自己没写实现、等子类填的方法,比如 _transform、setUp,这是第 20 章的好莱坞原则;状态对象的方法里把上下文切到另一个状态对象,查第 21 章。

每章的“什么时候别用”各说各的场景,放在一起看,反复出现的是三条:

  • 只有一种实现。第 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.1 的表缩到一两章。
  • 两个模式像时,用各章“容易混淆的模式”里的那个问题当场分:谁来换、去掉它会怎样、中间有没有业务规则、靠反向操作还是快照。
  • 模式之间常是组成或搭配关系:工厂方法是模板方法的一步,抽象工厂由工厂方法组成,组合、迭代器、访问者围着同一棵树,命令和备忘录一起做撤销。
  • 在框架里认模式看结构不看名字,同一个库常同时用几个模式。
  • 只有一种实现、为了以后可能、包一层只是转发时,先别用;第二次变化来了再重构到模式。