代码化图表设计实战:用Graphviz构建清晰可维护的架构图
1. 为什么大多数技术图表又乱又难懂先解剖通病再谈设计1.1 图的本质是降低认知成本不是增加工作量画图这件事绝大多数人败在第一步没想清楚这张图到底要讲什么。diagram-design 做到后面你会发现它根本不是怎么画得好看的问题而是怎么让人十秒钟内看懂的问题。图是一种信息压缩手段——把一段需要读三千字才能讲明白的关系压缩成一个可以一眼扫完的空间结构。这个压缩做得好不好直接决定了图的价值。我见过很多架构图密密麻麻全是框每个框里还塞着几百字的功能描述连线交叉得跟毛线团一样。读者拿到这种图的第一反应不是明白了而是我该从哪看起。问题就出在画图的人把自己当成了摆放方块的人而不是设计信息路径的人。我自己踩过最大的坑是喜欢往图里堆信息。觉得不多画几个模块、不多连几条线就体现不了系统的复杂度。后来在一次评审会上被同事问住你这条虚线到底代表调用还是异步消息我当时居然答不上来。那一刻我才意识到一张图里的每一个元素都必须有明确语义否则它就是噪声。好的技术图表不是信息越多越好而是让人在最短时间内建立起正确的心理模型。1.2 丑但不自知的七种典型症状我把这些年见过的问题图总结成了七种症状你们可以对照一下自己画过的图把系统截图当架构图。直接把IDE里的工程目录、或者某个监控面板截个图贴上去再加三个箭头。这图的唯一作用就是告诉别人你不想画图。工程目录是给机器看的不是给人理解系统关系用的。方框大小全凭手气。明明两个模块地位相同一个框占了半个画布另一个小得看不见。视觉体量一旦和逻辑权重不一致读者会自动产生错误判断。箭头含义混乱。实线、虚线、粗线、细线、带箭头、不带箭头全在一个图里混用。问就是实线表示调用虚线表示依赖——但图里没有图例读者只能靠猜。交叉线密如蛛网。节点摆放完全随缘导致连线在画布中央结成团。人眼对交叉线非常敏感一旦交叉超过一定密度大脑会自动放弃解析。配色像彩虹糖。每个模块一个颜色颜色本身不携带任何语义。读者看完只能记住花花绿绿记不住系统边界在哪里。图里放整段文字。一个节点里写了三四行需求描述字体还缩到八号。图的优势在于关系和结构不适合承载大段文字。文字超过二十个字就该考虑拆分或者挪到图下方的注释里。没有标题、没有图例。一张图脱离了上下文读者既不知道这是哪个系统的图也不知道各种形状和颜色的含义。这在技术文档里尤其致命读者翻到图时往往已经跳过了前文。这七种症状本质上是同一个病根画图的人没有站在读者视角去设计信息的呈现顺序。1.3 一张好图的四个判断维度后来我给自己定了一个标准画完任何一张图先问四个问题相关性图里每个元素都是必要的吗删掉它会影响理解吗我画架构图时经常删掉数据源的具体表名只保留数据库这一个节点让读者先建立高层认知。清晰性第一次看这张图的人十秒内能复述出图的主题和主要关系吗如果不能说明视觉路径设计失败了。一致性同一张图里同一种含义是否永远用同一种颜色、形状、线型我的习惯是红色调一律代表告警或风险绿色调一律代表正常或成功路径。美感留白够不够间距是否均匀对齐是否严格美感不是装饰它能大幅降低眼睛的扫描成本。这四个维度可以当成一个自检清单。画图之前想一遍画完之后再过一遍出来的东西基本不会太差。2. 代码化设计把图表当成代码来写而不是当成画来画2.1 为什么我最终放弃了拖拽画图早期我也用拖拽式工具画架构图比如各种在线白板、桌面绘图软件。后来遇到三个问题让我彻底转了方向。第一个是版本管理问题。图形文件格式对人不可读保存一个架构图-final-v3之后过两天又出来一个架构图-final-v3-真改版最后根本不知道哪个是最新的。代码化图表本质是纯文本可以直接扔进Git和代码一起做Code Review改动记录一清二楚。第二个是复用和批量修改问题。拖拽式画图画十个微服务的依赖图得手动拖十组框和线。代码化之后我只要写一个循环或者用变量替换一个模板就能生成几十张同类图。微调一个节点样式改一行全局配置就全部生效。第三个是与文档系统的集成问题。我写的技术文档大多基于Markdown拖拽式工具生成的图片得手动上传、手动维护图一更新就要重新导图、传图、换链接。代码化图表可以直接在文档构建流程中自动渲染图随文档走文档更新时图也顺手更新了。这三个问题直接决定了我的选择把画图这件事当成一次小的软件开发来对待。有需求文档想表达什么信息、有设计稿布局和配色、有代码dot语言或Python脚本、有测试打开渲染结果检查布局、有持续集成提交后自动导出图片。2.2 主流代码化图表工具怎么选目前市面上主流的代码化图表工具我基本都用过这里给一个横向对比工具语言风格强项弱项适合场景Mermaid极简Markdown上手最快和Markdown生态无缝集成复杂布局控制弱大型图会乱快速流程图、时序图、甘特图GraphvizDOT语言布局算法强大绘图精确可控垂直布局需要技巧架构图、依赖图、状态机、集群图PlantUML类Java描述时序图、用例图非常专业自定义样式偏复杂UML类图、时序图、活动图DiagramsPythonPython代码用代码编程生成云架构图需要Python环境云原生架构、基础设施拓扑我个人最常用的组合是Graphviz DOT语言作为主力。原因很简单它把布局计算这件最烦人的事交给算法同时又保留了精确控制的接口。我只需要描述哪些节点连到哪些节点、哪些节点应该属于哪个集群至于每个节点放哪个坐标不用我操心。对于复杂架构图来说这就省掉了百分之八十的体力活。Mermaid我也不是不用。写一个快速的流程图给同事看思路Mermaid的体验是无敌的三分钟出图还不用编译。但一旦图稍微复杂比如超过二十个节点、有分组和跨组连线Mermaid的自动布局就开始放飞自我。所以我的惯例是草图用Mermaid正式图用Graphviz。2.3 环境准备与最小可用示例Graphviz的安装非常简单各平台都有对应方式# macOS brew install graphviz # Ubuntu / Debian sudo apt-get install graphviz # Windows choco install graphviz装完之后写一个最简单的DOT文件digraph demo { rankdirLR; A - B; B - C; }然后用一行命令编译dot -Tpng demo.dot -o demo.png如果你愿意也可以直接输出SVG后续编辑和放大都不失真dot -Tsvg demo.dot -o demo.svg从能出图到图好看中间差的就是后面几章的内容。但先把工具链打通这是第一步。3. 核心实操用Graphviz画一张可维护的系统架构图3.1 dot语言的核心概念一次讲清楚DOT语言上手其实比很多人想象中简单核心概念就五个digraph / graph声明这是一张有向图还是无向图。架构图我百分之九十九都用有向图因为依赖和调用天然有方向。节点node用名称表示例如api_gateway。严格来说节点不需要显式声明只要出现在连线语句里就算存在。但为了设置样式我通常会显式声明一批节点。边edge用-表示例如api_gateway - user_service。边也可以叠加 label 来说明关系比如labelHTTP子图subgraph以subgraph cluster_xxx { }的形式定义。注意cluster_这个前缀很关键带了它Graphviz才会把这个子图渲染成一个带边框的分组区域否则只是一堆节点的逻辑分组。rank同一层级的节点会被布局算法放在同一水平或垂直线上。rank 相同是实现对齐最常用的手段。掌握了这五个概念你已经能看懂绝大部分DOT文件了。真正需要花时间琢磨的是如何让布局结果符合你的预期这部分在第四章细讲。3.2 一张真实项目架构图的完整代码这里我给一个我在实际项目中用过的简化版架构图。不要小看这个例子它包含了主流的元素外部用户、网关层、应用服务、数据层以及服务之间的调用关系。我的思路是分层 分组让读者第一眼先感知到水平方向上的层次。digraph system_arch { rankdirLR; // 全局样式 node [shapebox, stylerounded,filled, fontnameHelvetica, fontsize12]; edge [color#5F6368, arrowheadnormal]; // 外部用户 subgraph cluster_client { label客户端; styledashed; color#9AA0A6; client [label移动端 / Web, fillcolor#E8F0FE, color#1A73E8]; } // 接入层 subgraph cluster_gateway { label接入层; stylerounded,filled; fillcolor#F8F9FA; color#DADCE0; gateway [labelAPI 网关, fillcolor#E8F0FE, color#1A73E8]; } // 应用层 subgraph cluster_app { label应用服务; stylerounded,filled; fillcolor#F8F9FA; color#DADCE0; user_svc [label用户服务, fillcolor#E6F4EA, color#188038]; order_svc [label订单服务, fillcolor#E6F4EA, color#188038]; pay_svc [label支付服务, fillcolor#E6F4EA, color#188038]; } // 数据层 subgraph cluster_data { label数据层; stylerounded,filled; fillcolor#F8F9FA; color#DADCE0; mysql [labelMySQL 主库, shapecylinder, fillcolor#FFF8E7, color#E37400]; redis [labelRedis 缓存, shapecylinder, fillcolor#FFF8E7, color#E37400]; mq [label消息队列, shapecylinder, fillcolor#FFF8E7, color#E37400]; } // 连接关系 client - gateway [labelHTTPS]; gateway - user_svc [labelHTTP]; gateway - order_svc [labelHTTP]; gateway - pay_svc [labelHTTP]; user_svc - mysql [label读写]; order_svc - mysql [label读写]; order_svc - redis [label读写]; order_svc - mq [label投递]; pay_svc - mq [label投递]; pay_svc - mysql [label读写]; }编译命令和前面一样dot -Tsvg system_arch.dot -o system_arch.svg打开渲染结果你第一眼看到的就是横向五层分区客户端、接入层、应用服务、数据层。每个节点都有明确的样式语义——蓝色的属于接入和入口绿色的属于业务服务橙黄色的属于数据存储。这就是我在1.3里说的一致性。3.3 布局引擎怎么选dot不是唯一选项Graphviz内置了多个布局引擎默认dot处理有向分层图最好但其他引擎各有各的用处引擎适用场景实际体会dot有向图、分层结构架构图和流程图首选层级清晰neato无向图弹簧模型适合拓扑关系节点会自动弹开fdp无向图简化力导向和neato类似但更好看sfdp超大规模图上千节点才会用到circo环形布局适合协议交互回路展示twopi放射性布局适合以某个中心点为根的依赖图我的经验是画技术架构图90%的情况用dot就够了。如果用了dot之后发现交叉线太多不要急着换引擎先检查是不是子图划分和层级设计有问题。引擎只是背锅侠问题通常出在图本身的结构上。3.4 实战中我踩过的三个坑这三个坑我基本每次带新人都会讲一遍第一个坑中文字体显示成方块。这个问题几乎人人碰到。根源不是Graphviz不支持中文而是默认字体里没有中文字形。解决方案是在节点或全局设置里指定一个中文字体node [fontnameMicrosoft YaHei];Linux服务器上我一般用fontnameNoto Sans CJK SCmacOS上用fontnamePingFang SC。字体名字填错了Graphviz会静默回退默认字体然后你又看到方块。第二个坑子图方向乱了。你以为subgraph cluster_app里的三个服务会水平排开结果实际渲染出来垂直排了。这是因为整个图的rankdirLR是水平方向子图内部默认继承这个方向但如果子图里的边没连好布局算法就会自己乱排。解决办法是强制加一个不可见的骨架边或者直接给子图里的节点加ranksame。后面第四章我会专门演示。第三个坑每次改动一点重新生成的图布局跳来跳去。原因很简单Graphviz的布局是基于能量最小化的一个节点的位置变化可能带动全局重排。虽然这不是一个错误但在迭代对比时会很痛苦。我的经验是先定好骨架和层级再调整标签和样式。骨架一旦定了后续改动就集中在属性上布局基本稳定。4. 层级、分组与布局把信息密度变成清晰的视觉路径4.1 用子图承载系统边界很多人的架构图看起来平是因为所有节点都在同一个平面上没有系统边界的概念。真实系统是有边界的客户端在一层网关在一层服务在一层数据在一层。如果不把这些边界画出来读者就只能靠箭头方向脑补层次感。子图subgraph就是用来承载边界的。回到3.2的代码你会发现我用了四个subgraph cluster_xxx每个都带着label标明这一层的名字。实际效果就是四块带边框的区域每块区域里住着属于这个边界的节点。读者不需要你解释自己就能领会哪些服务属于应用层、哪些属于数据层。构建子图时有一个小细节子图之间不要互相跨越。如果在应用层的子图里直接去连数据层的子图外部节点Graphviz一般也能正确渲染但风格上会破坏区域的感觉。我一般会把跨层访问的边放在最后统一声明让连接关系在视觉上穿过各层的边界而不是让某个子图本身跨界。4.2 rank 与不可见边把对齐变成代码rank是Graphviz里最强的布局工具之一它的作用是把某些节点强制放在同一水平线或垂直线上。比如我想让用户服务订单服务支付服务三个服务在视觉上绝对对齐就可以在文件末尾加一句{ ranksame; user_svc; order_svc; pay_svc; }注意这里用了一个匿名子图结构{ ranksame; ... }它不是cluster不会产生边框只会告诉布局引擎这三个节点的rank必须一样。在rankdirLR的图里rank相同就意味着它们在同一垂直线上整个应用服务层的边界就特别清晰。rank还有一种高级玩法叫不可见边。有时候你发现两个区域之间的隐形逻辑没有连线但你又希望它们在空间上靠近或者错开。我经常用带styleinvis的边来撑开布局user_svc - order_svc [styleinvis];这条边不会渲染出任何线条但它会影响布局算法让user_svc和order_svc保持相邻关系。遇到我明明没连线为什么这俩节点跑一起去了之类的玄学问题用不可见边手动干预是最快的解法。4.3 一张反例到正例的改造流程我拿一个具体的反例来说明改造流程。以前我画过一个订单系统依赖图节点直接平铺连了三十几条线渲染出来交叉严重。后来我做了三步改造第一步按职责划分层。把所有节点归到接口层、领域层、基础设施层三个子图里。这是宏观结构先把整体骨架立住。第二步确定关键对齐路径。把所有节点之间最重要的那条调用链拎出来controller - service - repository - datasource。用ranksame把它们对齐让读者第一眼就看到这条主线。剩余的非主线依赖比如缓存、消息队列放在主线两侧。第三步用不可见边做微调。主线对齐后我发现服务A和服务B偶尔会跑到奇怪的位置就加了四条styleinvis的边硬是让布局稳定下来。改造完之后交叉线从十几处降到三处以内图的可读性完全不一样了。这三步不只是画图顺序背后其实是个先宏观后微观的信息设计思路——和写代码时先分层再实现细节是一个道理。5. 配色、字体与排版图表的气质藏在细节里5.1 为什么配色必须有语义而不是为了好看关于图表配色我见过两种极端一种是完全不管全部黑色方框整张图灰蒙蒙一片另一种是每个节点一个颜色把图画成儿童涂鸦。正确的心态应该是颜色是信息通道不是装饰。如果有读者问为什么这个节点是绿色你能回答因为它属于正常的业务服务那这个颜色就用对了。如果他问为什么这个节点是这个颜色而你答不上来那这个颜色就是噪声不如删掉。我这里给一套我自己用了很久的语义色方案语义填充色边框色文字色用途主要入口#E8F0FE#1A73E8#174EA6网关、Controller业务服务#E6F4EA#188038#0D652D正常业务模块数据存储#FFF8E7#E37400#B06000数据库、缓存、MQ外部依赖#F1F3F4#5F6368#3C4043第三方系统风险/告警#FCE8E6#D93025#A50E0E异常路径、降级开关这套配色并不是我的原创而是参考了主流设计系统的语义色做了一点微调。饱和度不高放在文档里不刺眼也方便打印。你完全可以直接抄过去用。5.2 字体、箭头、圆角、留白的统一规范配色之外四个细节决定了图的专业程度。字体。一张图里最多出现两种字重常规和加粗。标题用加粗14号节点文字用常规12号注释文字用灰色10号。不需要在单个节点里再强调某几个字Graphviz也不方便做富文本。箭头。箭头类型全图统一。我的默认配置是普通实线箭头arrowheadnormal只有异步消息才用arrowheadempty依赖用styledashed。这些语义要写进图例或者至少在文档正文里交代一句。圆角。所有节点统一用stylerounded,filled让方框的圆角半径保持一致。不要有的节点圆形、有的节点方形、有的节点带阴影——每多一种形状读者要多记一条规则。留白。节点内部留白通过margin0.2之类的参数控制节点外部通过nodesep和ranksep控制。我常用的全局配置是graph [nodesep0.4, ranksep0.6, pad0.2];这几个参数决定了节点之间、层级之间的间距。数值太小图会挤成一团太大图会散成一篇。实际使用时我一般先取一个初始值看渲染效果再微调 0.1 的幅度。5.3 一套可直接复用的全局模板为了方便直接在项目里落地我把上面的经验打包成了一个小模板。以后画任何图先把这个头部贴上去再填业务节点digraph common { rankdirLR; graph [nodesep0.4, ranksep0.6, pad0.2, fontnameHelvetica, fontsize12]; node [shapebox, stylerounded,filled, margin0.2, fontnameHelvetica, fontsize12, color#5F6368]; edge [color#5F6368, arrowheadnormal, fontnameHelvetica, fontsize10]; // 把业务节点和连线写在这里 }这个模板看起来简单但它把我在第五章列的所有规则都固化成了默认值。从全局模板开始画图意味着你不会在画到一半时突然纠结这个节点要圆角还是直角——规则已经在最开始定好了剩下的只是填充业务内容。6. 从单张图到图表体系diagram-design 的团队落地体会6.1 沉淀模板库而不是每次从零开始单人画图画完就完。但在团队里做 diagram-design如果不沉淀模板就会出现一个文档里十张图十种风格的情况。我的做法是建立一个diagrams目录里面按类型放好模板架构图模板、时序图模板、依赖图模板。每个模板都遵循第五章的配色和字体规范。团队里的同事要画图先复制模板再修改业务部分而不是从空白文件开始。这样做的好处是经过几次迭代之后所有图的视觉风格会自然收敛读者看任何一张图都不需要重新学习编码规则。我墙裂建议模板库本身就是团队技术资产的一部分和代码库一样需要维护和评审。6.2 图表与CI集成SVG随文档自动更新代码化图表最大的红利在持续集成。我有过一个项目文档里嵌了二十多张架构图。以前用拖拽工具维护这些图的时候每次架构调整都要手动重画、手动上传、手动替换链接至少半天时间。换成Graphviz后我在CI脚本里加了一步for f in diagrams/*.dot; do dot -Tsvg $f -o docs/ diagrams/$(basename ${f%.dot}).svg done每次提交代码CI都会自动把所有.dot文件重新编译成.svg再发布到文档站点。架构改了只要顺手改了对应的.dot文件文档里的图就自动更新。从此再也不会出现架构图还停留在三个月前的尴尬。这一步对团队的收益最大。画图不再是文档编写阶段的一次性工作而是和代码演进同步的日常任务。6.3 团队审图清单拉齐所有人的质量标准我在团队里推行过一张审图清单每次评审架构图时逐条过。这张清单只有六项但每一项都能挡住大部分劣质图[ ] 图有明确的标题和日期吗脱离文档上下文能看懂吗[ ] 所有节点和连线用的颜色/线型有图例或说明吗[ ] 同一个系统边界内的节点是否用子图框起来了[ ] 关键主线是否有层级/rank对齐交叉线是否控制在三处以内[ ] 节点内文字是否简洁有没有超过二十个字的文本块[ ] 渲染后是否检查过中文字体和导出高清版本这张清单我贴在团队文档里每次画完图先自查一遍再发出来审图效率高了很多。它不限制创意只保证底线。6.4 关于 diagram-design 的一点个人体会说句实话从随便画一画到把画图当工程做最大的变化不是图变好看了而是想问题的思路变清晰了。每次动手画一张图我都要先回答三个问题这张图主要讲什么谁是读者他们需要从这里带走什么结论一旦这三个问题想清楚工具、配色、布局都是水到渠成的事。Graphviz只是把想清楚的结果忠实地渲染出来而已。最后分享一个我在实际项目中养成的习惯每张正式发布的图我都会用dot -Tsvg导出一个高清版本同时在代码注释里写明这张图的维护者。这样即使半年后某张图出了问题也能找到责任人快速修复而不是让一张过期的图在文档里躺到天荒地老。diagram-design 不是一个画图技巧而是一条把信息讲清楚的路你走得越远回头越觉得值得。