1. 为什么我会想写一个“diagram-design”工具:被架构图逼疯之后
先说个背景。前几年我负责某个跨平台系统的整体设计,每次评审会之前都要画架构图、时序图、数据流图。最开始我用的是通用绘图软件,画倒是能画,但图层一多就乱,连线经常对不齐,改一版要手动调整半小时。后来换成代码生成方案,又遇到另一个问题:图是能出了,但样式很死板,想调个颜色、分个层、高亮一下关键路径,得改一堆模板配置。
当时我就在想,为什么不能有一个工具,既能用结构化数据定义图的内容,又能精细控制视觉表现?最好还能直接输出矢量图,方便贴进文档和汇报材料。于是就有了“diagram-design”这个小项目。它的定位是:JSON配置驱动、Graphviz布局引擎、SVG渲染输出的一套图表设计工具,重点解决架构图、链路图、拓扑图这类“关系密集型”图表的生成与美化问题。
这篇文章会把这套工具的完整设计思路、核心实现、踩过的坑、以及可以继续演进的方向都写清楚。如果你平时也要画架构图、画调用链、画网络拓扑,或者你正在做类似的图表生成工具,这篇内容应该能给你不少可以直接用的经验。
先把我建议的方案清单放在前面,方便你快速判断值不值得往下读:
| 模块 | 技术路线 | 解决的痛点 |
|---|---|---|
| 图结构定义 | JSON Schema | 数据与渲染解耦,改图不碰代码 |
| 布局计算 | Graphviz(dot / fdp) | 复杂关系图的自动排版,省掉手动拖拽 |
| 视觉定制 | DOT属性模板 + CSS | 统一风格,支持主题切换 |
| 矢量输出 | SVG | 可缩放、可标注、可二次编辑 |
| 二次开发 | Python + Flask Web服务 | 内网部署,多人协作出图 |
下面按我实际开发的顺序,把这套东西怎么一步步做出来讲清楚。
2. 选型阶段:为什么是 JSON + Graphviz + SVG,而不是现成画图应用
2.1 先说说我对比过的几种方案
在动手写代码之前,我其实先列了一张表,梳理了几类常见做图方式的优缺点。这个环节非常关键,因为选错技术路线,后面返工成本会很高。
| 做图方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 通用画图软件 | 上手快,操作直观 | 图层多了难管理,连线不对齐,版本管理困难 | 简单示意图、头脑风暴 |
| HTML/CSS手写 | 布局灵活,样式可控 | 复杂关系图工作量爆炸 | 少量固定结构的展示图 |
| 图表库直出(如ECharts) | 图形丰富,交互好 | 强依赖前端环境,导出矢量图麻烦 | 数据可视化、动态图 |
| 代码生成SVG | 可脚本化、可复用 | 直接手写SVG语法太痛苦 | 高度定制化的图形元素 |
| 描述语言+自动布局 | 数据驱动、布局省心 | 需要学习描述语法,定制细节要调参 | 架构图、UML、流程图 |
我第一次尝试的是通用画图软件加手绘,后来发现只要图里节点超过二十个,维护成本就急剧上升。接着我试过用图表库直接渲染,但同事需要把图贴进内部文档,图表库输出的位图放大就糊。最后才锁定“JSON描述图结构、Graphviz算布局、SVG做输出”这条路线。
2.2 JSON作为图结构描述的核心优势
我选择JSON不是因为流行,而是因为它有三个别人替代不了的优势:
第一,JSON天然是树形结构,图的节点、边、分组、样式都可以用嵌套描述清楚。比如一个节点下面可以挂子节点,一个子图可以包含多个节点,这在描述分层系统时特别自然。
第二,JSON可以直接映射到编程语言的数据结构。Python读JSON就是字典,JavaScript读JSON就是对象,几乎不用做转换。这样后续想用脚本批量生成图、批量修改样式都非常方便。
第三,JSON是可读可写的。手写一套DOT语法、手写一套XML,都不如JSON直观。我自己在调试的时候,经常直接打开一个JSON文件改几个字段,保存再刷新就出图了,体验非常顺。
一个典型的节点描述大概是这样的:
{ "id": "service-user", "label": "用户服务", "type": "service", "layer": "application", "style": { "fill": "#4A90D9", "fontColor": "#FFFFFF", "shape": "box" }, "ports": [ {"name": "in-http", "direction": "in", "protocol": "HTTP"}, {"name": "out-rpc", "direction": "out", "protocol": "RPC"} ] }这个JSON里既有人的可读信息(label),也有机器可读的布局信息(layer、type),还有视觉信息(style)。这三者分离之后,改图就变成了改JSON,而不是改SVG源码。
2.3 Graphviz:自动布局才是最省心的地方
很多人一听要画架构图,第一反应是自己拖拽、自己对齐。但图一旦复杂起来,手动的效率极低。Graphviz最大的价值就是自动布局:你告诉它有哪些节点、节点之间的连接关系,它通过算法计算出一套合理的坐标位置,尽可能减少线的交叉、保持边界的整齐。
我在这套工具里主要用了两种Graphviz布局引擎:
| 引擎 | 适用场景 | 特点 |
|---|---|---|
| dot | 分层架构图、依赖关系图 | 自上而下或自左而右的层级布局,适合表达调用链 |
| fdp | 拓扑关系图、网状结构图 | 力导向布局,适合表达松散节点的关联关系 |
我自己的使用经验是,画系统架构图优先选dot,因为架构图天然有分层语义:接入层、应用层、数据层,正好符合dot的层级布局特性。画微服务调用关系网,反而用fdp更合适,因为服务之间的关系是网状而不是层状。
3. 核心模块拆解:从一份JSON到一张SVG,中间发生了什么
3.1 模块总览与数据流向
整个“diagram-design”的运行时逻辑可以分成五个阶段:模型校验、图结构构建、布局计算、样式注入、SVG组装。
原始JSON → 模型校验 → 内部图模型 → DOT脚本生成 → Graphviz布局 → SVG解析与样式化 → 最终SVG文件我在项目里把这五个阶段分别封装成独立模块:validator.py、graph_builder.py、dot_generator.py、layout_engine.py、svg_renderer.py。每个模块只做一件事,互相之间通过标准数据结构通信。
这样拆的好处是,后面如果不想用Graphviz了,想换成别的布局引擎,只需要替换layout_engine.py;如果不想输出SVG了,想直接输出PNG,只需要替换svg_renderer.py。接口不变,内部随便改。
3.2 第一步:模型校验,避免垃圾数据进入布局阶段
很多初学者会忽略这个阶段,直接把JSON丢给解析器。但实际项目中,JSON数据往往是手工维护甚至由其他系统生成的,难免出现字段缺失、类型错误、ID重复之类的问题。如果等Graphviz报错再来排查,那报错信息非常不友好。
所以我实现了一个轻量级校验器,核心逻辑很简单:
import json from jsonschema import Draft202012Validator schema = { "type": "object", "required": ["nodes", "edges"], "properties": { "nodes": { "type": "array", "items": { "type": "object", "required": ["id", "label"], "properties": { "id": {"type": "string"}, "label": {"type": "string"}, "type": {"type": "string"}, "layer": {"type": "string"}, "style": {"type": "object"} } } }, "edges": { "type": "array", "items": { "type": "object", "required": ["source", "target"], "properties": { "source": {"type": "string"}, "target": {"type": "string"}, "label": {"type": "string"} } } } } } def validate_diagram(json_path): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) validator = Draft202012Validator(schema) errors = sorted(validator.iter_errors(data), key=lambda e: e.path) if errors: for err in errors: print(f"校验失败: {list(err.path)} {err.message}") return False print("校验通过") return True这里我用的是JSON Schema标准。可能你觉得多此一举,但实际跑下来之后,我发现在项目后期,90%的图表异常都是输入数据问题,而不是渲染逻辑问题。有了校验层,定位问题的时间从半小时缩短到一分钟。
3.3 第二步:图模型构建,把平面JSON变成带语义的结构
校验通过之后,下一步是把JSON转成内部图模型。这里的“图模型”不是数据结构里的图那么简单,它要承载很多额外的语义信息,比如分层、分组、端口。
我定义了三个核心类:
class DiagramNode: def __init__(self, node_id, label, node_type="default", layer=None): self.id = node_id self.label = label self.node_type = node_type self.layer = layer self.style = {} self.ports = [] self.children = [] class DiagramEdge: def __init__(self, source, target, label=None, edge_type="solid"): self.source = source self.target = target self.label = label self.edge_type = edge_type class DiagramGroup: def __init__(self, group_id, label, nodes=None): self.id = group_id self.label = label self.nodes = nodes or []其中DiagramGroup对应Graphviz里的cluster(集群),用来表达逻辑分组。比如一个微服务系统里的“订单域”,它包含订单服务、库存服务、支付服务三个节点,这三个节点会通过cluster视觉上圈在一起。
构建图模型的过程中,我还会同时做一件事:校验边的两端节点是否存在。这个逻辑不放在JSON Schema校验里,因为Schema只能查格式,查不了业务完整性。比如你定义了一条边,source写了一个不存在的节点ID,Schema是发现不了的。
3.4 第三步:DOT脚本生成,JSON到Graphviz的翻译层
图模型构建好之后,下一步就是把它翻译成Graphviz能识别的DOT语言。这一步是整个工具最核心的翻译逻辑,也是最容易出细节问题的地方。
以dot引擎为例,一个最简单的DOT脚本长这样:
digraph G { rankdir=TB; node [shape=box, style="rounded, filled"]; "service-user" [label="用户服务", fillcolor="#4A90D9", fontcolor="#FFFFFF"]; "service-order" [label="订单服务", fillcolor="#4A90D9", fontcolor="#FFFFFF"]; "service-user" -> "service-order" [label="RPC调用"]; }我的dot_generator.py就是把这个翻译过程自动化。关键点在于,它需要把图模型里的style字典展开成DOT属性语法。比如fillcolor对应JSON里的style.fill,fontcolor对应style.fontColor。
我在这块的实现思路是维护一个属性映射表:
STYLE_MAPPING = { "fill": "fillcolor", "border": "color", "fontColor": "fontcolor", "shape": "shape", "lineStyle": "style", "strokeWidth": "penwidth" } def build_node_attrs(node): attrs = {} for json_key, dot_key in STYLE_MAPPING.items(): if json_key in node.style: attrs[dot_key] = node.style[json_key] # 固定基础属性 attrs.setdefault("shape", "box") attrs.setdefault("style", "rounded, filled") return attrs这块有一个经验要分享:不要试图在DOT层做复杂的样式逻辑。DOT语言本身处理简单样式没问题,但复杂的比如渐变、圆角、阴影,它会遇到各种兼容性问题。我的做法是DOT层只负责基础的布局和简约颜色,真正的精细样式放到SVG渲染之后的CSS注入阶段。
3.5 第四步:布局计算与SVG解析
Graphviz执行dot命令之后,会生成一个DOT格式的输出,里面包含了每个节点的坐标位置。我的layout_engine.py负责执行命令并解析输出。
如果你用dot -Tdot输出,会看到类似这样的内容:
digraph G { node [label="\N"]; graph [bb="0,0,642,392"]; "service-user" [pos="274,350", width="1.39", height="0.50"]; "service-order" [pos="401,350", width="1.39", height="0.50"]; "service-user:e" -> "service-order:w" [pos="e,401,350 321,331 345,331 368,331 383,346"]; }这里的pos就是节点的坐标,width和height是节点尺寸,边上的pos则是一系列贝塞尔曲线控制点。svg_renderer.py要做的就是把Graphviz计算出来的这些坐标与曲线信息转成标准的SVG标签。
我实际实现时用了两种方式:
第一种,直接用dot -Tsvg生成SVG,然后用Python解析这个SVG,把多余的属性清理掉,注入自定义CSS。这种方式最省事,但产出的SVG结构不稳定,受Graphviz版本影响大。
第二种,用dot -Tdot拿坐标数据,然后用Python的xml.etree.ElementTree自己组装SVG。这种方式可控性最强,但代码量更大,需要自己处理贝塞尔曲线、箭头、文本对齐等细节。
我在工具里默认用的是第一种方式,方便快速出图。如果你需要精确控制SVG结构,那就要走第二种路线。
4. 视觉细节与样式注入:如何让自动生成的图不像“机器画的”
4.1 为什么自动生成的图容易“丑”
Graphviz默认输出的图确实功能完整,但视觉观感往往一般。主要问题有三个:
一是默认配色比较单调,节点全是白底黑框,看多了就觉得死板。
二是默认字体不小但排版偏松,同样面积的图,承载的信息量不够。
三是边上标签的排列不太讲究,有时候会被节点压住,有时候又离连线太远。
所以我在“diagram-design”里专门做了视觉优化层。原则是:布局交给Graphviz,审美交给我们自己。Graphviz算完坐标之后,SVG层面的颜色、字体、间距、样式全部由我控制的CSS来决定。
4.2 基于类型与分层的配色方案
对于架构图,最直观的视觉优化是“同一类服务同一种颜色,同一层同一个色调”。
我定义了一套简单的主题映射规则:
| 元素类型 | 填充色 | 文字色 | 边框色 | 使用场景 |
|---|---|---|---|---|
| application | #4A90D9 | #FFFFFF | #2C5F8A | 业务服务 |
| data | #50BFA6 | #FFFFFF | #2E8B7A | 数据库、缓存 |
| middleware | #F5A623 | #FFFFFF | #C77E0F | 消息队列、网关 |
| external | #D0021B | #FFFFFF | #A00013 | 外部依赖 |
| default | #E8E8E8 | #333333 | #AAAAAA | 其他节点 |
为了不把这些颜色硬编码在Python代码里,我把主题做成了独立JSON文件,支持切换:
{ "name": "ocean", "node": { "application": {"fill": "#4A90D9", "fontColor": "#FFFFFF", "stroke": "#2C5F8A"}, "data": {"fill": "#50BFA6", "fontColor": "#FFFFFF", "stroke": "#2E8B7A"} }, "edge": { "solid": {"stroke": "#555555", "strokeWidth": 1.5}, "dashed": {"stroke": "#AAAAAA", "strokeWidth": 1.2, "dashArray": "6,4"} } }这样换主题就是换JSON,一行代码都不用改。
4.3 端口与分组:被大多数人忽略的两个细节
使用这套工具的过程中,我发现两个细节对成图质量影响特别大:
一个是端口。所谓端口,指的是节点上的连接点。比如一个服务节点,左边是入站HTTP请求,右边是出站RPC调用。如果不指定端口,Graphviz会自动选择连接点,结果经常出现线从节点的正上方连出去、又斜穿整个图的情况,非常乱。如果显式指定了端口,连线就会从指定位置出发,图的整齐度立刻上升一个档次。
另一个是分组。画微服务架构图的时候,一批服务属于订单域,另一批属于用户域。如果想在视觉上把这两个域圈在一起,就得用cluster。有了cluster之后,Graphviz会自动把同一组内的节点尽量排在一起,再用一个带边框的背景矩形把它们圈起来。
我总结的实战公式是:节点用类型区分颜色,分组用背景框区分语义,端口用进出方向控制连线,这三者配合,生成的架构图基本可以达到直接放进汇报文档的水平。
5. 踩坑实录:Graphviz布局的隐藏陷阱与解决思路
5.1 节点大小不一致,连线错乱
我在开发早期遇到一个很典型的问题:同样一个服务节点,有些label长、有些label短,Graphviz根据label长度自动计算节点宽度,导致同一个图里节点大小差异很大,连线看起来像蜘蛛网。
排查下来,发现原因是DOT脚本里只设置了shape和fillcolor,没有对width和height做约束。Graphviz默认会根据label文本长度来推算节点尺寸,而且label越长,节点越大,最终造成视觉上的不协调。
解决方法是:在生成DOT脚本时统一约束节点最小宽度与高度,并且允许长label自动换行:
node [shape=box, style="rounded, filled", width=1.4, height=0.6];同时,在JSON的label里支持用\n换行,一个长服务名拆成两行显示,整个图的节点尺寸就稳定很多。这个调整加完之后,成图的整齐度提升非常明显。
5.2 边的Label位置:默认位置会压线
Graphviz边上的label默认放在边的中部,但如果一条边旁边还有其他边,label就容易彼此重叠,或者压在线条上。早期我做的图经常出现“调用关系看不清到底是哪条线”的情况。
这里有一个DOT属性可以调整:labeldistance和labelangle。对于横向布局的图,把label稍微偏移到线上方,视觉上会清爽很多。我最后的经验值是:
edge [labeldistance=2.5, labelangle=70];不过这个属性在不同布局方向下的表现不一致,需要反复试。我的建议是,在渲染前先跑一个小规模样例,把label角度调整到一个合适值,再套用到全量图中。
5.3 Graphviz版本差异导致的SVG结构不稳定
这是最折磨人的一个坑。我用brew安装的Graphviz和服务器上用yum安装的Graphviz,生成的SVG在节点坐标、样式属性上会有细微差别。最夸张的一次,同一个JSON在两个环境下跑出来的SVG,一个自带class="node",另一个没有。
解决方案是:在CI构建阶段把Graphviz版本固定下来。具体做法是在构建脚本里使用容器镜像,镜像中锁定Graphviz的确切版本,任何人生成的图表都基于同一个版本环境,就不会出现渲染不一致的问题。
5.4 中文字体渲染成方块
Graphviz默认字体是Helvetica,不包含中文字符。最初我在label里写了中文,渲染出来的SVG里中文全部变成了方块,非常难看。
解决方法有两种:
一种是全局设置中文字体,比如在DOT脚本开头加上:
graph [fontname="Microsoft YaHei"]; node [fontname="Microsoft YaHei"]; edge [fontname="Microsoft YaHei"];另一种是更彻底的做法:不在Graphviz层渲染文本,而是在SVG生成后用自定义字体替换。也就是把Graphviz输出的SVG当作纯布局框架,文本节点全部替换成HTML<text>元素,再用CSS统一指定中文字体。第二种方式灵活度更高,但操作复杂,适合需要精细排版的项目。
我现在的工具默认采用第一种方案,因为大部分场景下用微软雅黑或思源黑体就够了,没必要为了中文去改造SVG结构。
6. 分层渲染与多图联动:把“diagram-design”从单图工具变成制图系统
6.1 从单张图到多层架构图
日常画架构图的时候,很少只画一张就够。通常的做法是分层画:先画全局的系统间关系图,再点击某个服务节点,钻取到该服务内部的模块依赖图,再往下还能看到这个服务的数据库表结构图。
“diagram-design”把这种联动关系抽象成了三个渲染层次:
| 层次 | 内容 | 布局重点 |
|---|---|---|
| L1 全局层 | 所有系统与外部依赖的关系 | 突出边界与分组 |
| L2 服务层 | 单个服务内部的模块划分 | 突出模块间的调用方向 |
| L3 实现层 | 模块下的类/表/接口 | 突出细节与依赖 |
这三个层次之间通过节点ID关联。比如L1里的“订单服务”节点,它的ID是service-order,L2图里所有模块的父级归属都关联到这个ID。这样就能实现“点一个节点,跳到下一层图”的交互逻辑。
6.2 图与图之间的联动实现思路
虽然Graphviz本身只负责布局,但联动逻辑完全可以在上层实现。我在工具里加了一个Web服务,用户访问SVG时,每个节点上会绑定一个点击事件:
document.querySelectorAll('.node').forEach(node => { node.addEventListener('click', function(e) { const nodeId = e.target.dataset.nodeId; if (drilldownMap[nodeId]) { window.open(drilldownMap[nodeId], '_blank'); } }); });配合一个简单的映射表:
{ "service-order": "/diagram/service-order/detail", "service-user": "/diagram/service-user/detail", "service-pay": "/diagram/service-pay/detail" }这样,从全局图到细节图就实现了无缝跳转。整个工具从单张图变成了一个轻量级的制图系统。
6.3 批量出图与自动更新
做系统设计时,图最怕的就是“画完之后,代码一变,图就过期了”。所以我在工具里加了批量出图能力:只要代码仓库里的依赖关系JSON发生变化,CI流水线就自动重新生成全套SVG,并发布到内部文档站点。
这个流程跑顺之后,我最大的感受是:图不再是一张静态的附件,而是一个和代码同步更新的活文档。架构评审会上,大家看到的图永远是最新的,而不是六个月前的手绘版本。
7. 从命令行到Web服务:让不熟悉命令行的同事也能用上
7.1 命令行版本的实现
工具最早是纯命令行版的,语法特别简单:
python diagram_design.py --input architecture.json --theme ocean --format svg --output output/命令行版适合自己用,或者接进自动化流程。但对于团队里不熟悉命令行的产品、运维同事来说,命令行还是有一定门槛。
7.2 Web界面的封装
于是我用Flask写了一个极简的Web界面。上传JSON文件,选择主题,点击生成,页面直接显示SVG预览。核心代码很少:
from flask import Flask, request, jsonify import diagram_design as dd app = Flask(__name__) @app.route('/api/render', methods=['POST']) def render(): data = request.get_json() theme = request.args.get('theme', 'ocean') result = dd.render(data, theme=theme) return jsonify({"svg": result})这个Web界面大概花了两天时间,但团队采纳率一下子高了很多。后来我把生成的SVG也支持导出PNG,因为很多人并不习惯直接用SVG,而是需要贴到文档或者PPT里。
8. 优化与扩展:给这个工具加上的几个实用增强
8.1 自动美化连线路径
Graphviz算出来的贝塞尔曲线有时候会穿节点,尤其是节点比较密集的区域。我后来在SVG渲染阶段加了一层路径交叉处理:检测曲线是否与某个节点的边界框相交,如果相交,就把这条曲线拆成两段,产生一个“绕行”的效果。
这个算法不算多复杂,但效果立竿见影。整个图的连线瞬间干净了很多。
8.2 增量渲染模式
当JSON数据很大(几千个节点)的时候,全量渲染会非常慢。我在工具里加了增量渲染模式:只更新发生变化的节点与边,未变化的部分直接从缓存SVG片段里拿。
实际测试中,全量渲染需要十秒的图,增量渲染优化后只需要两秒。这在开发态的调试场景中体验改善特别明显。
8.3 主题预设与品牌色对齐
最后一点,如果你做出来的图是要对外的,建议把品牌色沉淀成一套主题预设。我只花了一个小时就把品牌色整理成了两套主题——一套浅色、一套深色,发布给团队后,所有图表的基本视觉风格就统一了。这比每个人各自调颜色效率高得多。
9. 一些个人的心得体会
“diagram-design”这个项目做到现在,我最想强调的一点是:做图表工具,布局算法只是基础,真正决定工具好坏的是抽象模型和视觉细节。JSON定义图结构虽然比拖拽画图要麻烦,但一旦你的图复杂到需要频繁修改,结构化定义的复利效应就会体现出来。
另一个体会是,工具一定要分层。不要试图让一个模块包揽所有事。图模型、DOT生成、布局计算、SVG渲染,每一层保持独立,后续扩展和排错都会轻松很多。
最后,如果你也在做类似的图表方案,我给你一个可以落地的小建议:先把主题JSON和节点类型定义做好,再纠结布局参数。因为视觉是用户感知最强的部分,布局反而可以让算法自动跑。主题统一了,整张图的质感立刻不一样,输出给人看的成就感也完全不一样。
目前这套工具仍在日常项目里持续积累模板,遇到新的图形需求(比如时序图变体、数据血缘图),我都会回到模型层调整JSON Schema,然后复用到渲染流程中。这种“以数据结构为核心驱动图形表现”的思路,应该也会适合大部分做架构可视化的人。