元数据卡
- 前置知识:第2章(ADT);能理解简单的 UML 类图
- 预计时间:50 分钟
- 核心难度:进阶
- 完成标志:能写出用户故事、画出 UML 类图,能用 C4 模型表达架构
你的进度
你按铁匠师傅的指导锻造了一批零件,兴冲冲地拿去交货。委托人说:“我要的是弩机,你给我做了个投石机。”
铁匠师傅叹了口气:“图纸都没看全就开始打铁了?下次先画图再开炉。”
你回到工匠之都的设计厅。墙上挂着工匠联盟的标准——在动了第一锤之前,你得先知道你在造什么、谁会用、坏了怎么修。这不是画图,是设计。 你的任务
你不会先砌墙再画设计图。但面对软件项目,你的习惯是从 main() 函数开始敲——跳过图纸,直接砌墙。这章让你停下来想一想:在你敲第一个字符前,有哪些工作能让你少拆三面墙? 需求分析回答"我该修什么",架构设计回答"我该怎么修"。两者之间是一条从模糊想法到具体代码的转换链条。
本章分层
- 必读:用户故事、UML 类图/时序图、C4 模型、4+1 视图
- 选读:领域驱动设计的战略建模
- 进阶:C4 与 4+1 视图的映射关系
本章不会要求你掌握
- UML 的全部 13 种图
- 完整的需求工程方法论
破局 · 溯源
客户说"我要一个比武擂台系统"。你坐在屏幕前,脑子里浮现的是《街头霸王》——血条、连招、角色选择界面。但你错了。客户说的是:"我在工匠之都开了一个擂台,两个冒险者报名,系统随机分配对战,记录胜负积分。"
"擂台系统"这个短语在不同的脑子里映射出完全不同的东西。你需要一个工具来缩小这个差距。
第一层:用户故事——说人话的需求
用户故事不是技术文档。它是一句自然语言的填空:
作为 <角色>,我想要 <功能>,以便 <价值>。这个格式的约束力在哪儿?它强制你从用户视角而不是系统视角想需求。"我要一个积分排行榜"是系统视角。"作为冒险者,我想看到我在擂台的排名,以便知道自己距离最强还有多远"是用户视角——而且暴露了隐藏细节:排名需要更新、需要显示差距、可能还需要分榜。
你的擂台系统的第一版用户故事:
故事 1:作为报名者,我想要报名参加擂台赛,以便和其他冒险者对战。
故事 2:作为报名者,我想要查看我的对战记录,以便回顾输在哪里。
故事 3:作为观众,我想要看到正在进行的对战直播,以便为支持的选手加油。
故事 4:作为擂台管理员,我想要每日生成积分榜,以便激励冒险者持续参赛。
故事 5:作为擂台管理员,我想要禁用违规的冒险者,以维持比赛公平。写完之后,你还会加验收条件(Acceptance Criteria):
故事 1 验收条件:
- 冒险者必须年满 18 级才能报名
- 报名成功后收到确认消息
- 同一冒险者不能同时报名多场擂台赛(防重复报名)验收条件的格式是Given-When-Then:
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() |
+------------------+// 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):关键用例,把前四个视图串起来
大多数程序员只关注逻辑视图(类怎么设计)和开发视图(项目怎么分层)。但部署视图和进程视图在分布式场景下极其关键。
部署视图示例(擂台系统):
+---------------------------+
| 负载均衡器 (Nginx) |
+---------------------------+
|
+------+------+
| |
+---------+ +---------+
| API 节点1| | API 节点2| ← 无状态,水平扩展
+---------+ +---------+
| |
+------+------+
|
+-------------------+
| PostgreSQL 主库 |
| | |
| PostgreSQL 从库 |
+-------------------+# 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 视图提醒你不要只盯着逻辑看漏了部署和进程——造软件的工序,和造城堡一样,先画图再动工。
下一站预告
有了蓝图,你开始思考一个更实际的问题:你怎么知道你的城堡是按照图纸建的?下一章——测试策略。