软件设计笔记

软件设计的目标不是让代码看起来更抽象,而是降低系统的理解与修改成本

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. 设计检查清单

评审模块或准备重构时,可以依次检查:

  • 一个小需求需要修改多少位置,是否存在未知影响?
  • 模块接口是否明显小于它提供的能力?
  • 每项业务规则和设计决策是否只有一个所有者?
  • 调用方是否知道内部步骤、顺序或存储格式?
  • 接口表达的是问题本质,还是某个调用场景的偶然操作?
  • 相邻层是否转换了语义,还是只做参数转发?
  • 能否重新定义操作语义,消除无意义的错误分支?
  • 注释是否记录原因、不变量和权衡,而非复述代码?
  • 当前修复是在增加特殊分支,还是消除结构原因?
  • 测试是否保护公开行为,并允许内部实现演进?
  • 当前方法是在消除或隔离复杂性,还是把它转移到文档、测试、组织或基础设施?
  • 生成代码是否引入重复机制、隐藏副作用或新的概念成本?

微服务把模块边界提升为部署和组织边界。只有当业务边界足够稳定、团队需要独立演进时,这种隔离才可能抵消分布式通信、数据一致性和运维成本;否则模块化单体通常更简单

这些原则不是相互独立的技巧。深模块依赖信息隐藏,信息隐藏帮助形成小接口,小接口减少错误分支与测试耦合,战略式编程则让这些改进持续发生

软件设计也没有可以机械套用的模式。最终标准始终是:系统变化时,开发者需要知道多少信息、修改多少位置,以及能否可靠地预测影响范围

参考资料