软件设计知识体系
一句话理解:
软件设计是在"满足当前需求"和"应对未来变化"之间做权衡,用合理的结构控制复杂度。
目录
• 一、为什么需要设计 • 二、设计的思维基础:编程范式 • 三、设计的准则:原则 • 四、设计的套路:模式与反模式 • 五、设计的系统方法:领域驱动设计(DDD) • 六、设计的演进:重构、技术债与 TDD • 七、设计的表达:UML、C4 与设计文档 • 八、设计的系统视角:并发、API、数据与安全 • 九、给零基础学习者的建议路线 • 十、最后的话
一、为什么需要设计
1.1 软件的天然敌人:复杂度
软件复杂度有三个来源:
| 本质复杂度 | ||
| 偶然复杂度 | ||
| 组织复杂度 |
1.2 设计要对抗的三大问题
• 模块耦合(Coupling):模块 A 一改,模块 B、C、D 跟着坏。 • 理解成本(Cognitive Load):新人看代码像看天书,不敢改。 • 需求变更的涟漪效应(Ripple Effect):改一个按钮颜色,系统崩溃了。
1.3 设计的核心目标
高内聚,低耦合
• 内聚(Cohesion):一个模块内部的元素彼此关联有多紧密 • 高内聚 = 模块只做一件事,且做得很好 • 耦合(Coupling):模块之间相互依赖的程度 • 低耦合 = 改 A 不需要改 B
可以用一个简单公式理解设计方向(此为隐喻表达,用于建立直觉,非严格数学定律):
可维护性 ∝ 内聚度 / 耦合度
(可维护性与内聚度成正比,与耦合度成反比)
二、设计的思维基础:编程范式
编程范式是"思考问题的方式",不是具体的语法。
2.1 结构化编程(1968 年提出,1972 年系统化)
核心思想:禁用 goto,用三种基本结构组织代码
| 顺序 | |
| 选择 | |
| 循环 |
意义:任何程序都可以用这三种结构表达,代码变得可预测、可推导。
2.2 面向对象编程(OOP)
核心思想:把数据和操作数据的方法绑定在一起,以"对象"为中心组织代码
| 封装 | |
| 继承 | |
| 多态 | |
| 抽象 |
⚠️ 重要提醒:继承是"is-a"关系,组合是"has-a"关系。现代设计更推荐组合优于继承(Composition over Inheritance),因为继承会引入紧耦合。
再进一步:组合的本质不仅是"拥有",更是委托(Delegation)—— 一个对象把某项职责"委托"给另一个对象去完成。例如,订单对象不自己计算折扣,而是"使用"(委托给)一个折扣策略对象。这种"使用关系"比"继承关系"灵活得多,因为可以在运行时替换策略。
补充:为什么多重继承很危险?
如果类 A 和类 B 都有一个方法
foo(),类 C 同时继承 A 和 B,那么 C 调用foo()时到底用哪个?这就是多重继承的歧义性问题。因此 Java 禁止类的多继承,Go 语言则完全用组合替代继承。
2.3 函数式编程(FP)
核心思想:把计算视为数学函数的求值,避免状态变化和可变数据
| 一等函数 | |
| 无副作用 | |
| 不变性 | |
| 组合性 |
为什么 FP 近年特别受欢迎?
除了代码优雅,还有一个关键原因:不变性天然线程安全。在并发环境下,不需要加锁就能保证数据安全,这让它成为高并发系统的利器。
三种范式的关系:
软件设计思维层次
├── 结构化编程:控制流程的基本组织方式
├── 面向对象:组织数据和行为的模块化方式
└── 函数式编程:处理计算和并行的思维方式现代语言(如 Java、Python、JavaScript)通常同时支持三种范式,不是非此即彼。
三、设计的准则:原则
3.1 SOLID 原则(面向对象设计的五大原则)
| S | |||
| O | |||
| L | |||
| I | |||
| D |
详细说明
SRP - 单一职责原则
一个类应当只有一个引起它变化的原因。如果修改需求 A 需要改这个类,需求 B 也需要改这个类,那就应该拆分。
反例:一个类同时处理"用户数据存储"和"用户邮件发送"
💡 补充:"一个类"是经典表述,但现代实践中这个原则同样适用于模块、服务或微服务——任何独立的代码单元都应该职责单一。
OCP - 开闭原则
新增功能时,尽量通过"添加新代码"实现,而不是"修改旧代码"。
实现方式:抽象 + 多态(策略模式是典型应用)
LSP - 里氏替换原则
从**行为契约(可替换性)**角度看:子类替换父类后,程序行为不应改变。
经典反例:正方形继承长方形(设置宽度时高度也会变,破坏长方形的行为预期)。注意,数学上正方形"是"长方形,但程序的行为契约被破坏,因此这种继承在代码中不成立。
ISP - 接口隔离原则
不要把所有方法塞进一个大接口,应该拆成多个小接口。
反例:一个"动物接口"包含 fly()、swim()、run(),但鱼不需要 fly()
DIP - 依赖倒置原则
高层模块不应该依赖低层模块,两者都应该依赖抽象。
通俗说:你依赖的是"插座接口",而不是"某个品牌的插头"
实践手段:依赖注入(DI)与控制反转(IoC)
• 依赖注入(DI):不自己在内部创建依赖,而是由外部"注入"进来(通过构造函数、Setter 或接口注入)。这是实现 DIP 最常用的手段。 • 控制反转(IoC):把对象创建和生命周期管理的控制权从业务代码交给容器/框架。DIP 是设计原则,DI 是编码模式,IoC 是容器机制,三者是"道、术、器"的关系。
3.2 其他重要原则
| KISS | ||
| YAGNI | ||
| DRY | ||
| LoD |
DRY 的边界说明:
DRY 针对的是**"知识/业务规则"**,而不是"代码字符"。
如果两段代码"看起来一样"但"表达的知识不同",重复是合理的。
例如:用户注册时校验手机号 和 修改手机号时校验手机号,虽然代码相似,但业务场景不同,硬要复用反而增加耦合。
更进一步:两个不同限界上下文中的"用户"概念,即使字段完全相同,也不应该复用同一个类——因为它们的业务含义和变化原因完全不同。
LoD(最少知识原则):
通俗说就是"别打听邻居的隐私"。
反例:
user.getDepartment().getManager().getName()正例:
user.getManagerName()(把细节封装在 user 内部)
3.3 组合优于继承
这是 GoF 设计模式的核心指导思想,也是现代软件设计的重要原则。
| 继承 | ||
| 组合 |
3.4 技术债(Technical Debt)—— 概念速览
定义:为了短期交付速度,在代码质量或设计上做出的妥协,未来需要连本带利偿还。
技术债不是"烂代码"的代名词,而是一种战略借贷。详见第六章的完整展开。
技术债成本示意:
总成本 = 初始开发成本 + Σ(逐期维护成本)
借债时初始开发成本降低(因为写得快),但维护成本本身会随时间呈超线性增长(因为混乱的代码互相纠缠,改一个地方影响十个地方)。没有偿还计划的技术债,会让总成本失控。
3.5 安全设计原则(Security by Design)
安全不是最后打补丁,而是设计时就考虑:
| 最小权限原则 | |
| 输入校验边界 | |
| 失败安全(Fail-Safe) | |
| 纵深防御(Defense in Depth) |
纵深防御举例:API 层参数校验 → 服务层业务规则校验 → 数据库层约束 → 最小权限访问控制。任何一层被突破,还有其他层兜底。
常识:至少了解注入攻击、越权访问、敏感数据泄露这三类常见风险。
四、设计的套路:模式与反模式
4.1 为什么需要设计模式
设计模式 = 前人总结的、经过验证的、解决特定问题的通用结构模板。
不是发明,而是发现——是代码中反复出现的"好结构"的总结。
4.2 GoF 23种设计模式
创建型模式(5种)—— 解决"如何创建对象"的问题
| 单例模式 | |
| 原型模式 | |
| 工厂方法 | |
| 抽象工厂 | |
| 建造者模式 |
⚠️ 单例模式的现代警示:单例模式虽然能保证唯一实例,但它本质上是全局状态。全局状态会导致隐藏耦合(你不知道谁在什么时候改了它)、单元测试困难(测试间互相污染)、多线程风险。在现代工程中,更推荐通过**依赖注入(DI)**由容器管理对象的生命周期,而非手写单例。
工厂方法 vs 抽象工厂的区别:
• 工厂方法:父类定义创建接口,子类决定具体创建什么(如:对话框基类定义 createButton(),Windows 对话框子类返回 Windows 按钮,Mac 对话框子类返回 Mac 按钮)• 抽象工厂:一个工厂创建一组相关产品(如:一个 UI 工厂同时创建按钮、文本框、下拉框)
结构型模式(7种)—— 解决"如何组合类和对象"的问题
| 代理模式 | |
| 桥接模式 | |
| 装饰者模式 | |
| 适配器模式 | |
| 外观模式 | |
| 组合模式 | |
| 享元模式 |
行为型模式(11种)—— 解决"对象之间如何交互"的问题
| 观察者模式 | |
| 模板方法模式 | |
| 策略模式 | |
| 职责链模式 | |
| 迭代器模式 | |
| 状态模式 | |
| 访问者模式 | |
| 备忘录模式 | |
| 命令模式 | |
| 解释器模式 | |
| 中介者模式 |
4.3 架构模式(比设计模式更高层)
设计模式解决"类与类之间的关系",架构模式解决"系统与子系统之间的关系"。
| 分层架构(Layered) | |
| 六边形架构(Hexagonal) | |
| CQRS | |
| 事件溯源(Event Sourcing) |
注意:事件溯源常与 CQRS 搭配使用,但两者并非绑定关系。事件溯源可以独立存在,CQRS 也可以不用事件溯源。
微服务拆分原则:微服务不是越小越好。一个微服务 ideally 对应一个限界上下文(Bounded Context)。如果两个微服务频繁互相调用(高频同步通信),往往说明拆分过细,或者限界上下文识别有误。拆分的第一依据是业务边界,而非技术便利。
4.4 反模式(Anti-patterns)—— 知道什么不能做
只学"好设计"不学"坏设计",就像只学交通规则不学危险驾驶。
| 上帝对象 | ||
| 面条代码 | ||
| 金锤子 | ||
| 重复代码(坏味道) | ||
| 过长参数列表 | ||
| 数据泥团 | ||
| 过度设计 |
过度设计警示:设计模式是应对特定复杂度的工具。如果为了"显得专业"而强行套用模式,反而会增加偶然复杂度。记住:简单是美德,复杂是代价。
五、设计的系统方法:领域驱动设计(DDD)
DDD 的核心思想:让软件设计紧密围绕业务领域,用业务语言(而非技术语言)描述系统。
5.1 通用语言(Ubiquitous Language)
开发人员和业务人员使用同一套词汇,消除"你说的用户是我说的客户吗?"这样的理解偏差。
5.2 模型驱动设计
战略设计(解决"大问题如何拆分成小问题")
| 子域 | |
| 核心域 | |
| 支撑域 | |
| 通用域 | |
| 限界上下文 | |
| 上下文映射图 |
限界上下文示例:
在"销售上下文"中,"客户"指下单的人;在"售后上下文"中,"客户"指需要服务的人。虽然是同一个词,但含义不同,应该分开建模。
上下文映射的模式:
| 合作关系 | |
| 共享内核 | |
| 客户-供应商 | |
| 遵奉者 | |
| 防腐层(ACL) | |
| 开放主机服务 | |
| 发布语言 | |
| 各行其道(Separate Ways) |
各行其道补充说明:
这是最容易被忽略的策略。当两个业务模块确实没有数据往来或协作需求时,强行建立集成关系反而增加复杂度。"不集成"本身也是一种架构决策——它让双方可以独立迭代、互不拖累。
例如:公司的"内部考勤系统"和"外部电商网站",除非有特殊需求,否则完全不需要打通,各行其道即可。
战术设计(解决"限界上下文内部如何设计")
| 实体(Entity) | ||
| 值对象(Value Object) | ||
| 聚合(Aggregate) | ||
| 聚合根(Aggregate Root) | ||
| 领域事件(Domain Event) | ||
| 领域服务(Domain Service) | ||
| 应用服务(Application Service) | ||
| 工厂(Factory) | ||
| 资源库(Repository) |
实体 vs 值对象:
• 实体:"用户"张三,即使改了名字还是同一个人(靠 ID 识别) • 值对象:"地址"北京市朝阳区,改了任何一个字段就是另一个地址(靠属性值识别)
聚合的重要性:
聚合是事务边界。原则上,一个事务只修改一个聚合,以此保证数据一致性。如果业务场景必须同时修改多个聚合(如银行转账涉及两个账户),应通过Saga 模式或领域事件实现最终一致性,而非在一个事务中强行修改多个聚合。
⚠️ 警惕贫血模型(Anemic Domain Model):
这是 DDD 实践中最常见的反模式——实体只有 getter/setter,所有业务逻辑都堆在 Service 中。这样的"实体"只是数据容器,不是真正的领域模型。
判断标准:如果只涉及单个实体/值对象内部的业务规则,修改时仍需要改 Service 而不是实体,可能就是贫血模型。正确的做法是把业务逻辑放回实体和值对象中(充血模型),Service 只负责编排跨实体的逻辑。
5.3 数据持久化设计
DDD 中的资源库屏蔽了存储细节,但理解数据设计本身也很重要:
| 数据库范式 | |
| 反范式 | |
| 事务边界 | |
| 最终一致性 |
CAP 定理(分布式环境):
在分布式系统中,分区容错性(P)是系统的固有属性,而非可被"满足"的条件。当未发生网络分区时,一致性(C)和可用性(A)可以同时保障;当发生网络分区时,C 和 A 不可兼得,必须在二者之间权衡。
重要提醒:CAP 定理不是让系统"全局选择 CP 或 AP"的二分法。现实中的分布式系统往往在不同场景下做不同选择:例如,订单创建需要强一致性(CP),商品浏览可以容忍短暂不一致而优先保证可用(AP)。理解"权衡空间"比记住"选 CP 还是 AP"更重要。
六、设计的演进:重构、技术债与 TDD
6.1 重构(Refactoring)
定义:在不改变外部行为的前提下,改进代码的内部结构。
重构与功能开发的两顶帽子理论(由 Kent Beck 提出,Martin Fowler 在《重构》中强调):
• 功能开发帽:添加新功能 → 先写最简单的实现让测试通过,不追求一步到位的设计完美 • 重构帽:改进代码结构 → 不能添加新功能,只优化现有代码 • 两顶帽子不能同时戴
为什么强调"最简单的实现"?
这不是鼓励写"烂代码",而是避免过度设计。先让功能跑起来,验证方向正确,再通过重构打磨结构。简单实现必须能通过测试,且紧接着就要戴上"重构帽"优化。
6.2 测试驱动开发(TDD)
TDD 不仅是测试方法,也是设计方法。
TDD 的三步循环(红-绿-重构):
1. 写测试:先思考"我希望这个模块怎么被使用"(红色,测试失败)。 2. 写代码:让测试通过,可以写最简单的实现(绿色,测试通过)。 3. 重构:优化代码结构,保持测试通过(保持绿色)。 4. 回到步骤 1,写下一个测试。
TDD 如何帮助设计:
因为先写测试,你被迫从"调用方"的角度设计接口。如果测试很难写,说明接口设计有问题。
TDD 的适用边界:
TDD 最适合业务逻辑清晰、接口先行的领域层。以下场景需要灵活调整:
• 探索性设计/架构设计阶段:需要大量前期探索时,小步快跑可能不适用 • 遗留系统改造:给没有测试的遗留代码补 TDD,成本极高,通常先补回归测试 • UI 层和复杂外部依赖:需要配合测试替身(Mock/Stub),不要强行对所有层用 TDD
6.3 技术债(Technical Debt)—— 完整展开
定义:为了短期交付速度,在代码质量或设计上做出的妥协,未来需要连本带利偿还。
技术债不是"烂代码"的代名词,而是一种战略借贷:
• 好的技术债:为了抢占市场,先快速上线,但有明确的偿还计划 • 坏的技术债:一直借新债还旧债,系统最终失控
技术债成本示意:
总成本 = 初始开发成本 + Σ(逐期维护成本)
借债时初始开发成本降低(因为写得快),但维护成本本身会随时间呈超线性增长(因为混乱的代码互相纠缠,改一个地方影响十个地方)。没有偿还计划的技术债,会让总成本失控。
技术债管理实践:
• 显性化:每次妥协时,在代码里留 TODO 注释,并在项目管理工具中记录偿还计划(何时、由谁、如何还)。 • 分类:区分"战略债"(有明确收益和偿还计划)和"非战略债"(单纯因为偷懒或不懂设计)。 • 偿还节奏:建议每个迭代(Sprint)预留 10%~20% 的带宽用于偿还技术债和重构,避免债务滚雪球。
七、设计的表达:UML、C4 与设计文档
7.1 UML 核心图(零基础先掌握这 5 种)
| 用例图 | ||
| 类图 | ||
| 时序图 | ||
| 活动图 | ||
| 状态图 |
7.2 C4 模型(现代架构描述方法)
UML 是经典,但 C4 模型对描述系统架构更直观:
| 系统上下文图(Context) | ||
| 容器图(Container) | ||
| 组件图(Component) | ||
| 代码图(Code) |
C4 组件图与 UML 类图的关系:
C4 组件图描述的是运行时/进程内的模块划分(如"订单服务内的支付模块"),UML 类图描述的是静态代码结构(如"PaymentService 类依赖 PaymentGateway 接口")。二者是正交视角,而非简单的高层与底层关系。
建议:设计文档中优先用 C4 描述架构,用 UML 描述细节。
7.3 设计文档写什么
设计文档结构:
1. 背景与目标(为什么要做) 2. 总体架构(系统分层/模块划分,附 C4 容器图) 3. 核心模型(领域模型/数据模型) 4. 接口设计(对外提供什么能力,附幂等性说明) 5. 关键流程(时序图/活动图) 6. 非功能性设计(性能、安全、扩展性) 7. 风险与待决事项
八、设计的系统视角:并发、API、数据与安全
8.1 并发设计基础
现代软件几乎都是并发环境,不了解并发容易写出"看起来设计很好,但运行就崩"的系统。
| 竞态条件 | |
| 不变性的并发价值 | |
| 原子边界 | |
| 乐观锁 vs 悲观锁 |
聚合与并发控制:
聚合根通常是乐观锁(版本号)的粒度单位,但这不意味着并发控制只能在聚合层面发生。实际工程中,并发控制还可以在更细粒度(如数据库行级锁)或更粗粒度(如分布式锁)实现,需根据业务一致性要求和性能权衡选择。
补充:Actor 模型与 CSP:
除了锁和不变性,还有两种重要的并发思维:
• Actor 模型(如 Akka):每个 Actor 有独立状态,只通过异步消息通信,不共享内存,从根本上避免竞态。 • CSP(通信顺序进程,如 Go 的 channel):通过显式的通道(Channel)传递数据,倡导"通过通信共享内存,而不是通过共享内存通信"。 二者都体现了 FP 的"不共享可变状态"思想,是高并发系统的重要设计选项。
8.2 API 设计原则
软件最终要通过接口对外服务,好的 API 设计是用户体验的一部分。
| 幂等性(Idempotency) | |
| 契约优先(Contract First) | |
| 版本控制 | /v1/)或 Header 管理版本,避免破坏现有用户 |
| 最小暴露原则 |
幂等性补充:
幂等性不是说"两次请求返回的结果完全一样",而是说"不会因为重复请求产生额外的副作用"。例如,第一次支付成功,第二次重复请求可能返回"已处理",状态不同,但钱不会多扣。
8.3 康威定律(Conway's Law)
设计系统的组织,其产生的设计等同于组织之间的沟通结构。
这是对"组织复杂度"的升维回应:
• 如果团队是三个孤岛,软件大概率会拆成三个互相扯皮的微服务 • 如果团队是一体化的,软件可能更适合单体架构
结论:组织架构与软件架构需要匹配,不要强行用微服务解决沟通问题。
微服务拆分与限界上下文:
微服务 ideally 应按限界上下文拆分。如果两个服务高频互相调用(同步依赖),往往说明它们本应属于同一个上下文,或拆分过细。记住:拆分的依据是业务边界和团队独立交付能力,而非技术潮流。
逆向康威定律(Inverse Conway Maneuver):
有意识地调整组织架构,来推动期望的软件架构。例如,想推动微服务拆分,可以先按服务边界拆分团队,让架构自然跟随组织沟通结构演进。
8.4 安全设计速查
| 注入攻击 | |
| 越权访问 | |
| 敏感数据泄露 | |
| 重放攻击 |
九、给零基础学习者的建议路线
以下时间仅供参考,根据个人时间投入和基础灵活调整,不要因进度焦虑,不必赶进度。
第一阶段(1-2 周):建立直觉
• 理解"高内聚低耦合"的含义 • 掌握 SOLID 原则 • 能看懂类图和时序图 • 练习:阅读一个你正在学习的开源项目,记录"看不懂的地方"
第二阶段(4-6 周):认识模式与反模式
• 通读 GoF 23 种模式,理解每种模式的"意图" • 重点掌握:工厂、策略、观察者、装饰者、适配器(单例了解即可,慎用) • 学习反模式:上帝对象、面条代码、金锤子、过度设计 • 练习:在 GitHub 找 Refactoring Kata(重构练习题),先识别坏味道,再应用模式重构
第三阶段(3-6 个月):实践 DDD 与系统视角
• 学习通用语言和限界上下文 • 练习识别实体、值对象、聚合 • 尝试画上下文映射图(包括"各行其道"的识别) • 理解依赖注入(DI)与 IoC 容器的基本思想 • 学习并发基础:理解竞态条件和不变性 • 练习:用 Event Storming(事件风暴)工作坊梳理业务流程,再映射到限界上下文
第四阶段(持续):刻意练习
• 读优秀开源项目的代码结构 • 写设计文档,画 C4 图和 UML 图 • 用 TDD 方式开发小项目 • 参与 Code Review:学习"如何优雅地指出设计问题" • 建立技术债意识:每次妥协时,在代码里留 TODO 并记录偿还计划
十、最后的话
设计不是一次性的活动,而是贯穿软件生命周期的持续过程。
好的设计不是"一开始就完美",而是"随时可以安全地改进"。
记住两个公式:
可维护性 ∝ 内聚度 / 耦合度(设计方向的直觉隐喻)
总成本 = 初始开发成本 + Σ(逐期维护成本)(技术债的代价)
在"快"和"好"之间找到属于当前阶段的平衡点,然后保持演进。
夜雨聆风