ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UML对象图实战:从航空购票系统理解对象、链与快照

UML对象图实战:从航空购票系统理解对象、链与快照

1. 对象图不是拿来背的,它是某一刻的业务切片

带过几批新人之后我发现,UML对象图是整套图里最容易被“背废”的一块。考试能默写“对象是类的实例,链是关联的实例”,一到真实项目里让他画一张订单对象图,立刻就卡住:对象名写不写、下划线画在哪、属性槽填什么、链要不要标多重性,全是问号。问题不在记性,在于教材把对象图从业务场景里抽离出去了,只剩一堆符号规则,谁背谁忘。

这篇就拿航空购票系统当例子,把UML对象图里的对象和链讲透。你会在里面看到一个订单从下单到支付成功之后的瞬时状态,看到乘客、订单、客票、航班、座位、支付记录这些对象怎么用链连起来,也会看到对象图怎么反过来帮你抓出类图里漏掉的关联。适合两类人看:正在学UML、被对象图符号绕晕的学生;以及工作里要写设计文档、但一直把对象图画成“第二张类图”的开发者。

先把话说在前面:所谓“5分钟搞懂”,5分钟指的是画图动作,前提是类图已经在你脑子里成型了。对象图从来不是凭空造出来的,它是类图在某个时刻的实例化快照。类图是图纸,对象图是按图纸造出来、并且正在运行的那台机器。你不可能跳过图纸直接造机器,也不可能背几个符号就学会看图纸。所以这篇会先用一大段把航空购票系统的类结构理干净,再动手画对象图,最后聊踩过的坑。整个节奏跟真实项目里的做法一致,你照着走一遍,比对着教材抄十遍定义管用。

2. 先把类图打底:没有类,对象图就是无根之木

2.1 从购票业务里捞“名词”和“动词”

建模最朴素的办法还是找名词。把“乘客在App上选航班、选座位、提交订单、完成支付、拿到客票”这条主线拆开,能捞出的名词有:乘客、航班、订单、客票、座位、支付记录。动词则是:下单、占座、支付、出票、退改签。名词大概率对应类,动词大概率对应方法或者状态流转。

这里有个取舍要先定:订单和客票是不是两个东西。很多初学者图省事,把订单当成“一次购买行为的容器”,客票当成“一张票”,这个拆分是对的。一个订单可以包含多张客票,比如两位同行旅客一起下单,生成两张客票、共用一个订单号、共用一次支付。如果合并成一个类,后面退票只退其中一张的时候,你就会发现状态没法表达。这个判断不是拍脑袋,是业务规则逼出来的:支付是订单级行为,乘机是客票级行为,两者生命周期不同,必须分开。

再一个取舍:座位要不要独立成类。如果只是记录“32A”这个字符串,确实可以塞进客票属性里。但真实系统里座位有座位类型(靠窗、过道、紧急出口)、有可用状态、有选座锁定,还要防止两个客票选中同一个座位。这些信息和客票的生命周期不完全一致,独立成类更稳。对象图阶段你会更直观地感受到这一点:当两张客票的链指向同一个座位对象时,问题一眼就暴露了。

2.2 六个核心类的属性与方法定稿

类图不用画得多漂亮,但属性类型得定清楚,不然对象图的属性槽没法填。下面这张表是我在这个示例里用的类结构,属性只保留跟对象图相关的部分,方法列出来是为了后面讲状态对应关系。

类名关键属性关键方法
乘客乘客编号、姓名、证件号、联系方式、常旅客等级查询行程、累积里程
订单订单号、下单时间、订单状态、总金额提交、取消、发起支付
客票票号、舱位等级、票价、税费、客票状态、旅客姓名出票、退票、改签
航班航班号、起飞地、降落地、计划起飞时间、机型、剩余座位数查询余票、锁定座位
座位座位号、座位类型、座位状态锁定、释放
支付记录支付流水号、支付金额、支付渠道、支付状态、支付时间发起支付、查询结果

属性名和类型必须定死,比如“总金额”是金额类型、“下单时间”是日期时间类型、“订单状态”是枚举。这一步偷懒,后面画对象图时就会出现“属性槽里写了个类型名”的经典错误,我在第5节会专门列出来。

2.3 关联和多重性,决定后面链能画几条

