高质量软件工程实践:从接口契约、文档与代码规范,到性能工程和简单设计

高质量软件工程实践:从接口契约、文档与代码规范,到性能工程和简单设计

写出“能运行”的代码并不难,真正困难的是让代码在一个长期演化的软件系统中依然能够被别人理解、被安全修改、被可靠复用,并且在真实负载下以合理的成本运行。

从这个角度看,接口规范、用户指南、编码规范、Code Review、回归测试、性能工程、需求控制和接口设计,并不是彼此割裂的主题。它们实际上围绕着同一个目标展开:降低软件生命周期中的协作成本、认知成本、修改成本和运行成本。

可以把整套方法理解成一条连续的工程链路:

flowchart LR
    A[真实问题与用户需求] --> B[问题拆解与范围控制]
    B --> C[简单接口与小代码块]
    C --> D[接口规范与开发指南]
    D --> E[编码规范与检查清单]
    E --> F[Code Review / 回归测试 / 静态分析]
    F --> G[性能工程]
    G --> H[用户体验与资源效率]
    H --> A

这条链路里有一个贯穿始终的关键词:简单

接口越简单,契约越容易稳定;代码越简单,越容易评审和测试;流程越简单,越容易自动化;需求越简单,系统越不容易过度设计;性能模型越简单、越可测量,越容易找到真正的瓶颈。

一、软件工程首先是协作问题:区分外部接口和内部实现

一个稍有规模的软件都会被拆成多个部分。MVC 将系统分成 Model、View、Controller,微服务把业务拆成不同服务,Java 类库把能力拆成不同类型和方法。拆分本身只是“分工”,真正让系统运转起来的是模块之间的“协作”。

协作必须依赖边界,而边界最重要的设计就是:

外部接口保持简单、稳定、清晰;内部实现允许复杂和变化。

以 Java 的 InputStream 为例。调用者拿到一个 InputStream 后,可以通过 read() 读取数据,却不需要知道底层数据来自文件、内存还是远程连接。底层实现可能非常复杂,但复杂性被隔离在接口后面。

这意味着良好的模块边界应该满足两个目标:

  • 调用者只需要理解接口,而不必阅读实现代码;
  • 实现者可以在不破坏接口契约的情况下自由优化内部实现。

从协作效率看,真正高效的方法并不是无限增加沟通,而是减少不必要的沟通。接口就是一种“把反复沟通固化成规则”的手段。

因此,对外接口应该尽量做到:

  • 数量少;
  • 粒度小;
  • 职责明确;
  • 行为可预测;
  • 文档完整;
  • 尽量稳定。

二、接口规范不是注释,而是调用者和实现者之间的合约

接口一旦对外公开,它就不再只是某个程序员的代码,而是调用者和实现者共同依赖的契约。

一个成熟的接口规范至少应该遵循四条原则:

  1. 成文:接口行为不能靠口头约定,更不能要求调用者阅读实现代码才能理解。
  2. 清楚:参数、返回值、边界条件和异常行为不能模棱两可。
  3. 稳定:已经公开并被依赖的行为,应尽可能保持不变。
  4. 变更谨慎:必须修改时,要优先考虑兼容性和迁移成本。

2.1 合约要成文

如果使用一个 API 必须去翻实现代码,说明接口与实现并没有真正分离。

成文的接口规范应该让调用者在不阅读内部实现的情况下回答这些问题:

  • 这个接口做什么?
  • 参数允许什么值?
  • 参数为空或非法时会怎样?
  • 返回值代表什么?
  • 什么情况下返回特殊值?
  • 会抛出哪些异常?
  • 哪些是运行时异常,哪些是检查型异常?
  • 边界条件是什么?
  • 极端情况下如何行为?
  • 是否有调用顺序要求?
  • 接口从哪个版本开始提供?

2.2 合约要清楚

接口规范的重点是定义精确行为,而不是承担所有文档职责。

例如一个接口规范更应该写:

  • read() 返回值范围是什么;
  • 读取结束时返回什么;
  • 参数越界时抛什么异常;
  • 是否允许 null
  • 调用前必须满足什么前置条件。

而术语解释、概念背景、教程、完整示例和问题排查,更适合放到开发指南中。

如果一个接口的规范本身已经复杂得难以解释,往往说明接口设计也已经过度复杂。

2.3 合约要稳定

InputStream.read() 的契约规定读取一个字节,并返回 0255 范围内的整数。如果将它改成 -128127,或者把“读取字节”改为“读取字符”,大量已有代码都会立即失去正确性。

接口越成功,使用者越多,兼容性成本越高。因此接口设计阶段值得投入更多时间,因为:

接口一旦发布,修改的成本通常远大于最初设计的成本。

2.4 接口变更需要治理,而不是“维护者想改就改”

接口既然是合约,就不应该由某个源码维护者单方面决定。

一个较完整的接口变更流程可以抽象为:

flowchart TD
    A[提出新接口或变更草案] --> B[相关领域评审]
    B --> C{评审通过?}
    C -- 否 --> A
    C -- 是 --> D[兼容性与规范性审查]
    D --> E{审查通过?}
    E -- 否 --> A
    E -- 是 --> F[更新接口规范]
    F --> G[按照新规范实现]
    G --> H[测试兼容性和行为]

