第 5 章 · 建造者(Builder)
一句话:用一个建造者对象一步步收集参数,最后由
build()统一校验,产出一个造好就不再变的对象。 解决的痛:构造函数的参数越加越多,调用处一排None靠位置对应,传错位置只要类型对得上就不报错;校验规则没有一个固定的地方放。
5.1 一个场景
Section titled “5.1 一个场景”还是那家做在线课程的公司。系统要发各种邮件:用户买完课发购课回执,开课前发上课提醒,每周一给老板发销售周报。
一封邮件有这些内容:
- 收件人
to:可以有好几个,必填。 - 主题
subject:必填。 - 抄送
cc和密送bcc:可选。抄送的地址所有收件人都看得见,密送的看不见。 - 纯文本正文
text_body和 HTML 正文html_body:可选。HTML 正文能排版、放链接,纯文本正文给不显示 HTML 的邮件客户端看。 - 附件列表
attachments:可选,本章只存文件名。 - 优先级
priority:可选,默认"normal",紧急的设成"high"。
你要写一个 Email 类装下这些内容,再写一个 send(email) 把它发出去。真实项目里 send 背后是 SMTP 或者邮件服务商的 API,本章用 print 打一行摘要代替。发邮件的代码分散在三个文件里:orders.py 发回执,reminders.py 发提醒,reports.py 发周报。和前两章一样,为了能直接运行,本章把它们放在一个文件里。
5.2 先按直觉写
Section titled “5.2 先按直觉写”最直接的写法是给 Email 一个构造函数,8 项内容对应 8 个参数,必填的在前,可选的给默认值:
from typing import Literal
class Email: def __init__(self, to: list[str], subject: str, cc: list[str] | None = None, bcc: list[str] | None = None, text_body: str | None = None, html_body: str | None = None, attachments: list[str] | None = None, priority: Literal["normal", "high"] = "normal") -> None: self.to = to self.subject = subject self.cc = cc or [] self.bcc = bcc or [] self.text_body = text_body self.html_body = html_body self.attachments = attachments or [] self.priority = priority
def send(email: Email) -> None: # 真实项目里这里调用 SMTP 或邮件服务商的 API parts = [f"收件人:{', '.join(email.to)}", f"主题:{email.subject}"] if email.cc: parts.append(f"抄送:{', '.join(email.cc)}") if email.bcc: parts.append(f"密送:{', '.join(email.bcc)}") parts.append("正文:HTML" if email.html_body else "正文:纯文本") if email.attachments: parts.append(f"附件:{', '.join(email.attachments)}") if email.priority != "normal": parts.append(f"优先级:{email.priority}") print("发送 | " + " | ".join(parts))
if __name__ == "__main__": # 调用点一:购课回执(真实项目里在 orders.py) # 调用点二:上课提醒(真实项目里在 reminders.py) # 调用点三:销售周报(真实项目里在 reports.py) send(Email(["[email protected]"], "本周销售周报", ["[email protected]"], None, "数据见附件", None, ["weekly.xlsx"]))
# 输出:# 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf# 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML# 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 正文:纯文本 | 附件:weekly.xlsxTypeScript 里更常见的写法是不写构造函数,把 Email 定义成接口,调用处直接写一个对象字面量,可选的字段加 ?:
interface Email { to: string[]; subject: string; cc?: string[]; bcc?: string[]; textBody?: string; htmlBody?: string; attachments?: string[]; priority?: "normal" | "high";}
function send(email: Email): void { // 真实项目里这里调用 SMTP 或邮件服务商的 API const parts = [`收件人:${email.to.join(", ")}`, `主题:${email.subject}`]; if (email.cc && email.cc.length > 0) parts.push(`抄送:${email.cc.join(", ")}`); if (email.bcc && email.bcc.length > 0) parts.push(`密送:${email.bcc.join(", ")}`); parts.push(email.htmlBody ? "正文:HTML" : "正文:纯文本"); if (email.attachments && email.attachments.length > 0) parts.push(`附件:${email.attachments.join(", ")}`); if (email.priority && email.priority !== "normal") parts.push(`优先级:${email.priority}`); console.log("发送 | " + parts.join(" | "));}
// 调用点一:购课回执(真实项目里在 orders.ts)send({ to: ["[email protected]"], subject: "购课成功", textBody: "你已购买《Python 入门》", attachments: ["receipt.pdf"] });// 调用点二:上课提醒(真实项目里在 reminders.ts)// 调用点三:销售周报(真实项目里在 reports.ts)send({ to: ["[email protected]"], subject: "本周销售周报", cc: ["[email protected]"], textBody: "数据见附件", attachments: ["weekly.xlsx"] });
// 输出:// 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf// 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML// 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 正文:纯文本 | 附件:weekly.xlsx两种写法都很常见。Python 版的调用点也可以写成关键字参数,但这个项目里三处调用都是从最早写的那一处复制来的,就一直沿用了位置参数,没用到的位置填 None。TS 版每个值前面带着字段名,读起来更清楚。字段不多、也没有什么规则要检查时,它们都够用。
5.3 需求变了
Section titled “5.3 需求变了”两件事一起来。运营要做定时发送和统一回复:邮件要能设定时发送时间 send_at 和回复地址 reply_to,比如周报的收件人点“回复”,回信要发到 [email protected],而不是发件用的通知邮箱。另外,上个月出了两次事故:一封上课提醒没有正文,用户收到一封空白邮件;一封周报的主题是空字符串、只带一个附件,老板以为是垃圾邮件直接删了。于是加两条规则:纯文本正文和 HTML 正文至少要有一个;有附件时主题不能为空。构造函数要求必须传主题,但传 "" 也能过。
要改的地方有四处:
Email.__init__的签名:加reply_to和send_at两个参数。第一个问题是放在哪儿。reply_to是地址,按意思应该挨着cc、bcc;放到最后,则不影响旧的调用点。- 三个调用点的位置参数。如果按意思插到
bcc后面,三处的参数全部错开一位。假设这次提交漏改了其中两处:
# 新签名:Email(to, subject, cc, bcc, reply_to, text_body, html_body, attachments, priority, send_at)
# orders.py:漏改。"你已购买…"落进了 reply_to,["receipt.pdf"] 落进了 html_body
# reminders.py:漏改。"<p>…</p>" 落进了 text_bodyorders.py 这一处,跑类型检查(mypy)会报 Argument 7 to "Email" has incompatible type "list[str]"; expected "str | None",还有机会发现;项目没配类型检查的话,它也照常运行:回执的正文成了 ['receipt.pdf'] 这串字符,附件没了,回复地址是一句“你已购买…”。reminders.py 这一处,类型检查也查不出来:错开之后每个值的类型恰好都对得上,HTML 被当成了纯文本正文,新规则“正文至少要有一个”也照样通过。程序正常运行,日志里只有一行:
发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:纯文本用户收到的提醒,正文就是原样的 <p>今晚 8 点开课</p> 这串字符。同样的事也会发生在 cc 和 bcc 之间:两个参数类型一样,写反了没有任何提示,本该密送的地址出现在所有人都看得见的抄送栏里。
如果把两个新参数放到最后,旧调用点不受影响,但要用新参数的地方,得把前面的位置全部填满。上课提醒要定时发送,就得写成:
None, "normal", None, datetime(2026, 10, 8, 19, 0))第 9 个位置是 reply_to,第 10 个才是 send_at,写的人和审代码的人都得数着逗号对。
- 两条新规则写在哪。Python 版最自然的位置是
__init__:
def __init__(self, to, subject, cc=None, bcc=None, reply_to=None, text_body=None, html_body=None, attachments=None, priority="normal", send_at=None): if text_body is None and html_body is None: raise ValueError("纯文本正文和 HTML 正文至少要有一个") if attachments and not subject: raise ValueError("有附件时必须有主题") self.to = to # ……后面还有九行赋值构造函数前一半是校验、后一半是赋值,以后每加一条规则都往这里塞。更麻烦的是,校验只在构造那一刻跑一次,之后字段谁都能改:先造一封主题为空、还没有附件的邮件,校验通过;再 email.attachments.append("weekly.xlsx"),第二条规则就被绕过去了。
- TS 版:接口里加
replyTo?和sendAt?两个可选字段,调用点一个都不用动,按名字传值也不会错位,这是 TS 写法的长处。它的问题全在校验上:对象字面量没有构造函数,规则只能放在两个地方。放在send()里,定时邮件要到发送那一刻才检查:一封下午 2 点排进队列、晚上 7 点发送的提醒,没有正文,异常要到 7 点才出现在后台发送任务的日志里,而下午排队的那个请求早就返回了“成功”。放在调用点,就要复制三份,第四个调用点忘了写也没有任何提示。对象字面量造好之后同样能改,和 Python 版一样能绕过规则。
问题的形状是:参数一多,位置要靠数,传错了只要类型对得上就不报错;校验没有一个“对象造出来之前”的固定位置,造出来之后字段又还能改。
图中 ①–④ 是上面列的四处,三个调用点都按位置连着同一个构造函数。
5.4 用建造者模式重写
Section titled “5.4 用建造者模式重写”换个思路:不再一次性把十个值塞给构造函数,而是先拿一个专门负责攒参数的对象 EmailBuilder(邮件建造者)。每一项内容对应它的一个方法,调一个设一项;每个方法都返回建造者自己,所以可以接着往下点,这种写法叫链式调用(method chaining)。全部设完调用 build(),它检查所有规则,通过了才产出 Email。Email 本身做成不可变(immutable)的:Python 用 @dataclass(frozen=True),列表换成元组;TS 的字段全部 readonly,再用 Object.freeze 冻住。
from dataclasses import dataclassfrom datetime import datetimefrom typing import Literal, Self
Priority = Literal["normal", "high"]
@dataclass(frozen=True)class Email: # 产品:造好之后字段不能再改 to: tuple[str, ...] subject: str cc: tuple[str, ...] bcc: tuple[str, ...] reply_to: str | None text_body: str | None html_body: str | None attachments: tuple[str, ...] priority: Priority send_at: datetime | None
class EmailBuilder: # 建造者:一步步记下每一项,build() 时统一检查 def __init__(self) -> None: self._to: list[str] = [] self._subject = "" self._cc: list[str] = [] self._bcc: list[str] = [] self._reply_to: str | None = None self._text: str | None = None self._html: str | None = None self._attachments: list[str] = [] self._priority: Priority = "normal" self._send_at: datetime | None = None
def to(self, address: str) -> Self: self._to.append(address) return self
def subject(self, subject: str) -> Self: self._subject = subject return self
def cc(self, address: str) -> Self: self._cc.append(address) return self
def bcc(self, address: str) -> Self: self._bcc.append(address) return self
def reply_to(self, address: str) -> Self: # 新选项:加一个方法 self._reply_to = address return self
def text(self, body: str) -> Self: self._text = body return self
def html(self, body: str) -> Self: self._html = body return self
def attach(self, filename: str) -> Self: self._attachments.append(filename) return self
def priority(self, level: Priority) -> Self: self._priority = level return self
def send_at(self, when: datetime) -> Self: # 新选项:加一个方法 self._send_at = when return self
def build(self) -> Email: # 所有规则都在这里 if not self._to: raise ValueError("至少要有一个收件人") if self._text is None and self._html is None: raise ValueError("纯文本正文和 HTML 正文至少要有一个") if self._attachments and not self._subject: raise ValueError("有附件时必须有主题") return Email(to=tuple(self._to), subject=self._subject, cc=tuple(self._cc), bcc=tuple(self._bcc), reply_to=self._reply_to, text_body=self._text, html_body=self._html, attachments=tuple(self._attachments), priority=self._priority, send_at=self._send_at)
def send(email: Email) -> None: parts = [f"收件人:{', '.join(email.to)}", f"主题:{email.subject}"] if email.cc: parts.append(f"抄送:{', '.join(email.cc)}") if email.bcc: parts.append(f"密送:{', '.join(email.bcc)}") if email.reply_to: parts.append(f"回复到:{email.reply_to}") parts.append("正文:HTML" if email.html_body else "正文:纯文本") if email.attachments: parts.append(f"附件:{', '.join(email.attachments)}") if email.priority != "normal": parts.append(f"优先级:{email.priority}") if email.send_at: parts.append(f"定时:{email.send_at:%Y-%m-%d %H:%M}") print("发送 | " + " | ".join(parts))
if __name__ == "__main__": # 调用点一:购课回执 .text("你已购买《Python 入门》").attach("receipt.pdf").build()) # 调用点二:上课提醒,定时在开课前一小时发出 .html("<p>今晚 8 点开课</p>").priority("high").send_at(datetime(2026, 10, 8, 19, 0)).build()) # 调用点三:销售周报,附件个数每周不同,在循环里一个个加 for filename in ["weekly.xlsx", "chart.png"]: report.attach(filename) send(report.build()) # 不合规则的邮件在 build() 这一步就被拦下 for builder in (no_body, no_subject): try: send(builder.build()) except ValueError as error: print(f"拒绝 | {error}")
# 输出:# 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf# 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML | 优先级:high | 定时:2026-10-08 19:00# 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 回复到:[email protected] | 正文:纯文本 | 附件:weekly.xlsx, chart.png# 拒绝 | 纯文本正文和 HTML 正文至少要有一个# 拒绝 | 有附件时必须有主题TS 版的方法返回类型写 this,产品 Email 是一个字段全部 readonly 的接口,build() 返回冻结过的对象:
type Priority = "normal" | "high";
interface Email { // 产品:造好之后字段不能再改 readonly to: readonly string[]; readonly subject: string; readonly cc: readonly string[]; readonly bcc: readonly string[]; readonly replyTo: string | null; readonly textBody: string | null; readonly htmlBody: string | null; readonly attachments: readonly string[]; readonly priority: Priority; readonly sendAt: Date | null;}
class EmailBuilder { // 建造者:一步步记下每一项,build() 时统一检查 private _to: string[] = []; private _subject = ""; private _cc: string[] = []; private _bcc: string[] = []; private _replyTo: string | null = null; private _text: string | null = null; private _html: string | null = null; private _attachments: string[] = []; private _priority: Priority = "normal"; private _sendAt: Date | null = null;
to(address: string): this { this._to.push(address); return this; }
subject(subject: string): this { this._subject = subject; return this; }
cc(address: string): this { this._cc.push(address); return this; }
bcc(address: string): this { this._bcc.push(address); return this; }
replyTo(address: string): this { // 新选项:加一个方法 this._replyTo = address; return this; }
text(body: string): this { this._text = body; return this; }
html(body: string): this { this._html = body; return this; }
attach(filename: string): this { this._attachments.push(filename); return this; }
priority(level: Priority): this { this._priority = level; return this; }
sendAt(when: Date): this { // 新选项:加一个方法 this._sendAt = when; return this; }
build(): Email { // 所有规则都在这里 if (this._to.length === 0) throw new Error("至少要有一个收件人"); if (this._text === null && this._html === null) throw new Error("纯文本正文和 HTML 正文至少要有一个"); if (this._attachments.length > 0 && !this._subject) throw new Error("有附件时必须有主题"); return Object.freeze({ to: Object.freeze([...this._to]), subject: this._subject, cc: Object.freeze([...this._cc]), bcc: Object.freeze([...this._bcc]), replyTo: this._replyTo, textBody: this._text, htmlBody: this._html, attachments: Object.freeze([...this._attachments]), priority: this._priority, sendAt: this._sendAt, }); }}
const pad = (n: number): string => String(n).padStart(2, "0");
function send(email: Email): void { const parts = [`收件人:${email.to.join(", ")}`, `主题:${email.subject}`]; if (email.cc.length > 0) parts.push(`抄送:${email.cc.join(", ")}`); if (email.bcc.length > 0) parts.push(`密送:${email.bcc.join(", ")}`); if (email.replyTo) parts.push(`回复到:${email.replyTo}`); parts.push(email.htmlBody ? "正文:HTML" : "正文:纯文本"); if (email.attachments.length > 0) parts.push(`附件:${email.attachments.join(", ")}`); if (email.priority !== "normal") parts.push(`优先级:${email.priority}`); if (email.sendAt) { const t = email.sendAt; parts.push(`定时:${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}`); } console.log("发送 | " + parts.join(" | "));}
// 调用点一:购课回执 .text("你已购买《Python 入门》").attach("receipt.pdf").build());// 调用点二:上课提醒,定时在开课前一小时发出 .html("<p>今晚 8 点开课</p>").priority("high").sendAt(new Date(2026, 9, 8, 19, 0)).build());// 调用点三:销售周报,附件个数每周不同,在循环里一个个加const report = new EmailBuilder().to("[email protected]").cc("[email protected]").subject("本周销售周报");for (const filename of ["weekly.xlsx", "chart.png"]) { report.attach(filename);}send(report.build());// 不合规则的邮件在 build() 这一步就被拦下for (const builder of [noBody, noSubject]) { try { send(builder.build()); } catch (error) { console.log(`拒绝 | ${(error as Error).message}`); }}
// 输出:// 发送 | 收件人:[email protected] | 主题:购课成功 | 正文:纯文本 | 附件:receipt.pdf// 发送 | 收件人:[email protected] | 主题:今晚 8 点开课 | 正文:HTML | 优先级:high | 定时:2026-10-08 19:00// 发送 | 收件人:[email protected] | 主题:本周销售周报 | 抄送:[email protected] | 回复到:[email protected] | 正文:纯文本 | 附件:weekly.xlsx, chart.png// 拒绝 | 纯文本正文和 HTML 正文至少要有一个// 拒绝 | 有附件时必须有主题看标红的 reply_to() 和 send_at():新选项各加一个方法,链一路点到 build()。
逐条对照 5.3 的四处改动:
- 第 1 处,签名:
reply_to和send_at各是建造者上的一个方法,再加上建造者__init__里的一行初值、Email里的一个字段、build()里传值的一项,都在同一个文件里,上下挨着。不存在“插在哪个位置”的问题,因为调用处根本不按位置传。 - 第 2 处,调用点:回执那一处一个字没改。用到新选项的提醒和周报,各多写一个
.send_at(...)、.reply_to(...)。HTML 正文只能通过.html(...)设,调用处写着的就是要设哪一项,不可能“落进”纯文本正文。.cc()误写成.bcc()照样能跑,但这次错误写在明处,代码评审时看得见,不用数逗号。 - 第 3、4 处,校验:三条规则都在
build()里,Python 和 TS 是同一份规则,加第四条规则只改这一个方法。各个调用点都通过build()拿Email,规则不用在调用点各写一份。定时邮件在排进队列之前就要build(),没有正文当场抛错,抛给的是发起请求的那段代码,不会拖到晚上 7 点。 - 造好再改:
Email是冻结的。Python 里给字段赋值会抛FrozenInstanceError,附件是元组,没有append;TS 里给readonly字段赋值编译不过,Object.freeze让运行时也改不动。过了build()的邮件,到发送时还是那封邮件。
周报那一段还多了一个好处:附件在循环里一个个加,攒齐了再 build()。用构造函数的写法,得先在外面把列表攒好,再一次传进去。
也要说清楚建造者没解决什么。它不会让你少写值:调用处该设的每一项还得设,代码比位置参数还长。每个字段在 Email、建造者的私有字段、建造者的方法、build() 里各写一遍,这个文件比 before 长了一倍多。它也拦不住有人绕过建造者,直接调用 Email(...) 造一个不合规的对象,这里只能靠约定。另外,“调用处写出字段名”这一条,Python 的关键字参数本来就能做到,5.7 会再说。
5.5 模式定义
Section titled “5.5 模式定义”GoF 对建造者的定义是:将一个复杂对象的构建与它的表示分离,使同样的构建过程可以创建不同的表示。
换成本章的话说:“构建”是一步步设值、最后检查的过程,交给 EmailBuilder;“表示”是最终得到的东西,就是 Email。两者分开后,Email 只管装数据,造好就不变;怎么一步步凑齐、凑齐了合不合规,全在建造者里。
GoF 原书的结构比本章多两个角色:一个抽象的建造者接口,和一个导演(Director)。导演知道“按什么顺序调哪些步骤”,但不知道造出来的是什么。比如写一个 compose_receipt(builder, user, course),按固定顺序调用 to、subject、text、attach:传进去一个 EmailBuilder,得到一封邮件;传进去一个实现了同样方法的 PreviewBuilder,得到后台用的预览网页。定义里“同样的构建过程创建不同的表示”,说的就是这种用法。现在代码里见到的建造者,大多只有一个具体建造者、没有导演,调用方自己决定调哪些步骤,本书以这种链式写法为主。
图里是 GoF 的角色,下表对应到本章的类。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 建造者(Builder) | 本章没有单独的接口,只有一种产品时用不上 | 声明每个构建步骤 |
| 具体建造者(ConcreteBuilder) | EmailBuilder |
记下每一步设的值,build() 时检查规则并产出产品 |
| 导演(Director) | 本章没有,由各个调用点自己决定调哪些步骤 | 按固定顺序调用构建步骤 |
| 产品(Product) | Email |
构建的结果,造好之后不再改变 |
常见场景:
- 参数多、大部分可选的对象:邮件、HTTP 请求(5.9 的练习)、带一长串选项的客户端配置。
- 产品造出来之前要集中检查几条跨字段的规则,比如“有附件就必须有主题”,并且希望检查过的对象之后不会再变。
- 要分好几步、在条件分支或循环里攒出来的对象:SQL 查询、命令行参数的定义,5.6 的两个例子都是这样。
- 测试数据:
UserBuilder().verified().with_courses(3).build(),每个测试只写和自己有关的那几项,其余全用建造者里的默认值。
5.6 真实系统里的例子
Section titled “5.6 真实系统里的例子”例一:argparse 的 ArgumentParser(Python)
Section titled “例一:argparse 的 ArgumentParser(Python)”Python 标准库的 argparse 用来解析命令行参数。先造一个 ArgumentParser,然后每个参数调用一次 add_argument()。每次调用会新建一个 Action 对象(普通取值的是 _StoreAction,开关用的是 _StoreTrueAction),追加到解析器的 _actions 列表里,这时候什么都还没解析。最后调用 parse_args(),它才拿着攒下来的全部 Action 去读命令行、检查必填项,产出一个 Namespace 对象,每个参数是它的一个属性。必填参数缺了,真实版会把用法说明和错误信息打到标准错误,以状态码 2 退出。简化版:
from typing import Callable
class Namespace: # 真实版也只是一个把参数值存成属性的简单对象 def __init__(self, **kwargs: object) -> None: self.__dict__.update(kwargs)
def __repr__(self) -> str: return "Namespace(" + ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items()) + ")"
class Action: # 真实版按 action= 分成 _StoreAction、_StoreTrueAction 等子类 def __init__(self, flag: str, type_: Callable[[str], object], default: object, required: bool, store_true: bool) -> None: self.flag, self.type, self.required, self.store_true = flag, type_, required, store_true self.dest = flag.lstrip("-").replace("-", "_") # --dry-run 存成 dry_run self.default = False if store_true else default
class ArgumentParser: def __init__(self, prog: str) -> None: self.prog = prog self._actions: list[Action] = [] # 真实版还会先放进一个 -h/--help
def add_argument(self, flag: str, type: Callable[[str], object] = str, default: object = None, required: bool = False, action: str = "store") -> Action: act = Action(flag, type, default, required, action == "store_true") self._actions.append(act) # 只记下来,不解析 return act
def parse_args(self, args: list[str]) -> Namespace: # 真实版不传 args 时读 sys.argv[1:] values = {a.dest: a.default for a in self._actions} by_flag = {a.flag: a for a in self._actions} seen: set[str] = set() # 真实版也是记下出现过的参数,不看值是不是 None i = 0 while i < len(args): act = by_flag[args[i]] # 真实版遇到不认识的参数会报错退出 seen.add(act.dest) if act.store_true: values[act.dest], i = True, i + 1 else: values[act.dest], i = act.type(args[i + 1]), i + 2 missing = [a.flag for a in self._actions if a.required and a.dest not in seen] if missing: # 真实版先打印用法说明,再以状态码 2 退出 raise SystemExit(f"{self.prog}: error: the following arguments are required: {', '.join(missing)}") return Namespace(**values)
parser = ArgumentParser(prog="send_mail")parser.add_argument("--to", required=True)parser.add_argument("--subject", default="")parser.add_argument("--retries", type=int, default=3)parser.add_argument("--dry-run", action="store_true")try: parser.parse_args(["--subject", "测试"])except SystemExit as error: print(error)
# 输出:# Namespace(to='[email protected]', subject='', retries=5, dry_run=True)# send_mail: error: the following arguments are required: --to把最后几行里的 ArgumentParser 换成 import argparse 的 argparse.ArgumentParser,打印出的 Namespace 一模一样,错误信息那一行也一样(真实版是打到标准错误)。ArgumentParser 对应本章的 EmailBuilder,每次 add_argument() 是一个构建步骤,parse_args() 对应 build():必填检查集中在这里,产出的 Namespace 对应 Email。和本章不同的是,它不是链式调用,add_argument() 返回新建的 Action,不是解析器自己;同一个解析器可以 parse_args() 很多次,每次读一组不同的命令行;产出的 Namespace 也是普通的可变对象。
例二:Knex 查询构造器(TypeScript)
Section titled “例二:Knex 查询构造器(TypeScript)”Knex 是 Node.js 里常用的 SQL 查询构造器(query builder)。knex("users") 返回一个构造器,select、where、orderBy、limit 每个方法往里加一部分条件,并返回构造器本身,所以能一路链下去;也可以中途停下,在 if 里再补一个 where。调用 toSQL() 时,它才把攒下来的各部分按数据库方言拼成 SQL,返回一个对象:sql 是带 ? 占位符的语句,bindings 是按顺序对应占位符的值。值不直接拼进 SQL 字符串,而是交给数据库驱动去绑定,这样能防住 SQL 注入(SQL injection)。toSQL() 也会检查:某个条件的值是 undefined 时,它抛出 “Undefined binding(s) detected” 错误,不会生成一句条件落空的 SQL。真实的构造器还可以直接 await,那会去执行查询;简化版只模拟 toSQL(),按 PostgreSQL 方言给表名和列名加双引号:
interface SqlAndBindings { sql: string; bindings: unknown[] } // 真实返回值还有 method、options 等字段
class QueryBuilder { private columns: string[] = []; private wheres: { column: string; op: string; value: unknown }[] = []; private orders: string[] = []; private limitValue: number | null = null;
constructor(private readonly table: string) {}
select(...columns: string[]): this { this.columns.push(...columns); return this; }
where(column: string, ...rest: [unknown] | [string, unknown]): this { // 两个参数时运算符是 = const [op, value]: [string, unknown] = rest.length === 1 ? ["=", rest[0]] : rest; this.wheres.push({ column, op, value }); return this; }
orderBy(column: string, direction: "asc" | "desc" = "asc"): this { this.orders.push(`"${column}" ${direction}`); return this; }
limit(n: number): this { this.limitValue = n; return this; }
toSQL(): SqlAndBindings { // 到这一步才检查、才拼 SQL const undefinedCols = this.wheres.filter((w) => w.value === undefined).map((w) => w.column); if (undefinedCols.length > 0) { throw new Error(`Undefined binding(s) detected when compiling SELECT. Undefined column(s): [${undefinedCols.join(", ")}]`); } const q = (id: string): string => `"${id}"`; // PostgreSQL 方言用双引号,MySQL 方言用反引号 let sql = `select ${this.columns.length > 0 ? this.columns.map(q).join(", ") : "*"} from ${q(this.table)}`; const bindings: unknown[] = this.wheres.map((w) => w.value); if (this.wheres.length > 0) sql += " where " + this.wheres.map((w) => `${q(w.column)} ${w.op} ?`).join(" and "); if (this.orders.length > 0) sql += ` order by ${this.orders.join(", ")}`; if (this.limitValue !== null) { sql += " limit ?"; bindings.push(this.limitValue); } return { sql, bindings }; }}
const knex = (table: string): QueryBuilder => new QueryBuilder(table); // 真实的 knex 要先传连接配置初始化
const onlyAdults = true;const query = knex("users").select("id", "email").where("status", "active");if (onlyAdults) query.where("age", ">=", 18); // 中途在分支里接着加条件const result = query.orderBy("created_at", "desc").limit(10).toSQL();console.log(result.sql);console.log(JSON.stringify(result.bindings));try { knex("users").where("id", undefined).toSQL();} catch (error) { console.log((error as Error).message);}
// 输出:// select "id", "email" from "users" where "status" = ? and "age" >= ? order by "created_at" desc limit ?// ["active",18,10]// Undefined binding(s) detected when compiling SELECT. Undefined column(s): [id]前两行输出和真实 Knex 在 client: "pg" 配置下生成的一样;真实的错误信息后面还附带了出错的那句 SQL。QueryBuilder 对应 EmailBuilder,where、orderBy 这些方法是构建步骤,toSQL() 对应 build(),检查和拼装都在这一步,产出的 { sql, bindings } 对应 Email。和本章不同的是,Knex 的构造器在 toSQL() 之后还能接着加条件、再生成一次,构造器本身就是一个可以反复使用的半成品。
5.7 什么时候别用
Section titled “5.7 什么时候别用”- 参数只有三四个。建造者让每个字段写四遍(产品字段、建造者私有字段、建造者方法、
build()里的一项),本章的after比before长了一倍多。三四个参数时,调用处Email(to, subject, body)一眼就能看懂,加一个建造者只是让读代码的人多翻一个类。 - 语言已经替你做了,又不需要集中校验。Python 有关键字参数和默认值,把可选参数放到
*后面,调用方就只能按名字传:Email(to=[...], subject="购课成功", html_body="..."),5.3 的错位问题直接消失;配合@dataclass(frozen=True),产品也不可变了,必要时还能在__post_init__里写几行检查。TS 的对象字面量加上接口类型,同样是按名字传、拼错字段名编译就报错。这时候再写一个建造者,多出来的只有一个类。它真正多做的,是 5.4 周报那样分好几步、在循环和分支里攒对象,以及把规则集中在一个build()里。这两样都用不上时,就用语言自带的写法。哪些模式被语言特性替掉了、替掉了多少,第 29 章会集中讨论。 - 产品本来就该是可变的。比如用户在编辑器里写了三天的邮件草稿,每次保存都要改主题、加附件。硬套“建造者加不可变产品”,每改一个字都得重新
build()一封新邮件。这种对象直接做成普通的可变类,在真正发送之前调一次validate()就行。
5.8 容易混淆的模式
Section titled “5.8 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 抽象工厂(Abstract Factory) | 成套创建一组相互配合的对象 | 调一次创建方法就立刻拿到一个完整的产品,关心的是几个产品配不配套;建造者是一步步造一个复杂的产品,最后才拿结果 |
| 工厂方法(Factory Method) | 父类定流程,子类决定创建哪一种产品 | 解决的是“造哪一个类”,参数通常不多,一次调用就完成;建造者的类是定的,解决的是“这一个对象的十项内容怎么一步步填齐、填得对不对” |
一句话区分:工厂方法和抽象工厂决定“造哪一种”,建造者决定“怎么一步步把这一个造出来”。两者也能一起用:抽象工厂的某个创建方法,内部可以用建造者去组装它返回的那个复杂产品。
5.9 练一练
Section titled “5.9 练一练”后台要调用各种第三方 HTTP 接口。一个请求有方法(本题只要求 GET)、URL、若干请求头、若干查询参数、超时时间(秒,默认 10)。请用建造者实现:
RequestBuilder().get(url).header(k, v).query(k, v).timeout(s).build(),header和query可以调多次,按调用顺序保留。build()检查两条规则:URL 必填;timeout 必须为正数。不合规时抛出异常,信息里说清是哪一条。- 产品命名为
HttpRequest(TS 里全局已经有一个Request类型,避免重名),造好之后不可变。 - 写一个
describe(request),打印一行摘要,例如GET https://api.example.com/courses?page=2&size=20 | 请求头:Authorization=Bearer abc | 超时:5 秒。再分别演示“没有 URL”和“timeout 为 0”两种被拒绝的情况。Python 和 TS 各写一版。
提示:查询参数先存成键值对列表,到 describe 时再拼到 URL 后面,Python 可以用 urllib.parse.urlencode,TS 可以用 URLSearchParams。做完想一想:如果要加“POST 请求必须有请求体”这条规则,要改几个地方?
5.10 小结
Section titled “5.10 小结”- 建造者用一个专门的对象一步步收集参数,每一项一个方法,最后由
build()检查规则并产出产品。 - 它换来三样东西:调用处写着每一项设的是什么,不会因为位置错开而传错;所有跨字段的规则集中在
build()一处;产品可以做成不可变的,过了检查就不会再被改坏。 - 加一个可选项是建造者上加一个方法(外加产品一个字段),不需要它的调用点不动。
- 它不会让你少写值,代码量还会明显变多。参数少、或者语言的关键字参数和对象字面量已经够用、又不需要集中校验时,别用它。
- GoF 原版还有导演和抽象建造者,用同一套步骤造出不同的表示;日常见得多的是没有导演的链式建造者。