对象图里的每一条链,背后都必须有一条类图里的关联撑着。所以关联和多重性得先定,而且要多问一句“业务上真的允许这样吗”。

  • 乘客 1 — 0..* 订单:一个乘客可以下多单,一个订单必须有一个下单人。
  • 订单 1 — 1..* 客票:一个订单至少一张客票,否则订单没有意义。
  • 客票 * — 1 航班:每张客票必须挂一个航班,一个航班可以被大量客票引用。
  • 客票 1 — 1 座位:一张客票对应一个座位,一个座位在同一航班同一时刻只能被一张客票占用。
  • 订单 1 — 0..1 支付记录:一个订单最多一条有效支付记录,未支付时为零条。

注意“客票 1 — 1 座位”这条,很多人的第一版类图会写成* — *,觉得“一个航班很多座位、一个座位历史上有过很多客票”。这是把当前占用和历史记录混在一起了。当前时刻,一个座位只能被一张有效客票占用,历史客票通过航班和日期区分,不应该体现在这条关联的多重性上。这个细节在对象图里会变得非常直观:你画两条链指向同一个座位对象,就说明当前快照违反了业务约束。

还有一条容易漏的关联:乘客和客票之间的“旅客”关系。如果同行两位旅客一起下单,下单人只有一个,但两张客票上各有一位旅客。常旅客积分要记在谁头上?里程累积给谁?如果只保留“乘客—订单”这一条关联,第二位旅客的信息就无处安放。这个漏洞,正是我在画对象图时发现的,后面第6节会详细说。

3. 五分钟画出一张对象图:以“两人同行下单并支付成功”为快照

3.1 选快照时刻:先定问题再定图

对象图最忌讳“我想把整个系统画下来”。它本质是一张照片,你得先决定拍什么。我给自己定的问题是:两位旅客同行、同一个订单、支付已完成、客票已出票,这个瞬间系统里有哪些对象,它们怎么连?

快照时刻定为:2024-05-12 09:20。这个时间点很关键,因为订单状态、支付状态、客票状态必须是同一时刻的,不能一个写“已支付”、另一个写“待支付”。很多对象图画出来自相矛盾,根因就是没有统一快照时刻,把不同时间点的状态拼在了一张图上。

对象选择上做减法:只画跟这个问题相关的六个类对应的实例。乘客画两个(下单人和同行旅客),订单一个,客票两张,航班一个,座位两个,支付记录一条。一共八个对象。超过十个对象,图就开始糊,评审时没人看得下去。

3.2 建对象:命名、下划线、属性槽三件套

对象框的写法是固定的:对象名:类名,整体加下划线,表示这是一个实例而不是类。对象名用业务上有意义的短标识,别用 obj1、obj2,评审的时候没人记得住。属性槽写成属性名 = 值,值是具体数据,不是类型。这一点是对象图和类图最本质的区别:类图写“票价: 金额”,对象图写“票价 = 680.00”。

下面是我这张图里的对象清单,用表格展示比贴图更清楚,你在工具里照着填就行。

对象标识类属性槽(节选)
p1:乘客乘客乘客编号 = P-1001,姓名 = 乘客甲,常旅客等级 = 银卡
p2:乘客乘客乘客编号 = P-1002,姓名 = 乘客乙,常旅客等级 = 普通
o1:订单订单订单号 = OD20240512001,下单时间 = 2024-05-12 09:13,订单状态 = 已支付,总金额 = 1560.00
tk1:客票客票票号 = TK-8801,舱位等级 = Y,票价 = 680.00,税费 = 80.00,客票状态 = 已出票,旅客姓名 = 乘客甲
tk2:客票客票票号 = TK-8802,舱位等级 = Y,票价 = 720.00,税费 = 80.00,客票状态 = 已出票,旅客姓名 = 乘客乙
f1:航班航班航班号 = CA-1520,起飞地 = 甲城,降落地 = 乙城,计划起飞时间 = 2024-05-20 08:30,剩余座位数 = 118
st1:座位座位座位号 = 32A,座位类型 = 靠窗,座位状态 = 已占用
st2:座位座位座位号 = 32B,座位类型 = 中间,座位状态 = 已占用
pay1:支付记录支付记录支付流水号 = PM20240512001,支付金额 = 1560.00,支付渠道 = 银行卡,支付状态 = 成功,支付时间 = 2024-05-12 09:20

