第九章 设计先行

一

王志远犯了一个大错。

不是代码层面的错——代码他写得越来越好了,测试覆盖率也上去了。这次的错,是在更高的层面上。

事情是这样的:产品经理李想提了一个新需求——给系统加一个"消息通知中心"。用户在系统里的各种操作(下单、退款、审批等)都会产生通知,用户可以在通知中心查看、标记已读、删除。

需求不算复杂,但也不算小。李想给了两个 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 设计、技术选型、风险分析。写的过程中,他发现了好几个之前没想到的问题:

  1. 通知的存储——如果每条通知都存数据库,数据量会很大。需要考虑定期清理或者归档策略。
  2. 群发通知——系统公告要发给所有用户,如果用户有十万个,就要插入十万条记录。需要用异步批量处理。
  3. 通知的幂等性——如果事件被重复消费,同一条通知可能会发两次。需要加幂等校验。

这些问题如果在编码阶段才发现,改起来的成本会大得多。

他把设计文档发给赵北辰 Review。赵北辰看了之后,提了几条意见:

  1. 事件总线建议用 Spring 的 ApplicationEventPublisher,轻量级,不需要引入外部消息队列。如果将来需要跨服务通信,再升级到 Kafka 或 RabbitMQ。
  2. 通知存储建议加一个 expired_at 字段,配合定时任务清理过期通知。
  3. 群发通知不要一次性插入十万条,用分批插入 + 异步处理。

王志远根据意见修改了设计文档,然后在组内做了一次设计评审。苏薇、陈锐、马德华都参加了。

苏薇提了一个好问题:"如果 WebSocket 连接断了,实时推送的通知怎么办?"

王志远想了想:"可以加一个降级策略——WebSocket 推送失败时,自动降级为站内信。用户下次打开页面时会看到未读通知。"

苏薇点了点头:"可以。记得在设计文档里补上这个降级策略。"

设计评审通过后,王志远才开始写代码。

八

这次的编码过程顺利得多。

因为设计已经想清楚了,他不需要在写代码的时候纠结架构问题。时序图告诉他各个组件之间怎么交互,类图告诉他需要哪些类和接口,API 设计告诉他每个接口的输入输出。

他按照设计文档,一步一步地实现:

  1. 先定义 DomainEvent 接口和各种事件类
  2. 再定义 NotificationChannel 接口和各种渠道实现
  3. 然后实现 NotificationRouter,根据事件类型路由到对应的渠道
  4. 最后在各个业务 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 是否有已知风险?应对方案是什么?

核心原则:写代码之前多花一天做设计,写代码的时候能省三天。

:::