资讯动态

用代码定义架构图:从DSL设计到自动布局的完整实践

发布时间:2026/10/16 3:21:19 来源:尧图企业网站定制
先说一个可能很多人都有过的经历给系统画架构图第一版画了两天定稿之后产品需求变了三回我蹲在画图软件里改了四回连线最后图上的箭头走向连我自己都解释不清了。后来我实在受不了这种状态开始研究用代码定义图、让程序自动生成图这条路于是就有了这个项目diagram-design。这个项目的目标说简单也简单——把画图这件事从手工劳动变成写文本描述自动出图。说复杂也复杂因为它要同时处理图的数据结构设计、文本语法设计、布局算法、渲染导出每一个环节都能单独拿出来做很久。这篇文章把我从零开始做这个项目的完整思路、关键决策、踩过的坑和优化经验整理出来尤其适合两类人看一是被复杂绘图工具折磨过、想换一种方式管理架构图和流程图的人二是对如何设计一套小型DSL和渲染管道感兴趣的开发者。文章里的代码片段和数据结构都是我实际用过的版本你可以直接拿去改。1. 项目立项前我先想清楚了三件事1.1 手画图的三个痛点才是这个项目的真正起点做技术方案评审的时候大家有个心照不宣的习惯图比字重要。但手画图这件事本质上是在用像素存储逻辑关系这带来三个绕不开的痛点。第一个痛点是改一个节点要动全局布局。某个服务的名字从A改成B如果A在图的左上角、连线密集得像盘丝洞那改完之后整个图的重心就崩了所有和它相关的节点都得手动微调。第二个痛点是协作效率极低。两个人在同一张图上改东西不管用共享白板还是轮流编辑总会有覆盖和冲突图文件本身也很难做差异对比。第三个痛点是图与代码脱节。架构图是PowerPoint里的图实际代码是仓库里的代码时间一长图必然过期最终变成一张仅供参考的装饰图。这三个痛点的根源其实是同一个图被当作一张静态的位图而不是一份可以被解析和生成的结构化数据。diagram-design的出发点很简单——让图成为代码仓库里的源文件用文本描述图的全部信息用命令行工具把它编译成SVG或PNG。这样图就有了版本有了diff有了自动校验的可能。1.2 定设计目标不追求画得最美追求改得最快立项之前我给自己定了几条目标后来回头看这几条目标救了整个项目。第一输入必须是最朴素的文本不要设计一个需要专门GUI才能编辑的中间格式。第二自动布局必须够用图的布局由引擎计算不要求用户手工指定坐标。第三渲染输出必须可移植SVG是首选因为可以嵌入文档、网页和知识库PNG作为兜底。第四任何抽象能力都要有逃生舱比如允许用户用CSS覆盖样式允许手动微调某个节点的最终位置不然遇到特殊需求就会卡死。有了这四个目标后续所有技术选型都有了判断标准。任何一个特性如果会让文本定义图这个核心体验变复杂我就宁可不做或者做成可选的扩展。2. 核心数据模型如何用有限的类型表达无限的图2.1 节点、连边、分组微观上只有三种东西图的底层数据结构我最后收敛成三类节点Node、连边Edge、分组Group。所有花里胡哨的图——流程图、架构图、时序图、脑图——在数据层面都可以还原成这三个东西的组合。// 这是diagram-design v0.3版本的核心Schema简化版 { nodes: [ { id: auth-service, label: 认证服务, group: backend }, { id: api-gateway, label: API网关, group: infra } ], edges: [ { from: api-gateway, to: auth-service, label: HTTP } ], groups: [ { id: backend, label: 后端域, color: #e8f4fd } ] }这里有一个容易被忽略的设计点连边的from和to存的是节点ID不是节点对象本身。这看起来是小事实际影响很大。算布局的时候引擎需要高频访问连边的两端节点如果存的是对象引用或嵌套对象序列化和比较都会很麻烦存成ID字符串则意味着所有节点可以在一个Map里做O(1)查询而且做图diff的时候可以直接按ID对齐。2.2 为什么必须抽象出端口和方位第一版我只做了节点和连边跑了一个月之后发现实际场景让这两个概念不够用了。最典型的场景是数据库连接图。一个数据库节点同时连着应用服务、备份服务和监控服务三条线的语义完全不同。如果连边直接挂在节点上布局引擎只能把三条线画在节点边上任意一个点视觉上会显得很随意。后来我引入了端口Port的概念——节点可以声明若干个端口连边可以指定连接第几个端口。group backend { node db[ports: primary, replica, monitor] 标签: 主数据库 } node app 标签: 应用服务 edge app.primary - db.primary 标签: 写入 edge app.replica - db.replica 标签: 只读语法上node db[ports: primary, replica, monitor]声明了三个端口连边通过app.primary这种节点.端口的写法挂到具体端口上。这样做的直接收益是布局引擎可以把三条线分得清清楚楚不会挤成一团。端口本质上就是连边在节点上的精确锚点它把布局的美学问题转嫁成了数据结构问题。另外一个不得不处理的概念是方位Direction。节点A在节点B的上方还是下方对读图的人影响极大。我最终设计了一套非常朴素的约束语法允许用户对某些节点对声明relative关系比如a.relative(b, right)表示b画在a的右边。布局引擎优先采纳这类约束剩下的自由节点再用算法排。这样既保留了自动布局的省心又在关键位置给了用户控制权。2.3 样式与数据分离别让颜色污染核心结构做数据模型时最容易犯的错是把样式属性直接塞进节点结构里。color: red写在node里当然简单但后果是当你导出JSON再灌入另一个渲染器时样式和数据纠缠在一起很难单独做主题切换。我的做法是把样式放到独立的CSS规则里通过选择器匹配节点ID、分组、标签等属性。这本质上是学CSS的思路——结构和表现分离。node[groupinfra] { fill-color: #f0f7ff; border-color: #2563eb; border-width: 1.5px; } edge[labelHTTP] { line-color: #64748b; line-style: dashed; }有读者可能会问这样不是多了一层间接性吗的确多了一层但换来的能力是一个图定义可以配多套主题。同一份架构图用深色主题投到演示屏幕上用浅色主题贴到内部文档里数据文件一个字都不用改。对于一个长期演进的项目来说这个设计让我省了很多重复劳动。3. 文本语法设计让DSL像说话一样自然3.1 参考了已有方案然后放弃了照抄动手写语法之前我专门研究过几类既有方案。一类是Mermaid风格极简、上手快但复杂场景的表达能力明显不足尤其是分组嵌套和端口这类概念写着写着就开始憋屈。另一类是Graphviz的DOT语言表达能力很强布局质量高但语法本身偏老派方括号和属性写多了维护体验一般。还有一类是PlantUML思路很好但它的语法层级比较多初学者容易在缩进和括号上反复踩坑。最后我的选择是吸收Mermaid的自然语言感吸收DOT的结构严谨性再加上我自己的分组优先思想。语法设计的核心原则是——默认值贴着80%的场景走让常用写法极简让复杂写法显式。3.2 核心语法node、edge、group三种指令DSL最终收敛为三种核心指令再加上几个修饰符。看几个例子// 最简单的图三个节点两条边 node auth 标签: 认证中心 node order 标签: 订单服务 node pay 标签: 支付服务 edge auth - order edge order - pay // 带分组的写法 group frontend 标签: 前端接入层 { node web 标签: Web端 node mobile 标签: App端 } // 带端口和样式的写法 node db 标签: 数据库 [ports: write, read]; edge web - db.write 标签: 读写;这个语法的设计逻辑只有一个原则一个概念一行话概念之间的关系靠上下文嵌套表达。group后用花括号圈定范围node和edge直接平铺解析器不需要处理极端复杂的运算符只要维护一个当前分组栈就能正确地把node挂到对应group下面。用编译原理的话说这是一个LL(1)级难度的文法几乎不可能出现解析歧义。还有两个细节值得提。第一所有指令都以分号或换行结尾我选择换行作为默认终止符但允许用分号在一行内写多条指令适配不同用户的书写习惯。第二ID的命名规范限制为[a-zA-Z0-9_-]不允许空格和特殊符号为的是保证ID在命令行传参、URL引用、JSON导入导出时都不需要转义。3.3 解析器的容错策略宁可警告不要崩盘写解析器的时候我坚持一个策略语法错误不要直接让整个解析流程崩掉而是把错误记录成诊断信息能恢复的错误尽量恢复最终的图还能渲染出来只是某些属性被置为默认值。// 伪代码示意解析失败后的降级策略 function parseLine(rawLine, context) { const match grammar.match(rawLine); if (match) return match.handler(rawLine, context); // 无法匹配时降级作为孤立节点处理并记录警告 context.warnings.push(无法识别的行: ${rawLine}); return parseAsIsolatedNode(rawLine, context); }这个设计在实际使用中非常实用。团队里有人复制粘贴代码的时候漏了半行或者手误把edge写成edg图还是能出只是多了一条警告提示。这种软失败策略对工具类项目尤其重要——使用者是为了快捷而用这个工具的一旦因为一个小错误导致整个流程卡死他很可能就切回手工画图了。4. 布局引擎让自动排布的结果看着像人画的4.1 三类布局算法如何选型和取舍布局是整个项目里最烧脑的部分。我调研时发现主流图绘制工具使用的布局算法大致分三类算法类型适用场景优点缺点树形/层次布局层级分明的架构图、流程图方向清晰阅读顺序好不适合环路多的网状结构力导向布局关系图谱、网络拓扑自动避免重叠分布均匀可能旋转漂移方向感弱网格布局组件关系规整的场景整齐美观表达不出逻辑网的连接感diagram-design的默认策略是分层布局Layered Layout也就是把节点按离根节点的距离分到不同层同层节点竖直对齐上层节点居中对齐下方子节点。这个策略最符合人对架构图的阅读习惯——从左往右或从上往下看依赖关系一目了然。4.2 层次布局的改良从DAG分层到虚拟节点标准的层次布局算法原型是DAG分层算法核心步骤是先对图做拓扑排序再分层再分配坐标。但直接套用这个算法会遇到两个现实问题。第一个问题是非DAG图怎么办。实际的架构图往往有环路比如服务A调用服务B服务B又回调服务A。拓扑排序遇到环会直接失败我不能接受这个结果。我的解决方法是先跑一遍强连通分量检测把所有环内的节点做合并当成一个超节点参与分层布局完成后再把超节点展开成环状排列。这样环路场景也能优雅处理不会挂死。第二个问题是同层节点高度不齐会造成连线弯曲。标准算法处理后同一层的节点会分到不同的y坐标如果x轴分层的画法连线在两个节点之间就可能出现斜线。为了让连线尽量走水平或垂直的路径我引入了虚拟节点技巧在同层节点间插入辅助占位节点让连线先走到辅助节点再拐弯。整个布局计算阶段会存在一批虚拟节点渲染前统一删除只保留连线的路径关键点。这一步做完之后视觉干净程度明显提升。4.3 减少连线交叉直接贪心不要硬求最优连线交叉问题在计算几何里是一个NP难问题分层布局中减少交叉的经典算法是Barycenter算法。思路不复杂每一层的节点顺序不是固定的计算某个节点所有邻居在相邻层的平均位置把它作为该节点在排序中的权重然后按权重重排。这个算法不保证全局最优但实践中效果很好而且时间复杂度可控。我在这基础上加了一个细节优先保证长边不交叉。实际阅读中跨越多个层级的连线比相邻层的连线更干扰视线。我在计算排序权重时给长边额外加一个惩罚因子牺牲一点较短连线的理顺程度换最碍眼的长线尽量不交叉。用下来整体图的观感提升非常明显。还有一个容易被忽视的小点节点尺寸不同会严重干扰交叉计算。布局算法里我统一用包围盒BoundingBox参与计算而不是直接使用节点内的文字尺寸。渲染阶段再把文字放进去用锚点对齐方式让文字居中或左对齐。这种尺寸与内容分离的做法保证布局引擎的数学模型始终保持简单。5. 渲染与导出SVG为主多种格式兼容5.1 为什么最终选了SVG而不是Canvas第一版渲染我用Canvas做过实验的确能扛住几千个节点的大图不卡顿但后来还是换成了SVG。核心原因有三个。第一SVG是结构化文档可以被搜索引擎检索、被辅助工具读取、被代码版本管理工具做diff而Canvas的输出就是一张位图没有这些能力。第二SVG的样式体系非常完整支持CSS、支持内联事件嵌入网页之后可以直接做交互比如点击一个节点展开详情这个特性令我无法放弃。第三SVG在高DPI屏幕上显示锐利PNG在Retina屏幕上会有轻微模糊SVG是矢量的怎么放大都清晰。当然Canvas并非没有用武之地。图规模超过1500个节点、且不需要任何交互时我导出的PNG就是由Canvas渲染的。这个双轨策略让我两头都占住了。5.2 渲染细节坐标空间、锚点、连接线走向渲染器拿到的数据是布局引擎产出的坐标但直接把这些坐标画成图效果会很生硬。我做了三个层面的细节处理。第一个处理是锚点机制。节点文字默认居中但对系统边界这一类宽节点我会自动切换成左对齐这样整体视觉更整齐。第二个处理是连接线的走线方式。默认情况下连边用贝塞尔曲线或正交折线。我最终选择自动判断同层相邻节点之间的连边用简单直线跨层连边用正交折线。第三个处理是连边标签的摆放。标签默认放在连线的中点如果该标签被其他线条遮挡会自动尝试上下左右四个偏移位置找出一个与最近线条距离最大的点。你在最终输出图上看到的那些恰到好处的标签位置背后跑的其实是这么一段不太复杂的距离计算逻辑function bestLabelPosition(edge, nodes, lines, labelBox) { let candidates [mid(edge)]; // 默认中点 // 尝试上下左右四个偏移 for (const offset of [{x:0,y:-20},{x:0,y:20},{x:20,y:0},{x:-20,y:0}]) { candidates.push(offsetFrom(mid(edge), offset)); } // 选出与所有线条距离最大者 return candidates .map(pos ({ pos, score: distanceTo(Lines, pos, labelBox) })) .sort((a, b) b.score - a.score)[0].pos; }5.3 导出兼容性的坑字体度量、DPI、裁切白边导出PNG时最容易踩的坑是裁切白边。很多绘图软件导出的PNG在四周会留一圈多余的空白放入文档时非常难看。问题的根因是SVG画布在渲染时总是从(0,0)开始而图的实际内容可能从某个负坐标开始。我的做法是在导出PNG前做一次边界计算用渲染内容的实际BoundingBox重设画布尺寸和原点然后留出8px的统一边距。另一个坑是字体度量跨平台不一致。同样的字体在Linux服务器上渲染和本机Mac上渲染文字宽度可能有几个像素的偏差导致节点宽度不一样。我最终用了一个固定策略布局阶段计算文字宽度时不信任系统的measureText而是内置一张常用中英文字体宽度表对未见过的字符做估算。这样同一份图在任何环境导出的尺寸都完全一致版本管理才能做到真正意义上的diff。6. 实测三个典型场景的真实表现6.1 200个节点内完全无压力在一台普通的开发机上4核8线程16G内存我从两个方向做了压力测试。节点数在200以内时从源文件解析到SVG输出整个过程耗时在0.2秒到0.8秒之间体感就是一瞬间。布局计算占其中四到五成的时间渲染反而是很快的。把规模拉到1000个节点时布局计算时间涨到了6到8秒。这个量级的瓶颈主要出现在交叉优化那一层Barycenter算法要迭代多轮。我加了早停机制——连续三轮布局质量指标不再提升时直接退出优化循环实际耗时降了一半以上。6.2 场景一微服务架构图一张图容纳60个服务某个内部项目用了diagram-design来管理微服务架构图60多个服务节点按领域拆成了8个分组。这个场景最大的挑战是连线普遍很长——一个服务调用链会跨越四五个分组。布局优化里长边加重惩罚的策略正是为这种场景准备的最终效果看着像一位很有经验的人慢慢整理出来的主链路清晰跨域连线绕走空白区域没有出现一根连线穿过另一个分组的事故。6.3 场景二数据库关系图依赖端口规避交叉有一个做数据中台的团队用这个项目绘制ER图他们大量用到了端口特性。每个表节点声明了三到四个端口主键、外键、索引分别走不同端口整体图面的连线几乎不会在节点边缘拥挤重叠。这个案例给我的启发是很多视觉层面的问题与其在布局算法里死磕不如在数据模型层面提前消解。端口就是一个典型的建模即优化思路。6.4 场景三大规模部署拓扑图500节点靠虚拟滚动支撑第三个场景是某个大型系统的部署拓扑图节点总数500多个单张SVG有1.2MB。直接放进浏览器会有一点卡顿。我的解决方案是配合一个前端组件做虚拟滚动只渲染视口内的节点子集缩放时动态加载。这个场景也验证了SVG分层布局虚拟滚动这套组合足够撑起中等规模的生产场景不需要上WebGL那套重型方案。7. 避坑实录四个典型的翻车现场7.1 根因排查一中文标签在某些Linux环境全变方块第一次在服务器上跑导出任务时出来的PNG里中文全部变成了豆腐块。查了差不多半天才找到根因——服务器上没装中文字体系统用Fallback字体替换后渲染失败。解决办法说来也简单把字体文件随项目一起打包渲染时指定绝对路径的字体。按这个思路处理之后中英文混排再没出过问题。这类问题属于典型的本地能跑、服务器不行排查路径可以总结为三步先看字体是否缺失再看渲染环境中的locale设置最后检查导出引擎是否有字体嵌入选项。7.2 死循环布局算法在环状子图上不收敛测试阶段有一次直接CPU跑满布局算法进入死循环。定位后根因是我在强连通分量合并的处理上留了个后门超节点的内部节点反复参与外层分层计算导致坐标在两个值之间震荡。修复思路是给超节点单独加一个内部锁定的状态一旦环内坐标确定就不再参与后续迭代。这类布局算法的不稳定性很少在文档里写明基本只能靠自己在压测中发现。7.3 样式覆盖顺序为什么用户写的颜色总是被覆盖用户反馈了一个很诡异的现象在样式表里写的node[groupbackend] { fill-color: #red; }明明语法正确效果却没出来。我查了一遍渲染器代码发现样式匹配器的权重设计有问题——节点直接命中的样式居然输给了分组派生的样式。修复方法其实就一行判断精确选择器优先级永远大于继承选择器。这个问题之后给我提了个醒凡是涉及多来源配置合并的功能必须在一开始就明确优先级规则并且要有测试用例覆盖。7.4 退化场景空组和孤立节点不能报错一个健壮的工具必须处理好退化输入。比如用户写了一个空的group或者建了一个没有任何连边的孤立节点这些情况在图形学里有一个专门的处理思路直接显示即可但要在渲染结果里给出对应的视觉提示。空分组我用浅色虚线框表示孤立节点我添加一个中心灰色的点这样一张图所有元素都有合理的样式不会出现元素消失了这种让人怀疑是不是自己写错的情况。8. 这个项目还能往哪些方向长经过几轮迭代之后diagram-design已经稳定支撑了我手头的日常架构图、脑图和ER图需求。但对图即代码这个方向来说我认为它仍然只是地基。几个明确可以扩展的方向我在项目文档里都留了接口。第一个方向是增量diff与自动合并。既然图是文本那理论上可以像Git对待代码一样对待图两个分支各自改了架构图能不能自动合至少可以在文本层面做三方合并。第二个方向是从代码反推架构图通过静态分析扫描服务的依赖关系再映射成diagram-design的DSL这让架构图永不腐坏。第三个方向是交互式编辑回写在SVG上直接拖拽节点导出新的DSL文件把自动布局和手工微调的鸿沟再填掉一部分。开发到这一步我最大的心得是画图工具难做的不是渲染而是让结构化表达和自由布局两个需求同时被满足。纯自动布局省心但不够灵活纯手工布局灵活但费人diagram-design的存在意义就是把这两者用一套数据模型串起来让用户在最需要省力的地方自动化在最需要表达的地方留控制权。最后分享一个小经验如果你也想做一个类似的代码生成图工具千万不要一上来就搞复杂的插件体系和云端服务。先把文本到SVG这条最小链路跑通再手动导入几个真实项目里的复杂图盯着输出的图找那些哪里看着不对劲的点一个个改。我前面写到的端口、虚拟节点、样式优先级、字体度量这些问题没有一个是拍脑袋想出来的全部来自对着真实图反复挑刺的过程。工具类项目能不能活下来不看功能清单长不长而看它能不能在你最痛苦的那个环节真正帮上忙。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