工程团队不一定需要复制 OpenJDK 的完整流程,但至少要明确:

  • 谁可以提出接口;
  • 谁必须参与评审;
  • 谁负责兼容性判断;
  • 变更如何通知调用方;
  • 旧接口保留多久;
  • 什么条件下才能删除旧接口。

对跨团队 API,比较稳妥的迁移方式通常不是“直接修改原接口”,而是:

  1. 发布新接口;
  2. 旧接口继续保留,并标记为废弃;
  3. 给调用方迁移窗口;
  4. 观察调用情况;
  5. 确认不再有调用后再删除旧接口。

对于风险较大的行为替换,还可以短期保留双轨逻辑:

1
2
3
4
if (isNewProcess()) {
return executeByNewProcess();
}
return executeByOldProcess();

这样在新逻辑出现线上问题时,可以快速切回旧逻辑。它本质上是在接口或实现演进过程中保留一个可逆路径。

三、JavaDoc:让接口规范和源码一起演进

接口规范有一个长期难题:文档和代码容易脱节。

JavaDoc 的价值在于把接口声明和接口契约放在同一个源码位置维护。例如:

1
2
3
4
5
6
7
8
9
10
/**
* Check if the {@code userName} is a registered name.
*
* @param userName the user name to check
* @return {@code true} if the name has been registered
* @throws IllegalArgumentException if {@code userName} is invalid
*/
boolean isRegisteredUserName(String userName) {
// implementation
}

这种方式有几个明显优势:

  • 修改方法签名时,很容易同时看到规范;
  • IDE 可以直接展示文档;
  • JavaDoc 工具可以生成 HTML 文档;
  • 文档与源码更容易保持一致。

但并不是所有规范都应该硬塞进 JavaDoc。对于较长的独立规范,可以把完整内容放在单独文档中,然后在 JavaDoc 中链接过去。

合理的文档组织原则是:

局部、精确、与方法强绑定的规则放在接口规范中;篇幅较长、跨多个接口的概念放到独立文档中,并建立双向可检索关系。

四、接口规范和开发指南不是一回事

软件项目中常见的开发者文档至少包含两种类型:

文档 主要解决的问题 典型内容
接口规范 “这个部件精确地怎么工作?” 参数、返回值、异常、边界、极端状态、调用约束
开发指南 “我怎么理解和使用整套系统?” 术语、概念、组件关系、Quick Start、示例、排障

接口规范描述的是“零件规格”,开发指南描述的是“零件如何组合成完整产品”。

对于一个陌生类库,正常的阅读路径往往应该是:

1
2
开发指南 -> 理解整体概念 -> Quick Start -> 开始使用
-> 遇到具体问题 -> 查询 API/接口规范

如果开发者一开始就必须阅读大量 API 细节才能完成最基础的操作,说明开发指南没有承担好降低学习门槛的职责。

五、写用户指南的第一原则:不要让文档替产品背锅

最好的用户指南其实是产品本身。

圆珠笔不需要几百页说明书,拿起来就能使用。软件也一样:如果一个功能必须依靠极其复杂的文档才能完成,问题往往不仅在文档,还在产品设计。

用户指南不能超越用户的理解能力和操作能力。

例如,如果浏览器要求普通用户每次访问网站时选择:

  • 使用 TCP 还是 UDP;
  • 使用 HTTP 还是 HTTPS;

即使文档把两个协议讲得非常详细,大量普通用户仍然无法做出有意义的选择。真正合理的产品应该把不需要用户承担的技术决策隐藏在实现中。

所以,用户指南设计之前要先回答两个问题:

  1. 谁是用户?
  2. 用户最自然的使用方式是什么?

而且这两个问题不应该在产品完成后才回答,而应该从产品设计阶段就进入研发流程,并在整个开发过程中反复验证。

5.1 Less is more

面对产品设计,开发者很容易不断增加:

  • 更多选项;
  • 更多配置;
  • 更多参数;
  • 更多“高级能力”。

但用户真正需要的往往不是“更多”,而是“更少的障碍”。

技术细节应该尽可能留在实现里,而不是推给用户。

5.2 用户指南必须和代码保持一致

用户指南独立于代码后,很容易产生版本漂移:

  • 文档里的命令已经失效;
  • 示例代码无法编译;
  • 界面已经变化;
  • 参数已经改名;
  • 输出结果已经不同;
  • 排障方法仍然针对旧版本。

更可靠的做法是把“代码变更”和“文档变更”放进同一个流程。

如果某次代码修改改变了:

  • 用户可见行为;
  • 对外接口;
  • 配置项;
  • 示例输出;
  • 使用步骤;

那么这个变更只有在文档同步完成后才算真正完成。

六、开发指南的三个硬指标:概念清楚、快速上手、示例可执行

6.1 先把概念讲清楚

不要假设用户和库作者拥有相同的知识背景。

可以假设一个程序员知道什么是 IP 地址,但不能假设他记得 IP 地址所有细节;可以假设 Java 开发者知道异常,但不能假设他理解某个框架内部定义的所有异常语义。

概念定义的作用,是在作者和读者之间建立共享语境,降低后续阅读的认知负担。

