大多数 UML 图只活在教科书里,而类图是工程师至今仍在画的那一种:它用一张图描绘系统所关心的事物、每样事物持有什么、能做什么,以及它们之间如何关联。画得好的类图,能在任何人动手写迁移脚本或 API 之前,就结束关于领域模型的争论。
本文为一个小型在线课程平台绘制 UML 类图,每次只做一个决定,并在用到时讲解相应的表示法:可见性、多重性、关联、组合、继承和接口。最后介绍会让类图失去价值的常见错误,并简单看看图如何映射到代码。
什么时候该用类图
UML 2.5.1 规范把图分为结构图和行为图两类。类图属于结构图:它展示类型、类型的属性和操作,以及类型之间的关系,不包含任何时间概念。
类图适合用来画领域模型、库的公开类型,或者你即将实现的设计。它不适合用来罗列一个仓库里的所有类:IDE 在这方面做得更好,而且下一次提交之后图就过时了。
用两张表看懂表示法
类是一个最多有三个分栏的方框:名称、属性和操作。任何一个分栏都可以省略。
| 元素 | 表示法 | 含义 |
|---|---|---|
| 抽象类 | 名称用斜体,或写 {abstract} |
不能实例化,由子类补全 |
| 接口 | 名称上方写 «interface» |
一组操作的契约 |
| 可见性 | + public、- private、# protected、~ package |
谁可以使用该特性 |
| 属性 | name: Type [multiplicity] = default |
对象持有的数据 |
| 操作 | name(param: Type): ReturnType |
对象提供的行为 |
| 静态特性 | 加下划线 | 属于类本身,而不是某个实例 |
关系是类之间的连线,而大部分含义都由线的两端承载:
| 关系 | 线型 | 读作 |
|---|---|---|
| 关联 | 实线;空心箭头标出可导航的一端 | “与……相关” |
| 聚合 | 实线,整体一端为空心菱形 | “聚集”;部分可以独立存在 |
| 组合 | 实线,整体一端为实心菱形 | “由……组成”;部分随整体一同消失 |
| 泛化 | 实线,父类一端为空心三角形 | “是一种……” |
| 实现 | 虚线,接口一端为空心三角形 | “实现” |
| 依赖 | 虚线,目标一端为空心箭头 | “使用”;目标的改动可能破坏源头 |
组合是最严格的关系。按照规范,一个部分对象在同一时刻最多属于一个组合体,组合体被删除时,它的部分也随之删除。共享聚合(空心菱形)则比较含糊:规范本身就说,它的确切语义因应用领域和建模者而异。只有当团队对它的含义达成一致时才使用;普通的关联能表达同样的意思,争议也更少。
多重性
多重性表示关联的一端可以有多少个对象:1 表示恰好一个,0..1 表示可选,*(与 0..* 相同)表示任意数量,1..* 表示至少一个,也可以写 2..5 这样的范围。把它写在被计数的那个类旁边的一端。Course 1 — 1..* Module 读作:“一门课程有一个或多个模块,每个模块恰好属于一门课程。”
漏写多重性,是类图引发争论而不是终结争论的最常见原因。下面的例子中,每个关联的两端都写明了多重性。
示例:在线课程平台
先从朴素的句子开始,而不是从方框开始:
- 讲师发布课程。一门课程有一位或多位讲师。
- 课程由有序的模块组成,模块由有序的课时组成。
- 课时可以是视频、测验或作业。
- 测验和作业需要评分,视频不需要。
- 学员选修课程。选课记录跟踪学习进度,并保存学员提交的作业。
- 学完一门课程的学员,会为这次选课获得一张证书。
- 课程按类别分组,一门课程可以属于多个类别。
用户:一个类,两个角色
第一反应往往是把 Student 和 Instructor 设计成 User 的子类。可一旦某位讲师去学别人的课程,这个设计就崩了:在大多数编程语言里,一个对象一生只有一个类,既不能同时是两者,之后也不能切换。应该把角色建模为关联:一个 User 讲授课程,一个 User 拥有选课记录。继承要留给那些终其一生都属于某一类的事物,比如测验是课时的一种。
生命周期绑定时使用组合
模块离开所属课程就没有意义,课时离开所属模块也一样。删除一门课程,两者都会被删除。这就是组合:在 Course 和 Module 一端画实心菱形,整体一端写 1。类别则不同。删除“数据工程”类别,不能把其中的课程也删掉,所以 Category 到 Course 是聚合,两端都是 *。
继承加接口
Lesson 是抽象类;VideoLesson、Quiz 和 Assignment 继承它的标题和位置。评分则横跨这棵继承树:测验和作业要评分,视频不用,而明年新增的同伴互评可能需要评分,却根本不是课时。这就是接口 Gradable,由 Quiz 和 Assignment 实现。GradingService 依赖 Gradable,而不是具体类,虚线依赖箭头表达的正是这一点。
带有自身数据的关联
Enrollment 位于 User 和 Course 之间。UML 为此提供了专门的表示法,即关联类,用一条虚线挂在关联线上。不过大多数团队会把它画成一个普通的类,带两条关联,因为它有自己的身份、数据和行为:选课日期、进度、提交的作业,以及最多一张证书。
图表内容
- Category
- Course
- User
- Module
- Enrollment
- Certificate
- Lesson
- Submission
- VideoLesson
- Quiz
- Assignment
- Gradable
- GradingService
- groups
- teaches
- enrolls
- for
- earns
- holds
- answers
- «use»
这张图的读法如下:
- 左上,课程目录。
Course由Module组成,模块又由Lesson组成,实心菱形位于整体一端。Category用空心菱形把课程归组。 - 左下,课时类型。
Lesson是抽象类。VideoLesson、Quiz和Assignment用空心三角形指向它。 - 右侧,学习。 一个
User讲授课程,每门课程有1..*位讲师;一个用户可以有任意数量的选课记录,每条记录恰好对应一门课程。一个Enrollment保存提交的作业,最多获得一张Certificate。 - 右下,评分。
Quiz和Assignment通过虚线和空心三角形实现Gradable。每个Submission对应一个可评分项,GradingService只依赖接口。
常见错误
- 把代码库里的每个类都画上。 每张图十到十五个类是个不错的上限。更大的模型可以按领域拆分,或者把其余的类只画成不带分栏的名称。
- 把 getter 和 setter 列为操作。 应该列出有领域含义的操作,比如
publish()或enroll(),而不是访问器。 - 把表画成类。 像
courseId这样的属性应该放在 ER 图里。在类图中,关联线本身就是引用。 - 用继承表示角色。 如果一个对象可以获得或失去某个角色,那就是关联,而不是子类。
- 没有含义的菱形。 如果没人说得清删除整体会对部分产生什么影响,就用普通关联。
- 在领域层面暴露实现细节。 除非图本身就是讲这些,否则省略框架注解、DTO 和仓储类。
从图到代码
这张图可以直接映射到代码。用 TypeScript 写出来是这样:
interface Gradable {
maxScore(): number;
autoGrade(submission: Submission): Score | null;
}
abstract class Lesson {
constructor(public title: string, public position: number) {}
}
class Quiz extends Lesson implements Gradable {
questions: Question[] = [];
maxScore(): number {
return this.questions.length;
}
autoGrade(submission: Submission): Score | null {
return submission.scoreAgainst(this.questions);
}
}
class Course {
private readonly modules: Module[] = []; // composition: owned and removed with the course
categories: Category[] = []; // aggregation: categories exist on their own
addModule(title: string): Module {
const created = new Module(title, this.modules.length);
this.modules.push(created);
return created;
}
}
组合变成了所有权:课程自己创建模块,课程消失后,没有任何东西会让模块继续存活。聚合则变成对一个拥有独立生命周期的对象的引用。代码和图不一致时,在同一个拉取请求里更新图;正如不会过时的架构图一文所说,当图是一个链接而不是截图时,这件事会容易得多。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 为每个概念添加一个 UML 节点包中的 类,属性和操作每行写一个。把
Lesson的构造型设为abstract。 - 为
Gradable添加一个 接口,写上它的两个操作。 - 连接各个类。整体一端的箭头样式,组合设为 实心菱形,聚合设为 菱形;继承则在父类一端设为 三角。实现画成在接口一端带 三角 的虚线,依赖画成带 空心箭头 的虚线。
- 把多重性作为文本放在每一端旁边,并在连线标签里写上关联名称,比如“teaches”。
- 为设计决定添加一个 UML 注释,比如为什么把角色建模为关联。
这个示例共用 13 个图表节点,在 Free 套餐的限制之内。借助通过 MCP 连接的 AI 智能体,你可以描述需求,让它先起草这些类,再自己修正多重性。
常见问题
类图和对象图有什么区别? 类图展示类型以及类型之间的规则。对象图展示某一时刻的具体实例,包括它们的值和链接,适合用真实的例子来检验类图。
聚合和组合有什么区别? 在组合中,部分只属于一个整体,并随整体一起删除。在聚合中,部分可以独立存在,也可以被共享;它的确切含义由建模者决定,所以很多团队干脆改用普通关联。
类图必须和代码完全一致吗? 领域模型应当与概念和关系保持一致,而不是与每个字段和方法一致。把它保持在产品经理和工程师都能读懂的层次。
类图和 C4 组件图有什么不同? C4 组件图展示可部署的构建块及其职责。类图再往下深入一层,展示它们内部的类型;参见 C4 模型图示例。
可以从代码自动生成类图吗? 很多 IDE 都可以,但生成的结果会列出所有类,很难阅读。领域模型最好手工绘制,或借助 AI 智能体绘制,并只保留重要的类。