做技术文档最折磨人的不是写代码,而是画图。方案评审要用例图,详细设计要类图,流程梳理要活动图,联调排障要时序图。我见过太多团队用 Visio 画完第一版就再也没更新过,也见过不少同事把 StarUML 里的箭头拖来拖去拖到崩溃。后来我彻底切换到 PlantUML,所有图都用纯文本描述,代码进 Git,改需求就改几行文字,再重新渲染一次,图永远和代码同步。这篇文章就把我平时用得最多的四类图形完整过一遍:用例图、类图、活动图、时序图,从语法拆解到实战示例,最后附上常见问题速查表,希望能帮你少踩些坑。
PlantUML 适合谁?只要你的工作里需要画软件设计图的都合适。准备软考软件设计师的人可以用它刷用例图、类图的真题场景,做嵌入式开发的人可以用它画 I2C、SPI 通信时序图,写后端服务的人可以用它描述订单、会员这类业务模型,甚至做产品需求分析的人也能用它把用户角色和功能边界画得清清楚楚。它是一个免费开源工具,语法简单,渲染方式灵活,本地装插件、命令行跑 jar、网页版都能用。
1. 为什么文档里的图总是过期,以及我为什么选择 PlantUML
先说一个我自己的观察:大部分项目的设计图不是一开始画错了,而是画完之后没人维护。需求一变,代码改了,图还在原地待着。原因很简单——用鼠标拖出来的图,维护成本太高了。改一个类名,你得找到那个矩形框,右键编辑,重新对齐连线;换一个角色名,整张图的布局可能都要调整。时间一长,图自然就废了。
PlantUML 解决的是"图即代码"的问题。它有一套接近自然语言的 DSL,你用纯文本描述参与者和关系,工具负责自动排版。比如我要画一个最简单的类,只需要写class User {},剩下的事情交给渲染引擎。这个思路和写代码一样:文本永远是最容易维护的,diff 看得见,注释写得清,版本管理也顺手。代码评审的时候,直接看 diff 就知道哪条关系线被改了,这在团队协作里太重要了。
1.1 PlantUML 的核心优势和适用边界
很多人问我说,UML 工具有那么多,StarUML、Visio、Draw.io 都免费或常用,为什么偏要费劲学一套 DSL?我的回答是:工具的选择取决于使用场景。
如果你的需求是“一次性画一张精美的架构图拿去汇报”,那 PlantUML 确实不是最优解,它的默认样式偏朴素,排版自由度也受限。但如果你要的是“设计文档里的一套图,会随着版本迭代持续更新”,PlantUML 的文本化优势就完全体现出来了。它可以把图直接嵌入 Markdown、Confluence、GitLab,改几个字母就能重建整张图,还能用脚本批量校验语法错误。这几年我用它画过的图少说也有几百张,除了少数特别复杂的架构图我用 Draw.io 补充外,其余全部是 PlantUML 搞定。
我再给个对比感受一下:
| 能力 | PlantUML | StarUML | Visio / Draw.io |
|---|---|---|---|
| 学习成本 | 低,30 分钟上手 | 中,需要熟悉建模概念 | 低,靠拖拽 |
| 版本管理 | 天然支持,文本可 diff | 差,二进制文件 | 差,文件难以对比 |
| 维护成本 | 低,改文字即可 | 中,拖拽改模型 | 高,布局重新调整 |
| 自动化生成 | 支持,可嵌入 CI | 弱 | 弱 |
| 复杂布局 | 一般,依赖自动排版 | 强,手动微调 | 强,自由绘制 |
用一句话来概括:如果你想长期维护一套跟代码同步的图,PlantUML 是性价比最高的选择。如果你只是为了应付一次性的汇报图,其他工具更顺手。
1.2 环境准备:三种常用的跑图方式
PlantUML 本身是一个 Java 程序,运行方式非常灵活,我常用的有三种:
第一种是 VS Code 装插件。在扩展商店搜 PlantUML,装好后写代码,按Alt+D就能预览,修改实时刷新,日常画图效率最高。要导出 PNG 或 SVG,右键选导出即可。第二种是命令行运行,适合批量处理和 CI 集成。官方 jar 包下载后,可以用类似java -jar plantuml.jar -tsvg diagram.puml的命令直接渲染。第三种是官网的在线服务,临时画一张图不求人,直接把代码贴进网页就能出图,但源码如果不脱敏就别往上贴。
我日常的主力方式是 VS Code 加插件,配合 PlantUML 的语法高亮和实时预览,写起来很像在写代码。命令行方式我也会定期用,尤其是做文档自动化的时候——把仓库里所有.puml文件批量导出,出图结果直接发布到内部文档站。这里提醒一句:PlantUML 依赖 Graphviz 做布局计算,Windows 上如果出图报错,多半是 Graphviz 没装或者没加入 PATH,这是最常见的环境问题。
2. 用例图:把需求边界画清楚
用例图是 UML 里业务味道最重的图,它不需要描述类和方法的细节,只需要表达“谁,能用系统做什么”。我一般只在两个阶段用它:项目启动期做需求分析,和软考备考期做案例分析题。这里的“谁”叫参与者(Actor),“能做什么”叫用例(Use Case),两者之间用线条连接,就可以快速确认一个系统的边界。
很多人觉得用例图简单,无非就是画个火柴人加椭圆。但真到动手画的时候,最容易犯的错是把用例拆得太细,或者把系统内部的步骤画到用例图上。记住一个判断标准:用例必须给参与者带来可观察的结果。举一个图书管理系统的例子,如果画一个“读者借书”用例,那“验证读者身份”就不是一个独立的用例,它是借书流程中的内部步骤,应该用include关系挂在主用例上面。
2.1 用例图基础语法与图书管理系统示例
先看我画图书管理系统时最常写的模板,这段代码包含了参与者、用例、系统边界框和关系线:
@startuml left to right direction skinparam packageStyle rectangle actor 读者 as reader actor 管理员 as admin rectangle 图书管理系统 { reader --> (查询图书) reader --> (借阅图书) reader --> (归还图书) (查询图书) .> (登录) : include (借阅图书) .> (登录) : include (借阅图书) .> (检查库存) : include (归还图书) .> (处理逾期) : extend admin --> (管理图书) admin --> (管理读者) (管理图书) --> (添加图书) (管理图书) --> (删除图书) } @enduml这段代码渲染出来的效果是:读者在系统边界框左侧,右侧是查询、借阅、归还三个用例;管理员在下方,对应管理图书和管理读者两个用例。关系线箭头分别表达依赖、包含、扩展。实际绘图时,left to right direction能把参与者放到左边,让整张图更紧凑。
需要特别留意的是include和extend的区别。include表示基础用例一定包含被包含用例的执行,比如借阅图书一定包含登录,所以箭头从“借阅图书”指向“登录”,虚线箭头写上 include。extend表示基础用例在某些条件下才触发扩展用例,比如归还图书时,如果需要缴纳逾期费,才会执行“处理逾期”这个扩展用例,所以箭头方向是从“处理逾期”指向“归还图书”,表示扩展点在扣款发生时才介入。方向反了,图的意思就完全变了。
2.2 用例之间的关系与软考真题场景
软考软件设计师里,用例图的考察频率很高,而且最喜欢问的就是 include、extend、泛化这三种关系的判定。泛化关系用带空心三角的实线表示,通常用在参与者继承或者用例继承上。比如系统里可以有“普通读者”和“VIP 读者”,两者都有借书的权限,但 VIP 有额外额度,这时候就可以让“VIP 读者”泛化“普通读者”。
有一种快速判定关系的方法我一直用:如果基础用例每次执行都必须执行另一个用例,那就是 include;如果基础用例执行到某个分支时,才可能执行另一个用例,那就是 extend;如果两个用例有共同的行为,但一个是另一个的特殊化,那就是泛化。把这个标准套到软考真题里,基本不会选错。
我刷题时发现,很多同学在用例图上纠结的不是关系,而是“这个功能该不该画成用例”。比如图书管理系统的“库存管理”,如果你只画了一个“管理库存”椭圆,它其实不是一个好用例,因为没有参与者会直接对着一个抽象的管理系统喊“帮我管理库存”。更合理的画法是拆成“添加图书”“下架图书”“盘点库存”这样的具体操作,它们才对参与者有明确价值。
3. 类图:设计评审阶段最常用的关系图
如果说用例图是给业务方看的,那类图就是给程序员自己看的。类图描述系统的静态结构,包括类、接口、属性、方法,以及它们之间的关联、聚合、组合、依赖、继承、实现关系。每次做技术方案评审,我几乎都是先甩一张类图出来,大家对着图讨论字段归属和调用关系,比对着几百行代码高效得多。
类图画得好不好,关键在两点:一是类的职责划分是否清晰,二是关系箭头是否用对。箭头用错非常容易被资深评审专家一眼抓出来,因为它在语义上直接反映你的设计意图。下面我把六种常见关系全部用 PlantUML 写一遍,配上实际业务场景,保证你下次画法不会再混淆。
3.1 类、接口、属性的 PlantUML 表达
先看一个订单域的类图示例:
@startuml class Customer { -id: Long -name: String -email: String +getOrders(): List<Order> +addOrder(order: Order): void } class Order { -orderId: String -createTime: Date -status: String +calcTotal(): BigDecimal } class OrderItem { -productName: String -quantity: int -price: BigDecimal +getSubtotal(): BigDecimal } class Payment { -method: String -amount: BigDecimal +pay(): boolean } interface Discountable { +getDiscountRate(): double } Customer "1" --> "*" Order : creates Order "1" *-- "*" OrderItem : contains Order --> "1" Payment : has Order ..> Discountable : implements @enduml这个例子涵盖了类、接口、属性和方法。属性前的符号和代码里的可见性对应:-是 private,+是 public,#是 protected,~是包内可见。类型写法和 Java 语法一致,冒号后跟类型,方法写括号和返回类型。这些语法细节不复杂,但对应到代码里就是实实在在的字段声明,画的时候顺便能帮自己核对一遍设计是否合理。
我说一个实用的习惯:画类图的时候,先只画出对外暴露的 public 方法,private 方法一律不写。因为类图是给别人看设计意图的,不是给 IDE 做代码索引的。你写十几个 private 方法进去,读者根本分不清哪些是核心能力,哪些只是内部辅助逻辑。想了解完整方法列表,看代码仓库就行。
3.2 类图箭头:关联、聚合、组合、依赖、继承、实现
这是整个类图里最容易出错的环节,也是热词里“类图箭头”被搜爆的原因。我直接用 PlantUML 语法逐一组装:
- 关联:
A --> B,实线普通箭头。比如 Customer 创建 Order,两者各自独立存在,互不控制生命周期。 - 聚合:
A o-- B,空心菱形加实线。比如部门和员工,部门没了,员工还能流转到其他部门,这是弱拥有关系。 - 组合:
A *-- B,实心菱形加实线。比如 Order 和 OrderItem,订单没了,订单明细就没有存在的意义,这是强拥有关系。 - 依赖:
A ..> B,虚线箭头。比如 Order 需要调用 PaymentService,只是方法级依赖,不持有对方引用。 - 继承:
A --|> B,空心三角加实线。子类继承父类。 - 实现:
A ..|> B,空心三角加虚线。类实现接口。
用 PlantUML 画的时候,箭头方向一定要写成“从子指向父”“从依赖者指向被依赖者”。很多人画继承时箭头方向写反,渲染出来的图语义完全错了。我自己的记忆方式很简单:箭头的尖端指向被依赖的一方。继承时子类依赖父类,尖端指向父类;实现时实现类依赖接口,尖端指向接口;关联时订单依赖客户,尖端指向 Customer。
另外顺便提一句,网上经常有人拿“如何用 StarUML 画类图”做教程,核心思路和 PlantUML 是一样的,只是操作方式不同。你如果已经理解了语义模型,换任何工具都只是背按钮位置的事。但 StarUML 的类图文件很难做文本 diff,所以团队协作我依然首选 PlantUML。IDE 自带的类图生成功能(比如 IDEA 里右键 Diagram)适合快速查看已有代码结构,不适合一开始做设计,因为它生成的是“代码现状”,而不是“目标设计”。
4. 活动图:梳理流程和泳道
活动图在 UML 里的定位是描述业务流程或算法逻辑,比流程图更规范,支持并发、分支、泳道这些高级表达。我通常在两种场景下使用:一是分析业务流程,比如审批流、订单状态流转;二是描述用例图中的某个复杂用例的内部过程。软考面试也很喜欢考活动图里判断节点和并发叉的语义,稍不注意就会在分支合并处丢分。
很多人把活动图和时序图混在一起,其实两者视角完全不同。活动图关注“做什么、按什么顺序做、哪些可以并行”,不关心具体谁调用谁。时序图关注“谁发的消息、消息的先后顺序、返回结果”,严格强调交互对象。一句话记忆:活动图是流程视角,时序图是通信视角。
4.1 活动图基础元素和借阅流程示例
下面是一个带泳道的图书借阅活动图,泳道用|角色|来声明:
@startuml |读者| start :查询图书; :选择图书; if (库存充足?) then (是) :提交借阅申请; else (否) :登记需求; stop endif |系统| :校验读者身份; :扣减库存; :生成借阅记录; |管理员| :审核申请; :办理借出; stop @enduml这里的if (条件) then (分支名)是活动图里最常用的判断结构。注意每个判断分支都必须给标签,比如“是”和“否”,否则渲染出来线上没有说明文字,读者要猜半天。start表示开始节点,stop表示结束节点。泳道把不同角色负责的活动划分开,在合作流程里非常直观。
我见过不少新手写活动图,从start到stop只有一条直线,没有任何判断,这种图本质上就是步骤列表,价值不大。活动图的核心价值在于表达分支和并发,所以设计的时候先问自己:这条流程有没有条件分流?有没有可以并行处理的任务?有的话,放心用判断节点和 Fork/Split 来表达。
4.2 分支合并与并发分叉的进阶写法
当流程出现并发时,我用fork和split来拆两条平行路径:
@startuml start :接收订单; fork :扣减库存; fork again :创建发货单; end fork :通知用户; stop @endumlfork和fork again之间的节点会并行执行,end fork表示汇合点。真实业务里,“扣减库存”和“创建发货单”没有严格的先后关系,可以并行,用并发分支表达就非常合适。需要注意的一个细节是:并行分支汇合后,后续活动必须等待所有分支完成。如果业务上只需要其中一个分支完成就能继续,那就要重新设计流程,而不是硬用一个end fork收口。
循环场景我也简单提一下。PlantUML 没有专门的循环关键字,一般用判断节点自己指向自己来实现,比如“检查库存不足则补货,直到库存充足再继续”。写法是判断条件的“否”分支直接回到某个活动节点上面。这种图的渲染效果一开始可能有点不直观,但读习惯了就会发现,它跟标准流程图里的循环表达完全对应。
用活动图做软考真题时,还要特别注意合并多个判断的条件。比如“读者借阅上限为 5 本,且无逾期未还”才能借书,这其实是两个判断条件叠加。有些参考书把它画成一条判断线上写库存充足 && 无逾期,我建议拆成两个判断节点,分支标签分别写“是/否”,这样语义更清晰,阅卷也更容易理解你的思路。
5. 时序图:把交互过程落到实处
时序图是日常开发中出场率最高的一种图。联调接口时,后端同学发我一张时序图,我立刻就能知道该在哪个环节返回数据、哪条消息需要阻塞等待、异常分支从哪里触发。底层协议分析也离不开它,比如 I2C 和 SPI 的通信过程,你要是用文字描述 START、STOP、ACK、数据位,读者脑壳疼,但画成时序图就一目了然。
PlantUML 的时序图语法非常直观,核心就三件事:声明参与者、画消息箭头、标注返回。先用actor表示外部用户,用participant或直接用类名表示系统内部组件,然后用箭头表示调用关系,用虚线表示返回结果。我强烈建议你在关键消息上加上注释,因为时序图是给人做交互分析的,信息足够完整才有价值。
5.1 时序图基础语法和订单交互示例
看一个用户下单的时序图:
@startuml actor 用户 participant "订单服务" as orderService participant "库存服务" as stockService participant "支付服务" as payService 用户 -> orderService : 提交订单 activate orderService orderService -> stockService : 扣减库存 activate stockService stockService --> orderService : 返回扣减结果 deactivate stockService alt 扣减成功 orderService -> payService : 发起支付 activate payService payService --> orderService : 支付结果 deactivate payService else 扣减失败 orderService --> 用户 : 提示库存不足 end orderService --> 用户 : 返回下单结果 deactivate orderService @enduml这个例子里有几个容易忽略的点:activate和deactivate表示生命线的激活和释放,对应代码里的方法调用栈,激活块的宽度能直观看出哪个服务处于占用状态;alt和else表达条件分支,渲染时会在消息区域画出分隔框,非常清晰;-->是返回消息,通常画成虚线。如果消息少、交互简单,不写 activate 也能出图,但交互一复杂,激活块能帮你快速定位嵌套调用关系。
PlantUML 还支持loop、opt、break这些组合片段。loop表示循环,比如重试三次的场景;opt表示可选分支,比如“如果用户是会员,则执行折扣计算”;break表示中止场景。组合片段可以嵌套,用法类似代码块,语法也很简单,只要注意每个片段必须有一个合法的结束标签就行。
5.2 用 PlantUML 表达 I2C 和 SPI 通信时序
做嵌入式开发时常有一类需求:在串口屏、传感器、MCU 之间联调,要画 I2C 或 SPI 的总线时序。传统做法是用 Wavedrom 画波形图,但 Wavedrom 画的是信号级波形,用来表达“谁在什么时刻拉高拉低”,而 PlantUML 更适合表达协议层的交互流程,比如主机发地址、从机回 ACK 这种消息级时序。
我拿 I2C 的一个简单读写流程举例:
@startuml actor MCU as master participant "I2C Slave" as slave master -> slave : START (SDA low while SCL high) master -> slave : Send Address + R/W bit slave --> master : ACK master -> slave : Send Register Address slave --> master : ACK master -> slave : Read Data Byte slave --> master : Data + ACK master -> slave : STOP (SDA high while SCL high) @enduml这里用参与者和消息文本,把 I2C 的每个阶段按时间顺序排布,非常适合汇报和调试。SPI 也是类似思路,角色换成 Master 和 Slave,消息换成 CS 拉低、发命令、发地址、读数据。有些同学喜欢在消息里描述电平变化,比如SCL high、SDA low,那就要注意表达粒度:如果面向驱动开发,建议信号级波形用 Wavedrom;如果面向协议交互理解,PlantUML 时序图反而更清爽。
需要特别提醒的是,PlantUML 里不要把所有内容都塞进一张巨长时序图,图一旦超过一屏,阅读体验会很差。我的做法是:一个完整链路拆成多张小图,比如“初始化时序”“单次读写时序”“异常处理时序”,然后在文档里用目录组织。这样每张图信息集中,也方便复用。
6. 常见问题快查与几个我用着很顺手的小技巧
写 PlantUML 时间长了,总会遇到一些重复出现的坑。我整理了一个速查表,专治各种渲染异常和语义误解。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 中文显示为方块 | 字体配置缺失 | 加!pragma layout smetana或设置skinparam defaultFontName "Microsoft YaHei" |
| 渲染提示语法错误 | 多数是箭头数量不对 | 检查->、-->、..>、`-- |
| 类图太长放不下 | 布局方向不优 | 加left to right direction或拆分多个包,用package组织 |
| 时序图消息序号乱 | 没有开启自动编号 | 加autonumber,自动生成消息序号 |
| 活动图分支标签缺失 | 条件分支必须写标签 | if (库存充足?) then (是)里的(是)不能省略 |
| 组合片段不闭合 | alt缺end | 检查每个组合片段是否有对应end |
| 导出图片过大 | 尺寸参数不合理 | 用scale 2放大,或直接导出 SVG 避免模糊 |
这个表格里的前两条我基本每周都会遇到一次。中文乱码的坑,从 Windows 切换到 Mac 时尤其明显,电脑上没有对应中文字体时,HTML 预览正常但导出 PNG 就变成方块。解决方案就是在文件顶部加一行 font 设置,或者统一用!include引入一个主题文件,把字体信息集中配置。箭头数量的问题则更隐蔽,->是消息箭头,-->是返回箭头,..>是依赖箭头,这三者混用时,很容易多打一个或少打一个横线,渲染器就会报错。我写长图时习惯写完一段就预览一次,把错误定位在最小范围内再继续。
6.1 几个让图更专业的小习惯
除了解决报错,我还想分享三个让出图效果更专业的小习惯。
第一,给每张图加头部注释。PlantUML 支持双单引号注释,我通常在@startuml下面写清楚这张图的用途、维护人、修改日期。别小看这几行字,过三个月你自己回来看这张图,就能迅速想起来当时为什么这么画。团队协作时,这个注释更是减少沟通成本的利器。
第二,用!include拆公共子图。如果多个 .puml 文件都包含同一个参与者定义或同一个类定义,可以抽到公共文件里,用!include引入。比如整个项目的角色列表,我定义在actors.puml里,每个图的文件只写业务逻辑。这样全局改一个角色名,只需要改一行,所有图同步更新。这个思路跟代码里的抽公共模块完全一致。
第三,为每个重要图配一段文字说明。图是给人快速理解的,但复杂的业务约束光靠图形表达不了。我写时序图的时候,会在分支片段附近用note right或者note over加注释,说明这个分支的业务触发条件、异常处理策略。一个简短的 note 比画十根箭头更有解释力。
6.2 如何把 PlantUML 集成进团队的文档流程
如果你已经尝到了甜头,建议再往前走一步,把 PlantUML 接入团队的文档流水线。目前 GitLab、GitHub、Confluence、飞书文档都有对应的 PlantUML 插件,只要把.puml代码块放进 Markdown 或文档页面里,渲染和更新都能自动完成。我最常用的方式是:在代码仓库里建一个docs/diagrams目录,所有图源文件存 Git,然后用 CI 脚本在每次合并请求时自动导出 PNG,再上传到内部文档站。
这套流程解决了团队协作里最头疼的“文档图过期”问题。因为图的源文件跟着代码走,代码变更的时候顺手把图也改了,评审的时候一眼就能看出改了什么。我甚至见过一个项目组用 Python 脚本扫描仓库中所有的.puml文件,凡是解析失败的就在 CI 里直接给红灯,从机制上保证图永远能渲染。
在这里我想强调一句:不要追求把所有图都画成一张巨型总图。真实项目里,我更愿意用多张小图组合,按模块、按功能拆开,然后用目录组织。每张小图控制在 20 个元素以内,阅读负担小,也更容易维护。
我个人的体会是,PlantUML 真正改变我的不是画图速度,而是画图的元认知——我开始把图当作代码一样认真对待。变量名起得好不好,关系设计得对不对,依赖方向有没有搞反,这些问题在写文本的时候会被自然放大。画图的过程,其实就是在做一次低成本的设计评审。如果你还在用鼠标拖框画图,我真心建议你花一个下午试试 PlantUML,先画一张图书管理系统的用例图,再画一张订单类图,等你感受到“改一行文字图就更新了”的快感,你可能就回不去了。