
做技术方案或者写设计文档的时候最烦的一件事是什么在我看来画图绝对排前三。需求评审要画流程图架构评审要画时序图接口联调要画链路图等图终于画完了产品又改需求了整个人都是崩溃的。后来我做了个有点“反常识”的决定把画图这件事从“拖拽鼠标”变成“写代码”于是就有了“diagram-design”这个项目。简单说diagram-design 是一个纯文本驱动的图表设计工具核心思路就是“Diagram as Code”用一段结构化的文本描述来生成 SVG 矢量图再按需转成 PNG、PDF 甚至网页。它解决的核心问题有两个一是让图表内容进版本库可以 review 可以 diff二是让图表能批量生成、自动更新彻底告别一张张手动改图的低效循环。如果你是写代码的、写文档的、或者经常要和技术方案打交道这个思路值得花十分钟了解一下。1. 为什么要把画图变成写代码1.1 传统画图工具的痛点先聊一个实际场景。你在 Visio、draw.io 或者 Figma 里画了一张架构图画完自我感觉良好。结果到了评审会上张工说“网关这块放到外层吧”李工说“数据源少了一个”王经理说“字体再大一点投屏看不清”。于是你回到编辑器里挪框、改字、调连线折腾二十分钟。好不容易改完会议纪要来了一句“第二版方案里加一个熔断降级的模块。”此刻你的内心是崩溃的。这不是手残或者工具不好用而是传统的“手动画图”存在三个结构性问题。第一图形文件本质上是二进制或者私有格式核心信息藏在坐标、图层、样式里想用文本工具对比两份图的差别基本是不可能完成的任务。第二图形不能和代码、文档天然联动代码改了文档改了图还是那张旧图信息很快就失真。第三手动调整的边际成本高图越大越复杂挪一个节点往往要连带挪半张图。我自己经历过最痛的一次是维护一张包含四十多个服务节点的调用链路图。每次有新服务上线我就要打开编辑器把新节点加进去然后花十几分钟整理布局。后来甚至专门总结出了“节点摆放三原则”核心思想就是“看着整齐就行”。但当图的大小超过一屏之后什么原则都不好使了。1.2 “文本即图表”的核心思路diagram-design 的出发点很简单如果把图表拆开看本质上就三样东西——节点、连线、样式。而这三样东西全部可以用结构化文本描述出来。节点是什么形状、什么颜色、放在哪一层连线从哪到哪、带什么标签、用什么箭头这些统统可以写成代码。这个思路其实和软件工程里的“约定优于配置”一脉相承。当图表的描述变成文本之后原本做不到的事情都变得顺理成章了。你可以把图表的源文件提交到 Git 仓库里和代码一起管理每次改动都有历史记录评审的时候可以直接看 diff谁加了一个节点、谁改了一条连线的标签一目了然。你可以写脚本批量生成图表比如根据 Kubernetes 的 deployment 清单自动生成服务拓扑图或者从接口定义文件生成时序图。同样的思路在很多地方已经验证过了。代码里写注释生成文档文档里嵌入代码片段生成示例本质上都是“把人类要读的信息和机器要读的信息统一成一份源文件”。diagram-design 做的就是把图表也拉进这套体系让图和代码之间不再是两张皮。1.3 设计目标与边界项目启动的时候我给自己定了几个明确的目标避免做着做着变成一个“什么都想干的巨无霸”。第一个目标是轻量。我不打算做一个能画出所有类型图表的全能工具先把使用频率最高的流程图、架构图、时序图这几类做好每类语法都尽量精简让人可以“看着示例就能上手”。第二个目标是不依赖重型运行时。diagram-design 的核心实现是 Python标准库加一个轻量的 CLI 框架就够用。渲染 SVG 是纯字符串拼接不依赖 Graphviz 那一类重量级的外部程序这样部署起来很省心。第三个目标是“默认能跑”。源码仓库克隆下来装好依赖命令行敲一条命令就能把示例图生成出来。对于工具型项目来说这一点太重要了如果一个项目连跑起来都要折腾半天那基本没人愿意用。边界也很明确复杂的美术设计、像素级的排版控制、手绘风格的自由表达这些不是它的目标场景。它擅长的是结构化、规范化、需要长期维护的图表走的是“生成”而不是“手绘”的路线。2. 核心细节解析与实操要点2.1 简化 DSL 的语法设计diagram-design 最核心的部分是那一套用来描述图表的 DSLDomain Specific Language领域特定语言。怎么设计这套语法直接决定了工具好不好用。我的原则是让人能一眼读懂让解析器好处理让报错信息足够友好。先直接看一段示例digraph { rankdir TB node A 用户中心 shapebox color#4a90d9 node B 订单服务 shapebox color#4a90d9 node C 消息队列 shapediamond color#f5a623 node D 数据库 shapecylinder color#7ed321 A - B A - C 发送订单事件 C - D 异步落库 }语法设计围绕“尽量减少关键字”展开。顶层叫digraph表示这是一个有向图。rankdir TB表示层级方向是从上到下Top to Bottom这是布局引擎要用的关键参数。node关键字用来声明节点后面依次是节点 ID、节点显示名称以及可选的属性和值。-用来声明连线关系连线的标签放在箭头后面用引号包起来。为什么这样设计关键是让解析器的工作量降到最低。我没有引入复杂的词法分析器而是用规则表达式配合状态机来解析每条语句一行语法固定格式非常“死板”。好处是代码好写、好调坏处是表达力有限不能写太复杂的嵌套结构。不过对于流程图和架构图来说这个表达能力完全够用了。节点形状我内置了box矩形、circle圆形、diamond菱形、cylinder圆柱体四种覆盖了大部分技术图的使用场景。颜色设置就写在节点声明的同一行里方便复制粘贴。2.2 解析流程与数据模型解析器采用按行扫描、逐行解析的方式。整个过程分三步走预处理、语句解析、构建对象模型。预处理阶段做的事情很少主要是去掉空行和注释行。注释我用#开头这样设计的好处是 DSL 文件本身就具备了“自解释”能力比如# 这里生成一个订单流转的流程图 digraph { rankdir LR node A 创建订单 shapebox ... }预处理完之后进入语句解析。每次读一行先按空格切分再看第一个 token 是什么。如果是digraph就记录图的方向参数如果是node就进入节点解析分支如果包含-就拆分出起点和终点解析连线如果识别不了就抛出带行号、带原始文本的报错信息告诉用户是哪一行写错了、期望的格式是什么。这一点实在很重要我自己用的时候最烦的就是“第 17 行解析失败”这种不告诉你为什么失败的错误提示。解析完成之后所有数据会进入三个核心对象Node、Edge、Graph 的实例中。Node 保存 id、label、shape、color、坐标和宽高等信息Edge 保存 source、target、label 和计算出来的路径点Graph 则是对前二者的总装同时保存全局的布局方向参数。后续的布局、渲染全靠这三个对象协作数据模型定得清晰后面写布局算法和渲染器的时候会省掉大量返工。2.3 为什么不用现成的渲染引擎可能有人会问Graphviz 已经那么成熟了为什么不用我明确说一个原因Graphviz 的布局引擎确实强大但它输出的 SVG 结构和自定义样式控制起来非常别扭而且它的配置语法有自己的一整套体系学习和调试成本不比写一个 DSL 低。更重要的是打包部署一个 Graphviz 到 CI 流水线里在有些环境里还挺费劲的。diagram-design 自研的布局渲染方案等于把整个链路控制在了自己手里。解析完之后节点坐标怎么排、连线的贝塞尔曲线怎么插值、颜色和字体怎么设置这些全部由自己的代码来决定。意味着想改一个布局细节不需要去理解第三方工具的配置项改自己的代码就行。这个取舍在维护了半年之后我很确定是值得的。3. 实操过程与核心环节实现3.1 树形布局算法的参数计算布局是图表生成里面最“硬核”的部分。diagram-design 内置的布局引擎是基于层级layered layout思想的也就是把节点按拓扑关系分层然后逐层计算坐标。先做一次拓扑排序把节点分到不同的层。假设从上到下布局rankdirTB那么起点节点在第 0 层它直接指向的节点在第 1 层依此类推。同一层的节点不能有直接连线否则说明分层逻辑有 bug不同层的节点按照依赖关系依次排开。分完层之后问题就变成了“如何把每一层的节点摆得不重叠、连线不交叉”。我采用的是一种简化的宽度优先策略先计算每一层节点的总宽度找到最宽的那一层让它作为整个画布宽度的基准。其他层的节点根据自身宽度做水平居中对齐。具体到坐标计算我用这些参数# 布局参数 VERTICAL_GAP 80 # 层与层之间的垂直间距 HORIZONTAL_GAP 40 # 同层节点之间的水平间距 NODE_PADDING_X 20 # 节点内部左右内边距 NODE_PADDING_Y 12 # 节点内部上下内边距 FONT_SIZE 14 # 字号单位像素假设第 i 层有 n 个节点第 j 个节点的文本宽度为 text_width_j那么节点高度和宽度的计算方法是node_width text_width NODE_PADDING_X * 2 node_height FONT_SIZE NODE_PADDING_Y * 2接下来算这一层的总宽度layer_width sum(node_width_j for j in range(n)) HORIZONTAL_GAP * (n - 1)为了整张图居中整张画布的宽度取所有层宽度的最大值。某一层中第 j 个节点的 x 坐标则是从这一层的最左侧起点开始依次向右叠加“前一个节点的宽度 水平间距”。y 坐标就简单多了y LAYER_INDEX * VERTICAL_GAP LAYER_OFFSET真实代码里还有一个细节中文文本的宽度计算不能简单地用len(text)因为中文字符的显示宽度约为西文字符的两倍。所以在计算文本宽度时我做了字符宽度归一化def text_width(text, font_size): unit font_size * 0.6 width 0 for ch in text: if ord(ch) 0x2E80: # CJK 统一表意文字范围 width 2 * unit else: width unit return width这个细节不处理的话一个全是中文的节点很可能被算窄了然后把整层的布局挤乱。3.2 连线的贝塞尔路径与箭头计算节点坐标算完之后就该画线了。diagram-design 的连线采用三次贝塞尔曲线Cubic Bezier这样比直线好看也比直线更能表达“跨层”的相对关系。假设有一条从节点 A 指向节点 B 的边。A 和 B 的坐标中心分别为 (ax, ay) 和 (bx, by)。在垂直布局TB模式下连线的起点取 A 的底部中心点终点取 B 的顶部中心点。贝塞尔曲线的两个控制点这样生成# 垂直布局 start_point (ax, ay node_height_a / 2) end_point (bx, by - node_height_b / 2) # 控制点与起点、终点在同一垂直方向偏移 ctrl1 (start_point.x, start_point.y curve_strength) ctrl2 (end_point.x, end_point.y - curve_strength)curve_strength 取 40 到 60 之间根据两个节点的垂直距离动态调整。距离越远弯曲程度越大这样线看起来更自然也不会和中间的节点贴得太近。箭头部分我在 SVG 的defs里预定义了一个marker元素通过marker-end属性挂在路径上。由于 SVG 的 marker 默认方向跟随路径终点的切线方向所以不需要自己计算箭头的旋转角度省了不少事。3.3 从内存对象到 SVG 文件渲染层是整个项目里最容易理解的部分核心思想是“字符串拼接”。我用一个 RenderContext 对象维护整个 SVG 的内容。每渲染一个节点就往这个对象里追加一段 SVG 字符串每渲染一条边就追加另一段。最后把所有内容包到svg根元素里写到文件。一个节点的 SVG 输出大概长这样def render_node(node): x, y node.x, node.y w, h node.width, node.height if node.shape box: shape_svg ( frect x{x} y{y} width{w} height{h} frx6 ry6 fill{node.color} stroke#333 stroke-width1.5/ ) elif node.shape diamond: # 菱形用 polygon 绘制四个顶点分别是上下左右中心点 points [ (x w / 2, y), (x w, y h / 2), (x w / 2, y h), (x, y h / 2) ] point_str .join(f{px},{py} for px, py in points) shape_svg fpolygon points{point_str} fill{node.color} stroke#333 stroke-width1.5/ # 节点文字 label_svg ( ftext x{x w / 2} y{y h / 2} ffont-size{FONT_SIZE} text-anchormiddle dominant-baselinecentral ffont-familysans-serif{escape(node.label)}/text ) return fg{shape_svg}{label_svg}/g这里有个容易踩的坑dominant-baseline属性在部分 SVG 渲染器里支持不佳。如果发现文字位置偏上或偏下可以改用dy0.35em这种手动偏移的方式兼容性更好。整体 SVG 根元素设置一个透明背景和适量 marginsvg xmlnshttp://www.w3.org/2000/svg width{canvas_width MARGIN * 2} height{canvas_height MARGIN * 2} viewBox...viewBox也是要设置的否则后续嵌入网页或者做缩放的时候会有兼容性问题。3.4 命令行走通完整流程为了让项目好用diagram-design 提供了一个命令行入口用法非常简单python diagram.py input.ddg -o output.svg python diagram.py input.ddg -o output.png --scale 2 python diagram.py input.ddg -o output.pdf核心实现用argparse就够不需要额外的依赖。-o指定输出文件--scale控制导出倍率。生成 SVG 之后转 PNG 我调的是cairosvg这个第三方库转 PDF 则是先转 PNG 再包一层功能不算丰富但足够日常用。命令行工具做出来之后基本上我的日常工作流就变成了在写设计文档的时候顺手打开一个.ddg文件改两行代码跑一下命令一张新的架构图就出现在文档里了。修改起来也很快改关键字、改颜色最迟三分钟就能看到新的图。4. 常见问题与排查技巧实录4.1 中文乱码与字体陷阱第一次把生成的 SVG 拿到浏览器里打开所有中文全部变成了一堆豆腐块。当时第一反应是“编码问题”但其实并不是编码的问题。SVG 是 UTF-8 编码解析没有问题问题是文本渲染时找不到合适的中文字体。解决方法是双管齐下。第一在 SVG 根节点上显式设置字体栈svg font-familyHelvetica Neue, PingFang SC, Microsoft YaHei, sans-serif第二生成 PNG 的时候必须确保操作系统里装了对应的中文字体否则 cairosvg 渲染出来的图片依然是方框。这个坑项目文档里我反复强调过看效果之前先检查环境字体。4.2 节点重叠与布局修正早期版本里节点重叠是个高频 bug。复现步骤很简单画一个有十几个节点的图其中几个节点的文本特别长比如“Kubernetes 集群服务发现与负载均衡”这种十几个字符的标签。由于文本宽度计算不准节点被画得特别窄同一层的两个长文本节点就叠在一起了。这个问题让我折腾了一阵子最后确定根因是字符宽度估算公式过于粗糙。后来我把字符宽度计算从“按字符个数估算”改成“按字符类型加权”也就是前面提到的中文按 2 倍宽度算再叠加实际测试好的比例系数。改完之后重叠问题基本消灭。另外还在布局里增加了一个“最小节点宽度”的硬限制防止文本太短的节点被压成一道细线。4.3 箭头方向与曲线交叉问题还有一类问题是箭头方向不符合预期。在垂直布局里如果边是从上层节点指向下层节点箭头应该朝下但如果两个节点指向了同一层或者存在反向连线箭头就会变得混乱。排查之后发现问题出在接线点的选取上。方向向上的边起点应该取节点底部终点取目标节点顶部方向向下的边则相反。做了一个小优化根据两个节点的相对位置动态选择接线点比如目标节点在下方就连起点底部到终点顶部目标节点在上方就连起点顶部到终点底部。这样处理后箭头方向再也不乱了。连线交叉的问题我的态度是“接受一部分”。完全消除折线和交叉是一个 NP 级困难问题与其让布局引擎拼命挣扎不如允许用户手动微调部分节点的层位置在 DSL 里加一个可选的layer属性来干预。比如node B 订单服务 shapebox layer2这个设计虽然不“自动”但胜在可控。实际使用下来手动干预的次数其实很少。4.4 第三方渲染依赖不兼容deploy 到 CI 流水线的时候cairosvg在 Linux 环境上遇到过安装失败的问题。原因是它依赖的系统库 cairo、pango 在纯 Alpine 镜像里默认没有。后来我把运行环境从 Alpine 换成了 Debian slim并在 Dockerfile 里显式安装 libcairo2 和 fonts-noto-cjk问题就根治了。如果是跑在 macOS 上则基本没有这个问题Pentium 的渲染后端很完善。这类问题的通用排查思路是先本地跑通再进容器跑如果容器里遇到缺库优先用包管理器补齐而不是去改 Python 代码绕。而且字体也一样容器里同样需要安装中文字体。另外补充一个性能上的注意点当初我以为用 Pillow 画纯位图会更快实测下来当图里有几百个节点和上百条贝塞尔曲线时先产生 SVG 再用 cairosvg 渲染成 PNG在线条抗锯齿和渐变效果上远好于 Pillow 手绘速度差距也没有想象中大。所以最后的实现方案是一切渲染都基于 SVG。5. 实际应用场景与扩展玩法5.1 技术架构图与方案评审diagram-design 最常被我用的场景是架构图。写方案文档的时候架构图放仓库里改一版提交一个 commit评审会上面谁说了什么意见对应到代码里就是哪一行变化。评审结束后意见汇总、修改提交整个过程非常透明。比如画一个微服务的调用关系图DSL 描述文件可以写得非常紧凑digraph { rankdir TB node gateway API 网关 shapebox node auth 认证中心 shapebox node trade 交易服务 shapebox node pay 支付服务 shapebox node mysql MySQL 主库 shapecylinder node redis Redis shapecylinder gateway - auth 校验 token auth - redis 读取会话 gateway - trade 创建订单 trade - mysql 写入订单表 trade - pay 发起付款 }这段文本对应的图和手动画十分钟的图信息量完全一致但优势在于我可以把它直接塞进代码仓库的 docs 目录里每次服务拓扑调整了修改这段文本即可图永远和实际部署保持一致。5.2 文档自动配图与流水线集成另一个我很满意的用法是让图表自动和代码注释联动。我在项目的 Python 代码里写了一个装饰器扫描所有微服务类的方法自动生成调用关系的 DSL 文件然后调用 diagram-design 生成最新的架构图。每天凌晨 CI 跑一次把最新架构图发布到内部 wiki。也就是“凌晨两点架构图自己更新了”。这个玩法最初的动机很简单团队里的架构图老是过期代码倒是天天更新。既然代码是事实的最终来源为什么图就不能从代码生成呢后来在多个内部项目的实践里验证这个思路完全可行。如果你也有类似的文档维护压力可以试试这个思路即使不写代码把 SQL 的表关系转成 DSL 自动生成 ER 图也是常见的诉求。5.3 从静态图到可交互图形SVG 格式天然支持交互所以我也做了一层 HTML 导出。生成的 SVG 里每个节点都会带上>