如何进行软件设计
软件开发中有一个常见的误区:拿到需求就开始编码,边做边想。这种做法在小型项目中或许可行,但面对复杂系统时,缺乏设计的后果往往是返工、烂尾或者难以维护。
软件设计,本质上是一种预先的思考——把我们从代码细节中抽离出来,用更高层次的抽象去审视问题:我们要解决什么?为什么值得解决?可能的方案有哪些?
设计文档是这种思考的成果,但不是流水账,不是功能列表,更不是代码的翻版。它是逻辑的载体——记录我们如何从问题走到解决方案。
一、设计目的:回答”为什么”而非”做什么”
设计的起点是目的,而不是任务。
“实现一个 RPC 通讯协议”不是目的,这只是描述要做什么。真正的目的应该是:”为分布式服务提供一种高效的远程调用机制,使得调用方能像调用本地函数一样调用远程服务,同时保证可靠性和性能。”
区分目的和任务:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
用目的表述的好处:
-
1. 可验证——能够判断目标是否达成 -
2. 可取舍——多个方案都能达成目标时,选择最简单的 -
3. 可对齐——团队对”为什么要做”形成共识,减少无效争论
从当前状态出发
设计总是从项目当前的状态开始。如果接手的是已有系统,设计的目标是改进这个系统,而不是构建一个理想中的系统。
明确起点有助于避免两种错误:
-
• 历史虚无主义:忽视现有实现,设计出没人能用的”完美方案” -
• 路径依赖陷阱:被现有实现绑架,只是在打补丁
记录起点:Git版本号、时间戳,或简单描述”在XXX功能已上线、YYY模块开发中的情况下,设计Z特性”。
二、统一语言:团队共享的领域词汇
DDD 中有一个核心概念叫通用语言(Ubiquitous Language)——同一限界上下文内,所有人用同一套术语描述领域,无歧义、无冗余。
软件设计同样如此:最重要的工作之一是建立统一语言。
语言塑造思维
语言不仅是描述工具,更是思考工具。
用模糊的语言思考问题,思维本身就会变得模糊。两种思考方式对比:
用基础设施语言思考
“客户端连接服务端,发送请求数据,服务端处理后返回响应。”
思考的问题:如何建立连接?如何组织数据?如何传输字节?解决方案向 Socket 编程靠拢。
用领域语言思考
“调用方(Caller)向被调用方(Callee)发起 RPC 调用。调用方可能重试,被调用方可能返回异常。”
思考的问题:调用方和被调用方的职责边界在哪里?重试策略如何设计?异常如何分类?解决方案向领域模型靠拢。
两种语言导向两种不同的设计。
当我们谈论同一个问题,就应该使用同一套语言。 不是先有代码再有文档,而是先有共享的语言,语言会引导我们走向一致的设计。
知识消化:语言是如何产生的
DDD 强调知识消化(Knowledge Crunching)——团队通过与领域专家的持续对话,从混沌的业务知识中提取出精确的领域模型。
语言不是预先定义的,而是在解决问题的过程中逐渐清晰:
问题 → 讨论 → 发现歧义 → 命名澄清 → 更精确的问题 → 更精确的语言
以订单系统为例:
-
1. 最初用”用户”描述所有参与者 -
2. 讨论”取消订单”时发现”用户”指代混乱:下单的客户、审批的管理员、接收通知的仓库人员 -
3. 引入客户、管理员、仓储员三个概念 -
4. 取消流程清晰了:客户发起 → 管理员审批 → 仓储员知会
不是”先定义术语表再开始工作”,而是在解决问题的过程中自然产生术语。
限界上下文:不同语境的隔离
有些术语在不同上下文中含义不同。例如”订单”:销售领域指销售订单,仓储领域指出库单,财务领域指应收账款。
DDD 通过限界上下文隔离这种歧义,每个上下文有自己独立的通用语言。
软件设计也需要这种意识,设计文档中引入新术语时,明确它属于哪个上下文,例如:
**调用方(Caller)**(RPC上下文):发起RPC请求的进程。是RpcCall的发起者。 **被调用方(Callee)**(RPC上下文):接收并处理RPC请求的进程。是RpcCall的接收者。 **连接(Connection)**(网络上下文):与目标进程建立的TCP连接。一个连接可以承载多个调用。
说出口的语言就是模型
DDD 有句名言:“说出口的语言就是你的模型”。团队讨论时如果不得不绕着弯说话,说明模型还不够清晰。
设计文档同样如此:读起来别扭、充满例外和补充说明,说明语言还不够统一。
好的设计文档:
-
• 术语前后一致——全文用同一套词汇,不出现同义词混用 -
• 语言即代码——代码中的类名、方法名与设计文档的术语对应 -
• 团队共享——开发、测试、产品都能读懂
术语应该像呼吸一样自然,融入设计的每个句子中。但这不意味着变成术语手册——文档读起来应该流畅、直击要害。
统一语言必须在代码中落地。 否则语言和代码是两张皮,沟通成本不减反增:
|
|
|
|---|---|
|
|
class Caller
interface Caller |
|
|
caller.call(method, args) |
|
|
RetryPolicy
|
代码即模型。代码中的类名、方法名、变量名与设计文档术语不一致,说明设计没有真正指导实现。
实践:每引入一个新术语,同时在设计文档中定义、在代码中实现。
三、设计逻辑:抓住核心取舍
设计逻辑回答:我们如何达成目标?
核心挑战,而不是面面俱到
很多设计文档的错误是试图列出所有细节,结果变成技术手册。
设计逻辑的核心是识别2-3个关键挑战,说明选择什么方案、为什么不用其他方案。
以缓存系统为例:

