软件分析与设计笔记 22:分析建模:边界、实体与控制对象
不经过分析直接写代码会踩什么坑、三种分析对象各管一段、怎么用顺序图完成一次用例实现,以及从顺序图导出类的属性、操作与关系的方法。
本文是 《软件分析与设计笔记》 系列的第 22 篇,共 28 篇
这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的。理解有偏差的地方欢迎指出。
贯穿案例:片区家电维修派单系统。21 篇讲了 UML 的符号,这一篇用它做一次真正的分析建模 —— 产物就是 27 篇考试里那道顺序图题。
0 跳过分析直接写代码会怎样
需求给了、数据库也建好了,直接开始写代码 —— 这是很多项目的实际做法。它通常在三到六个月后付出代价:
- 界面上的一段逻辑,被复制到三个页面里(因为写的时候没想清楚该放哪)。本例:客服端和工程师端都要「校验工单是否进行中」,结果这段判断写了两遍;
- 业务规则散落在控制器、数据访问和界面代码里。本例:「库存不足就转待件挂起」这条规则如果写在页面里,仓管端就没法复用;
- 想做一个「批量导入」,发现没有地方可以复用已有的处理逻辑。本例:想给仓管做「批量出库」,发现出库逻辑埋在单个出库的页面事件里。
分析建模(analysis modelling) 就是在需求和代码之间插一层:先想清楚每个用例由哪些对象协作完成。
1 它在流程里的位置
| 阶段 | 回答的问题 | 产物 |
|---|---|---|
| 需求建模(requirements modelling) | 系统要做什么 | 用例模型、活动图 |
| 分析建模(analysis modelling) | 由哪些对象协作完成 | 分析模型(类图 + 顺序图) |
| 设计建模(design modelling) | 怎么落到平台与数据上 | 设计模型(含表、访问层) |
分析建模有个重要特征:它刻意不关心实现平台。不讨论用哪个框架、哪张表、什么字段类型 —— 只讨论职责怎么分。先把「该谁做」定下来,再决定「怎么做」。
这一点在本例里有个很具体的表现:分析模型里写「:PartsController 调用 :StockItem.getStock()」,不写这个调用是走数据库还是走缓存,也不写 getStock() 返回的是对象还是 DTO。这些等设计阶段再说。
2 三种分析对象
分析对象(analysis object)通常按职责分成三种构造型(stereotype):
2.1 边界对象
① 边界对象(boundary object) —— 系统与外部交互的地方:界面、外部接口。它负责接收输入与展示结果,不负责业务规则。
它的一个重要特征是改动最频繁:界面几乎每次迭代都在变。所以业务规则一定不能写在边界对象里,否则每次改界面都要重新验证业务逻辑。
本例里有两个边界对象,而且是两种不同的边界:
:PartsForm—— 人的界面(工程师填申请的那个表单);:SupplierAPI—— 系统的接口(去查供应商库存的那一端)。
两者都叫 boundary,但性质完全不同:前者会因为「按钮放左边还是右边」而改,后者会因为「供应商改了接口版本」而改。区分它们的意义在于:改动的触发源不同,所以不能混在一个类里。
2.2 实体对象
② 实体对象(entity object) —— 业务上需要长期记住的东西,也就是笔记 09 里的业务实体。它承载数据与规则,通常能对应到数据表。
本例的实体是 :WorkOrder(工单)、:StockItem(库存项)、:PartUsage(配件消耗)、:IssueNote(出库单)。
这里有个关键点值得强调:业务规则应该落在实体上,而不是控制对象上。「工单在什么状态下允许申请配件」这条规则属于 :WorkOrder,因为它管的是工单自己的状态;如果写进控制器,那么别的用例(比如批量出库)想复用这条规则就复用不了。
2.3 控制对象
③ 控制对象(control object) —— 协调一个用例的执行顺序:接收请求,判断该交给谁处理(这个动作就是委派,delegation),再汇总结果。它本身不该堆积业务规则。
一般给每个用例配一个控制对象,命名上直接对应::RequestPartsController 实现「申请配件」这个用例。这个名字本身就能追溯 —— 看到类名就知道它服务哪个用例。
控制对象是三者里最容易失控的 —— 因为它「什么都管」,很容易变成什么都往里塞的上帝类(god class)。一个判断方法:如果这个类的方法里出现了大量「先查 A、再判断 B、然后更新 C」,那这些逻辑大多属于实体对象。
本例里一个具体的越界:如果 :RequestPartsController 里写了「如果数量大于 20 就报错」,这条规则就放错了 —— 它属于 :PartUsage。控制器只该做「收到申请 → 让 :PartUsage 自己校验 → 根据结果决定下一步」。
3 用顺序图做一次用例实现
3.1 做法
把「每个用例由哪些对象协作完成」画出来,这个过程叫用例实现(use case realization)。
做法是:取一个用例的主流程(basic flow),逐个步骤问「这一步由谁执行」,把参与者与对象的交互画成顺序图。
3.2 本例完整走一遍:申请配件
08 篇那个「申请配件」的主流程有 10 步。逐行问「谁执行」,得到这样一张对照:
| flow 步骤 | 谁发起 | 发给谁 | 消息 |
|---|---|---|---|
| 1. 工程师提交申请 | :Engineer | :PartsForm | submit(workOrderId, partNo, qty) |
| 2. 校验工单状态 | :PartsForm | :RequestPartsController | requestParts(...) |
| 3. 查询当前库存 | :RequestPartsController | :StockItem | getStock(partNo) |
| 4. 调用供应商接口 | :RequestPartsController | :SupplierAPI | queryAvailable(partNo) |
| 5. 比对判断 | :RequestPartsController | 自己 | 自身方法 |
| 6. 生成出库单 | :RequestPartsController | :IssueNote | createIssueNote(...) |
| 7. 写入工单 | :RequestPartsController | :WorkOrder | addPartUsage(...) |
| 8. 通知仓管 | :RequestPartsController | :Storekeeper | notify() |
| 9. 显示成功 | :RequestPartsController | :PartsForm | 返回结果 |
这张表就是顺序图的草稿。注意第 4 步的接收者是 :SupplierAPI(boundary),不是实体 —— 判断依据在 21 篇讲过:「你需要写代码去和它对接,那它是系统外部接口」。
把这张表画成图,就是本例的用例实现:
3.3 顺序图暴露了什么
画完这张图,有两件事立刻变得清楚:
① 控制器确实在协调,但没有堆积规则。它做了 4 次委派(去库存、去供应商、去出库单、去工单),自己只做了「比对」这一个判断。这个比例是健康的。
② 第 8 步「通知仓管」是个可疑点。仓库通知是「申请配件」这个用例的一部分吗?还是应该独立成一个用例?
讨论的结论是:应该是独立的。因为通知的渠道和时机(短信 / App / 邮件)与配件申请无关,而且仓管可能想改通知方式而不影响申请流程。所以分析模型里保留 notify(),但设计时它可以变成一个独立的消息服务。
这就是分析建模的价值:它让你在写代码之前就发现「这里可能该切开」。
4 从顺序图导出类
4.1 导出规则
顺序图画清楚之后,类图基本就出来了 —— 导出规则是机械的:
| 顺序图里的东西 | 对应到类图 |
|---|---|
| 每个参与对象 | 一个类 |
| 对象收到的消息 | 该类的一个操作 |
| 需要记住的数据 | 类的属性 |
| 谁调用谁 | 类之间的关系 |
按这张表,从上面那张顺序图导出的类图是这样的:
| 类 | 构造型 | 操作(来自收到的消息) |
|---|---|---|
PartsForm | boundary | submit()、displayResult() |
RequestPartsController | control | requestParts() |
StockItem | entity | getStock() |
SupplierAPI | boundary | queryAvailable() |
IssueNote | entity | createIssueNote() |
WorkOrder | entity | addPartUsage() |
注意这三个实体的操作全都是「名词 + 动词」的形式,而且都跟自己的数据有关 —— 这正是 21 篇强调的「行为跟着数据走」。
4.2 为什么先画顺序图
这也是为什么推荐先画顺序图、再画类图:如果反过来,先列一堆类再想它们怎么协作,很容易列出一批「看起来合理但用不上」的类。
本例里可以验证这一点:如果先凭空列类,很容易列出「NotificationService」「InventoryManager」「ValidationHelper」这类看起来专业的类 —— 但它们在这条流程里一个都用不上(通知只有一行 notify(),校验只有 qty > 20 一个判断)。
用了才建,不用不建。这是分析建模最实用的一条纪律。
5 关系上的限制
5.1 不是所有关系都能随便用
从顺序图导出关系时,有一点要留意:不是所有关系都可以随便用。
- 依赖 —— 出现在方法参数里,最轻;
- 关联 —— 需要长期保留引用时才用;
- 聚合 / 组合 —— 要有明确的整体—部分语义。
一个常见错误是「为了让图好看,把所有关系都画成组合」。判断依据还是那句:整体没了,这个部分还活得下去吗?
本例里 :WorkOrder 与 :PartUsage 是真组合(配件消耗随工单消亡),而 :PartUsage 与 :StockItem 只是引用(库存项独立存在,配件消耗只是记了个 partNo)。这两个如果都画成组合,实现的人会以为删工单要删库存项 —— 那是灾难。
5.2 分析模型的三条硬限制
讲义明确列出的三条,属于「画了就扣分」的类型:
| 关系 | 能不能用 | 本例的说明 |
|---|---|---|
| boundary 直接关联 entity | ❌ 不推荐 | :PartsForm 不能直连 :StockItem,中间必须隔着 :RequestPartsController |
| actor 与 entity / control 直接关联 | ❌ 不推荐 | :Engineer 只接触 :PartsForm,不能直接连 :WorkOrder |
| 不同构造型之间做继承 / 聚合 / 组合 | ❌ 无意义 | 「控制器继承实体」这种关系没有业务含义 |
第一条的理由值得说清:如果界面能直接拿数据,那「业务规则该放哪」这件事就彻底失守了。因为一旦 :PartsForm 能直接读 :StockItem,它就会顺手在那里写判断,然后这段判断再也不会被别处复用。
5.3 关系方向要反映调用方向
还有一条实践建议:关系的方向要能反映调用方向。如果 A 调 B、B 又调 A,那张图上会出现环 —— 环通常不是错,但值得停下来看看是不是职责切分有问题。
本例的分析模型是无环的::Engineer → :PartsForm → :RequestPartsController → 各实体。这个单向性是健康的 —— 它说明职责分得清楚,没有互相依赖。
6 类图怎么文档化(documenting the class diagram)
分析模型最终要交出去,类图的说明通常有三种组织方式:
- 每张图配一段文字 —— 逐个类说明职责。适合类少的场景;
- 统一一张职责表 —— 类名 + 职责 + 关键操作 + 协作对象。适合类多的场景,也便于对照检查职责有没有重叠;
- 在图上直接标注 —— 把职责写在类旁边的注释框里。适合评审时快速对齐。
本例用第二种最合适,因为类不多不少(六个):
| 类 | 一句话职责 | 协作对象 |
|---|---|---|
PartsForm | 接收工程师的申请输入,显示结果 | RequestPartsController |
RequestPartsController | 协调一次配件申请的执行顺序 | 其余五个 |
StockItem | 记住某个配件的当前库存与安全库存 | RequestPartsController |
SupplierAPI | 对接供应商,返回实时可售量 | RequestPartsController |
IssueNote | 记录一次出库(谁、何时、多少) | RequestPartsController |
WorkOrder | 记录工单状态与它用掉的配件 | RequestPartsController |
三种方式可以混用。关键是每个类的职责必须有一句话能说清楚 —— 说不清,通常意味着这个类做了两件不相关的事。
上表里如果有哪一行写不出来,就该回去检查那个类了。
本节自测
- 分析建模和设计建模的关注点差别是什么?
- 业务规则为什么不该写在边界对象里?
- 怎么判断一个控制对象是不是已经变成上帝类?
- 从顺序图导出类图时,「对象收到的消息」对应到类图的什么?
- 为什么推荐先画顺序图再画类图,而不是反过来?
- 本例里
:PartsForm和:SupplierAPI都是 boundary,为什么要分成两个类? - 本例分析模型为什么是「无环」的?有环说明什么?
参考答案
- 分析建模只讨论「职责怎么分」,不关心实现平台(不讨论框架、表、字段类型);设计建模才把这些落到数据库、访问层与框架上(§1)。
- 因为边界对象改动最频繁(界面每次迭代都在变)。业务规则写在那里,每次改界面都要重新验证业务逻辑,而且别的用例无法复用(§2.1)。
- 看它的方法里是不是有大量 「先查 A、再判断 B、然后更新 C」 —— 这类逻辑大多属于实体对象。本例反例:控制器里写「数量大于 20 就报错」,这条规则应属于
:PartUsage(§2.3)。 - 对应到该类的一个操作(operation)。本例
:WorkOrder收到addPartUsage()消息,所以它就有addPartUsage()这个操作(§4.1)。 - 因为反过来先列类,很容易列出一批 「看起来合理但用不上」 的类。本例若凭空列,会列出
NotificationService、ValidationHelper等,但流程里它们各只有一个方法(§4.2)。 - 因为它们的改动触发源不同:
:PartsForm因「按钮放哪」而改,:SupplierAPI因「供应商接口改版」而改。混在一个类里会让两种变化互相牵连(§2.1)。 - 因为调用方向是单向的:
:Engineer → :PartsForm → :RequestPartsController → 各实体。有环说明职责切分有问题(A 与 B 互相依赖)(§5.3)。
术语表
| 术语 | 中文 | 一句话解释 |
|---|---|---|
| analysis modelling | 分析建模 | 确定每个用例由哪些对象协作完成 |
| analysis object | 分析对象 | 分析模型里承担一种职责的对象 |
| stereotype | 构造型 | 给模型元素附加语义的分类标记 |
| boundary object | 边界对象 | 系统与外部交互的对象,如界面与外部接口 |
| entity object | 实体对象 | 承载业务数据与规则的对象 |
| control object | 控制对象 | 协调用例执行顺序的对象 |
| use case realization | 用例实现 | 用对象协作把用例的流程具体化 |
| basic flow | 主流程 | 一切顺利时的步骤序列 |
| delegation | 委派 | 把请求转给该处理它的对象 |
| god class | 上帝类 | 职责过多、什么都管的类 |
| documenting the class diagram | 类图文档化 | 用文字、表格或注释说明每个类的职责 |