6.2 Quick Start 必须足够靠前

“Hello, World!” 的价值不在于技术深度,而在于让用户尽快获得第一个成功反馈。

一个优秀的开发指南应该让用户尽早做到:

1
安装 -> 最小配置 -> 运行 -> 看到结果

Quick Start 不应该藏在几十页背景介绍之后,也不应该一上来就要求理解全部架构。

6.3 示例必须可操作、可验证

伪代码最大的维护问题是:版本升级以后,很难自动验证它是否仍然正确。

如果文档示例可以真正编译、运行和校验输出,那么文档本身就获得了接近“测试”的能力。

例如,可以把指南中的示例纳入 CI:

flowchart LR
    A[修改代码] --> B[编译主项目]
    B --> C[编译文档示例]
    C --> D[运行示例]
    D --> E[验证输出]
    E --> F[发布文档]

这比依赖人工记忆去同步文档可靠得多。

七、编码规范的本质:建立团队共同的认知模式

很多“编码规范”严格来说不是标准(Standard),而是指南(Guideline)。

二者区别在于:

  • 标准:必须遵守,具有强制性;
  • 指南:给出推荐方向和最佳实践,可以根据场景调整。

编码规范的价值并不仅仅是“代码看起来整齐”,它至少解决四类问题:

  1. 提高编码效率;
  2. 提高代码质量;
  3. 降低维护成本;
  4. 扩大代码的可理解性和可协作范围。

7.1 为什么统一风格会让团队更快

人在阅读代码时,不会每次都从零开始推理,而是在不断进行模式匹配。

熟悉的结构、命名和排版可以迅速被识别;不符合预期的模式会触发额外的注意和思考。

编码规范的一个重要作用,就是让大量低价值判断变成习惯:

  • 命名大致是什么形式;
  • 缩进是多少;
  • 变量放在哪里;
  • 异常怎么处理;
  • 注释怎么写;
  • 方法大致怎么分块。

当这些问题形成共识以后,评审者可以把注意力留给真正重要的问题:业务逻辑、安全、并发、数据一致性和性能。

7.2 不要让短期记忆超载

代码阅读受到人类认知能力限制。

短期记忆容量有限,代码如果同时要求读者记住太多变量、状态和跨页面逻辑,理解成本会迅速上升。

因此,代码组织应该尽量做到:

  • 信息块短小;
  • 方法职责明确;
  • 变量命名提供足够线索;
  • 相关代码尽量靠近;
  • 一次阅读能看到完整逻辑;
  • 每行不要过长;
  • 结构和缩进稳定。

阅读代码时,眼睛既会从左到右、从上到下阅读,也会纵向快速扫描变量名、关键字和结构。过长的行、混乱的换行和过深的嵌套都会增加扫描成本。

八、一份可直接用于 Code Review 的代码规范检查清单

下面把资料中的检查点重新按工程场景整理,方便直接用于日常 Review。

8.1 正确性与可维护性

  • 代码是否按照团队编码指南编写?
  • 代码是否能够按照预期工作?
  • 文件是否位于正确的模块和目录?
  • 支撑文档是否充分?
  • 代码是否易于阅读和理解?
  • 代码是否易于测试和调试?
  • 是否有充分测试覆盖关键逻辑和负面场景?

8.2 命名

  • 名字是否遵循命名规范?
  • 拼写是否正确?
  • 名字是否简单易懂?
  • 名字是否准确表达真实含义?

8.3 代码结构与排版

  • 代码分块是否恰当?
  • 缩进是否清晰、整洁?
  • 是否存在过长代码行?
  • 换行是否可能引起歧义?
  • 每一行是否尽量只表达一个行为?
  • 变量声明是否容易检索和识别?
  • 变量是否正确初始化?
  • 括号使用是否一致、清晰?
  • 源代码组织结构是否一致?
  • 限定词顺序是否遵循项目约定?
  • 文件头、版权日期等项目元数据是否符合项目规则?

8.4 冗余与死代码

  • 是否存在注释掉但长期保留的代码?
  • 是否存在永远执行不到的代码?
  • 是否存在可以复用或删除的冗余代码?
  • 复杂表达式是否可以拆成更简单的代码块?

8.5 注释

  • 是否存在真正需要的注释?
  • 注释是否准确、必要、清晰?
  • 不同类型注释的风格是否统一?
  • 注释是否仍与当前实现一致?

8.6 API 生命周期

  • 是否使用了已经废弃的接口?
  • 是否能够迁移到替代接口?
  • 不再推荐的新接口是否应该尽早标记废弃?
  • 覆盖父类方法时是否正确使用 @Override

8.7 异常处理

  • 是否错误地用异常处理正常业务流程?
  • 异常类型是否准确?
  • 异常信息是否清晰?
  • 是否需要进行异常转换?
  • 转换异常时是否需要保留原始异常信息?
  • 是否存在不应该被吞掉的异常?

8.8 接口规范

  • 外部接口与内部实现是否隔离?
  • 接口规范是否准确、清晰?
  • 是否描述返回值?
  • 是否描述运行时异常?
  • 是否描述检查型异常?
  • 是否说明参数范围?
  • 是否说明边界条件?
  • 是否说明极端状况?
  • 接口起草或变更是否经过评审?
  • 是否需要标明接口起始版本?