关键取舍:
-
• 淘汰策略:为什么选 LRU 而不是 LFU?因为假设”最近访问的数据更可能再次被访问”。这个假设是否成立,需要验证。 -
• 一致性与性能:业务允许短暂不一致,可以设较长 TTL 提高性能;需要强一致性,则需要更复杂的同步机制。
这两个取舍决定设计走向,其他参数(初始容量、扩容步长等)可以在实现时调整。
多个逻辑视图
复杂系统的设计往往需要多个逻辑视图。
例如设计高性能网络通讯框架:功能视图建模协议栈的封装关系,性能视图计算端到端延迟组成:

每个视图解决不同问题:
-
• 功能视图:数据如何流转,模块如何协作 -
• 性能视图:瓶颈在哪里,优化的边际收益是多少 -
• 状态视图:系统有哪些状态,状态之间如何转换
根据设计目的选择重点,不需要穷举所有视图。
分析与已有模块的关系
确定设计逻辑之前,需要回答:新功能放在哪里实现?
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
反模式:在新模块中重复实现依赖模块已有的能力。浪费开发资源,还会导致后续维护困难——依赖模块升级时,重复代码如何同步?
四、核心数据结构:围绕问题建模
设计逻辑是”骨骼”,核心数据结构是”肌肉”——决定系统如何存储、传递和处理信息。
围绕领域对象建模
DDD 强调领域模型——软件要解决的业务问题在代码中的投影。领域模型不是数据库表的映射,不是 API 请求/响应的结构,而是业务概念在代码中的存在形式。
RPC 领域的核心领域对象:
-
• RpcCall:一次完整的远程调用,包含调用方、被调用方、方法名、参数、结果/异常 -
• RpcRequest:从调用方发往被调用方的消息,携带调用标识和参数 -
• RpcResponse:从被调用方返回给调用方的消息,携带执行结果或错误信息 -
• Connection:调用方与被调用方之间的通信通道,可承载多次调用
抓住核心领域对象,其他数据结构往往是它们的组合或衍生。例如重试队列中的元素就是 RpcCall 和重试次数的组合。
定义关键成员,而非全部细节
设计阶段不需要定义每个字段的精确类型。重点是明确有哪些关键属性、这些属性之间的关系:
struct RpcRequest { string caller_id; // 调用方 ID string method_name; // 方法名 bytes arguments; // 序列化后的参数 string trace_id; // 追踪 ID};
string 还是 string_view、用 JSON 还是 Protobuf 序列化,是详细设计阶段的决策。
数据结构之间的关系
当有多个核心数据结构时,需要描述它们之间的关系:

五、接口定义:表达设计决策
接口是设计的最终交付物——它连接了设计文档和代码实现。
关注”是什么”而非”怎么用”
设计文档中的接口定义,重点是说明:
-
• 接口的职责是什么 -
• 它与其他接口的关系是什么
/** * 建立到指定服务的连接。 * * 设计意图:连接是会话的起点,一个连接对应一个长连接生命周期。 * 与 connect() 对应的是 close(),中间通过 send()/recv() 进行通讯。 * * @param service_address 目标服务的地址,格式为 "host:port" * @return 连接句柄,后续操作使用此句柄 */ConnectionHandle connect(const std::string& service_address);/** * 在连接上发送RPC请求。 * * 注意:此函数是异步的,调用返回时请求可能尚未到达对方。 * 调用方应通过 response() 或回调获取响应。 * * @param conn 已建立的连接 * @param request RPC请求 * @return 请求ID,用于追踪和获取响应 */RequestId send(ConnectionHandle conn, const RpcRequest& request);
这样的接口描述不仅说明了用法,更传达了设计决策:为什么发送是异步的、为什么要区分连接和请求。
总结接口之间的关系
定义多个接口后,总结它们如何协作:
通过
connect()建立连接,用send()发送请求、response()获取响应,通讯完成后close()释放连接。流程:connect() → send() → response() → close()
接口关系一目了然,也为代码审查和测试提供检查点。
内部接口同样重要
设计涉及多个内部模块时,模块之间的接口同样需要描述。
例如 RPC 框架可能包含:
-
• 编码层:将请求/响应对象序列化为字节流 -
• 传输层:将字节流可靠地发送到对端 -
• 路由层:根据服务名找到目标地址
传输层的”消息格式”是一种内部接口:
struct TransportMessage { uint32_t magic; // 协议标识 uint16_t version; // 协议版本 uint32_t body_length; // 消息体长度 uint8_t body[]; // 消息体(编码层的输出) uint32_t checksum; // 校验和};
描述这个格式是为了帮助读者理解”发送的消息具体是什么样的”,不是写协议规范。
六、一致性校验:设计的自我审视
设计完成后,需要进行系统性的一致性检查,确保设计没有内在矛盾。
检查维度
|
|
|
|---|---|
| 概念一致性 |
|
| 状态完备性 |
|
| 接口完备性 |
|
| 层次一致性 |
|
一个状态机的检查示例
假设设计一个连接管理器:

检查点:
-
• 状态完备性:Disconnected 状态下调用 close()会发生什么? -
• 事件处理: connect()在 Connecting 状态被再次调用,是报错还是排队? -
• 异常恢复:网络断开后立即重连,状态转换是否正确?
这种检查往往能发现设计中的漏洞。如果在实现阶段才发现,修复成本会非常高。
七、迭代完善:设计是动态的
设计不是一次完成的。随着对问题的深入理解、需求的变化、测试的反馈,设计需要迭代演进。
保持设计时间线
每次迭代修改设计文档时,回顾最初的设计目的和起点。
典型场景:
-
1. 最初设计:轮询算法实现定时任务调度 -
2. 实现中发现:高频场景下 CPU 开销过大 -
3. 迭代决策:改用时间堆实现 -
4. 设计文档更新:描述时间堆结构,说明选择时间堆而非轮询的原因
关键:迭代后的设计文档应该假设”从一开始我们就选择了时间堆”,而不是描述”先用了轮询,后来换成时间堆”。后者混淆了目标和中间方案——目标是”实现一个高效的定时任务调度器”,不是”实现轮询算法”。
结语:设计是一种能力
软件设计不是套模板、填表格的机械工作,而是思考能力的体现。
好的设计者具备以下特质:
-
• 抽象能力:从具体问题中提取本质,忽略无关细节 -
• 取舍意识:理解没有完美的方案,只有最适合当前场景的选择 -
• 沟通能力:用清晰的语言表达复杂的逻辑,让设计文档成为团队协作的桥梁 -
• 批判思维:不断质疑自己的假设,主动寻找设计的漏洞
设计能力的提升没有捷径。但有一个简单的方法:认真对待每一个设计文档。
把每一次写设计文档当作思维训练的机会,而不是完成任务的形式主义。假以时日,对问题的理解更深了,设计的质量更高了,沟通的成本更低了。
设计改变代码,更改变你。
夜雨聆风