软件分析与设计笔记 09:功能需求(二):从流程到业务实体
活动图画完之后,怎么从流程里筛出真正的业务实体:三条判断标准、哪些名词其实不该建实体、属性从哪里来,以及领域模型和后面的数据模型分别负责什么。
本文是 《软件分析与设计笔记》 系列的第 9 篇,共 28 篇
这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的。理解有偏差的地方欢迎指出。
贯穿案例:片区家电维修派单系统。08 篇画完了用例图和活动图,这一篇从流程里抽出业务实体,产出领域模型 —— 它是 18 篇 ERD 的原料。
0 流程画对了,那系统要记住什么
08 篇那张「申请配件」的活动图画完,流程是通的。但开发会接着问:
「工程师提交申请的时候,系统要记住哪些信息?是记住「谁申请了什么」,还是记住「库存扣了多少」?」
这个问题已经不属于流程层了。它问的是:这个业务里有哪些东西需要被系统长期记住——也就是业务实体(business entity)。
1 从流程里找名词
起步方法很朴素:把活动图和用例描述里出现的名词圈出来。但圈出来的东西里,只有一部分是实体,其余要么是动作,要么是界面元素,要么是算出来的值。
拿 08 篇那张活动图圈一遍,会得到这样一堆词:
工程师、工单、备料申请、配件、库存、出库单、数量、
提交、查询、校验、待件挂起、安全库存、缺料率
这十几个词里,真正该建实体的只有五个左右。剩下的怎么筛,就是下面三条标准。
2 三条筛选标准
2.1 是业务概念,不是动作
「提交备料申请」是动作,「备料申请」才可能是概念。「查询库存」是动作,「库存项」才是概念。
上面那堆词里,提交、查询、校验、待件挂起全是动作或状态,先划掉。
有一个边界情况值得单独说:「备料申请」和「配件消耗」是不是同一个东西?
- 如果业务上要追踪「申请了几次、每次批了多少、被拒过没有」→ 申请本身是实体;
- 如果只是「这一次用了两个压缩机」,用完就完了 → 它就是工单上的一条配件消耗记录,不是独立实体。
判断依据是业务上要不要追踪它,而不是「它听起来像不像一个东西」。
2.2 系统需要长期记住它
只在一个界面里瞬间用一下的数据(比如当前选中的那一行、筛选条件「只看空调」),不需要建实体。判断依据是:下一次会话还能用得上吗?
这条标准在本例里能筛掉不少东西:
- 「当前筛选的片区」—— 不用记住,划掉;
- 「安全库存阈值」—— 要记住,而且每个配件不同,所以它是
StockItem上的一个属性,不是独立实体; - 「缺料率」—— 不用存,它是算出来的(见 §3)。
2.3 它有多个实例
只有一个的配置类对象(比如「系统设置」),通常不该建成实体,直接作为配置项或常量处理就好。
本例里有个现成的例子:「派单规则」(比如「同一工程师一天不超过 5 单」)。如果整套系统只有一条规则,那它是配置;但如果将来要按片区、按技能等级设不同的规则,那它就该是实体。
第 ③ 条有个常见的例外:即便当前只有一个实例(比如「公司」),如果业务上将来可能变成多个(多分公司),也值得建实体。判断标准是业务会不会扩张,不是现在有几个。
本例恰好就是这个情况:现在只有一个片区在试点,但王姐明确说了「跑通了要推到另外五个片区」。所以片区(District)就该建实体,尽管当前只有一个 —— 否则三个月后要改表结构。
3 哪些东西不该建实体
| 不该建实体的东西 | 本例中的例子 | 更好的处理方式 |
|---|---|---|
| 动作 | 提交申请、查询库存、派单 | 作为操作(操作记录可能才是实体) |
| 界面元素 | 派单按钮、筛选下拉框 | 属于界面设计,不属于领域模型 |
| 计算出来的值 | 缺料率、平均上门时长、完成率 | 由其他字段算出,不存储 |
| 只出现一次的配置 | 派单规则(如果全公司只有一条) | 配置项或枚举 |
| 外部系统的内部数据 | 供应商的仓库编号 | 只在必要处保存引用字段 |
「计算出来的值」这一条最容易出错。把「缺料率」存进实体,就意味着每次工单状态变动都要记得同步更新它 —— 一旦漏了,数据就永久不一致。能算出来的不要存(这也是后面讲规范化时反复出现的主题,见笔记 19)。
这里还有个反直觉的点:缺料率是考核王姐的 KPI,但它依然不该存。KPI 是报表层的事,存的是原始事实(每张工单有没有缺料),算的是展示层。把 KPI 存成字段,等于把「口径」焊死在数据里,以后想换算法就得改表。
4 属性从哪里来
4.1 属性来自「系统需要记住的信息」
实体确定了,属性(attribute)从哪里来?来自用例描述里那些「系统需要记住的信息」。
举一个具体做法:翻开「申请配件」这个用例的描述,每读到一句与数据有关的句子,就把它变成一个属性:
| 用例描述里的句子 | 变成的属性 |
|---|---|
| 「工程师选择工单,输入配件编号与数量」 | PartUsage.partNo、PartUsage.qty |
| 「系统校验工单状态是否为进行中」 | WorkOrder.status |
| 「系统根据配件编号查询当前库存」 | StockItem.onHandQty |
| 「系统调用供应商接口获取可售数量」 | 不建属性 —— 那是外部实时数据,用时去查 |
| 「若可满足,系统生成出库单,并扣减库存」 | IssueNote.issuedAt、StockItem.onHandQty(更新) |
| 「系统把申请记录写入工单」 | PartUsage.workOrderId |
注意第四行:外部系统的实时数据不落成属性。这条很容易被忽略 —— 有人会把「供应商可售量」也存一份,然后就永久不一致了。
4.2 每个属性都要能追溯
这样做的好处是每个属性都能追溯到一句需求。反过来,如果一个属性说不出它来自哪句话,多半是你自己加上去的(通常不是坏事,但要意识到这是你做的决定)。
本例里有个真实的例子:WorkOrder.priority(工单优先级)。用例描述里没提过,但如果你加上它,就得能说出理由 —— 「因为看板要用红黄绿灯排序,而排序需要一个优先级」。这个理由成立,但要写进假设里。
4.3 标识符
其中有些属性会承担特殊角色:能唯一区分同类实例的那个,叫标识符(identifier),例如 WorkOrder.orderNo。它后面会变成数据表里的主键(见笔记 18)。
本例里有个值得注意的细节:工单号该用什么?
- 自增数字(
1001、1002)—— 简单,但会暴露业务量; - 带日期前缀(
WO-20261119-001)—— 客服在电话里好念,客户也认; - UUID —— 不会冲突,但没法口头念。
这个选择看起来是技术细节,其实受业务影响(客服要在电话里报工单号)。这就是为什么标识符要在领域模型里就定下来。
5 领域模型(Domain Model)长什么样
5.1 三样东西
把这些实体和它们的关系画出来,就是领域模型,通常用类图(class diagram)表示:
- 实体类(entity class) —— 对应一个业务实体,用业务术语命名(
WorkOrder、Engineer),不要用技术名词(WorkOrderTable、WorkOrderDTO); - 属性 —— 业务关心的那些信息;
- 关系(relationship)与重数(multiplicity) —— 一个客户可以有多张工单(
1对*);一张工单可以有多条配件消耗(1对*);一条配件消耗只对应一个库存项(*对1)。
把这三样落到图上,就是本例的领域模型:
5.2 怎么读这张图
这张图里有四个地方值得逐一看,因为它们每一个都对应一条业务判断:
| 看什么 | 图上长什么样 | 背后的业务判断 |
|---|---|---|
| 类名 | WorkOrder 而不是 WORK_ORDER / WorkOrderDTO | 概念模型用业务术语,表名属于数据模型(§6) |
| 属性 | - status : WorkOrderStatus | 属性来自「系统需要记住的信息」(§4.1),且只对实体类定义 |
| 操作 | + canRequestParts() | 行为跟着数据走 —— 判断「什么状态允许申请」属于工单自己(笔记 20) |
| 关系 | 实心菱形 vs 普通实线 | 组合与关联的差别(见下) |
关系的两种画法在这一张图上就能对照出来,这是本例最值得讲的一处:
- 工单 ◆—— 配件消耗 是组合(实心菱形):配件消耗行离开工单没有意义,工单作废它跟着失效;
- 配件消耗 —— 库存项 只是关联(图上线名写「引用」):库存项独立存在,配件消耗只是记了个
partNo。
这两个如果都画成组合,实现的人会以为「删工单要删库存项」 —— 那是数据事故,因为库存项是独立资产。所以图上线名不是装饰,它承担了区分语义的作用。
还有一个细节:PartUsage.amount() 是操作,不是属性。因为金额能由单价 × 数量算出来 —— 能算出来的不要存(§4、笔记 19)。
5.3 重数要问业务方
重数是需要认真确认的地方,它直接决定后面数据表怎么建。「一张工单能有多条配件消耗吗」这种问题,只有业务方能回答,不要自己假设。
本例里有三个重数特别值得问清楚,因为它们都会改变表结构:
| 要确认的问题 | 如果答「是」 | 如果答「否」 |
|---|---|---|
| 一张工单能有多条配件消耗吗? | 需要独立的 PartUsage 表 | 可以直接放在工单上 |
| 一个客户能有多台机器吗? | 需要 Appliance 实体 | 机器信息直接写在工单上 |
| 一个工程师能同时属于多个片区吗? | 需要多对多关联表 | Engineer 上一个外键就够 |
第三个问题在本例里答案很可能是「能」—— 旺季时跨片区支援。这一个「能」字,就决定了要不要多一张关联表。
6 领域模型和数据模型不是一回事
这是最容易混淆的一点。领域模型(domain model)和数据模型(后面笔记 18–19 要讲的 ERD)关注的东西不同:
| 领域模型 | 数据模型 | |
|---|---|---|
| 关注 | 业务概念与它们的关系 | 数据怎么存、表怎么建 |
| 允许 | 继承、多态这类概念性表达 | 需要落到具体的表与字段 |
| 命名 | 业务术语 | 常常带类型后缀、缩写 |
| 变化频率 | 业务变它就变 | 随技术选型调整 |
领域模型是一种概念模型(conceptual model):它只描述业务概念,不涉及实现细节(implementation detail)。一个业务概念在数据库里可能拆成三张表,也可能被合并进别的表 —— 那是设计阶段的事。领域模型先把业务说清楚,暂不背实现的包袱(这一点在笔记 20–23 还会反复出现)。
本例里有个具体例子能说明差别:领域模型里 WorkOrder 和 PartUsage 是「整体—部分」关系,并且 PartUsage 随工单一起消亡。到了数据模型阶段,这条关系要落成「PartUsage 表带 workOrderId 外键,且 ON DELETE CASCADE」。领域模型说的是「概念上它依附于工单」,数据模型才决定「用外键还是别的机制实现」。
本节自测
- 「提交备料申请」和「备料申请」,哪个是实体?判断依据是什么?
- 为什么「缺料率」不该作为属性存储?它同时是 KPI,这有影响吗?
- 「目前只有一个片区」是否意味着不需要建片区实体?为什么?
- 领域模型的属性从哪里推导出来?举出本例中一个「不建属性」的情况。
- 领域模型和数据模型在「命名」上的差别是什么?为什么会有这个差别?
- 本例中「一个工程师能同时属于多个片区吗」这个问题,为什么值得单独问?
- 领域模型里,「工单与配件消耗」和「配件消耗与库存项」这两条线在本例中应该怎么画?画错的后果是什么?
参考答案
- 「备料申请」可能是实体,「提交」是动作。判断依据是业务上要不要追踪它 —— 如果要记「申请了几次、批了多少、被拒过没有」,它就是实体;如果只是「用了两个压缩机」这种一次性记录,那它就是工单上的一条配件消耗(§2.1)。
- 因为它是算出来的值,存进去就意味着每次状态变动都要同步更新,漏一次就永久不一致。它同时是 KPI 也不改变这个结论 —— KPI 是报表层的事,存原始事实、算展示值;把 KPI 存成字段等于把口径焊死在数据里(§3)。
- 不意味着。判断标准是业务会不会扩张,不是现在有几个。王姐明确说要推到另外五个片区,所以现在就该建实体,否则三个月后要改表(§2.3)。
- 属性来自用例描述里「系统需要记住的信息」。本例中「供应商可售数量」不建属性 —— 那是外部实时数据,用时去查,存下来就永久不一致(§4.1)。
- 领域模型用业务术语(
WorkOrder、PartUsage),数据模型常带类型后缀或缩写(t_work_order、wo_id)。差别来自关注点不同:领域模型给业务方看,数据模型给实现看(§6)。 - 因为它的答案直接决定表结构:答「能」就要多一张多对多关联表,答「不能」就只需
Engineer上一个外键。本例旺季会跨片区支援,所以答案很可能是「能」(§5.3)。 - 工单 ◆—— 配件消耗画成组合(实心菱形贴工单端,配件消耗离开工单没有意义);配件消耗 —— 库存项画成普通关联(库存项独立存在,只是被引用)。如果把后者也画成组合,实现的人会以为删工单要删库存项 —— 那是数据事故(§5.2)。
术语表
| 术语 | 中文 | 一句话解释 |
|---|---|---|
| business entity | 业务实体 | 业务中需要被系统长期记住的对象 |
| domain model | 领域模型 | 业务概念及它们之间关系的模型 |
| entity class | 实体类 | 领域模型里代表一个业务实体的类 |
| attribute | 属性 | 实体上需要记录的信息项 |
| relationship | 关系 | 两个实体之间的业务关联 |
| multiplicity | 重数 | 关系两端各自可以有多少个实例 |
| class diagram | 类图 | 用类、属性与关系表达静态结构的图 |
| identifier | 标识符 | 能唯一区分同一实体各实例的信息 |
| conceptual model | 概念模型 | 只描述业务概念、不涉及实现细节的模型 |
| implementation detail | 实现细节 | 与具体技术选型相关的表达方式,概念模型里应避免 |