8.9 产品与文档

  • 产品设计是否方便用户使用?
  • 用户指南是否能够让用户快速上手?
  • 用户指南中的示例是否可操作?
  • 用户指南是否和软件代码保持一致?

这份清单不是永远不变的“法律”。真正成熟的团队会根据技术栈、业务风险和事故经验不断删改检查项。

九、高质量代码不能只靠自觉,要靠流水线

“大家认真一点”“大家有责任心一点”并不是可靠的质量机制。

人会忘记、会疲劳、会重复犯错误。工程流程的意义,就是把高质量行为从“个人习惯”变成“团队默认路径”。

一条基础的软件质量流水线可以包括:

flowchart LR
    A[开发者本地检查] --> B[编译器告警]
    B --> C[单元测试]
    C --> D[提交变更]
    D --> E[Code Review]
    E --> F[自动回归测试]
    F --> G[静态代码分析]
    G --> H[合并]
    H --> I[持续质量跟踪]

9.1 把编译器警告当成学习入口

忽略全部 Warning 很容易,但代价是团队永远不会理解这些 Warning 的来源。

更好的习惯是逐步清理告警:

1
发现告警 -> 理解原因 -> 修复 -> 形成经验 -> 下一次自然避免

当这个过程形成习惯以后,最初看起来增加工作量的规则,反而会减少未来的工作量。

9.2 回归测试必须自动化

回归测试真正有价值的前提,是它足够容易执行。

理想状态应该是一条命令或一个 CI Job 就能运行全部相关测试,例如资料中的典型形式:

1
make test

自动化以后,每次哪怕只改一行代码,也可以重新跑回归测试。

回归测试的核心逻辑非常简单:

  1. 把原本人工执行的测试固化成测试案例;
  2. 每次代码变化重新执行;
  3. 如果失败,说明本次变更可能破坏已有行为。

更重要的是要建立制度约束:

新增或修改行为原则上必须有对应测试;没有测试的变更需要解释为什么例外。

9.3 Code Review 不需要“牛人审批”

Code Review 有两个常见误区。

第一个误区是:只有比作者更强的人才有资格评审。

实际上,Review 的重要价值之一就是增加观察视角。评审者不一定比作者更熟悉全部业务,也可能发现作者因为过度熟悉代码而忽略的问题。

第二个误区是:Review 浪费时间。

Review 确实消耗时间,但它同时减少:

  • 缺陷进入主分支的概率;
  • 后期返工;
  • 事故排查时间;
  • 知识只掌握在一个人手里的风险。

因此真正有效的制度通常是:

未经 Review 的代码不能直接合并。

9.4 静态分析是低成本的自动检查员

静态分析工具可以周期性发现:

  • 可疑空指针;
  • 资源泄漏;
  • 错误 API 使用;
  • 死代码;
  • 一部分并发问题;
  • 一部分安全风险。

资料中以 SpotBugs 为例。关键不是“必须使用某个具体工具”,而是:

1
定期扫描 -> 保存结果 -> 分析趋势 -> 修复问题 -> 更新规则

十、中小团队也能搭建高质量研发流水线

质量流水线并不是大公司的专属能力。

一套可落地的团队方案可以分为十个动作:

  1. 使用成熟的版本控制工具,例如 Git;
  2. 使用统一的问题/缺陷管理工具;
  3. 提供清晰的代码 Diff 页面;
  4. 建立集中式 Review 入口;
  5. 未评审的代码不能合并;
  6. 把“规范、测试、测试代码”加入 Review 准入条件;
  7. 自动执行回归测试;
  8. 定期执行静态代码分析;
  9. 把需求、架构和接口设计也纳入评审,而不是只 Review 最后的代码;
  10. 把 Review、发现问题和经验沉淀纳入团队贡献,而不是只计算代码行数。

工具只是放大器。真正的变化在于把团队工作方式从:

1
写完 -> 自己感觉没问题 -> 上线

变成:

1
设计 -> 实现 -> 测试 -> Review -> 自动验证 -> 合并 -> 持续反馈

十一、性能不是“代码跑得快”,而是资源管理的经济学

谈性能时,一个常见误区是把它简化成“Java 快不快”“C 快不快”。

真正的软件性能问题,本质上是如何管理计算机资源:

  • CPU;
  • 内存;
  • 磁盘;
  • 网络;
  • 操作系统内核;
  • 并发执行能力;
  • 多机资源。

语言会影响实现方式,但性能并不由语言名称直接决定。错误的架构、算法或资源模型,可以让 C 程序很慢;合理的架构也可以让高级语言应用拥有足够好的性能。

11.1 正确只是第一道门槛,效率是第二道门槛

对于有长期运营价值的软件,至少应该同时追求两个目标:

1
Correctness(正确性) + Efficiency(效率)

只正确但资源消耗巨大,会带来运营成本和扩展问题;只快但结果错误,则没有任何意义。

11.2 是否优化性能,不应该只看用户总量

“我们只有一万个用户,不需要考虑百万用户的问题”是一种过度简化。

