Skip to content

元数据卡

  • 前置知识:第2章(ADT);能理解简单的 UML 类图
  • 预计时间:50 分钟
  • 核心难度:进阶
  • 完成标志:能写出用户故事、画出 UML 类图,能用 C4 模型表达架构

你的进度

你按铁匠师傅的指导锻造了一批零件,兴冲冲地拿去交货。委托人说:“我要的是弩机,你给我做了个投石机。”

铁匠师傅叹了口气:“图纸都没看全就开始打铁了?下次先画图再开炉。”

你回到工匠之都的设计厅。墙上挂着工匠联盟的标准——在动了第一锤之前,你得先知道你在造什么、谁会用、坏了怎么修。这不是画图,是设计。 你的任务

你不会先砌墙再画设计图。但面对软件项目,你的习惯是从 main() 函数开始敲——跳过图纸,直接砌墙。这章让你停下来想一想:在你敲第一个字符前,有哪些工作能让你少拆三面墙? 需求分析回答"我该修什么",架构设计回答"我该怎么修"。两者之间是一条从模糊想法到具体代码的转换链条。

本章分层

  • 必读:用户故事、UML 类图/时序图、C4 模型、4+1 视图
  • 选读:领域驱动设计的战略建模
  • 进阶:C4 与 4+1 视图的映射关系

本章不会要求你掌握

  • UML 的全部 13 种图
  • 完整的需求工程方法论

破局 · 溯源

客户说"我要一个比武擂台系统"。你坐在屏幕前,脑子里浮现的是《街头霸王》——血条、连招、角色选择界面。但你错了。客户说的是:"我在工匠之都开了一个擂台,两个冒险者报名,系统随机分配对战,记录胜负积分。"

"擂台系统"这个短语在不同的脑子里映射出完全不同的东西。你需要一个工具来缩小这个差距。

第一层:用户故事——说人话的需求

用户故事不是技术文档。它是一句自然语言的填空:

作为 <角色>,我想要 <功能>,以便 <价值>。

这个格式的约束力在哪儿?它强制你从用户视角而不是系统视角想需求。"我要一个积分排行榜"是系统视角。"作为冒险者,我想看到我在擂台的排名,以便知道自己距离最强还有多远"是用户视角——而且暴露了隐藏细节:排名需要更新、需要显示差距、可能还需要分榜。

你的擂台系统的第一版用户故事:

text
故事 1:作为报名者,我想要报名参加擂台赛,以便和其他冒险者对战。
故事 2:作为报名者,我想要查看我的对战记录,以便回顾输在哪里。
故事 3:作为观众,我想要看到正在进行的对战直播,以便为支持的选手加油。
故事 4:作为擂台管理员,我想要每日生成积分榜,以便激励冒险者持续参赛。
故事 5:作为擂台管理员,我想要禁用违规的冒险者,以维持比赛公平。

写完之后,你还会加验收条件(Acceptance Criteria):

text
故事 1 验收条件:
  - 冒险者必须年满 18 级才能报名
  - 报名成功后收到确认消息
  - 同一冒险者不能同时报名多场擂台赛(防重复报名)

验收条件的格式是Given-When-Then

gherkin
Given 冒险者 A 已经年满 18 级
  And 冒险者 A 当前没有报名的擂台赛
 When 冒险者 A 提交报名表
 Then 报名成功
  And 冒险者 A 收到确认消息
  And 冒险者 A 出现在参赛者名单中

这个格式的主要价值不是"写下来",是和客户一起过一遍。读到最后一行时客户可能会说:"等等,参赛者名单应该公开还是只有管理员可见?"于是你又发现了一个需求分歧。

第二层:从需求到设计——用 UML 说人话

回到你的世界,你还在用白板画方框和箭头。当你需要把设计传递给别人时,方框和箭头不够精确了——它们是模棱两可的(这个箭头是继承还是依赖?这个虚框是模块还是类?)。

UML(Unified Modeling Language)是一套精确的图形语言。你不必学会全部(UML 2.5 有 13 种图),只需要三种覆盖 80% 的场景:类图(结构)、时序图(交互)、状态机图(生命周期)。

