Article
软件设计笔记
软件设计的目标不是让代码看起来更抽象,而是降低系统的理解与修改成本
John Ousterhout 在《A Philosophy of Software Design》中把复杂性视为软件设计的核心问题。复杂逻辑无法完全消失,但可以被放进边界清晰的模块,让大多数开发者只接触少量稳定接口
本文参考 Book Notes: A Philosophy of Software Design,不使用单一业务贯穿全文。每个原则选择一个独立案例,通过“问题实现—改进实现—适用边界”说明它解决了哪种复杂性
全文遵循一条判断主线:完成一次修改前,开发者必须知道多少信息,又必须改动多少位置?
1. 软件设计的目标是控制复杂性
复杂性不是代码行数,也不是用了多少设计模式,而是开发者理解和修改系统时遇到的困难。它通常表现为三种症状
| 症状 | 判断方式 |
|---|---|
| 修改放大 | 一个小需求是否需要修改许多位置 |
| 认知负担 | 完成任务前是否必须理解大量背景 |
| 未知的未知 | 是否不知道还有哪些代码会受影响 |
以销售报表为例。CSV 导出、邮件标题和审计日志分别保存了一份字段知识。当 customer 改名为 customerName 时,开发者必须找到所有副本
const csvColumns = ['orderId', 'customer', 'total']
function buildEmailTitle() {
return 'Report: orderId, customer, total'
}
function writeAuditLog() {
audit.info('exported orderId, customer, total')
}改进后的代码没有消灭字段依赖,而是让依赖集中且可见。导出、邮件和日志都从 salesReport 读取定义,字段变化只需要修改一个知识所有者
复杂性的两个主要根源是依赖与晦涩性。依赖使代码无法独立修改;晦涩性使重要约束难以发现。未知的未知往往就是没有被接口、类型或模块边界表达的依赖
设计的第一步不是立即拆分类,而是识别哪些复杂性无法删除,以及它们应该由谁承担
2. 方法论:同一个敌人,不同的恒温器
过去半个世纪的重要软件方法论,与其说是相互竞争的意识形态,不如说是对准同一团火的不同恒温器。它们介入软件生命周期的不同位置,却都在处理不确定性、依赖和认知负担
| 方法 | 控制复杂性的手段 | 可能引入或转移的复杂性 |
|---|---|---|
| Waterfall | 用前置分析和完整设计压缩后期的不确定性 | 需求变化时,大量前置结论可能同时失效 |
| Agile | 缩小变更批次,用快速反馈尽早发现依赖陷阱 | 高频交付不能代替长期架构判断 |
| BDD | 用业务示例迫使参与者在实现前消除需求歧义 | 场景过细会退化为维护成本高的操作脚本 |
| TDD | 建立快速反馈与重构安全网,使持续降复杂度在经济上可行 | 绑定内部步骤的测试会反过来固化实现 |
| 面向对象 | 用封装隐藏状态与设计决策 | 实现继承常把隐式耦合扩散到类型层次中 |
| 分层架构与设计模式 | 提供关注点分离的结构和共同词汇 | 过度套用会用间接层替代原本足够的简单性 |
| 微服务 | 用部署与组织边界隔离变化和责任 | 网络、数据一致性与运维成为新的复杂性来源 |
这些方法在战术上分歧明显,但对敌人的判断相近:失控的依赖会扩大修改范围,未解决的歧义会变成架构错误,缺少反馈则会让错误设计逐渐固化
因此,采用方法论时不应只问“是否符合流程”,还应问:复杂性被消除了、被边界隔离了,还是仅仅转移到了文档、测试、组织或基础设施?
3. 深模块:让接口小于能力
模块是否值得存在,要比较它的接口成本与内部能力。深模块提供很强的能力,却只暴露很小的接口;浅模块只做一点工作,却要求调用方理解许多细节
保存文档时,如果调用方必须知道压缩、加密、重试和写入顺序,存储模块就没有真正隐藏机制
async function saveDocument(id: string, content: string) {
const compressed = await compress(content)
const encrypted = await encrypt(compressed)
await retry(() => storage.write(id, encrypted), {
attempts: 3,
})
}复杂性并没有消失。压缩、加密与重试仍在 DocumentStore 内部,但调用方只依赖“保存文档”这一稳定能力
“一个模块只做一件事”不等于函数越小越好。如果把压缩器、加密器和重试器全部暴露给调用方,得到的是许多浅模块和一条必须记忆的调用协议
深度也不等于代码行数。一个内部实现较长但接口稳定的模块,通常比五个需要按固定顺序组装的小函数更容易维护
面向对象的封装可以帮助形成深模块,但类本身不保证信息隐藏。尤其是实现继承会让子类依赖父类内部结构;相比之下,组合与小接口通常能保留更多实现自由
确定模块边界后,下一个问题是:哪些设计知识必须留在边界内部?
4. 信息隐藏:让知识只有一个所有者
信息隐藏不是把字段改成 private,而是让一项设计决策只有一个权威位置。即使两个模块互不调用,只要共享同一隐式规则,它们仍然彼此依赖
订单价格通常包含折扣、税率和货币舍入。若控制器与结算页面分别实现这些规则,任何规则变化都可能产生不一致
const controllerTotal = round(
subtotal * tierDiscount(customer.tier) * regionTax(customer.region),
)
const checkoutTotal = round(
sumCart(cart) * tierDiscount(customer.tier) * regionTax(customer.region),
)OrderPricing 拥有定价规则。控制器和页面只请求最终价格,不再知道折扣与税的先后顺序,也不负责货币舍入
判断信息是否泄漏,可以问:这项规则改变时,需要修改多少个位置?如果答案跨越多个模块,知识还没有明确所有者
BDD 把同一原则提前到需求阶段:通过业务人员、开发者和测试人员共享的具体示例,让含糊规则在进入代码前获得明确含义。需求歧义也是一种尚未确定所有者的知识
但集中知识只是第一步。模块还需要一个能表达问题本质、又不会暴露偶然细节的接口
5. 通用接口:表达本质而非操作清单
好的接口通常比当前用例稍微通用,因为它表达的是底层统一概念,而不是调用方碰巧提出的操作名称
文本编辑器可以分别实现插入、删除和覆盖,也可以把它们归纳为“用新文本替换一个范围”
function insertText(offset: number, text: string) { /* ... */ }
function deleteText(start: number, end: number) { /* ... */ }
function overwriteText(start: number, text: string) { /* ... */ }插入是替换空范围,删除是替换为空文本,覆盖则是普通范围替换。一个通用原语减少了重复机制,也让边界行为保持一致
通用不等于为未来预建插件系统、撤销树或协同编辑。接口可以表达通用概念,实现仍只满足当前需求。这是“接口稍通用,功能不过度设计”
接口清晰之后,还要检查系统中的相邻层是否真的提供了不同抽象
6. 抽象分层:相邻层应提供不同视角
分层的价值不是增加目录和类,而是让上层使用业务语言,下层处理机制细节。只有转发参数的包装层会增加导航成本,却没有隐藏信息
订单服务不应要求业务代码理解 URL、JSON 字段和 HTTP 状态码。它只需要表达“提交订单并获得确认”
async function submitOrder(order: Order) {
return api.post('/orders', {
body: JSON.stringify(order),
headers: { 'content-type': 'application/json' },
})
}OrderGateway 使用业务概念,HttpOrderGateway 负责协议转换。HTTP 客户端更换、响应格式调整时,订单业务不需要同步修改
如果某一层只把同样的参数转发给下一层,又不改变语义、隔离依赖或建立策略,它就是浅包装层。删除它通常比为它寻找一个模式名称更有效
分层架构与设计模式提供了讨论边界的共同词汇,但模式名称不能证明抽象有效。只有当一层隐藏机制、转换语义或隔离变化时,它带来的间接性才有回报
模块和层次减少了调用方需要理解的知识,接口语义还可以进一步减少调用方必须处理的错误分支
7. 从设计中消除错误
异常会扩大接口:每增加一种错误,调用方就多一条需要理解、测试和维护的控制流。最好的异常处理有时是重新定义操作语义,让某些情况不再是错误
删除任务的目标是“调用后任务不存在”。如果任务本来就不存在,目标已经达成,无需先查询再抛出 TaskNotFound
async function removeTask(id: string) {
const task = await tasks.find(id)
if (!task) throw new TaskNotFound(id)
await tasks.delete(id)
}幂等删除消除了查询与删除之间的竞争窗口,也让调用方无需捕获一个不能改变决策的异常
错误消除不是无条件吞掉失败。权限不足、存储不可用和数据损坏会影响正确性,调用方或系统边界可能采取不同动作,因此仍应明确报告
判断标准是:调用方收到这个错误后,能否做出有意义且不同的决策?如果所有调用方都只能忽略它,接口可能定义得过于狭窄
代码能表达正常路径与错误语义,却不总能解释非直观约束。此时需要注释补足设计信息
8. 注释记录代码无法表达的约束
“好代码不需要注释”只适用于代码已经完整表达的信息。代码说明程序做什么,注释应解释为什么这样做、哪些不变量不能破坏,以及为何没有采用更直观的方案
缓存刷新时,先清空旧值看似更整洁,却会让一次瞬时加载失败破坏仍然可用的数据
// 清空缓存并加载新值
cache.delete(key)
const next = await loader.load()
cache.set(key, next)问题注释只是逐行翻译代码,改进注释则记录一致性约束。未来维护者即使调整刷新流程,也知道旧值不能提前删除
名称负责识别概念,注释负责解释概念的边界和理由。无限延长函数名不能替代一段简洁的约束说明
设计意图被模块、接口和注释表达后,还需要在持续开发中守住这些边界
9. 战略式编程:修复结构原因
战术式编程只关心让当前需求尽快工作。它常通过复制代码和增加条件分支交付功能,短期成本低,却不断扩大后续修改范围
通知服务最初只有邮件。每增加一个渠道就增加一个条件,发送策略、重试和配置逐渐混在同一函数里
async function notify(kind: string, message: Message) {
if (kind === 'email') await sendEmail(message)
if (kind === 'sms') await sendSms(message)
if (kind === 'push') await sendPush(message)
}改进后的模块把“遍历渠道”与“渠道如何发送”分开。增加渠道只需提供一个实现,不必继续扩展中央条件分支
战略式编程不是预先实现传真、聊天机器人等假设功能。它只为已经出现的变化建立稳定边界,是持续的小额设计投资,而不是一次大型重写
Waterfall 倾向在实现前集中支付设计成本,Agile 则通过小批量变化和反馈持续校正设计。两者真正的分界不在于是否需要设计,而在于何时投入,以及如何验证设计假设
结构原因被修复后,测试应保护公开行为,避免把内部实现重新变成外部约束
10. 测试公开行为,而非内部步骤
测试也是模块的调用方。如果测试依赖私有方法、内部调用次数或执行顺序,重构实现就会造成修改放大,即使公开行为完全没有变化
订单折扣测试真正关心的是金卡客户得到正确总价,而不是定价模块内部先调用哪个辅助函数
it('calculates a discount', () => {
const discount = vi.spyOn(pricing, 'applyTierDiscount')
pricing.total(lines, goldCustomer)
expect(discount).toHaveBeenCalledBefore(applyTax)
})行为测试允许模块调整算法和拆分方式,只要公开语义保持不变。只有调用顺序本身属于契约时,例如事务提交必须发生在事件发布之前,才应测试顺序
测试不是越接近实现越精确。稳定测试应覆盖调用方可观察的结果、重要边界和错误语义,把内部自由留给模块
BDD 用示例澄清系统应表现出的业务行为,TDD 用快速测试循环推动局部实现。二者都依赖可观察行为,但都不要求把测试写成内部调用记录
生成式工具能快速补充实现与测试,但不会自动维护这些边界,因此最终仍需要设计判断
11. AI 编程时代的设计判断
代码生成降低了产出成本,却没有自动降低理解成本。一个常见结果是多个导出函数分别生成正确代码,同时重复日期、金额和空值格式规则
function exportCsv(rows: Row[]) {
return rows.map((row) => `${formatDate(row.date)},${row.total.toFixed(2)}`)
}
function exportJson(rows: Row[]) {
return rows.map((row) => ({ date: formatDate(row.date), total: row.total.toFixed(2) }))
}两个版本都能工作,但后者让格式规则有明确所有者。新增 XML 导出时,可以复用稳定的数据表示,而不是复制第三份业务规则
审查生成代码时,应重点寻找四类问题:重复知识、只转发调用的浅模块、接口未表达的副作用,以及泄漏到底层库的错误类型
AI 适合生成局部实现和机械修改。模块边界、错误语义与系统级约束仍需要人负责,因为这些决策决定未来必须理解多少代码
12. 设计检查清单
评审模块或准备重构时,可以依次检查:
- 一个小需求需要修改多少位置,是否存在未知影响?
- 模块接口是否明显小于它提供的能力?
- 每项业务规则和设计决策是否只有一个所有者?
- 调用方是否知道内部步骤、顺序或存储格式?
- 接口表达的是问题本质,还是某个调用场景的偶然操作?
- 相邻层是否转换了语义,还是只做参数转发?
- 能否重新定义操作语义,消除无意义的错误分支?
- 注释是否记录原因、不变量和权衡,而非复述代码?
- 当前修复是在增加特殊分支,还是消除结构原因?
- 测试是否保护公开行为,并允许内部实现演进?
- 当前方法是在消除或隔离复杂性,还是把它转移到文档、测试、组织或基础设施?
- 生成代码是否引入重复机制、隐藏副作用或新的概念成本?
微服务把模块边界提升为部署和组织边界。只有当业务边界足够稳定、团队需要独立演进时,这种隔离才可能抵消分布式通信、数据一致性和运维成本;否则模块化单体通常更简单
这些原则不是相互独立的技巧。深模块依赖信息隐藏,信息隐藏帮助形成小接口,小接口减少错误分支与测试耦合,战略式编程则让这些改进持续发生
软件设计也没有可以机械套用的模式。最终标准始终是:系统变化时,开发者需要知道多少信息、修改多少位置,以及能否可靠地预测影响范围