更应该问:

  • 一万个用户会不会同时请求?
  • 一个真实用户会不会触发几十个后台请求?
  • 是否存在突发流量?
  • 是否需要抵抗恶意请求?
  • 单次请求的资源成本是多少?
  • 这项服务的业务价值有多高?
  • 一旦失败,损失有多大?

性能的重要程度,更接近:

1
业务价值 × 请求压力 × 单次资源成本 × 失败影响

而不是简单的“注册用户数”。

11.3 一个历史性能错配案例

资料使用了 2014 年春运售票网站作为例子:2014 年 1 月 9 日,相关报道中的点击量达到约 144 亿次,平均每秒约 16,000 次点击,峰值还可能更高。

这个例子的意义不是某个具体数字,而是说明:只要负载模型与系统设计不匹配,即使业务逻辑完全正确,系统仍然会失败。

十二、不要把“加机器”当成性能设计

增加 CPU、内存和服务器当然可能提高吞吐量,但它不是万能解法。

系统中常见的不可扩展瓶颈包括:

  • 全局锁;
  • 单线程核心路径;
  • 单数据库热点;
  • 串行远程调用;
  • 固定大小的数据结构;
  • 共享状态;
  • 网络或磁盘瓶颈;
  • 算法复杂度过高。

如果应用无法利用更多内核,那么从 8 核换到 128 核也不会得到 16 倍性能。如果架构无法水平扩展,增加第 4 台、第 16 台服务器也可能没有意义。

因此性能设计有两个不同方向:

  1. 把有限资源使用得更高效;
  2. 让程序能够利用更多资源。

这两个目标经常需要权衡。

例如:

  • 用更多内存换更少 CPU;
  • 用更多 CPU 减少网络传输;
  • 把部分计算放到客户端,降低服务端压力;
  • 通过并行化换取更高吞吐量。

性能优化不是单纯追求“所有资源都最少”,而是为当前约束寻找更合适的资源分配策略。

十三、性能工程要左移:不要等 QA 才发现架构级性能问题

很多团队的旧流程是:

1
需求 -> 设计 -> 开发 -> 功能完成 -> QA -> 性能测试 -> 发现严重问题 -> 返工

这类流程最大的问题是:性能问题如果来自架构,到了 QA 阶段再修复,成本会非常高。

性能工程(Performance Engineering)的核心是把性能问题“左移”:

flowchart LR
    A[需求阶段
定义性能目标] --> B[架构阶段
评估容量与瓶颈]
    B --> C[开发阶段
选择合适算法与资源模型]
    C --> D[持续测试
基准 / 回归 / 负载]
    D --> E[上线监控
持续反馈]
    E --> B

不同角色都需要承担性能责任:

  • 架构师:架构到底支持什么吞吐量和扩展方式;
  • 开发者:核心代码资源成本是否合理;
  • 项目管理:性能风险是否被跟踪;
  • 测试人员:有足够时间做负载和压力测试,而不是最后一天才“验性能”。

性能工程不是一次测试,而是一种贯穿开发周期的质量管理方式。

十四、从用户感受建立性能指标:Apdex

程序员很容易关注:

  • 某个方法执行多少毫秒;
  • 某条 SQL 跑多少毫秒;
  • 某个 RPC 延迟多少毫秒。

但用户真正感受到的是“任务完成时间”。

一个页面可能涉及几十个网络请求,但用户只会认为自己做了一件事:打开页面

因此性能最终需要回到用户体验。

14.1 Apdex 的三个区间

Apdex(Application Performance Index)把任务响应时间划分为三个区间:

  • Satisfied(满意):响应时间小于目标阈值 T
  • Tolerating(容忍):响应时间大于 T、但小于最大可容忍阈值 F
  • Frustrated(挫败):响应时间大于 F,或者任务失败。

公式为:

1
Apdex = (满意样本数 + 0.5 × 容忍样本数) / 总样本数

资料采用的经典示例阈值是:

1
2
T = 2 秒
F = 4T = 8 秒

实际工程中,T 应结合具体业务任务定义,而不是所有接口统一套用同一个数字。

假设 100 次任务中:

  • 70 次小于 2 秒;
  • 20 次在 2~8 秒;
  • 10 次超过 8 秒或失败;

那么:

1
2
Apdex = (70 + 0.5 × 20) / 100
= 0.8

14.2 平均值不代表稳定体验

即使一个服务平均性能不错,只要用户经常遇到极慢请求,整体评价仍然会非常差。

所以性能不仅要关心“快”,还要关心“一致”。

这也是为什么实际性能工程中,需要关注尾部延迟和极端情况。资料强调的核心思想是:

用户对最差体验的记忆,往往远强于对平均体验的记忆。

十五、性能评价有三个层级:产品、架构、算法

可以把高效代码的评价体系分成三个层次。

15.1 产品层:用户体验

关注:

  • 用户等待时间;
  • 失败率;
  • 性能是否稳定;
  • 高峰期是否仍可接受。

15.2 架构层:资源效率和扩展能力

关注:

  • CPU 是否成为瓶颈;
  • 内存是否合理;
  • 网络带宽是否浪费;
  • 是否能利用多核;
  • 是否能扩展到多台机器;
  • 增加资源后吞吐量能否提升。

