软件分析与设计笔记 23:设计建模:从对象模型到能实现的方案
分析模型能用但还不能直接开工:对象模型怎么映射到关系模型、继承映射的三种方案怎么选、数据访问层与 Data Broker 解决什么问题,以及三层架构与 MVC 的分工。
本文是 《软件分析与设计笔记》 系列的第 23 篇,共 28 篇
这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的。理解有偏差的地方欢迎指出。
贯穿案例:片区家电维修派单系统。22 篇产出了分析模型,这一篇把它落到表、访问层与架构上。
0 分析模型能用,但还不能直接开工
有了分析模型,团队已经知道「每个用例由哪些对象协作完成」。但开发打开 IDE 还是会问:
WorkOrder和PartUsage怎么存?组合关系落成外键还是要级联删除?- 对象自己去连数据库吗?
RequestPartsController里直接写 SQL 行不行? - 界面、业务、数据这三层怎么分?
设计建模(design modelling) 补的就是这一层。它和分析建模的分工是:
| 分析建模 | 设计建模 | |
|---|---|---|
| 关注 | 职责怎么分 | 怎么落到平台与数据上 |
| 是否考虑数据库 | 不考虑 | 必须考虑 |
| 是否考虑框架 | 不考虑 | 必须考虑 |
| 是否可执行 | 不是 | 是 |
一句话:分析模型是「想清楚」,设计模型是「能开工」。
设计建模是需求驱动的(requirements-driven design):每一个设计决定都应该能指回某条需求或某个业务规则,否则它只是一个技术偏好。它和分析建模合在一起,构成面向对象的分析设计工作流(OOAD workflow)。
本例里有个很能说明问题的对比:分析模型说「:WorkOrder 与 :PartUsage 是组合」;设计模型要回答「那删除工单时,配件消耗记录是级联删还是保留?」—— 而这个问题其实是业务问题:工单不能删只能作废(12 篇讨论出的结论),所以 ON DELETE CASCADE 根本用不上,实际要写的是「作废时保留所有记录」。
注意这里发生了什么:一个看似技术性的外键选择,最后是由业务规则决定的。这就是「需求驱动的设计」的意思。
1 对象模型 → 关系模型
1.1 映射规则
从面向对象的模型映射到关系模型,大部分对应关系是直接的:
| 对象模型(object model) | 关系模型(data model) | 本例 |
|---|---|---|
| 类 | 表 | WorkOrder → work_order |
| 属性 | 列 | status → status 列(用枚举或码表) |
| 关联(1 对多) | 「多」的一端加外键 | work_order.customer_id |
| 聚合 / 组合 | 外键(层级深浅靠级联删除表达) | part_usage.work_order_id |
| 继承 | 三种方案,见下节 | 本例没有继承 |
1.2 数据库没有「整体—部分」和「继承」
这里有件事值得注意:数据库本身没有「整体—部分」和「继承」的概念。你在类图里画的实心菱形,落到表上就只是一个外键 —— 强弱语义丢失了。这部分语义必须靠代码或约束去补,所以设计模型里通常要注明「删除订单时级联删除明细」这类规则。
本例里有个具体的例子能说明「语义丢失」的代价::PartUsage 有个 partNo 引用 :StockItem。如果只看表结构,part_usage.part_no 和 part_usage.work_order_id 长得一模一样,都是外键。但语义完全不同:
work_order_id是「归属」,工单作废它跟着失效;part_no是「引用」,库存项消失不该影响历史记录的准确性。
如果一个开发把两者当成同一类外键处理(都加级联删除),历史工单的配件记录就会凭空消失 —— 而那是结算依据。所以设计文档必须显式写出这两者的差别,不能指望从表结构里看出来。
2 继承映射(inheritance mapping)的三种方案
继承是唯一需要做选择的映射,三种方案各有代价。
本例里的领域模型没有继承(09 篇刻意做到这一点),所以这一节用一个补充案例来讲 —— 假设「工单」后来分化出了两种:普通维修单与保修单,前者要收费,后者免费但需要保修凭证。
abstract class WorkOrder { String orderNo; String status; }
class RepairOrder extends WorkOrder { double fee; }
class WarrantyOrder extends WorkOrder { String warrantyNo; }
2.1 所有类一张表(single table)
父类子类字段放同一张表,加一个区分列(discriminator column) 标记这条记录属于哪个子类。
work_order(order_no, status, order_type, fee, warranty_no)
-- order_type: 'REPAIR' | 'WARRANTY'
- 优点:表少,查任何一个子类都不用连接;
- 缺点:子类专有字段必须允许为空,表会越来越宽,约束难加。
本例的代价很具体:fee 对保修单永远为 NULL,warranty_no 对维修单永远为 NULL。而「保修单必须有保修凭证」这条约束,在单表里没法用 NOT NULL 表达 —— 只能靠代码校验。
2.2 每个具体类一张表(concrete class per table)
只给能实例化的类建表,父类字段在每张表里重复出现。
repair_order(order_no, status, fee)
warranty_order(order_no, status, warranty_no)
- 优点:查询单个子类最快;
- 缺点:父类字段重复,改一处要改多张表。
本例的问题:想查「所有未完成的工单」(不管哪种),必须 UNION 两张表。而经理看板恰好就要这个。
2.3 每个类各一张表(class per table)
父类一张表,每个子类再各建一张,主键与父类共用。
work_order(order_no, status)
repair_order(order_no, fee)
warranty_order(order_no, warranty_no)
- 优点:最贴近面向对象的模型,没有冗余;
- 缺点:查一个子类要连接多张表,层级越深越麻烦。
本例的代价:查「所有未完成工单」要 join 三张表;查一条维修单要 join 两张。
2.4 怎么选
怎么选?看两个变量:子类之间的差异有多大、平时怎么查。
| 方案 | 什么时候选 | 本例的判断 |
|---|---|---|
| ① 单表 | 子类差异小、经常需要一起查 | 本例的看板要一起查,差异也不大 → 选它 |
| ② 具体类建表 | 子类差异大、很少一起查 | 差异大但本例要一起查 → 不适合 |
| ③ 每类建表 | 层次固定、对约束要求严格 | 约束要求不高 → 过度设计 |
本例的结论是方案 ①,理由是看板那个查询。这个理由能指回需求(经理要一屏看全部工单),所以它是个需求驱动的决定,不是技术偏好。
代价也要写清:fee 与 warranty_no 允许为空,所以「保修单必须有凭证」这条规则要在业务层校验,并通过 order_type 上加 CHECK 约束尽量兜住。
3 数据访问层(data access layer)
3.1 为什么需要单独一层
分析模型里,「按配件查库存」这个操作由 :StockItem.getStock() 负责。但在设计阶段要回答:它怎么真的读到数据?
最省事的做法是让实体对象自己连数据库。但这样会带来三个问题:
- 换数据库要改业务类;
- 业务逻辑和 SQL 混在一起,测试必须依赖真实数据库;
- 同一条查询在不同地方各写一遍。
所以通常引入一层数据访问层(data access layer):业务对象只调用「查一个配件的库存」这样的方法,不关心底层是 SQL、缓存还是远程接口。
3.2 数据代理与命名约定
这一层的具体实现形式就是数据代理(data broker):每个实体对应一个 broker,负责它的增删改查。
broker 怎么划分有两种做法:
- 每个实体一个 broker(
WorkOrderDB、StockItemDB…)—— 划分清楚,但类数量会变多; - 少量通用 broker —— 类少,但责任模糊,容易变成万能类。
命名上通常约定用实体名加后缀(例如 WorkOrderDB),这样一眼能看出它和哪个实体对应 —— 这就是命名约定(naming convention) 的作用。
本例选第一种,因为实体只有四五个,而且 StockItemDB 里会有「按配件号查」「按片区查缺货」这类差别很大的查询 —— 塞进一个通用 broker 里会很难读。
3.3 一个值得单独处理的例外
本例里有个不属于任何实体的数据来源:供应商库存接口。它既不是表,也不该由 StockItemDB 负责。
做法是给它单独一个 broker 风格的类(SupplierGateway),理由是:它的失败模式与数据库完全不同(网络超时、限流、返回格式变化)。混在一起会让「数据库异常」和「外部接口超时」这两种情况无法区分处理 —— 而 15 篇的降级策略恰恰要求区分它们。
这是「按变化原因划分」的一个具体应用:会因为不同原因变化的东西,不该放在同一个类里。
4 三层架构与 MVC
4.1 三层架构
再往上一层,是整个系统的分层。三层架构(3-tier architecture) 把系统分成:
| 层 | 职责 | 本例里对应 |
|---|---|---|
| 表现层(presentation layer) | 界面与交互 | 工程师的申请表单、客服的派单台 |
| 业务层(business layer) | 业务规则与流程 | RequestPartsController + 各实体 |
| 数据层(data layer) | 数据的存取 | WorkOrderDB、StockItemDB、SupplierGateway |
4.2 MVC 是另一种切法
MVC(Model-View-Controller) 是另一种切法:它把注意力放在「界面这一层内部」——View 负责展示、Controller 接收请求、Model 承载数据。
两者不冲突:MVC 通常用来组织表现层,三层架构描述的是整个系统。把 MVC 当成整体架构来套,会出现「业务逻辑写在 Controller 里」这类典型问题。
本例里这个坑很容易踩:如果只有一个 MVC,那么「申请配件」的业务规则很可能被写进 PartsController(MVC 的 Controller)。但它不是表现层的控制器 —— 它应该属于业务层,命名上是 RequestPartsController,跟界面的 Controller 是两个东西。
命名撞车是这个混淆最常见的来源。建议团队约定:业务层的叫 XxxService 或 XxxController 时,表现层的就叫 XxxPageController 或 XxxAction —— 至少不要两者同名。
5 设计建模的工作清单
5.1 五步清单
把这一篇收成一串动作:
- 把分析模型里的实体类映射成表(含字段类型、长度、外键);
- 决定继承怎么映射(本例没有继承,跳过;有的话按 §2.4 的两个变量选);
- 决定数据访问层怎么划分(broker 的数量与命名,外部接口单独一个);
- 把分析阶段的顺序图按设计模型重画一遍(这时候要体现访问层);
- 标注持久化(persistence)相关的规则:级联、删除策略、事务边界。
5.2 两张顺序图本来就不一样
第 4 步值得单独说:同一张用例的顺序图,在分析阶段和设计阶段本来就不一样。
同样的「申请配件」,两张图的差别在于:
| 分析阶段 | 设计阶段 | |
|---|---|---|
| 生命线 | :Engineer / :PartsForm / :RequestPartsController / :StockItem / :SupplierAPI | 多了 :StockItemDB、:IssueNoteDB、:WorkOrderDB |
| 消息 3 | :RequestPartsController → :StockItem.getStock() | :RequestPartsController → :StockItemDB.findByPartNo() |
| 事务 | 不画 | 要标出「生成出库单 + 扣库存 + 写工单」在同一事务内 |
如果两张图完全一样,说明设计阶段该做的事没做。反过来,如果设计阶段的图比分析阶段复杂很多倍,也可能是过度设计(比如为每个字段都加一个 getter 消息)。
这张对照图里最值得盯的是右下那个粉红色虚框 —— 它是事务边界,分析阶段根本画不出来。因为分析模型只回答「谁调用谁」,不回答「哪些步骤必须一起成功或一起失败」。
5.3 事务边界:分析阶段完全看不到的东西
第 5 步里「事务边界」这一项在本例特别关键。回到 08 篇那个活动图:「有库存 → 生成出库单 → 接口减库存 → 写工单」,这三件事必须一起成功或一起失败。
如果它们不在同一事务里,会出现「出库单生成了但库存没减」或「库存减了但工单没记录」这类数据不一致。而这类问题在分析模型里根本看不出来 —— 分析模型只画了「谁调用谁」,没画「哪些步骤是一个整体」。
这就是设计建模不可替代的地方:它要补上「原子性」这类只有落地时才需要考虑的东西。
本节自测
- 分析建模和设计建模最大的区别是什么?
- 为什么「组合」映射到关系模型后,语义会丢失?要靠什么补回来?
- 继承映射的三种方案里,哪一种会导致表里出现大量空字段?为什么?
- 数据访问层解决了哪三个问题?
- MVC 和三层架构的关系是什么?把它们当成一回事会导致什么问题?
- 本例为什么给「供应商库存接口」单独建一个类,而不是塞进
StockItemDB? - 本例里
part_usage.work_order_id与part_usage.part_no都是外键,为什么必须区别对待? - 为什么说「设计阶段的事务边界,分析阶段完全看不出来」?
参考答案
- 分析建模只分职责、不碰平台(不讨论数据库、框架);设计建模必须落到表、访问层、框架,产出可执行(§0)。
- 因为数据库只有外键,没有整体—部分语义。强弱差别要靠代码或约束补回来,所以设计文档里必须写明「作废时保留记录」这类规则(§1.2)。
- 单表方案(single table)。因为子类专有字段在同一张表里必须允许为空 —— 本例保修单的
fee永远为 NULL,维修单的warranty_no永远为 NULL,而「保修单必须有凭证」无法用 NOT NULL 表达(§2.1)。 - ①换数据库不用改业务类;②业务逻辑不与 SQL 混在一起(可脱离真实库测试);③同一条查询不会在多处各写一遍(§3.1)。
- MVC 组织表现层,三层架构描述整个系统,两者不冲突。当成一回事会导致业务逻辑写进(表现层的)Controller。本例:
RequestPartsController属于业务层,与界面的 Controller 不是一回事(§4.2)。 - 因为它的失败模式与数据库完全不同(网络超时、限流、格式变化)。混在一起就无法区分「数据库异常」与「接口超时」,而 15 篇的降级策略要求区分它们。这是「按变化原因划分」(§3.3)。
- 因为语义不同:
work_order_id是归属(工单作废它跟着失效),part_no是引用(库存项变化不该影响历史记录)。若都加级联删除,历史工单的配件记录会消失,而那是结算依据(§1.2)。 - 因为分析模型只画「谁调用谁」,不画「哪些步骤必须一起成功或一起失败」。本例「生成出库单 + 减库存 + 写工单」必须同事务,否则出现数据不一致(§5.3)。
术语表
| 术语 | 中文 | 一句话解释 |
|---|---|---|
| design modelling | 设计建模 | 把分析模型落到平台与数据上 |
| requirements-driven design | 需求驱动的设计 | 设计的依据来自需求而不是技术偏好 |
| object model | 对象模型 | 用对象与关系描述系统的模型 |
| data model | 数据模型 | 描述数据表、字段与关系的模型 |
| inheritance mapping | 继承映射 | 把类的继承关系落成表结构的做法 |
| single table | 单表方案 | 所有子类共用一张表,用区分列标记类型 |
| concrete class per table | 具体类建表 | 只给可实例化的类建表 |
| class per table | 每类建表 | 父类子类各建一张表,主键共用 |
| discriminator column | 区分列 | 单表方案里用来标记记录类型的字段 |
| data access layer | 数据访问层 | 专门负责读写数据的一层 |
| data broker | 数据代理 | 数据访问层里负责某个实体读写的对象 |
| persistence | 持久化 | 把对象状态保存到存储中 |
| transaction boundary | 事务边界 | 哪些步骤必须一起成功或一起失败 |
| 3-tier architecture | 三层架构 | 表现层、业务层、数据层的划分 |
| presentation layer | 表现层 | 负责界面与交互的一层 |
| business layer | 业务层 | 承载业务规则与流程的一层 |
| data layer | 数据层 | 负责数据存取的一层 |
| MVC | 模型—视图—控制器 | 组织界面层的常见模式 |
| naming convention | 命名约定 | 团队统一遵守的命名规则 |
| OOAD workflow | 面向对象分析设计工作流 | 从需求到设计的完整工作流 |