这里有个金额计算要交代清楚,不然读者会问 1560 是怎么来的。税费我按每张票固定 80 元算(机建 50 + 燃油 30),那么 tk1 的含税价是 680 + 80 = 760,tk2 是 720 + 80 = 800,合计 760 + 800 = 1560,与订单总金额、支付金额三者一致。这个对账动作看着琐碎,但它是对象图最有价值的地方之一:数字对不上,说明类图缺属性或者业务规则没建模。后面第5节的排查案例就是从这个对账开始的。

剩余座位数写 118 也有依据:航班初始可售 120 座,本单锁定 2 座,快照时刻剩余 118。对象图里的每个数字最好都能推出来,推不出来的数字就是隐患。

3.3 连链:把关联一条条落到实例上

链是关联的实例化。类图里有一条关联,对象图里就可能出现若干条链。画链的时候我习惯按关联清单逐条过,过完打勾,避免漏。

链端点 A端点 B对应关联角色标注
L1p1:乘客o1:订单乘客 1 — 0..* 订单下单人
L2p2:乘客tk2:客票乘客 1 — 0..* 客票旅客
L3o1:订单tk1:客票订单 1 — 1..* 客票所属订单
L4o1:订单tk2:客票订单 1 — 1..* 客票所属订单
L5tk1:客票f1:航班客票 * — 1 航班承运航班
L6tk2:客票f1:航班客票 * — 1 航班承运航班
L7tk1:客票st1:座位客票 1 — 1 座位占用座位
L8tk2:客票st2:座位客票 1 — 1 座位占用座位
L9o1:订单pay1:支付记录订单 1 — 0..1 支付记录支付凭据

这里能看出链和关联的本质差别:类图里“订单—客票”只画一条线,标上1..*;对象图里 o1 伸出两条链,分别连 tk1 和 tk2,每一条链就是一个具体的事实。链上不需要再标1..*,因为这个多重性描述的是“一个订单可以有多少张客票”这种规则,已经由类图表达了;到了实例层面,有几条链就是几条,事实本身不需要再标范围。

链上标角色名是我强烈建议的习惯。L1 标“下单人”、L2 标“旅客”,这两个角色名一标,p1 和 p2 的区别立刻清楚了:一个是付钱的人,一个是坐飞机的人。不标角色名,看图的人会以为画重复了。

还有一个细节:p2 和 o1 之间没有链。这不是漏画,而是业务规则决定的——同行旅客不一定是下单人,订单归属只认下单人。如果业务要求“每位旅客都能在自己的账户里看到这个订单”,那就要么补一条 p2—o1 的链,要么在查询逻辑里走“乘客—客票—订单”的路径。这个选择要在设计文档里写清楚,不能靠看图的人猜。

3.4 校验:多重性、金额、状态三道对账

图画完不算完,得校验。我固定做三道:

第一道,多重性对账。o1 有 2 条客票链,满足1..*;tk1、tk2 各挂 1 个航班,满足*—1里“每张客票一个航班”;tk1、tk2 各挂 1 个座位,且座位对象不同,满足“一座一票”;o1 挂 1 条支付记录,满足0..1;p1 挂 1 个订单,满足1—0..*。全部通过。

第二道,金额对账。客票含税价之和 760 + 800 = 1560,等于订单总金额,等于支付金额。通过。这一步如果不等,先别改对象图,回头查类图是不是漏了优惠、手续费、退改签差价这些属性。

第三道,状态对账。订单状态“已支付”、支付记录“成功”、客票状态“已出票”、座位状态“已占用”,四个状态在同一时刻是自洽的:支付成功触发订单状态变更,进而触发出票和占座。如果这里出现“订单已支付但客票未出票”,那是正常的中间态,但你得确认快照时刻是不是选在了中间态上,别把中间态和终态混着画。

三道对账做完,这张对象图才算能用。整个过程熟练之后确实五分钟能画完,但前面类图的功夫省不掉。

4. 对象图与邻家图的分工,别画串了

4.1 对象图 vs 类图:一个是“图纸”,一个是“成品”

这两个图被混淆的概率最高。区分方法特别简单:看属性栏。类图写的是类型,票价: 金额;对象图写的是值,票价 = 680.00。看名字栏,类图写客票,对象图写tk1:客票并且加下划线。看关系栏,类图写关联名和多重性,对象图只画链,一条链对应一个事实。

还有一个更本质的区别:类图描述的是所有可能,对象图描述的是某一个确定。类图说“订单可以有 1 到多张客票”,对象图说“o1 这个订单现在有 tk1 和 tk2 两张客票”。前者是规则,后者是证据。