15.3 代码层:时间复杂度和空间复杂度

关注:

1
2
Time Complexity  -> CPU/执行时间趋势
Space Complexity -> 内存占用趋势

三个层级不能互相替代。

一个 O(n) 算法并不意味着整个系统一定快;一个微秒级方法也不意味着用户页面一定快;一台 CPU 利用率很低的服务器也不意味着系统没有性能问题。

十六、用 Two Sum 看“算法快”为什么还不等于“工程上高效”

资料反复使用了经典的 Two Sum 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.HashMap;
import java.util.Map;

class Solution {
/**
* Given an array of integers, return indices of the two numbers
* such that they add up to a specific target.
*/
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();

for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[] {map.get(complement), i};
}
map.put(nums[i], i);
}

throw new IllegalArgumentException("No two sum solution");
}
}

从算法角度看:

  • 循环:O(n)
  • HashMap 查找:平均 O(1)
  • HashMap 插入:平均 O(1)
  • 总体时间复杂度:O(n)
  • 额外空间:最坏 O(n)

这是一个很快的算法,但仍然可以继续从工程视角分析。

16.1 HashMap 扩容

如果输入非常大,又能提前估计元素数量,没有给 HashMap 合理初始容量,就可能产生多次扩容和重新哈希。

这不会改变大 O 复杂度,却会影响真实运行时间和临时内存开销。

16.2 不要用异常表示正常结果

如果“找不到结果”在业务语义上属于正常情况,那么每次通过构造异常和堆栈来表达这个状态并不经济。

不过这里不能为了性能机械地改成 null。真正应该先确定 API 契约:

  • 是保证一定有答案?
  • 还是允许无答案?
  • 无答案时应该返回空数组、Optional、结果对象,还是异常?

语义正确优先,然后再选择成本合理的表达方式。

16.3 时间复杂度和空间复杂度是权衡关系

HashMap 解法用 O(n) 空间换来了 O(n) 时间。

如果数据极大而内存非常紧张,就可能需要选择更节省空间但更慢的算法,例如排序后双指针:

1
2
时间复杂度:O(n log n)
额外空间:取决于排序算法,可接近 O(1)

但如果题目要求返回原始索引,排序会改变元素位置,还需要额外设计保存索引关系。这恰好说明:算法不能脱离完整需求讨论。

十七、真正昂贵的性能问题,往往来自需求膨胀和过度设计

性能问题并不总是 CPU 指令不够快。

很多系统最昂贵的“性能优化”,其实应该发生在写代码之前:少做不必要的事情。

需求会自然膨胀:

1
2
3
4
5
6
核心需求
-> 衍生功能
-> 管理功能
-> 配置功能
-> 配置的配置
-> 更多状态和流程

每增加一个功能,往往不仅增加一段代码,还会增加:

  • 更多接口;
  • 更多状态;
  • 更多测试;
  • 更多权限;
  • 更多异常路径;
  • 更多文档;
  • 更多数据库字段;
  • 更多未来兼容性负担。

控制需求和设计时,最重要的是反复问两个问题:

什么是必须做的?

什么是现在就必须做的?

17.1 识别最终用户的核心需求

软件系统中有很多中间角色:

  • 产品经理;
  • 开发者;
  • 运营;
  • 管理者;
  • 支撑团队。

每个角色都会产生合理诉求,但系统最终必须回到最终用户的核心任务。

如果核心任务被大量衍生需求挤到角落,系统就会越来越“大而全”,同时越来越难用。

17.2 不要试图一步到位

有些需求确实重要,但不代表它们现在必须完成。

更经济的方式是:

1
2
3
当前最小核心需求 -> 做好 -> 上线验证
-> 新事实出现
-> 重新选择下一批最重要需求

时间本身也会过滤需求:有些“未来一定需要”的能力,几年后会发现根本没人使用,或者已经被另一种方案取代。

因此,代码中不要为了想象中的未来长期保留:

  • 未使用字段;
  • 未使用扩展点;
  • “以后可能用”的接口;
  • 无调用的抽象层;
  • 大量占位逻辑。

学习新技术可以尽情实验,但产品代码应该是“实验完成后筛选出的结果”,而不是把所有实验痕迹一起带进生产系统。

十八、简单和直观的真正价值:让团队行动更快

“简单”不是为了代码看起来优雅,而是为了让整个团队能够更快、更可靠地行动。

18.1 简单降低沟通成本

一个简单方案更容易被:

  • 作者解释;
  • 同事理解;
  • 新人接手;
  • 用户学习;
  • 测试人员验证;
  • 运维人员排障。

越复杂的方案,沟通过程中信息损失越大。

18.2 简单降低风险

软件中的可用性、可靠性、性能和维护困难,很多时候都来自复杂性。

系统越复杂:

  • 越难完全理解;
  • 越难测试所有状态;
  • 越难预测修改影响;
  • 越容易出现隐藏依赖;
  • 越难快速恢复。

因此,当存在多个功能相同的方案时,一个非常实用的默认策略是:

优先选择最简单、最直观、最容易验证的方案。

十九、复杂问题的处理方式:做小事,边拆解边验证

