乐于分享
好东西不私藏

如何进行软件设计

如何进行软件设计

软件开发中有一个常见的误区:拿到需求就开始编码,边做边想。这种做法在小型项目中或许可行,但面对复杂系统时,缺乏设计的后果往往是返工、烂尾或者难以维护。

软件设计,本质上是一种预先的思考——把我们从代码细节中抽离出来,用更高层次的抽象去审视问题:我们要解决什么?为什么值得解决?可能的方案有哪些?

设计文档是这种思考的成果,但不是流水账,不是功能列表,更不是代码的翻版。它是逻辑的载体——记录我们如何从问题走到解决方案。

一、设计目的:回答”为什么”而非”做什么”

设计的起点是目的,而不是任务

“实现一个 RPC 通讯协议”不是目的,这只是描述要做什么。真正的目的应该是:”为分布式服务提供一种高效的远程调用机制,使得调用方能像调用本地函数一样调用远程服务,同时保证可靠性和性能。”

区分目的和任务:

任务
目的
实现缓存模块
解决热点数据的重复计算问题,响应时间降至50ms以内
引入消息队列
解耦生产者和消费者,避免同步调用导致的系统阻塞
重构数据库访问层
统一数据访问模式,消除散落的SQL语句

用目的表述的好处:

  1. 1. 可验证——能够判断目标是否达成
  2. 2. 可取舍——多个方案都能达成目标时,选择最简单的
  3. 3. 可对齐——团队对”为什么要做”形成共识,减少无效争论

从当前状态出发

设计总是从项目当前的状态开始。如果接手的是已有系统,设计的目标是改进这个系统,而不是构建一个理想中的系统。

明确起点有助于避免两种错误:

  • • 历史虚无主义:忽视现有实现,设计出没人能用的”完美方案”
  • • 路径依赖陷阱:被现有实现绑架,只是在打补丁

记录起点:Git版本号、时间戳,或简单描述”在XXX功能已上线、YYY模块开发中的情况下,设计Z特性”。

二、统一语言:团队共享的领域词汇

DDD 中有一个核心概念叫通用语言(Ubiquitous Language)——同一限界上下文内,所有人用同一套术语描述领域,无歧义、无冗余。

软件设计同样如此:最重要的工作之一是建立统一语言

语言塑造思维

语言不仅是描述工具,更是思考工具

用模糊的语言思考问题,思维本身就会变得模糊。两种思考方式对比:

用基础设施语言思考

“客户端连接服务端,发送请求数据,服务端处理后返回响应。”

思考的问题:如何建立连接?如何组织数据?如何传输字节?解决方案向 Socket 编程靠拢。

用领域语言思考

“调用方(Caller)向被调用方(Callee)发起 RPC 调用。调用方可能重试,被调用方可能返回异常。”

思考的问题:调用方和被调用方的职责边界在哪里?重试策略如何设计?异常如何分类?解决方案向领域模型靠拢。

两种语言导向两种不同的设计。

当我们谈论同一个问题,就应该使用同一套语言。 不是先有代码再有文档,而是先有共享的语言,语言会引导我们走向一致的设计。

知识消化:语言是如何产生的

DDD 强调知识消化(Knowledge Crunching)——团队通过与领域专家的持续对话,从混沌的业务知识中提取出精确的领域模型。

语言不是预先定义的,而是在解决问题的过程中逐渐清晰:

问题 → 讨论 → 发现歧义 → 命名澄清 → 更精确的问题 → 更精确的语言

以订单系统为例:

  1. 1. 最初用”用户”描述所有参与者
  2. 2. 讨论”取消订单”时发现”用户”指代混乱:下单的客户、审批的管理员、接收通知的仓库人员
  3. 3. 引入客户管理员仓储员三个概念
  4. 4. 取消流程清晰了:客户发起 → 管理员审批 → 仓储员知会

不是”先定义术语表再开始工作”,而是在解决问题的过程中自然产生术语

限界上下文:不同语境的隔离

有些术语在不同上下文中含义不同。例如”订单”:销售领域指销售订单,仓储领域指出库单,财务领域指应收账款。

DDD 通过限界上下文隔离这种歧义,每个上下文有自己独立的通用语言。

软件设计也需要这种意识,设计文档中引入新术语时,明确它属于哪个上下文,例如:

**调用方(Caller)**(RPC上下文):发起RPC请求的进程。是RpcCall的发起者。                                                          **被调用方(Callee)**(RPC上下文):接收并处理RPC请求的进程。是RpcCall的接收者。                                                  **连接(Connection)**(网络上下文):与目标进程建立的TCP连接。一个连接可以承载多个调用。                                          

说出口的语言就是模型

DDD 有句名言:“说出口的语言就是你的模型”。团队讨论时如果不得不绕着弯说话,说明模型还不够清晰。

设计文档同样如此:读起来别扭、充满例外和补充说明,说明语言还不够统一。

好的设计文档:

  • • 术语前后一致——全文用同一套词汇,不出现同义词混用
  • • 语言即代码——代码中的类名、方法名与设计文档的术语对应
  • • 团队共享——开发、测试、产品都能读懂

术语应该像呼吸一样自然,融入设计的每个句子中。但这不意味着变成术语手册——文档读起来应该流畅、直击要害。

统一语言必须在代码中落地。 否则语言和代码是两张皮,沟通成本不减反增:

设计文档中的术语
代码中的体现
调用方(Caller)
class Caller

 或 interface Caller
发起RPC调用
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. 1. 最初设计:轮询算法实现定时任务调度
  2. 2. 实现中发现:高频场景下 CPU 开销过大
  3. 3. 迭代决策:改用时间堆实现
  4. 4. 设计文档更新:描述时间堆结构,说明选择时间堆而非轮询的原因

关键:迭代后的设计文档应该假设”从一开始我们就选择了时间堆”,而不是描述”先用了轮询,后来换成时间堆”。后者混淆了目标和中间方案——目标是”实现一个高效的定时任务调度器”,不是”实现轮询算法”。

结语:设计是一种能力

软件设计不是套模板、填表格的机械工作,而是思考能力的体现

好的设计者具备以下特质:

  • • 抽象能力:从具体问题中提取本质,忽略无关细节
  • • 取舍意识:理解没有完美的方案,只有最适合当前场景的选择
  • • 沟通能力:用清晰的语言表达复杂的逻辑,让设计文档成为团队协作的桥梁
  • • 批判思维:不断质疑自己的假设,主动寻找设计的漏洞

设计能力的提升没有捷径。但有一个简单的方法:认真对待每一个设计文档

把每一次写设计文档当作思维训练的机会,而不是完成任务的形式主义。假以时日,对问题的理解更深了,设计的质量更高了,沟通的成本更低了。

设计改变代码,更改变你。