用对象图验证类图,是我最常用的一招。你按类图实例化一遍,如果发现某个业务事实画不出来——比如同行旅客的积分没地方挂、优惠金额没属性存——那就说明类图缺东西。这个思路比盯着类图空想有效得多,因为实例化会逼你把每个属性填上具体值,填不出来的地方就是设计缺口。

4.2 对象图 vs 顺序图:截面和过程

顺序图讲的是“消息按时间顺序怎么走”,横轴是对象,纵轴是时间,重点在交互过程。对象图讲的是“某一刻这些对象长什么样、怎么连”,没有时间轴,是一条横截面。两者经常配合使用:先用顺序图画“下单—支付—出票”的消息流,再从流里挑一个关键节点,用对象图把这个节点的状态定格下来。

比如支付回调这个节点,顺序图能告诉你系统收到了支付成功通知、更新订单、调出票服务;对象图则告诉你回调处理完之后,订单、客票、支付记录、座位这四个对象各自是什么状态、链有没有变。一个管过程,一个管结果,谁也不能替代谁。

4.3 对象图 vs 状态图:群体快照和单体生命周期

状态图盯的是一个对象自己的生命周期,比如订单从“待支付”到“已支付”再到“已完成”或者“已取消”。对象图盯的是一群对象在某一刻的关系。拿订单举例,状态图会画出“待支付—已支付—已出票—已完成”的迁移条件;对象图只会告诉你此刻 o1 处在“已支付”这个状态上,以及它连着哪些客票和支付记录。

有人问,那对象图里能不能体现状态迁移?不能,也不该。你要表达“支付成功后订单从待支付变成已支付”,那是状态图或者顺序图的活。对象图只负责定格,不负责演绎。把这个边界守住,图就不会画得四不像。

5. 踩坑实录:对象图里最容易翻车的八种情况

5.1 错误速查表

下面这张表是我在评审里见过、自己也犯过的错误,按出现频率排序。对着查一遍,能省不少返工。

现象根因修正做法
对象框写成“订单: o1”对象名和类名位置搞反固定写法o1:订单,整体加下划线
属性槽写“票价: 金额”直接把类图属性抄过来改成票价 = 680.00,填具体值
链上标了1..*把关联的多重性搬到链上链就是一条事实,多重性留在类图
同一个订单画了两个框以为“被引用两次就要画两次”一个实例一个框,多条链连同一个框
订单“已支付”、支付记录“待支付”快照时刻不统一,状态拼接先定时刻,再逐对象核对状态
客票没连航班漏链,没按关联清单逐条过画链时对着关联表打勾
两张客票连到同一个座位对象座位分配逻辑缺唯一约束检查占座规则,补数据库唯一索引
一张图塞了二十个对象想用一张图讲完整个系统按问题拆图,单图控制在十个对象内

表格里第八条值得多说两句。对象图的信息密度很高,每个对象都带属性槽,画多了就是灾难。我的经验是单图不超过十个对象、不超过十五条链,超过就拆。拆图的依据不是类,是问题:这张图回答“支付完成后的状态”,那张图回答“退票后的状态”,各自独立,互不干扰。

5.2 三个排查过程,都是对象图救的场

第一个:金额对不上。最初的对象图里,两张客票含税价合计 1560,订单总金额也写 1560,但支付记录金额我随手写了 1500。三道对账一跑就露馅。回头查业务,发现这位乘客用了一张 60 元的优惠券。问题来了:类图里订单只有“总金额”,没有“优惠金额”和“实付金额”的区分。补法有两种,简单的是给订单加“优惠金额”属性,实付金额 = 总金额 - 优惠金额;规范的是把优惠券独立成类,订单和优惠券建立关联,这样能追踪券的来源和使用规则。我们选了后者,因为业务上还要做优惠券核销和对账。这个缺口,靠盯类图是发现不了的,是对象图的对账逼出来的。

第二个:座位重复分配。有一次画另一张快照,两张客票的链指向了同一个座位对象,座位号都是 32A。第一反应是画错了,检查发现不是——是占座服务的并发控制没做好,两个请求同时锁到了同一个座位。对象图上一条链指向同一个框,视觉上极其刺眼,比看代码里的 if 判断容易发现多了。后来补了座位状态的原子更新和唯一约束,再画一遍,两条链分别指向 st1 和 st2,问题闭环。