类图: 看你的擂台系统需要哪些数据结构:

+----------------+          +-------------------+
|    Adventurer   |          | Tournament         |
+----------------+          +-------------------+
| -id: String     | 1      * | -id: String        |
| -name: String   |<------->| -status: Status     |
| -level: int     | 参赛者   | -maxParticipants: int |
| -score: int     |          | -matches: Match[]   |
+----------------+          +-------------------+
| +register()     |          | +register(Adventurer)|
| +getScore()     |          | +start()           |
| +getHistory()   |          | +close()           |
+----------------+          +-------------------+
        |                            |
        | 1                          | *
        |                            |
        |    +------------------+    |
        +--->|      Match       |<---+
             +------------------+
             | -id: String      |
             | -playerA: String |
             | -playerB: String |
             | -winner: String  |
             | -status: MatchStatus|
             +------------------+
             | +start()         |
             | +recordWin(player)|
             | +getResult()     |
             +------------------+
java
// ch03/domain/Tournament.java
public class Tournament {
    private final String id;
    private Status status;
    private final int maxParticipants;
    private final List<Adventurer> participants = new ArrayList<>();
    private final List<Match> matches = new ArrayList<>();

    public enum Status { OPEN, IN_PROGRESS, CLOSED }

    // 不变量:参与者数量不能超过 maxParticipants
    // 不变量:状态为 CLOSED 时不能注册

    public boolean register(Adventurer adventurer) {
        if (status != Status.OPEN) return false;
        if (participants.size() >= maxParticipants) return false;
        if (participants.contains(adventurer)) return false;
        return participants.add(adventurer);
    }
}

记得上一章说的不变量吗?它现在就落在类图的约束位置。

时序图: 展示一次交互中发生的事情。一次报名操作的流程:

报名者                     Tournament             MatchService
  |                          |                       |
  |-- register(id, name) -->|                       |
  |                          |-- canRegister(id) ->|
  |                          |<- yes ---------------|
  |                          |                       |
  |                          |-- saveParticipant() ->|
  |                          |<-- ok ---------------|
  |                          |                       |
  |<-- success ------------|                       |

时序图特别适合表达协议(protocol)——谁在什么时候做什么事。你在写代码前画出时序图,会发现很多"啊,这里缺一个确认响应"的问题。

状态机图: 一次擂台赛的生命周期:

[OPEN] --报名满--> [IN_PROGRESS] --比赛结束--> [CLOSED]
   |                    |
   |<--管理员取消---|
   |                    |-- 超时--> [CLOSED]
   v                    v
 [CANCELLED]         [ERROR]

状态机图让你不得不想清楚每个状态转换的条件和前置——这是写代码暴露得最晚的维度,但也是最多 bug 的来源。

第三层:C4 模型——从十米高空到地面

类图和时序图是"草丛视角"——你趴在地上看每一株草。但你还需要一个俯瞰全局的视角,告诉新人"这团草和那片树林有什么关系"。

C4 模型提供了四个层次:

Level 1: 系统上下文图(System Context)
  你在市中心。地图上画着:你的系统(一个方块),外围系统(另外几个方块),用户(一个小人),箭头标注关系。
  
Level 2: 容器图(Container)
  你放大你的系统。它由几个容器组成(Web App、API 服务、数据库、消息队列)。每个容器是一个可独立部署的运行单元。

Level 3: 组件图(Component)
  你放大一个容器。API 服务里面包括:认证组件、擂台管理组件、积分计算组件。

Level 4: 代码级(Code)
  你放大一个组件。擂台管理组件里面是 Tournament、Match、Adventurer 这些类。

C4 的精髓:先画哪个视图中,永远先画 Level 1,确定边界再说细节。 太多团队从 Level 4 开始设计——上来就讨论 Tournament 该用哪个集合——结果发现系统边界定了三遍。

系统上下文图(L1):

  [冒险者] ---报名---> [擂台系统]
       <---结果----
       
  [擂台系统] ---验证身份---> [身份服务]
  [擂台系统] ---推送结果---> [通知服务]

