ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

UML类图详解:六种关系区分与实战绘制指南

UML类图详解:六种关系区分与实战绘制指南 很多年不带实习生的时候我心里其实有点发怵——怕把人家带偏。尤其画 UML 类图这件事教科书上讲得特别严谨可真正落到项目里十个人能画出八种风格。有人在类框里只写个类名有人把聚合和组合混着用还有人一张图塞了二十多个类箭头密密麻麻看得人头皮发麻。我写这篇“UML详解1”就是想把这些年画类图踩过的坑、总结出来的判断方法一次说清楚。重点就两件事类与类之间那几种关系到底怎么区分以及一张能真正指导编码的类图应该怎么画、用什么工具画。适合刚接触 UML 的学生也适合写技术文档、做系统设计时急需理清类关系的开发同学。看完你应该能对着自己的代码或者对着一个需求独立画出一张关系正确、别人看得懂的类图。1. 类框里那点事属性、方法与可见性符号1.1 类的三段式结构别把类名一写就完事类图里最基本的元素就是类框。很多人以为画类图就是把类的名字放上去然后拉几条线连起来这是最大的误解。一个规范的类框是分成三段的矩形第一段写类名如果是抽象类类名用斜体表示第二段写属性成员变量第三段写方法操作。举个例子一个用户类User标准画法是------------------------ | User | ------------------------ | - id: Long | | - name: String | | - email: String | ------------------------ | login(): void | | changePassword(old: String, new: String): boolean | ------------------------看着简单但这里有个容易被忽略的点类名必须能准确表达这个类在系统中的角色不能用DataBean、ManagerUtil这种含糊的名字。我在代码评审里见过类名叫DataInfo的打开一看里面既有订单信息又有用户信息——这种类在图上也画不清楚它到底跟谁关联。类名是类图的第一信息起名偷懒后面所有关系都会跟着乱。如果把 Java 和 C# 的习惯带进来类名首字母大写、驼峰命名属性名和方法名首字母小写、驼峰命名这是跨语言都通用的惯例。真要画 Python 代码对应的类图也是同样的规则只是类型标注风格略有不同。1.2 可见性符号与类型标注画全但别画满类框内部属性和方法前面的符号表示可见性UML 标准里就四种符号含义public公共可见-private仅类内部可见#protected子类及同包可见~package包内可见属性格式是可见性 属性名: 类型方法格式是可见性 方法名(参数名: 参数类型): 返回类型。静态成员Java 里的static加下划线抽象方法用斜体这些细节都是 UML 规范里明确写的。不过我建议一个原则画全但别画满。画全的意思是类型要写完整id: Long比id有信息量得多别画满的意思是一个类有三十个属性和二十个方法时你不需要全部堆上去挑跟当前设计意图相关的成员展示即可。类图是用来沟通设计的不是用来替代源码的。塞得太满信息密度过大反而失去了“一眼看懂结构”的作用。很多工具比如 IDEA 的类图插件能自动把全部成员生成出来那一屏幕的 getter/setter 就是典型的“画满”反例。我一般只保留核心业务字段和关键业务方法访问器方法默认隐藏。2. 六种类关系的语义拆解从代码习惯到图形符号2.1 依赖关系最弱的关系虚线箭头依赖关系在 UML 里用一条带开放箭头的虚线表示从使用方指向被使用方。它的语义是一个类在某个时刻“临时用了一下”另一个类但并没有长期持有对方。代码里最常见的就是方法参数和局部变量。看一个例子public class OrderService { public void createOrder(ShoppingCart cart, User user) { // 这里使用了 cart 和 user但 OrderService 并没有保存它们 } public BigDecimal calculateDiscount() { DiscountCalculator calculator new DiscountCalculator(); return calculator.calculate(this.amount); } }OrderService和ShoppingCart的关系就是依赖方法调用结束后这种“使用关系”就不存在了。另一个典型场景是调用静态工具方法比如StringUtils.isEmpty(name)OrderService依赖StringUtils但两个类之间没有任何持有关系。绘制依赖关系最容易犯的错是一旦代码里“用到了”就把两个类画成关联甚至画成聚合。我后面会专门讲怎么区分这两者。先记住一个判断点依赖是短期的、一次性的使用关联是长期的、结构性的持有。2.2 关联关系长期握手的实线关联关系用一条实线表示可以是箭头也可以没有箭头双向关联就不画箭头。它的语义是一个类长期持有另一个类的引用也就是说另一个类以成员变量的形式存在于这个类中。看代码public class Teacher { private ListStudent students; public void addStudent(Student student) { students.add(student); } }Teacher持有ListStudent这就是一个关联关系。单项关联用一个开放箭头指向被持有方画成Teacher —— Student。双向关联则是一条没有箭头的实线两边都可以访问对方。关联关系和依赖关系的本质区别不是“画不画得出来”而是“引用存在的时间维度”。成员变量是跟对象生命周期绑定的方法参数只活在方法调用栈里。很多初学者把OrderService调用了OrderRepository就画成依赖这没错但如果OrderRepository是以private final字段注入进来的这就已经是关联了。2.3 聚合关系整体与部分的“松散联盟”聚合关系是关联关系的一种强化表示“整体与部分”的关系但整体和部分可以独立存在。图形上用一条实线在整体那一端加一个空心菱形。最好的例子是班级和学生。班级里有学生但学生转学了学生对象依然存在班级解散了学生也不会被销毁。代码上聚合通常表现为整体通过构造函数或 setter 接收部分public class SchoolClass { private ListStudent students; // 通过构造器传入学生对象在外部已经存在 public SchoolClass(ListStudent students) { this.students students; } }注意聚合在代码层面跟普通关联几乎一样都是成员变量。它区别于点“语义”——整体和部分是否有明确的“包含”意味。如果只是两个平级对象互相引用那就不是聚合只是关联。判断时不要只盯着代码结构要看业务语义。2.4 组合关系强绑定的“共生关系”组合关系是比聚合更强的整体-部分关系部分的生命周期完全由整体管理。图形上用实线加实心菱形实心菱形在整体那一端。最经典的例子是订单和订单项。一个订单项离开订单没有任何业务意义订单删除时订单项也应该被删除。代码上的典型实现是部分对象在整体内部创建new并且外部拿不到部分的引用public class Order { private ListOrderItem items; public Order() { // 订单项在订单内部创建外部无法独立持有 this.items new ArrayList(); } public void addItem(Product product, int quantity) { items.add(new OrderItem(product, quantity)); } }这里有个非常实用的区分口诀整体没了部分还在吗还在就是聚合不在了就是组合。还有另一句部分能被外部共享吗能共享是聚合不能共享是组合。学生可以被多个班级使用、可以被多个老师共享所以班级和学生是聚合一个订单项只属于一个订单属于组合。2.5 泛化继承关系实线空心三角泛化关系就是继承关系用一条实线加空心三角箭头箭头指向父类。语义是 “is-a”子类是父类的一种。代码示例public abstract class Animal { public abstract void speak(); } public class Dog extends Animal { Override public void speak() { System.out.println(Woof); } }画法Dog指向Animal实线空心三角形在Animal这端。泛化关系有个容易纠结的点要不要在子类里把父类的属性和方法重复画一遍不需要。子类框里只写自己新增的属性和方法继承自父类的部分看图的人自己会去父类里找重复画只会让类框图面混乱。2.6 实现关系虚线空心三角实现关系描述类实现接口用虚线加空心三角箭头箭头指向接口。代码上就是implementspublic interface Payment { void pay(BigDecimal amount); } public class Alipay implements Payment { Override public void pay(BigDecimal amount) { // 支付宝支付逻辑 } }画法Alipay指向Payment虚线空心三角形在Payment这端。这六种关系看起来不多但它们的符号非常相似尤其是依赖虚线开放箭头、泛化实线空心三角、实现虚线空心三角三种不熟悉的人经常画串。我把它们整理成一张汇总表画图时对着查关系图形符号代码体现强弱依赖虚线 开放箭头方法参数、局部变量、静态调用最弱关联实线可加箭头成员变量弱聚合实线 整体端空心菱形成员变量外部传入中组合实线 整体端实心菱形成员变量内部创建强泛化实线 空心三角箭头extends强实现虚线 空心三角箭头implements强3. 三组最容易画错的关系聚合与组合、依赖与关联、继承与实现3.1 聚合和组合区分的关键在生命周期与所有权很多教材把聚合和组合画得很接近导致实际使用中大家全凭感觉。其实只要抓住两个问题就能稳定判断第一整体的销毁会不会导致部分的销毁第二部分能否被整体之外的其他对象共享以“公司-部门-员工”为例。公司和部门是聚合还是组合很多教科书说“公司包含部门部门解散了就不存在了”所以画组合。但实际业务中部门是一个独立实体可以被重新分配到新公司下组织架构调整时部门整体转移是常见操作。从生命周期来看公司破产部门这个“组织单元”的数据可能仍然存在只是归档了。所以我的判断是聚合更贴近现实语义。而“订单-订单项”则几乎没有争议地是组合。OrderItem这个对象脱离了Order在业务上没有任何独立存在意义它不可能被两个订单共享。判断时还有一个代码层面的参考组合关系中部分对象通常由整体内部new出来整体对外部只暴露操作接口不暴露部分对象的引用聚合关系中部分对象通常在外部创建好再通过构造器、Setter 注入给整体。我在代码评审时看到构造器里new了一个复杂业务对象并且保存成字段就会多问一句这个对象是否应该独立存在如果答案是否定的那你画的类图应该是组合而不是聚合。3.2 依赖和关联看“引用”存在多久依赖和关联在代码里的判别最直接就看这个引用是放在字段上还是放在方法里。// 依赖方法参数 public void export(ReportData data) { } // 关联成员字段 public class ReportService { private ReportRepository repository; }但有一种情况经常让人犯迷糊类 A 的方法里通过new创建了类 B 的实例并且使用这算依赖还是关联看一段代码public class NotificationService { public void sendWelcomeEmail(User user) { EmailSender sender new EmailSender(config); sender.send(user.getEmail(), Welcome); } }这里NotificationService和EmailSender之间是依赖关系。虽然代码里出现了new EmailSender(config)但EmailSender实例的生命周期完全在方法内方法返回后就不存在了。真正定义类之间长期结构关系的是成员字段不是方法内部的临时对象创建。还有一种特殊情况是静态工具类调用。UserUtil.formatName(name)这种调用是典型的依赖哪怕你的代码里到处都在调用它它在 UML 上也依然是依赖因为UserUtil不会作为状态存储在UserService里。这一点经常被人忽略一张图上如果一个类被十几个类依赖那很正常如果你画成十几个关联图就乱得没法看了。3.3 继承和实现别把接口实现画成继承继承和实现在语义上一个关键词是“复用”另一个是“契约”。继承表达的是“子类复用父类代码并且子类是父类的一种”代码里对应extends实现表达的是“类遵循接口的契约但两者没有 is-a 的强约束”代码里对应implements。画图时很多人会把接口理解成一个“特殊的父类”然后画成实线空心三角。这是常见的错误。范型的判断很简单看代码里是 extends 还是 implements。extends 对应实线implements 对应虚线这在 Java、C#、TypeScript 里都适用。我还遇到过一个有意思的场景一个类同时继承了一个抽象类并实现了多个接口。画图时三者都要体现抽象类用实线空心三角接口用虚线空心三角各自独立连线互不干扰。这样图面虽然线多但每条线的语义是清晰的。另外多说一句UML 的“实现”关系只适用于接口吗严格说实现关系还可以用于描述“用例与用例的实现”等场景但在类图的日常绘制中我们只需要关注“类实现接口”这一种情况别过度扩展。4. 从零画一张类图工具对比、绘制流程与出图细节4.1 主流工具怎么选画类图的工具五花八门我按自己的使用体验分了三类你可以按实际场景选工具特点适合场景StarUML桌面端、专业建模、支持多种 UML 图正式设计文档、教学演示PlantUML文本描述生成图片、可版本管理跟随代码提交、团队协作draw.io / diagrams.net免费、浏览器可用、模板丰富快速原型、非正式沟通VisioOffice 生态、拖拽方便公司标准文档、非技术人员协作IDEA / Eclipse 插件逆向生成代码类图理解已有代码结构我在团队里主要用 PlantUML原因是它能用文本来描述类图方便做代码审查diff 里能看到关系的变化也方便写进 Markdown 文档里。StarUML 适合需要精细排版、交付正式设计文档的场景。如果只是临时脑筋急转弯式地画一张给同事看draw.io 拖拽最快。有一个常见需求值得单独提一下从代码自动生成类图。IDEA 自带简单类图功能右键类 → Diagrams → Show DiagramEclipse 也有 ObjectAid 之类的插件。这类工具适合“逆向看懂别人写的代码”不适合“正向设计系统”因为工具生成的类图会把所有细节都暴露出来你还需要手动删减、调整关系后才适合给团队讲解。4.2 从需求到类图的核心步骤拿到一个需求怎么开始画我的流程是四步第一步找候选类。把需求描述里出现的名词圈出来。比如“用户下单购买商品”“用户”“订单”“商品”就是候选类。名词不一定全部成为类但如果多个名词之间有稳定的数据关系基本可以确定要建模。第二步找属性。每个名词有哪些信息需要记录用户有用户名、邮箱、手机号订单有订单号、金额、状态。属性直接用自然语言列出来画图时再补类型。第三步找关系。这是最花时间的一步。问自己三个问题哪些类之间是“拥有”关系订单拥有订单项哪些类之间是“使用”关系订单服务使用支付接口哪些类之间有“is-a”关系支付宝是一种支付方式把答案标注到类之间这一步基本就确定了关系类型。第四步标注多重性。每个关联关系的两端要写清楚数量关系。一个用户可以有零个或多个订单所以用户端是 1订单端是 0..一个订单包含一个或多个订单项订单端是 1订单项端是 1..。多重性是类图里最容易漏掉的信息但恰恰是数据库设计、接口定义时最重要的参考。举个例子画一个极简订单系统的类图候选类有User、Order、OrderItem、Product、Payment。关系词分析User 和 Order一个用户拥有多个订单 → 聚合1 对 0..*Order 和 OrderItem订单包含订单项订单删除订单项删除 → 组合1 对 1..*OrderItem 和 Product订单项引用商品快照 → 关联0..* 对 1Order 和 Payment订单使用支付接口完成支付 → 依赖或者关联取决于 Payment 是否作为长期策略持有这些关系画完这张类图基本就能指导建表和写代码了。4.3 我常用的绘制流程以 PlantUML 和 StarUML 为例PlantUML 画类图非常快用代码描述类与关系改起来也方便。一个最小示例startuml class User { - id: Long - name: String } class Order { - orderNo: String - amount: BigDecimal } class OrderItem { - productName: String - quantity: int } class Payment { interface pay(amount: BigDecimal): void } class Alipay implements Payment User 1 -- 0..* Order Order 1 *-- 1..* OrderItem OrderItem .. Product : uses endumlPlantUML 里--是实线..是虚线依赖*--是组合o--是聚合|是继承或实现配合implements关键字。语法记住这几个日常画图足够用了。StarUML 的好处是可交互地调整布局。我的操作习惯是新建项目选择 UML 模型在模型树里右键添加类双击类框分别填入属性、方法、可见性从工具栏拖出对应箭头Associations、Aggregation、Composition、Inheritance、Realization点击连线在两端属性面板里设置多重性Multiplicity。StarUML 有个小坑它默认的依赖箭头跟关联箭头长得比较接近颜色也相似画完后建议用图例或颜色区分一下关系类型否则打印出来容易看混。VI 是另一个极端用 Visio 画类图的模板需要自己找连线默认是直角线调整起来特别费劲。除非公司强制要求交付 Visio 格式否则我不太推荐在类图上用它。5. 拿到现成代码反推关系我的五步判断法5.1 五步法从代码看关系的完整链路写文档时我们经常要对着别人的老代码画类图。这不只是“把图生成出来”而是要理解代码里的关系。我总结了一个五步判断法基本能应付日常大部分情况。第一步看继承关键字。类声明里如果有extends、implements直接确定泛化和实现关系这是最没有争议的。把这两种关系挑出来剩下的都是一对一或一对多的“合作关系”。第二步看成员变量字段。这是关联关系、聚合关系、组合关系的来源。凡是出现在字段类型里的类一定有关联关系级别的连接。但要进一步区分是聚合还是组合需要进入第五步看生命周期。第三步看方法参数和返回类型。这些类跟当前类之间画依赖关系。不过要小心如果参数对象在方法内部被存入字段那它其实升格为关联关系了。这个细节很容易漏。第四步看方法内部的局部变量创建。new出来的、方法内短暂使用的对象画依赖。但局部变量如果是某个复杂子系统而且被持有在更外层那要看它在整个调用链中的角色不要过度把它当成依赖。第五步看生命周期的控制点。问自己三个问题这个对象是在外部创建好、传入构造器的吗如果是聚合。这个对象是在当前类内部new的吗当前类销毁、它也随之销毁吗如果是组合。如果这个对象是外部传入、但整体销毁后它还可以独立存在聚合。如果两者都模棱两可那就老老实实画关联不要硬凑聚合组合。5.2 实战案例订单系统的类图反推我拿一个真实项目中常见的订单模块来演示一下完整链路。假设代码结构是这样public class Order { private Long id; private LocalDateTime createTime; private ListOrderItem items; private User user; private Payment payment; public Order(User user, Payment payment) { this.user user; this.payment payment; this.items new ArrayList(); } public void addItem(Product product, int quantity) { items.add(new OrderItem(product, quantity)); } public void checkout() { payment.pay(this.getTotalAmount()); } }按五步法来第一步没有继承关键字跳过。第二步看字段ListOrderItem、User、Payment都是成员变量三者都可以画实线。Order对User是关联Order对Payment是关联Payment在构造器外部传入Order对OrderItem还要继续看生命周期。第三步addItem(Product product, int quantity)的参数Product在方法内被封装成OrderItemOrder对Product是依赖。方法内部的new OrderItem(...)指向OrderItem依赖 组合候选。第四步payment.pay()调用Payment接口的方法Order对Payment的依赖关系也存在。但因为Payment已经是成员变量所以整体画关联即可不需要再画一条依赖虚线避免重复线。第五步OrderItem在Order内部通过new创建且Order销毁后OrderItem没有独立价值所以Order与OrderItem是组合。User从外部传入Order没了User还在所以是普通关联。Payment从外部传入、策略可替换也是普通关联。最终类图的关系就是Order→User关联1 对 1Order→Payment关联1 对 1Order→OrderItem组合1 对 1..*Order→Product依赖通过方法参数产生。这个反推流程看起来很基础但真到了几十个类的大模块只要每个类都按这五步走一遍基本上关系不会画错。比对着代码人肉硬记高效得多。6. 日常画图最容易踩的坑关系方向与多重性6.1 箭头方向跟着“依赖/引用”走UML 里箭头的方向不是随便定的它是“谁知道谁、谁依赖谁”的体现。依赖关系和关联关系箭头从使用方指向被使用方泛化和实现关系箭头从子类指向父类/接口。这个方向弄反了看图的人就会把依赖关系理解成反向。我之前看过一张图UserService和UserRepository之间画了一个箭头从UserRepository指向UserService理由是“UserRepository 被 UserService 用”。但 UML 的标准语义是箭头的方向表示“依赖的方向”也就是“UserService 依赖 UserRepository”所以箭头应该从UserService指向UserRepository。这个细节初学者非常容易搞混画完最好自己念念看从 A 到 B 的箭头意味着 A 能看见 B、知道 B 的存在。如果这句话成立方向就对了。6.2 多重性标注确定是 1 还是 0..*多重性是类图里信息量最大的标注之一但也最容易被忽视。它描述的是一个整体对应多少个部分。常见的符号有这些符号含义1恰好一个0..1零个或一个* 或 0..*零个或多个1..*一个或多个标注的位置也有讲究多重性是写在线两端的。比如订单和订单项订单端写 1订单项端写 1..*表示“一个订单包含一到多个订单项”。反过来读也成立一个订单项必须属于一个订单。容易错的地方是画图时大家习惯性地把*写在右边但 UML 的多重性标注是跟具体那一端的位置绑定不跟纸张方向绑定。建议每条线画完都自上而下、自左而右读一遍确认数量词跟自己想表达的一致。多重性写错最直接的后果是数据库设计会跟着错。表结构里经常出现的“一条订单记录对应多条订单项记录”对应的就是 UML 里订单端 1、订单项端 1..。如果画成 1..对 1..*数据库就得设计成多对多的中间表完全是另一套方案了。6.3 双向关联要不要画取决于设计意图有些类之间确实是互相引用的。比如Teacher持有ListStudentStudent也持有Teacher。这种情况下你可以画一条没有箭头的实线表示双向关联。但双向关联在代码层面通常意味着耦合更紧我一般会建议在图上明确标注导航方向。所谓导航方向就是“这个类是否真的需要持有很多那个类的引用”。Teacher需要知道有哪些学生但Student真的需要知道所有老师吗很可能不需要。真实项目里大多数关联应该设计成单向的图上也只画单箭头。画图时如果发现一个类跟很多类都有双向关联这是个危险信号说明这个类的职责可能过重。我之前重构过一个数据导出模块就是因为ExportService关联了十几个配置类且大部分双向关联其实只是取一个配置值改成都依赖之后图面立刻清清爽爽。6.4 什么时候不需要画类图聊了这么多怎么画我想泼盆冷水不是所有项目都需要类图。一个只有五六个类的小工具或者一个还在原型验证阶段的功能硬画类图反而拖慢节奏。类图的价值在于沟通和设计验证当团队规模大、模块边界多、或者你的代码要实现一个有复杂状态流转的业务时类图才真能省钱。还有一类场景我也不建议一上来就画代码还在高频变动的阶段。这时候画类图图会天天过期最后没人维护。我的习惯是等模块的接口稳定下来之后再回头补一张类图用来沉淀设计而不是在代码还没定型时花大量精力去维护一张天天变的面板。最后分享一个我自己的实操技巧我把类图放进项目的docs/design目录用 PlantUML 写跟着代码一起走版本。每次代码评审时如果类结构有变更顺手把类图也改了这个习惯能省掉很多“文档和代码脱节”的麻烦。类图不是交付物而是思考工具这个定位想清楚了你自然知道该在什么时候、用什么深度去画它。
返回列表