第九章 设计先行
一
王志远犯了一个大错。
不是代码层面的错——代码他写得越来越好了,测试覆盖率也上去了。这次的错,是在更高的层面上。
事情是这样的:产品经理李想提了一个新需求——给系统加一个"消息通知中心"。用户在系统里的各种操作(下单、退款、审批等)都会产生通知,用户可以在通知中心查看、标记已读、删除。
需求不算复杂,但也不算小。李想给了两个 Sprint 的时间,大约四周。
王志远拿到需求后,兴冲冲地打开了 IDE。
他觉得自己已经很有经验了——User Story 他会写,验收标准他会定,测试他会先写。他跟李想确认了需求细节,写了 User Story 和验收标准,然后就开始写代码了。
他从数据库表设计开始:一张 notification 表,字段有 id、user_id、type、content、is_read、created_at。然后写 DAO 层、Service 层、Controller 层,一路往上搭。
写了三天,基本功能跑通了——能创建通知、查询通知列表、标记已读。他觉得进度不错,按这个速度,两周就能搞定。
然后问题来了。
二
第四天,他开始做"通知发送"的功能——当用户下单时,系统自动给用户发一条通知。
他在 OrderService 的 createOrder 方法里加了一行代码:
notificationService.send(userId, "您的订单已创建成功");
跑了一下,没问题。
然后他去做退款通知。在 RefundService 的 processRefund 方法里加了一行:
notificationService.send(userId, "您的退款申请已提交");
也没问题。
然后是审批通知、库存预警通知、系统公告通知……
他发现自己在越来越多的 Service 里加 notificationService.send() 调用。每加一个,就要改一个 Service 的代码,就要加一个依赖注入,就要写一组新的测试。
更麻烦的是,不同类型的通知有不同的处理逻辑:
- 订单通知要实时推送(WebSocket)
- 退款通知要同时发站内信和短信
- 系统公告要群发给所有用户
- 库存预警只发给管理员
他的 NotificationService 越写越臃肿,里面塞满了 if-else:
public void send(Long userId, String content, NotificationType type) {
if (type == NotificationType.ORDER) {
// WebSocket 推送
} else if (type == NotificationType.REFUND) {
// 站内信 + 短信
} else if (type == NotificationType.ANNOUNCEMENT) {
// 群发
} else if (type == NotificationType.STOCK_ALERT) {
// 只发管理员
}
// 还有更多类型...
}
写到第二周,他发现自己陷入了泥潭。代码越来越乱,每加一个新的通知类型就要改好几个地方,测试也越来越难写。
他开始怀疑自己的设计有问题。
三
他去找赵北辰。
赵北辰听完他的描述,问了一个问题:"你在写代码之前,画过设计图吗?"
"没有。"王志远老实说。"我觉得需求不复杂,直接写就行了。"
赵北辰叹了口气:"你这毛病,十个程序员九个犯过——还没想清楚就开始敲代码了。"
他在白板上画了一条线:
需求 ──→ 设计 ──→ 编码 ──→ 测试
你跳过了这一步 ↑
"你直接从需求跳到了编码,跳过了设计。对于一个小功能,这样做问题不大。但对于一个有一定复杂度的模块,跳过设计就像盖楼不画图纸——盖到三楼发现承重墙的位置不对,只能拆了重来。"
"那我现在怎么办?"
"先停下来。不要再写代码了。"赵北辰说。"先画图。"
四
赵北辰教他画了两种图。
第一种是时序图(Sequence Diagram)——描述系统中各个组件之间的交互顺序。
"你先想一下,当用户下单时,通知是怎么产生和发送的?"
王志远想了想,在白板上画了一个流程:
用户 → OrderService → NotificationService → 数据库
→ WebSocket
→ 短信网关
赵北辰摇了摇头:"问题出在这儿——OrderService 直接调 NotificationService,俩人绑死了。将来要加个邮件通知?改 NotificationService。要加个评价事件?改业务 Service。改一个地方牵一串,越改越乱。"
他擦掉王志远的图,重新画了一个:
用户 → OrderService → 发布事件(OrderCreatedEvent)
│
▼
事件总线(EventBus)
│
┌─────┼─────┐
▼ ▼ ▼
站内信 WebSocket 短信
处理器 处理器 处理器
"用事件驱动的方式。OrderService 不直接调用 NotificationService,而是发布一个事件——'订单已创建'。通知模块监听这个事件,根据事件类型决定用什么渠道发送。"
"这么干的好处是:OrderService 不用管通知怎么发,通知模块也不用管事件是谁产的。两边互不认识,靠事件传话。将来加新渠道?加个处理器就行。加新事件?发布一下就完了。已有的代码一行不用动。"
王志远盯着白板上的图,突然想起了苏薇教他的 SOLID 原则——这不就是开闭原则吗?对扩展开放,对修改关闭。
五
第二种图是类图(Class Diagram)——描述系统中各个类之间的关系。
赵北辰在白板上画了一个简化的类图:
┌─────────────────┐
│ <<interface>> │
│ NotificationChannel │
├─────────────────┤
│ + send(message) │
└────────┬────────┘
│ implements
┌────┼────┬────────────┐
▼ ▼ ▼ ▼
站内信 WebSocket 短信 邮件
Channel Channel Channel Channel
┌──────────────────┐
│ NotificationRouter│ ←── 根据事件类型选择渠道
├──────────────────┤
│ - channels: Map │
│ + route(event) │
└──────────────────┘
┌──────────────────┐
│ <<interface>> │
│ DomainEvent │
├──────────────────┤
│ + getType() │
│ + getUserId() │
│ + getPayload() │
└────────┬─────────┘
│ implements
┌────┼────┬──────────┐
▼ ▼ ▼ ▼
Order Refund Approval Stock
Created Submitted Completed Alert
Event Event Event Event
"你看,通知渠道是一个接口 NotificationChannel,每种渠道(站内信、WebSocket、短信、邮件)是一个实现类。将来加新渠道,只需要加一个新的实现类,不需要改已有的代码。"
"业务事件也是一个接口 DomainEvent,每种事件是一个实现类。NotificationRouter 根据事件类型选择对应的渠道发送。"
"这就是面向接口编程——依赖抽象,不依赖具体实现。"
王志远看着白板上的类图,觉得整个系统的结构一下子清晰了。之前他写的那堆 if-else,本质上就是因为没有用接口和多态来处理不同的通知类型。
六
"但画图只是设计的一部分。"赵北辰说。"你还需要写一份设计文档。"
他给王志远看了一个设计文档的模板:
# 消息通知中心 - 技术设计文档
## 1. 背景与目标
- 为什么要做这个功能?
- 要解决什么问题?
- 成功标准是什么?
## 2. 整体架构
- 架构图(组件之间的关系)
- 核心流程(时序图)
## 3. 数据模型
- 数据库表设计(ER 图)
- 关键字段说明
## 4. 接口设计
- API 列表(RESTful 接口)
- 请求/响应格式
- 错误码定义
## 5. 技术方案
- 关键技术选型及理由
- 事件驱动方案详细设计
- 消息队列选型(如果需要)
## 6. 非功能性需求
- 性能要求(QPS、延迟)
- 可用性要求
- 数据一致性策略
## 7. 风险与应对
- 已知风险及应对方案
- 技术债务
## 8. 里程碑
- 分阶段交付计划
"设计文档不需要写得很长,但每一节都要想清楚。尤其是第 5 节'技术方案'和第 7 节'风险与应对'——这两节是最容易被忽略的,也是最重要的。"
"你之前直接开始写代码,就是因为没有想清楚第 5 节。如果你在写代码之前就画了时序图和类图,就会发现'直接调用'的方案有耦合问题,就会提前选择事件驱动的方案。"
七
王志远花了两天时间写设计文档。
他画了时序图、类图、ER 图,写了 API 设计、技术选型、风险分析。写的过程中,他发现了好几个之前没想到的问题:
- 通知的存储——如果每条通知都存数据库,数据量会很大。需要考虑定期清理或者归档策略。
- 群发通知——系统公告要发给所有用户,如果用户有十万个,就要插入十万条记录。需要用异步批量处理。
- 通知的幂等性——如果事件被重复消费,同一条通知可能会发两次。需要加幂等校验。
这些问题如果在编码阶段才发现,改起来的成本会大得多。
他把设计文档发给赵北辰 Review。赵北辰看了之后,提了几条意见:
- 事件总线建议用 Spring 的
ApplicationEventPublisher,轻量级,不需要引入外部消息队列。如果将来需要跨服务通信,再升级到 Kafka 或 RabbitMQ。 - 通知存储建议加一个
expired_at字段,配合定时任务清理过期通知。 - 群发通知不要一次性插入十万条,用分批插入 + 异步处理。
王志远根据意见修改了设计文档,然后在组内做了一次设计评审。苏薇、陈锐、马德华都参加了。
苏薇提了一个好问题:"如果 WebSocket 连接断了,实时推送的通知怎么办?"
王志远想了想:"可以加一个降级策略——WebSocket 推送失败时,自动降级为站内信。用户下次打开页面时会看到未读通知。"
苏薇点了点头:"可以。记得在设计文档里补上这个降级策略。"
设计评审通过后,王志远才开始写代码。
八
这次的编码过程顺利得多。
因为设计已经想清楚了,他不需要在写代码的时候纠结架构问题。时序图告诉他各个组件之间怎么交互,类图告诉他需要哪些类和接口,API 设计告诉他每个接口的输入输出。
他按照设计文档,一步一步地实现:
- 先定义
DomainEvent接口和各种事件类 - 再定义
NotificationChannel接口和各种渠道实现 - 然后实现
NotificationRouter,根据事件类型路由到对应的渠道 - 最后在各个业务 Service 里发布事件
整个过程就像按照图纸盖楼——每一块砖放在哪里,事先都规划好了。
两周后,消息通知中心完成了。代码结构清晰,测试覆盖率 88%,所有验收标准全部通过。
更重要的是,当李想在验收后临时加了一个需求——"加一个邮件通知渠道"——王志远只用了半天就搞定了。因为他只需要新建一个 EmailChannel 类,实现 NotificationChannel 接口,然后在配置里注册一下就行了。不需要改任何已有的代码。
这就是好设计的力量——扩展新功能的成本极低。
九
李想验收完之后,感慨了一句:"这次做得很顺利啊,比上次导出功能那次好多了。"
王志远笑了笑:"因为这次我先画了图。"
"画图?"
"设计图。时序图、类图。先想清楚怎么做,再动手。"
李想想了想:"这就像我们做产品之前先画原型图一样?"
"差不多。你们画原型图是为了在写代码之前就确认交互逻辑,我们画设计图是为了在写代码之前就确认技术架构。道理是一样的——先想清楚,再动手。"
李想点了点头:"有道理。以后你们的设计文档能不能也给我看看?我虽然看不懂技术细节,但至少能了解一下整体方案。"
"当然可以。"
这是王志远第一次觉得自己和产品经理之间有了一种"平等对话"的感觉。以前他觉得产品经理是"甲方",自己是"乙方",产品说什么他就做什么。现在他意识到,开发和产品是合作关系——产品定义"做什么",开发定义"怎么做",两者缺一不可。
十
那天晚上,王志远看了一眼桌上的《重构》。旁边又多了一本新书——赵北辰借给他的《设计模式:可复用面向对象软件的基础》。
他翻开第一页,看到了一句话:
"针对接口编程,而不是针对实现编程。"
他笑了。两周前他还在盖楼不画图纸,盖到三楼才发现承重墙不对。现在他终于明白了——写代码之前多花一天做设计,写代码的时候能省三天。
说白了,磨刀不误砍柴工。目的无他,先想清楚再动手而已。
:::{admonition} 小胖的笔记 :class: tip
本章知识卡片:设计先行
1. 开发流程中设计的位置
需求分析 ──→ 设计 ──→ 编码 ──→ 测试 ──→ 部署
↑
不要跳过这一步!
什么时候需要设计? - 功能涉及多个模块的交互 - 需要新建数据库表 - 开发周期超过一周 - 有性能、并发、可扩展性要求
2. 常用 UML 图
| 图类型 | 用途 | 什么时候画 |
|---|---|---|
| 时序图 | 描述组件间的交互顺序 | 涉及多个服务/模块交互时 |
| 类图 | 描述类的结构和关系 | 设计核心领域模型时 |
| ER 图 | 描述数据库表结构和关系 | 涉及数据库设计时 |
| 流程图 | 描述业务流程 | 复杂业务逻辑时 |
| 组件图 | 描述系统的模块划分 | 系统架构设计时 |
3. 设计文档模板
1. 背景与目标(为什么做?成功标准?)
2. 整体架构(架构图 + 核心流程)
3. 数据模型(表设计 + ER 图)
4. 接口设计(API + 请求/响应格式)
5. 技术方案(选型理由 + 详细设计)
6. 非功能性需求(性能、可用性、一致性)
7. 风险与应对
8. 里程碑(分阶段交付)
4. 事件驱动架构
传统方式(紧耦合):
OrderService → NotificationService → 短信/邮件/WebSocket
事件驱动(松耦合):
OrderService → 发布事件 → EventBus → 各渠道处理器
优点: - 发布者和消费者解耦 - 加新渠道只需加新处理器 - 加新事件只需发布新事件 - 符合开闭原则
Spring 实现:
// 发布事件
applicationEventPublisher.publishEvent(new OrderCreatedEvent(orderId));
// 监听事件
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// 发送通知
}
5. 设计评审检查清单
| # | 检查项 |
|---|---|
| 1 | 架构是否清晰?组件职责是否明确? |
| 2 | 组件之间是否松耦合? |
| 3 | 是否考虑了扩展性?加新功能的成本高吗? |
| 4 | 是否考虑了异常和降级? |
| 5 | 是否考虑了性能和并发? |
| 6 | 数据模型是否合理?有没有冗余或遗漏? |
| 7 | API 设计是否符合 RESTful 规范? |
| 8 | 是否有已知风险?应对方案是什么? |
核心原则:写代码之前多花一天做设计,写代码的时候能省三天。
:::