第三个:同行旅客查不到行程。这就是前面提过的 p2 没有链的问题。测试提了个缺陷:同行旅客登录自己的账户,看不到这趟行程。查下来,行程查询走的是“乘客—订单”这条关联,而 p2 和 o1 之间压根没有链。根因在类图:乘客和客票之间缺一条“旅客”关联。补上之后,p2 通过p2—tk2—o1就能查到订单。这个案例让我养成一个习惯:画对象图时,把每个对象的“查询入口”都想一遍,谁能查到我、我通过谁被查到,想不通的地方就是关联缺失。

5.3 工具层面的操作要点

工具选择上,我分两种场景。日常设计评审、需要进版本库的,用文本化的方式描述对象和链,好处是能 diff、能 review、改一行就是改一个属性值,冲突好解。给业务方讲解、需要直观展示的,用图形化工具拖拽,对象框、下划线、链都是现成的,注意别开自动布局把链绕成一团。

不管用哪种,有几个操作细节得注意。对象名统一用业务编号前缀,p开头乘客、o开头订单、tk开头客票、f开头航班、st开头座位、pay开头支付,评审时一眼能对上。属性槽只填跟当前问题相关的,别把二十个属性全列上,图会撑爆。链的角色名要标,尤其是同一个类出现多个实例的时候,不标角色名等于没画。最后,图上加一行注释写清楚快照时刻和这张图要回答的问题,过两周你自己回来看都得靠这行字。

6. 我自己的用法:把对象图当“设计验算纸”

6.1 用对象图反查类图漏洞

类图画完,别急着评审,先挑三个典型场景各画一张对象图:正常下单支付、退掉其中一张票、改签到一个新航班。画的过程中你会发现各种填不进去的值、连不上的链。填不进去的值,说明类缺属性;连不上的链,说明类缺关联;链的数量违反多重性,说明约束没定对。这三类问题,在类图阶段都是隐形的,一实例化全现形。

我现在的习惯是,类图评审材料里必须附一张对象图。评审的时候大家对着对象图吵架,比对着类图吵架有效率得多,因为对象图上的每个值都是具体的,没法含糊。有一次评审,两位同事为了“订单要不要记录旅客联系方式”争了半小时,我把对象图一画,发现同行旅客的联系方式无处安放,争议当场就有了结论:要么客票带联系方式,要么乘客实体独立维护,二选一,别再空谈。

6.2 用对象图生成测试数据

对象图里的属性槽,天然就是一组测试数据。把 p1、p2、o1、tk1、tk2、f1、st1、st2、pay1 这些值直接导成数据库的 insert 语句,就是一套可用的集成测试数据集。金额之间的等式关系(客票合计 = 订单总额 = 支付金额)还能直接写成断言。测试同学最喜欢这种图,因为不用自己编数据,而且编出来的数据是自洽的。

要注意脱敏。对象图里填的姓名、证件号、手机号一律用虚构值,证件号写成ID-8821****这种带掩码的形式,别把真实数据贴进设计文档。这不是小节,是底线。

6.3 几条实操心得

第一条,先写一句图注再动笔。这句话的格式是“本图展示某时刻某场景下哪些对象及其关系”,写不出来说明你还没想清楚要画什么。

第二条,属性槽填“像真的”的值。别写“票价 = 0”或者“姓名 = 测试”,假数据会掩盖问题。你填 680 和 720,才会去做加法、才会发现金额对不上;你填 0,什么问题都发现不了。

第三条,画完从任意一个对象出发,把链走一遍。从 p1 出发能不能走到 f1?路径是 p1—o1—tk1—f1。从 pay1 出发能不能走到 p2?pay1—o1—tk2—p2。走不通的地方,要么是关联缺失,要么是业务上本来就不该通,两种结论都得写进文档。

第四条,别在对象图上做推演。看到链就想着“如果这里加个条件会怎样”,那是顺序图和状态图的活。对象图是静态的,守住这个定位,图才干净。

第五条,对象图会过期。业务规则一改,类图变了,旧对象图就成了错误示范。我的做法是把对象图跟类图放在同一个版本库里,类图改动时同步检查受影响的快照图,改不动就删掉,别留着一张过期的图误导后来的人。这一点比画图技巧重要得多,毕竟一张画错的图,比没有图危害更大。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进