简单并不意味着问题本身必须简单。

真正的工程能力是把复杂问题拆成一组小问题。

19.1 一个代码块只做一件事

例如下面的方法同时承担了两种职责:

  1. 校验用户名格式;
  2. 判断用户名是否注册。

更清晰的设计是拆开:

1
2
3
4
5
6
7
boolean isValidUserName(String userName) {
// validate syntax
}

boolean isRegisteredUserName(String userName) {
// check registration
}

这样每个方法都有更清晰的目标,也更容易独立测试和复用。

19.2 不要只设计不验证,也不要只写代码不设计

两种极端都容易出问题。

一上来就写代码:

1
需求 -> 写 -> 修 -> 再补丁 -> 再修 -> 逻辑逐渐纠缠

一直设计不验证:

1
需求 -> 长时间抽象 -> 建立复杂模型 -> 真正实现时才发现关键假设不成立

更好的节奏是:

1
拆一点 -> 验证一点 -> 再拆一点 -> 再验证一点

可以把它理解成“剥洋葱式设计”。

资料中用了一个很形象的经验比例:优秀程序员可能把大量时间花在设计、拆解和验证上,真正敲代码只占较少时间。这个数字不应该机械套用,但它表达的工程思想非常重要:

写代码不是软件开发中唯一产生价值的动作。

19.3 使用可视化工具降低思考负担

面对稍大的问题,可以使用:

  • 思维导图:拆解问题、避免遗漏;
  • 时序图:理解组件交互和调用顺序;
  • 问题清单:记录未解决事项和状态;
  • 纸笔:快速探索,不受工具限制。

例如登录流程可以先画成:

sequenceDiagram
    participant U as User
    participant C as Client
    participant S as Server

    U->>C: 输入用户名
    C->>S: 提交用户名/上下文
    S-->>C: 返回下一步认证要求
    U->>C: 输入密码或其他凭据
    C->>S: 提交认证凭据
    S-->>C: 返回认证结果

资料把“先用户名、再密码”的流程视为可以增加错误定位和安全检查空间,例如根据账号和设备上下文决定是否增加验证码等步骤。

现代实现还要额外注意:第一步不能通过错误信息、响应时间等方式轻易泄漏“账号是否存在”,否则会产生用户名枚举风险。也就是说,流程拆分带来的安全能力必须与信息泄露风险一起设计。

二十、设计简单接口:从真实问题开始,而不是从对象开始

接口设计最危险的路径之一,是看到一个名词就立即开始建模。

例如看到“用户”,很容易马上设计:

1
2
3
4
5
6
7
8
9
10
11
User
- id
- name
- gender
- birthday
- address
- phone
- email
- department
- preferences
- ...

但如果真实问题只是“是否允许该用户访问某个服务”,这些字段绝大部分都与当前问题无关。

正确的起点应该是问题本身。

20.1 用 MECE 拆问题

MECE(Mutually Exclusive and Collectively Exhaustive)可以翻译为:

  • 相互独立
  • 完全穷尽

假设问题是:

是否授权一个用户访问某项服务?

最初可能被错误地拆成:

1
2
1. 用户是否已经注册?
2. 用户是否持有正确密码?

这两个问题并不真正独立,因为“已注册用户”这个概念本身通常依赖用户名和密码验证。

更合理的层级是:

1
2
3
4
5
是否授权用户访问?
├── 该用户是否是有效注册用户?
│ ├── 用户名是否已注册?
│ └── 密码是否匹配?
└── 该用户是否有访问该服务的权限?

用 Mermaid 表示:

flowchart TD
    A[是否允许访问服务?]
    A --> B[是否为有效注册用户?]
    A --> C[是否拥有访问权限?]
    B --> D[用户名是否已注册?]
    B --> E[密码是否正确?]

这个分解带来两个直接收益:

  • 完全穷尽降低遗漏关键条件的风险;
  • 相互独立降低不同逻辑互相纠缠的复杂度。

20.2 接口可以从问题拆解中自然产生

问题中的动词、名词和形容词,往往就是接口命名的现实来源。

例如:

1
“用户名是否已注册?”

自然对应:

1
boolean isRegisteredUserName(String userName);

而下面这个名字就容易误导:

1
boolean isRegisteredUser(String userName);

因为只有用户名参数,并不足以判断“这个人是不是合法注册用户”,它最多只能判断“这个用户名是否已注册”。

好的命名不是文学修饰,而是让接口职责与现实问题保持一致。

二十一、一个接口只做一件完整、独立的事

“一件事”不是“一行代码”,而是某个抽象层级上的一个完整职责。

一个好的接口操作通常满足三点:

  1. 它只表达一件事;
  2. 这件事相对独立;
  3. 这件事本身完整。

21.1 不完整接口会制造非法中间状态

考虑下面的设计:

1
2
3
4
5
6
7
8
9
10
11
12
class HelloWords {
private String language = "English";
private String greeting = "Hello";

void setLanguage(String language) {
this.language = language;
}

void setGreeting(String greeting) {
this.greeting = greeting;
}
}

看起来 languagegreeting 是两个字段,所以设置它们的接口也拆成两个方法。