容器图(L2):

  [Web UI: React] ---HTTP---> [API Server: Spring Boot]
                                   |
                            [PostgreSQL]

第四层:4+1 视图——别漏掉运维视角

C4 之后你问了一个实际的问题:"这系统部署在哪?谁运维?跨机房吗?" 4+1 视图模型为这些问题提供了一个完整的框架。4+1 包括:

  1. 逻辑视图:功能分解——类图、包图
  2. 进程视图:并发、同步、分布式通信
  3. 开发视图:模块组织、代码结构、构建依赖
  4. 部署视图:物理部署、网络、硬件
  5. 场景视图(+1):关键用例,把前四个视图串起来

大多数程序员只关注逻辑视图(类怎么设计)和开发视图(项目怎么分层)。但部署视图和进程视图在分布式场景下极其关键。

部署视图示例(擂台系统):

+---------------------------+
|   负载均衡器 (Nginx)       |
+---------------------------+
           |
    +------+------+
    |             |
+---------+ +---------+
| API 节点1| | API 节点2|   ← 无状态,水平扩展
+---------+ +---------+
    |             |
    +------+------+
           |
+-------------------+
| PostgreSQL 主库    |
|   |               |
| PostgreSQL 从库   |
+-------------------+
yaml
# ch03/deployment/docker-compose.yml
version: '3.8'
services:
  api:
    image: tournament-api:1.0
    environment:
      - DB_URL=jdbc:postgresql://db:5432/tournament
    depends_on:
      - db
  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data

你画完部署视图后会产生一个问题:"API 节点是集群部署,那报名操作会不会有竞态条件?"——这就是前面进程视图要回答的事情。

进程视图关心的问题:你的服务是同步处理还是异步处理?有没有共享状态?锁怎么设计?

报名操作:
  1. API 节点接收报名请求
  2. 加分布式锁(Redis SETNX):lock:tournament:${id}
  3. 检查参赛者数
  4. 写入数据库
  5. 释放锁

常见陷阱

陷阱一:用户故事写成技术需求。 "作为一个系统,我要用 Redis 缓存积分榜"——这不是用户故事,这是技术实现。用户端不关心你用了什么缓存。

陷阱二:类图画成关系型数据结构。 类图的目的是表达代码级关系(继承、组合、聚合、依赖),不是数据库表。不要在类图上画外键和关联表。

陷阱三:第一个版本就搞完美架构。 C4 Level 1 在第一天画、Level 2 在第一个迭代画、Level 3 在第三个迭代才画。不要在需求不明确的时候深入 Level 4。

陷阱四:只画类图不画时序图。 很多设计缺陷("这里少了一个确认响应""这个调用返回什么")只有在画时序图时才会暴露。类图是静态的,时序图是动态的,两者结合起来才是完整的。

陷阱五:满足于故事卡,不做验收条件。 纯用户故事是歧义的源头。"我要报名"——是指随时可以报名?还是只能在某个窗口期报名?报名一次还是可以重复报名?这些在验收条件里敲死。


通关挑战

  • 热身:给擂台系统写 3 个完整的用户故事(含验收条件)。主题选擂台外的场景——比如"管理员关闭擂台"。
  • 挑战:选一个你写过的完整的项目(500 行以上),画出它的 C4 L2 容器图。如果你画不出来——说明这个项目缺架构设计。
  • 观察:找一个开源项目,找它的架构文档(通常在 /docs/architecture/ 下)。对照 C4 模型,看它覆盖了几个层次。

旅人笔记

写代码之前的图纸——用户故事让你和客户说同一种语言,UML 让你的设计有精确的表达,C4 给你分层级的俯瞰,4+1 视图提醒你不要只盯着逻辑看漏了部署和进程——造软件的工序,和造城堡一样,先画图再动工。


下一站预告

有了蓝图,你开始思考一个更实际的问题:你怎么知道你的城堡是按照图纸建的?下一章——测试策略。

Built with VitePress | Software Systems Atlas