软件分析与设计笔记 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 篇刻意做到这一点),所以这一节用一个补充案例来讲 —— 假设「工单」后来分化出了两种:普通维修单与保修单,前者要收费,后者免费但需要保修凭证。

java
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) 标记这条记录属于哪个子类。

sql
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)

只给能实例化的类建表,父类字段在每张表里重复出现。

sql
repair_order(order_no, status, fee)
warranty_order(order_no, status, warranty_no)
  • 优点:查询单个子类最快;
  • 缺点:父类字段重复,改一处要改多张表。

本例的问题:想查「所有未完成的工单」(不管哪种),必须 UNION 两张表。而经理看板恰好就要这个。

2.3 每个类各一张表(class per table)

父类一张表,每个子类再各建一张,主键与父类共用。

sql
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 五步清单

把这一篇收成一串动作:

  1. 把分析模型里的实体类映射成表(含字段类型、长度、外键);
  2. 决定继承怎么映射(本例没有继承,跳过;有的话按 §2.4 的两个变量选);
  3. 决定数据访问层怎么划分(broker 的数量与命名,外部接口单独一个);
  4. 把分析阶段的顺序图按设计模型重画一遍(这时候要体现访问层);
  5. 标注持久化(persistence)相关的规则:级联、删除策略、事务边界。

5.2 两张顺序图本来就不一样

第 4 步值得单独说:同一张用例的顺序图,在分析阶段和设计阶段本来就不一样。

同样的「申请配件」,两张图的差别在于:

分析阶段设计阶段
生命线:Engineer / :PartsForm / :RequestPartsController / :StockItem / :SupplierAPI多了 :StockItemDB、:IssueNoteDB、:WorkOrderDB
消息 3:RequestPartsController → :StockItem.getStock():RequestPartsController → :StockItemDB.findByPartNo()
事务不画要标出「生成出库单 + 扣库存 + 写工单」在同一事务内

同一用例在分析阶段与设计阶段的两张顺序图对照:左边的分析模型有四条生命线,控制器直接调用库存项;右边的设计模型多出三个数据代理生命线(StockItemDB、IssueNoteDB、WorkOrderDB),控制器改为调用代理方法,并把生成出库单、扣库存、写工单三步框在同一事务边界内。底部说明两张图必须不一样,一样说明设计阶段该做的事没做

如果两张图完全一样,说明设计阶段该做的事没做。反过来,如果设计阶段的图比分析阶段复杂很多倍,也可能是过度设计(比如为每个字段都加一个 getter 消息)。

这张对照图里最值得盯的是右下那个粉红色虚框 —— 它是事务边界,分析阶段根本画不出来。因为分析模型只回答「谁调用谁」,不回答「哪些步骤必须一起成功或一起失败」。

5.3 事务边界:分析阶段完全看不到的东西

第 5 步里「事务边界」这一项在本例特别关键。回到 08 篇那个活动图:「有库存 → 生成出库单 → 接口减库存 → 写工单」,这三件事必须一起成功或一起失败。

如果它们不在同一事务里,会出现「出库单生成了但库存没减」或「库存减了但工单没记录」这类数据不一致。而这类问题在分析模型里根本看不出来 —— 分析模型只画了「谁调用谁」,没画「哪些步骤是一个整体」。

这就是设计建模不可替代的地方:它要补上「原子性」这类只有落地时才需要考虑的东西。

本节自测

  1. 分析建模和设计建模最大的区别是什么?
  2. 为什么「组合」映射到关系模型后,语义会丢失?要靠什么补回来?
  3. 继承映射的三种方案里,哪一种会导致表里出现大量空字段?为什么?
  4. 数据访问层解决了哪三个问题?
  5. MVC 和三层架构的关系是什么?把它们当成一回事会导致什么问题?
  6. 本例为什么给「供应商库存接口」单独建一个类,而不是塞进 StockItemDB?
  7. 本例里 part_usage.work_order_id 与 part_usage.part_no 都是外键,为什么必须区别对待?
  8. 为什么说「设计阶段的事务边界,分析阶段完全看不出来」?

参考答案

  1. 分析建模只分职责、不碰平台(不讨论数据库、框架);设计建模必须落到表、访问层、框架,产出可执行(§0)。
  2. 因为数据库只有外键,没有整体—部分语义。强弱差别要靠代码或约束补回来,所以设计文档里必须写明「作废时保留记录」这类规则(§1.2)。
  3. 单表方案(single table)。因为子类专有字段在同一张表里必须允许为空 —— 本例保修单的 fee 永远为 NULL,维修单的 warranty_no 永远为 NULL,而「保修单必须有凭证」无法用 NOT NULL 表达(§2.1)。
  4. ①换数据库不用改业务类;②业务逻辑不与 SQL 混在一起(可脱离真实库测试);③同一条查询不会在多处各写一遍(§3.1)。
  5. MVC 组织表现层,三层架构描述整个系统,两者不冲突。当成一回事会导致业务逻辑写进(表现层的)Controller。本例:RequestPartsController 属于业务层,与界面的 Controller 不是一回事(§4.2)。
  6. 因为它的失败模式与数据库完全不同(网络超时、限流、格式变化)。混在一起就无法区分「数据库异常」与「接口超时」,而 15 篇的降级策略要求区分它们。这是「按变化原因划分」(§3.3)。
  7. 因为语义不同:work_order_id 是归属(工单作废它跟着失效),part_no 是引用(库存项变化不该影响历史记录)。若都加级联删除,历史工单的配件记录会消失,而那是结算依据(§1.2)。
  8. 因为分析模型只画「谁调用谁」,不画「哪些步骤必须一起成功或一起失败」。本例「生成出库单 + 减库存 + 写工单」必须同事务,否则出现数据不一致(§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面向对象分析设计工作流从需求到设计的完整工作流

© 2026 HuanYu Blog

记录技术,也记录思考