问题是,“设置问候方式”这件事实际上需要两个值保持一致。接口拆开以后就出现了新的问题:

  • 先调用哪个?
  • 只调用一个会怎样?
  • Chinese + Hello 是否允许?
  • 谁负责检查二者匹配?
  • 中间状态是否对外可见?

更直观的接口应该尽量把完整操作表达成一次调用,例如:

1
2
3
void setGreeting(String language, String greeting) {
// validate and update atomically
}

如果 greeting 可以完全由 language 推导,甚至可以只暴露:

1
2
3
void setLanguage(String language) {
// greeting is derived internally
}

核心目标是减少调用者必须记住的隐式规则。

二十二、如果接口存在调用顺序,至少让依赖关系显式化

有些 API 无法完全避免状态和调用顺序。

Java Signature 是资料中的典型例子。签名过程要求:

1
2
3
initSign(privateKey)
-> update(data)
-> sign()

验证过程则类似:

1
2
3
initVerify(publicKey)
-> update(data)
-> verify(signature)

之所以存在 update(),一个重要原因是待签名数据可能非常大,需要分块处理,而不能要求一次把全部文件或图像加载进内存。

这种设计的困难在于:

  • 方法之间存在强状态依赖;
  • 调用顺序错误只能在运行时发现;
  • 用户必须仔细阅读规范;
  • 一个对象同时承担“签名”和“验证”多种状态。

如果无法消除依赖,接口规范至少必须明确:

  • 正确调用顺序;
  • 每一步的前置状态;
  • 错误顺序会抛什么异常;
  • 操作完成后对象状态如何变化;
  • 是否可以复用对象再次执行。

更进一步的设计方向,是让非法调用顺序更难表达。例如概念上拆成不同角色:

1
2
3
4
5
6
7
8
9
interface Signer {
void update(byte[] data);
byte[] sign();
}

interface Verifier {
void update(byte[] data);
boolean verify(byte[] signature);
}

初始化以后返回已经进入正确状态的对象:

1
2
3
4
Signer signer = signature.createSigner(privateKey);
signer.update(chunk1);
signer.update(chunk2);
byte[] result = signer.sign();

这样仍然保留大数据分块处理能力,但把一部分状态约束从“文档记忆”转化成“类型和对象结构”。

这体现了一个很重要的接口设计目标:

让正确用法自然,让错误用法困难。

二十三、把整套方法压缩成一套工程决策顺序

面对一个新功能,不妨按照下面的顺序思考。

第一步:确定真实问题

不要先问“用什么设计模式”,先问:

1
用户真正要完成什么任务?

第二步:控制范围

反复问:

1
2
什么是必须做的?
什么是现在必须做的?

第三步:MECE 拆解

把复杂问题拆成相互独立、尽量穷尽的小问题,直到:

  • 已经有现成方法可以解决;
  • 或者问题足够简单,可以轻易验证。

第四步:让接口自然出现

从问题中的真实动作和对象产生接口,不要为了未来想象提前制造大量抽象。

第五步:减少状态与依赖

检查:

  • 一个接口是否只做一件事;
  • 操作是否完整;
  • 是否存在调用顺序;
  • 是否可能出现非法中间状态。

第六步:写契约

明确:

1
参数 + 返回值 + 异常 + 边界 + 极端情况 + 状态变化 + 版本

第七步:补开发指南

确保用户能够:

1
理解概念 -> 快速运行 -> 复制真实示例 -> 自己验证

第八步:通过流水线验证

1
编译告警 -> 单元测试 -> Code Review -> 回归测试 -> 静态分析

第九步:检查性能经济性

从三个层次检查:

1
用户体验 -> 架构资源 -> 算法复杂度

第十步:保持可演进

接口稳定,但内部实现可以持续优化;文档、测试、规范和代码必须一起演进。

二十四、最终检查:什么才算“高质量代码”

高质量代码并不等于“用了很多高级技术”。

真正值得长期维护的代码,往往同时具备下面这些特征:

维度 关键问题
正确性 能否按照预期工作?负面场景是否覆盖?
可理解性 一个新开发者能否快速理解?
接口 对外边界是否少、小、清楚、稳定?
文档 不读实现代码能否正确使用?
兼容性 接口升级是否有迁移路径?
测试 修改一行代码后能否快速验证没有破坏旧行为?
Review 是否有第二双眼睛检查设计和实现?
自动化 能否用工具重复执行检查,而不是靠记忆?
性能 是否以合理资源成本完成任务?
扩展性 增加 CPU、内存或机器时,系统能否真正利用?
简单性 是否存在不必要的功能、抽象、状态和依赖?
用户体验 用户真实任务是否简单、稳定、快速完成?

这些维度最终可以归结为一句话:

从真实问题出发,只做现在真正需要的事情;把复杂问题拆成简单问题,用清晰接口建立协作契约,用自动化流程守住质量,再以用户体验和资源成本验证代码是否真的经济。

这比单纯追求某一种“漂亮代码风格”更接近软件工程的本质。


高质量软件工程实践:从接口契约、文档与代码规范,到性能工程和简单设计
https://allendericdalexander.github.io/2026/08/12/geeker/036/3high_qa_performance/
作者
AtLuoFu
发布于
2026年8月12日
许可协议