1. 类图到底在解决什么问题
很多人第一次接触UML,都是从类图开始的。打开画图工具,拖几个方框,写上类名,画几条线连起来,感觉像是完成了一项任务。但真正做过几个项目之后你会发现,类图这东西,画得对不对、有没有用,跟画得好不好看完全是两码事。我见过太多团队把类图画成了“给领导看的装饰品”,画完之后没人再看第二眼,代码该乱还是乱。问题出在哪?出在画图的人自己就没想清楚类图到底在解决什么问题。
类图的核心价值,用一句话概括:它是在编码之前,把系统里“有哪些东西”“这些东西各自能干什么”“它们之间怎么打交道”这三件事用图形化的方式固定下来。注意我的用词——“固定下来”。这意味着类图是一种契约,一种共识,一种在团队之间传递设计意图的载体。你画了一个订单类,里面有一个计算总价的私有方法,那写代码的人就知道这个方法不应该被外部直接调用;你画了订单和商品之间是一对多关系,那建表的人就知道外键该放在哪一边。这些信息如果只存在于某个人的脑子里,项目稍微大一点就会出问题。
那为什么是类图而不是别的图?UML一共有十几种图,用例图描述的是系统能做什么,时序图描述的是某个具体场景下对象之间怎么交互,状态图描述的是单个对象在生命周期里的状态变化。类图是唯一一种静态结构图,它不关心“什么时候发生什么”,只关心“有什么、长什么样、怎么连”。这个定位决定了它的使用场景:当你需要向别人解释系统的骨架时,类图是最直接的表达方式。比如新同事入职,你给他看十张时序图,他可能还是不知道系统里有哪些核心实体;但你给他看一张类图,五分钟他就能把主要概念和它们之间的关系理清楚。
还有一个常见的误解:类图只适用于面向对象编程。实际上不是。类图描述的是“概念模型”,它跟具体用什么语言实现没有必然关系。你用Java写,类图里的类可以直接映射到Java类;你用Python写,类图里的类可以映射到Python的类或者模块;哪怕你用C写过程式代码,类图依然可以用来描述数据结构之间的关系。我做过一个嵌入式项目,底层全是C代码,但我们在设计阶段依然画了类图,把传感器、控制器、通信模块这些概念之间的关系理清楚,后面写代码的时候结构非常清晰。所以不要把类图跟“面向对象”绑死,它本质上是一种概念建模工具。
适合看这篇内容的人,我大致分三类。第一类是刚学UML的学生或者转行的开发者,课本上的类图符号一大堆,什么聚合、组合、依赖、关联,看得头晕,不知道哪些在实际工作中真正用得上。第二类是有一定经验但没系统画过类图的开发者,平时写代码靠直觉,设计靠拍脑袋,想补上设计表达这一课。第三类是技术负责人或者架构师,需要给团队建立设计规范,想知道类图应该画到什么颗粒度、哪些该画哪些不该画。这三类人的需求不一样,但核心诉求是相同的:让类图真正服务于开发,而不是走个形式。
2. 类图的核心元素与关系拆解
2.1 一个类框里到底该放什么
类图里最基本的元素就是类框,一个矩形分成三格:类名、属性、方法。看起来简单,但实际画的时候问题最多。先说类名,类名应该是一个名词,而且应该是业务领域里的概念,不是技术实现细节。我见过有人把类名写成“OrderServiceImpl”,这就把实现细节暴露在概念模型里了,不好。概念模型应该叫“Order”,至于它背后是不是有一个ServiceImpl的实现,那是代码层面的事,类图不关心。
属性那一格,写的是这个类“知道什么”。比如订单类有订单号、下单时间、总金额、状态这些属性。这里有一个关键决策:属性写到什么颗粒度。如果你把订单里每一个字段都列出来,类图会变得极其臃肿,而且很多字段是技术性的(比如创建人ID、更新时间戳),对理解业务概念没有帮助。我的经验是,只写那些对理解业务规则有意义的属性。比如“状态”这个属性很重要,因为订单状态决定了它能做什么操作;“总金额”很重要,因为它是计算出来的核心结果。至于“创建时间”这种审计字段,除非业务逻辑依赖它,否则可以不写。这个取舍没有绝对标准,但原则是:看类图的人能不能通过属性理解这个类的核心职责。
方法那一格,写的是这个类“能做什么”。这里最常见的错误是把所有getter/setter都写上去。完全没有必要。方法应该只写那些体现业务行为的。比如订单类有“计算总价”“提交订单”“取消订单”这些方法,这些是业务行为,值得写。而“setStatus”“getStatus”这种纯数据访问方法,写上去只会让图变得嘈杂。还有一个技巧:如果某个方法是私有的,可以在方法名前面加一个减号(-),公开的加加号(+),保护的加井号(#)。这个可见性标记在团队协作时很有用,它告诉读图的人哪些方法是内部实现细节,哪些是对外暴露的接口。
注意:类图不是代码的逐行翻译。如果你发现类图里的方法和代码里的方法一一对应,那说明你画得太细了。类图应该比代码高一个抽象层级,它描述的是设计意图,不是实现细节。
2.2 六种关系,哪些真正常用
类图里的关系符号是最让人头疼的部分。依赖、关联、聚合、组合、泛化、实现,六种关系,每种都有不同的箭头和线条样式。很多教程会花大量篇幅讲这些符号的区别,但实际工作中,真正高频使用的只有三四种。我先把六种关系过一遍,然后告诉你哪些值得花时间搞清楚。
泛化就是继承,画成一条实线加空心三角箭头,箭头指向父类。这个最直观,没什么歧义。实现是类实现接口,画成虚线加空心三角箭头,箭头指向接口。这两个在Java、C#这类语言里天天用,画起来也简单。
依赖画成虚线加普通箭头,表示一个类“临时使用”另一个类。比如订单服务在处理支付的时候,临时需要一个支付网关对象来发请求,但订单服务本身不持有支付网关的引用。依赖关系的特点是短暂、偶然,通常体现在方法参数或者局部变量里。关联画成实线加普通箭头,表示一个类“长期持有”另一个类的引用。比如订单类里有一个客户对象的成员变量,这就是关联。关联和依赖的区别在于生命周期:关联是长期的、结构性的,依赖是临时的、行为性的。
聚合和组合是关联的两种特殊形式,都表示“整体-部分”关系。聚合画成实线加空心菱形,菱形在整体那一端;组合画成实线加实心菱形,菱形也在整体那一端。区别在于部分能否独立于整体存在。聚合的例子:一个团队有多个成员,团队解散了,成员还在,这就是聚合。组合的例子:一个订单有多个订单项,订单删除了,订单项也没有意义了,这就是组合。组合的耦合度比聚合高,整体负责管理部分的创建和销毁。
实际工作中,我的建议是:泛化、实现、关联、依赖这四个必须搞清楚,聚合和组合知道概念即可,画图时不必过分纠结。为什么?因为聚合和组合的区分在代码层面往往体现为“部分对象是不是在整体对象内部new出来的”,这个细节在概念模型阶段并不重要。很多团队画图时干脆把聚合和组合都画成普通关联,然后在旁边加文字注释说明生命周期关系,这样反而更清晰。我见过有人为了区分聚合和组合争论半小时,最后代码写出来发现两种方式都行得通,那争论的意义就不大。
| 关系类型 | 线条样式 | 语义 | 代码体现 | 使用频率 |
|---|---|---|---|---|
| 泛化 | 实线+空心三角 | 继承 | extends | 高 |
| 实现 | 虚线+空心三角 | 实现接口 | implements | 高 |
| 关联 | 实线+普通箭头 | 长期持有引用 | 成员变量 | 高 |
| 依赖 | 虚线+普通箭头 | 临时使用 | 方法参数/局部变量 | 高 |
| 聚合 | 实线+空心菱形 | 整体-部分(可独立) | 成员变量(外部传入) | 中 |
| 组合 | 实线+实心菱形 | 整体-部分(不可独立) | 成员变量(内部创建) | 中 |
2.3 多重性标注:别小看那个数字
关系线两端的数字标注,学名叫多重性。常见的有1、0..1、、1..、0..。这个标注看起来不起眼,但它传递的信息量非常大。比如订单和订单项之间标了1对1..,意思是“一个订单至少有一个订单项”,这个“至少一个”就是业务规则。如果开发人员没注意到这个约束,写代码的时候就可能允许创建一个空订单,这就是bug的源头。
多重性标注的常见错误是只标一边。比如画了订单到客户的关联,只在订单那边标了“*”,客户那边什么都没标。这就有歧义了:一个客户可以有多少个订单?是0个、1个还是多个?读图的人只能猜。正确的做法是两端都标清楚。还有一个错误是标注跟业务规则不一致。比如业务上规定一个客户可以有多个订单,但画图的时候随手标了个1,后面开发就按1对1来设计,等到需求变更的时候才发现要改表结构。这种坑我踩过不止一次,后来养成了习惯:每画一条关系线,先问自己两端的多重性分别是什么,写下来,再画。
提示:多重性标注是类图里性价比最高的信息。一个数字就能表达一条业务约束,比写一段文字说明直观得多。画图时宁可多花十秒钟确认多重性,也不要留空。
3. 从零画一张能用的类图
3.1 先定边界,再找实体
画类图最怕上来就画。打开工具就开始拖方框,画到一半发现漏了核心概念,或者画了一堆不需要的类,返工成本很高。我的做法是分三步走:定边界、找名词、筛实体。
定边界的意思是,先明确这张类图描述的是哪个范围。是整个系统?还是某个子系统?还是某个业务模块?范围不同,类图的颗粒度完全不同。如果画整个电商系统,那类图里可能有几十个类,根本看不清;如果只画订单模块,那类图里可能就七八个类,清晰明了。我的经验是,一张类图控制在5到15个类之间,超过15个就拆成多张,用包图或者分组框来组织。这个数量不是拍脑袋定的,而是基于人的认知负荷:一张图里超过15个元素,读图的人就很难在脑子里建立完整的结构关系。
找名词的方法很土但很有效:把需求文档或者用户故事里所有的名词圈出来。比如“用户下单后,系统生成订单,订单包含多个商品,每个商品有价格和数量,订单关联收货地址和支付方式”。这段话里的名词有:用户、订单、商品、价格、数量、收货地址、支付方式。这些名词就是候选实体。注意“价格”和“数量”在这里是商品的属性还是独立实体?如果价格会随时间变化、需要记录历史价格,那它可能是一个独立的“价格记录”实体;如果只是下单那一刻的快照,那它就是订单项的一个属性。这个判断需要结合业务需求来做。
筛实体的标准有三个:有身份标识、有生命周期、有业务行为。有身份标识的意思是它能被唯一区分,比如订单有订单号,商品有商品编码。有生命周期的意思是它从创建到销毁有一个过程,比如订单从创建到完成或取消。有业务行为的意思是它不只是数据的容器,还有自己的逻辑,比如订单能计算总价、能取消、能退款。三个条件满足两个以上,就值得作为独立类画出来。只满足一个的,大概率是另一个类的属性。
3.2 关系梳理的实操顺序
实体找出来之后,下一步是梳理关系。我的习惯顺序是:先泛化,再组合,后关联,最后依赖。为什么是这个顺序?因为泛化关系决定了类的层次结构,它是最稳定的,先定下来后面不容易变。组合关系决定了哪些类必须一起存在,它影响模块划分。关联关系是类之间的引用,相对灵活。依赖关系最临时,放在最后画,而且很多依赖关系在概念模型阶段可以省略。
具体操作时,拿一张纸或者打开工具,把筛选出来的类先摆开,然后逐个问三个问题:这个类跟哪个类有“是一种”的关系?这个类跟哪个类有“整体-部分”的关系?这个类需要知道哪个类的信息才能完成自己的职责?第一个问题找出泛化,第二个问题找出组合或聚合,第三个问题找出关联。依赖关系不用刻意找,画完关联之后,那些没有关联但有交互的类之间自然就是依赖。
这里有一个容易忽略的点:关系的方向。关联关系是有方向的,箭头从“知道对方”的类指向“被知道”的类。比如订单知道客户是谁,但客户不需要知道订单(从客户这个类的职责来看,它不负责管理订单),那箭头就从订单指向客户。方向搞反了,读图的人会误解类的职责划分。我见过一张图,客户和订单之间画了双向箭头,问画图的人为什么双向,他说“客户能查自己的订单啊”。这就是把“查询操作”和“结构关系”混为一谈了。客户能查订单,是通过订单服务或者订单仓库来查的,不代表客户类里要持有一个订单列表的引用。结构关系要看类的成员变量,不是看业务操作。
3.3 属性与方法的填充策略
关系和结构定下来之后,再往类框里填属性和方法。这个顺序很重要:先定关系,后填细节。因为关系决定了类的职责边界,职责边界清楚了,属性和方法自然就知道该放哪。如果先填属性和方法,很容易出现“这个属性到底该放A类还是B类”的纠结,因为职责边界还没定。
填属性的时候,我遵循一个原则:只写业务属性,不写技术属性。业务属性是业务人员能看懂的,比如订单金额、商品名称、收货地址。技术属性是开发人员关心的,比如主键ID、创建时间、版本号、删除标记。技术属性在类图里写不写?我的做法是不写,或者最多写一个“id”作为身份标识。原因很简单:类图是给团队看的,包括产品经理和测试人员,技术属性对他们来说是噪音。而且技术属性往往跟具体的持久化方案绑定,概念模型阶段还没定用MySQL还是MongoDB,写这些属性没有意义。
填方法的时候,只写public的业务方法。私有方法除非特别重要(比如某个核心算法),否则不写。构造方法不写。getter/setter不写。方法的命名用动词+名词的形式,比如“计算总价”“提交订单”“取消订单”。参数和返回值在概念模型阶段可以省略,或者只写类型不写参数名。如果某个方法特别关键,可以在方法名后面加一个简短的注释,说明它的业务规则,比如“计算总价(含运费和折扣)”。这个注释比画一堆细节更有用。
注意:类图里的方法不是越多越好。一个类如果列了十几个方法,读图的人根本记不住。我的经验是每个类的方法控制在5个以内,只保留最核心的业务行为。那些辅助性的、工具性的方法,在类图里省略不影响理解。
4. 类图落地时的常见坑与排查
4.1 画完没人看,问题出在哪
类图画完没人看,这是最常见也最致命的问题。我分析过原因,大概有三种。第一种是画得太细,把类图当成了代码的图形化版本,每个字段每个方法都列上去,读图的人一看就头大,直接跳过。第二种是画得太抽象,只有类名和几条线,没有任何属性和方法,读图的人不知道这些类到底干什么用,看了等于没看。第三种是画完就锁进文档库,后续代码改了类图不改,类图和代码脱节,慢慢就没人信了。
针对第一种,解决办法是分层画图。概念层只画类名和关系,不画属性和方法,用于跟产品经理对齐业务概念;设计层画核心属性和关键方法,用于指导开发;实现层才画完整的属性和方法,用于生成代码或者写详细设计文档。大部分场景下,设计层就够了。针对第二种,解决办法是给每个类写一句话职责说明,放在类名下方或者用注释框标注。这句话不用长,十个字以内,比如“订单:管理订单生命周期和金额计算”。这一句话就能让读图的人快速理解类的定位。针对第三种,解决办法是把类图纳入代码评审流程,每次有结构性变更的时候,先改类图再改代码,让类图成为设计讨论的起点而不是事后补的作业。
还有一个容易被忽略的点:类图的可读性。同样一张图,布局好坏对理解成本影响巨大。我的布局原则是:继承关系从上到下,关联关系从左到右,核心类放中间,辅助类放边缘。线条尽量不要交叉,如果交叉不可避免,用跳线或者调整布局来减少视觉干扰。颜色不要用太多,两三种就够了,比如核心领域类用一种颜色,辅助类用另一种颜色。这些细节看起来是小事,但实际用起来差别很大。
4.2 类图与代码不一致怎么破
类图和代码不一致,几乎是所有团队的通病。原因很简单:代码是活的,类图是死的。代码天天改,类图改一次管半年。要解决这个问题,靠“提高意识”没用,得靠机制。我试过几种做法,说下效果。
第一种是代码生成类图。很多IDE和建模工具支持从代码反向生成类图,比如IntelliJ IDEA的Diagrams功能、PlantUML的代码扫描插件。这个做法的好处是类图永远跟代码一致,坏处是生成的图往往太细,包含大量技术细节,可读性差。我的用法是:用反向生成做差异检查,定期跑一次,看看代码里多了哪些类、少了哪些关系,然后手动更新设计层的类图。这样既保证了准确性,又保持了可读性。
第二种是类图即代码。用PlantUML或者Mermaid这样的文本化建模工具,把类图写成代码,跟项目代码一起提交到版本库。这样类图的变更历史可追溯,而且修改成本低。PlantUML的语法很简单,画一个类就几行代码,改起来比拖拽工具快得多。我现在的习惯是,设计讨论的时候直接在文档里写PlantUML代码,讨论完了代码也写完了,直接提交。这个做法特别适合敏捷团队,因为类图可以跟着迭代快速更新。
第三种是只画核心结构。不是所有类都值得画进类图。我的做法是只画领域模型,也就是业务核心概念之间的关系,那些技术性的类(工具类、配置类、框架适配类)不画。领域模型相对稳定,变更频率低,维护成本也就低。至于技术类的结构,用包图或者模块图来表达就够了,不需要精确到类级别。
| 问题现象 | 根因 | 解决思路 | 实操建议 |
|---|---|---|---|
| 类图太细没人看 | 把实现细节画进去了 | 分层画图,概念层/设计层/实现层分开 | 设计层只画核心属性和关键方法 |
| 类图太抽象看不懂 | 缺少职责说明 | 每个类加一句话职责注释 | 注释控制在10字以内 |
| 类图与代码脱节 | 缺少更新机制 | 文本化建模+版本库管理 | 用PlantUML写类图,跟代码一起提交 |
| 关系方向画反 | 混淆结构关系和操作关系 | 看成员变量定方向,不看业务操作 | 问自己“这个类里有没有对方的引用” |
| 多重性标注缺失 | 画图时没想清楚业务规则 | 每条关系线两端都标多重性 | 标注前先跟业务方确认约束 |
4.3 团队协作中的类图规范
团队一起画类图,最怕风格不统一。A画的类图用圆角矩形,B画的用直角矩形;A把属性写在方法上面,B反过来;A用中文命名,B用英文命名。这些不一致看起来是小事,但放在一起就是灾难。我的建议是,团队在开始画类图之前,先花半小时定一个最小规范,不用太复杂,覆盖以下几点就够了。
命名规范:类名用单数名词,首字母大写,不用下划线;属性名用小驼峰;方法名用动词开头的小驼峰。语言上统一用英文还是中文,团队自己定,但必须统一。我倾向于用英文,因为跟代码一致,减少翻译成本。但如果团队的产品经理需要直接读图,用中文也完全可以,关键是统一。
颗粒度规范:明确哪些类必须画,哪些可以不画。比如领域实体必须画,工具类不画,接口画不画看情况。属性和方法写到什么程度,也定一个标准,比如“只写业务属性和public业务方法”。这个标准写在一页纸的文档里,新成员进来先看这个文档。
工具规范:统一用同一个工具。PlantUML、draw.io、StarUML、Visual Paradigm,选一个团队用得顺手的,不要混用。混用会导致格式不兼容,合并的时候很麻烦。如果团队用Git管理文档,强烈建议用PlantUML这种文本化工具,diff和merge都方便。
提示:规范不用追求完美,先定一个能用的版本,跑一两个迭代再调整。最怕的是花两周时间讨论规范,结果一张图都没画。
5. 类图在不同场景下的变通用法
5.1 快速沟通场景:白板类图
不是所有类图都需要用工具画。很多时候,最快的沟通方式是在白板上画草图。白板类图的特点是快、糙、够用。不用纠结线条样式,不用管多重性标注,甚至不用写全类名,写个缩写就行。关键是快速把结构关系表达出来,让讨论有焦点。
我常用的白板类图画法是:方框代表类,里面只写类名;关系用箭头表示,箭头旁边写个数字表示多重性;如果某个类特别重要,在方框下面写一行职责说明。就这些。五分钟能画完一个模块的结构,然后大家对着图讨论哪里有问题。讨论完了拍张照片,会后用工具整理成正式版本。这个流程比直接上工具画效率高得多,因为白板允许犯错,允许涂改,思维不会被工具束缚。
白板类图还有一个好处:它天然是协作的。工具画图往往是一个人画、其他人看,白板画图是大家一起画、一起改。我见过很多设计决策是在白板前争论出来的,而不是在文档里写出来的。所以如果你的团队还在用“一个人画完发群里”的方式做设计,可以试试改成“白板前一起画”,效果会好很多。
5.2 文档沉淀场景:PlantUML文本化类图
需要把类图沉淀到文档里的时候,我首选PlantUML。原因很简单:文本格式,版本可控,修改成本低。一段PlantUML代码几十行,放在Markdown文档里,跟文字混排,阅读体验很好。而且PlantUML的语法很直观,没学过的人也能看懂个大概。
画一个订单模块的类图,PlantUML代码大概长这样:
@startuml class Order { - orderId: String - status: OrderStatus - totalAmount: BigDecimal + calculateTotal(): BigDecimal + submit(): void + cancel(): void } class OrderItem { - quantity: int - unitPrice: BigDecimal + subtotal(): BigDecimal } class Customer { - customerId: String - name: String } class Address { - province: String - city: String - detail: String } Order "1" *-- "1..*" OrderItem Order "0..*" --> "1" Customer Order "1" --> "1" Address @enduml这段代码渲染出来的图,结构清晰,关系明确。关键是它可以跟代码一起放在版本库里,每次修改都有记录。如果团队用Confluence或者语雀写文档,PlantUML插件可以直接渲染,不需要额外装工具。如果团队用GitHub或者GitLab,Markdown里的PlantUML代码块也能通过插件渲染。这个方案的迁移成本很低,值得推广。
5.3 代码审查场景:用类图做设计评审
类图还有一个被低估的用法:做代码审查的辅助工具。代码审查的时候,如果只看diff,很难判断一个改动对整体结构的影响。但如果有一张类图,审查者可以快速判断:新增的这个类放在哪个位置合适?它跟现有类的关系是否合理?有没有引入不必要的耦合?
我的做法是,在提交涉及结构性变更的代码时,附带一张更新后的类图。不需要很详细,标出新增的类、修改的关系就行。审查者对着类图看代码,比只看代码效率高得多。这个做法还有一个副作用:它倒逼开发者在写代码之前先想清楚结构,因为如果结构不合理,类图上一眼就能看出来。我试过一段时间之后,团队里“先写代码再补设计”的情况明显减少了。
6. 我踩过的坑和总结出的几条经验
说几个我实际踩过的坑,都是教科书上不会写的。
第一个坑:过度设计。刚开始画类图的时候,总想把所有可能性都考虑到,画了一堆抽象类和接口,结果代码写出来发现根本用不上。后来我学乖了,类图只画当前迭代需要的东西,未来的扩展点用注释标一下就行,不用真的画出来。YAGNI原则在类图设计上同样适用。
第二个坑:关系画得太满。每个类之间都画关系,图看起来密密麻麻,实际上很多关系是间接的、可以推导出来的。比如A关联B,B关联C,那A和C之间就不需要再画一条线。类图应该表达直接关系,间接关系让读图的人自己推导,这样图才清爽。
第三个坑:忽略非功能需求。类图只表达了结构,但性能、并发、事务这些非功能需求在类图上是看不出来的。比如订单和库存之间,类图上画一条关联就完了,但实际代码里要考虑并发扣减、分布式事务。这些信息如果不在类图旁边用注释说明,开发人员可能会忽略。我的做法是,在关键类或者关键关系旁边加一个注释框,写清楚非功能约束,比如“扣减库存需要加锁”。
第四个坑:把类图当文档交付物。有一段时间,项目要求每个模块都必须提交类图作为交付物,结果大家为了交差而画图,画完就扔,没有任何实际价值。后来我们改了做法,类图只在设计讨论和代码评审两个场景下使用,不强制交付。反而大家画得更认真了,因为画图是为了解决问题,不是为了交作业。
最后分享一个我觉得最实用的经验:类图的价值在于画的过程,不在于画的结果。你花两个小时画一张类图,最大的收获不是那张图本身,而是画图过程中你对系统结构的思考。哪些类该合并、哪些关系该拆开、哪些职责放错了位置,这些问题在画图的时候会自然浮现出来。所以我的建议是,不管最终画得好不好看,先画起来。画完再改,改完再画,迭代两三轮之后,你对系统的理解会完全不一样。至于那张图最后是贴在墙上还是锁进文档库,反而不重要了。