ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Diagram-as-Code:让架构图与流程图成为代码化工程资产

Diagram-as-Code:让架构图与流程图成为代码化工程资产 “图也要写代码”——这是我做 diagram-design 项目以来被问得最多的一句话。这个项目的初衷很简单把架构图、关系图、流程图、网络拓扑图的绘制变成一种“受版本管理、可自动布局、能被逻辑驱动”的工程能力而不是打开画板工具手拖一条线。过去两年我在多个内部工具中反复用同一种思路画图最终把这套经验收敛成了 diagram-design 这套基础设计它适合三类人被多版本架构图维护逼疯的后端开发者、需要在文档里嵌入高质量关系图的技术写作者、以及想做绘图类前端产品的工程师。今天把整个项目从需求到落地拆开聊干货很多但不绕弯子。1. “图也要进仓库”Diagram-as-Code 解决的真实痛点1.1 传统拖拽画图的最大痛点先说说我为什么不做“又一个拖拽画图工具”。你回想一下用传统画图软件维护一张架构图的经历第一次画很爽线条拉来拉去颜色随心配。三个月后系统加了消息队列你打开原文件发现为了找服务名和数据库之间的联系得放大缩小看半天挪一个框旁边三条线全飘了你还得手动把线的折点一个一个调回来。改一次架构图要半小时图还不一定对得上现网。这背后核心问题不是“工具不好用”而是“图没有逻辑结构”。拖拽工具里方框是散落的矩形箭头是独立的路径连接关系仅存在于视觉上文件里没有任何语义。结果就是图难以维护、难以检索、难以复用更别提自动重排和版本对比。1.2 用代码描述图的收益在哪里diagram-design 走的另一条路用结构化的数据描述图和图关系渲染器负责把数据变成视觉元素布局引擎负责算坐标。这意味着你可以把一张架构图写成一个 JSON 或一段 Python 字典像代码一样提交到 Git 仓库。团队里任何人改动业务模块直接改配置字段重新生成图提交 MRReviewer 看到的不只是“你改了什么代码”还能看到“你的改动对整体拓扑产生了什么影响”。这个方案的好处是实打实的图与代码同源不存在“代码新图旧”的漂移问题自动布局取代手工排版增删节点后重新计算分钟级恢复整洁图纸可复用一套数据模型可以导出 PNG、SVG或者嵌入 Web 页面可编程操控根据机房部署数据动态生成网络拓扑而不是手工画一张静态图。换句话说“图”从一次性消耗品变成了可持续维护的工程资产。你只要稍微调整一下工作习惯长期收益非常可观。2. 项目骨架我把渲染层和数据层拆得死死的项目如果上来就直接写画图的逻辑一定会把自己绕进去。diagram-design 的第一步是分模块核心原则就一条数据层永远不知道渲染层存在渲染层永远不能私自改数据。2.1 数据层Model 只描述图我用一个独立的Diagram类来容纳节点、连线和视口元信息它只描述“图里有什么”不碰任何“怎么画”的逻辑。基础结构大致长这样dataclass class Diagram: version: str nodes: list[Node] # 节点列表包含 id、标签、分组等 edges: list[Edge] # 连线列表包含 source、target、样式引用 groups: list[Group | None] # 分组信息用于子图边界计算 metadata: dict # 标题、作者、时间戳等 dataclass class Node: id: str label: str kind: str # 如 service / database / queue style: Style | None None # 覆盖全局样式的局部样式 fixed_position: Point | None None dataclass class Edge: id: str source: str target: str label: str 你可能会问一点为什么连线的source和target不用坐标而是用节点 id因为坐标是布局引擎算出来的结果不是图的固有属性。把坐标直接写进数据结构今天挪个节点明天就得改所有连线这个模式在复杂图里就崩了。而引用 id 的做法让布局引擎调整节点位置的时候连线自动跟随不需要额外维护一致性。数据层就是纯 Python 对象天然可以序列化成 JSON。这也给协作带来了非常大的灵活性——非程序员也能通过手写一段 YAML描述出节点和连线关系剩下的交给项目自动排版。2.2 渲染层Canvas 还是 SVG渲染层我最终选择了 SVG虽然很多绘图工具优先考虑 Canvas但做架构图这类场景 SVG 反而合适得多。原因有三SVG 的图元就是 DOM 节点给某个矩形加事件监听、改样式、做动画都是浏览器原生能力不需要自己实现一套图元管理缩放和坐标变换可以直接用 SVG 的 viewBox 机制边缘模糊问题比 Canvas 缩放好处理图规模通常控制在几百个节点以内SVG 的 DOM 压力在可接受范围现代浏览器的 SVG 渲染性能其实比想象中好很多。当然如果未来要支持上万节点的大规模拓扑Canvas 或 WebGL 都是更好的方向这套架构里渲染器是可替换的。你只要保持“数据输入 图形输出”的接口不变换渲染后端不会动到业务层。2.3 核心流程一次图是怎么画出来的diagram-design 的对一次完整渲染拆成了四个阶段。build接收原生数据转换成 Model 对象layout跑布局算法给每个节点算出坐标render根据坐标产出 SVGdecorate上样式和交互。每个阶段依赖前一个阶段的输出但又保持独立这样任何一步有 bug 都可以单独调试。build - layout - render - decorate我实际写下来最大的感触是把流程拆开之后每一个函数要处理的问题都变得非常具体调试的时候也不需要满世界找问题。就拿layout来说它只需要返回一个dict[id, Point]其他什么都不管单元测试也好写性能优化也好做完全不影响上下游。3. 布局引擎三件套子图边界、锚点计算、层级路由布局是整个项目最值得花时间的地方。图片要好看本质是节点位置要合理、连线要短且不交叉。这部分我的实现思路是三件套分层布局、子图边界、锚点路由。3.1 先分层再计算层次化布局的基本思路对于绝大部分服务架构图和流程图数据本身是有方向性的——从上游到下游从调用方到被调用方。所以第一个步骤是把所有节点按依赖关系分层这非常像拓扑排序没有前置依赖的放在第一层只依赖第一层的放第二层依此类推。打个比方这就像安排一条流水线每道工序必须放在它依赖的工序之后层数就是工序的批次。分层目的不是直接给出最终坐标而是减少后续排布的自由度把“二维问题”先降成“一维层序列问题”。一个简单的分层逻辑可以这样写def assign_layers(diagram: Diagram) - dict[str, int]: incoming {node.id: [] for node in diagram.nodes} for edge in diagram.edges: incoming[edge.target].append(edge.source) layers {} remaining [n.id for n in diagram.nodes if not incoming[n.id]] current 0 while remaining: next_remaining [] for nid in remaining: layers[nid] current for node in diagram.nodes: if node.id nid: for edge in diagram.edges: if edge.source nid and edge.target not in layers: incoming[edge.target].remove(nid) if not incoming[edge.target]: next_remaining.append(edge.target) remaining next_remaining current 1 return layers这段代码最终得到的结果是每个节点的“层号”。有了层号横向坐标就有了大方向剩下要解决的是纵向排序和连线交叉最小化。3.2 子图边界如何让分组集团不散架架构图里非常常见的场景是订单服务下有三个子模块支付服务下有两个子模块这些子模块之间是内聚的画图时应该聚拢在一起。如果布局引擎不认识“分组”这个概念这些子模块会被随意打散图面看着就会很乱。我的做法是给布局引擎额外传入分组的边界约束同一租内的节点y 方向尽量连续租与租之间留出固定间距。具体算的时候先按租金作为第一排序键再按租内拓扑顺序作为第二排序键这样一个租的节点不会跑到另一个租的中间去。这背后的本质是在“图的美观”和“逻辑关系”之间做取舍。没有分组约束的算法可以做到连线更短、线交叉更少但画出来的图在语义上是乱的反而不如带一点约束的稍差几何效果更符合人的理解习惯。3.3 锚点计算与层级间的连线路由分层和定坐标之后另一个隐藏问题浮出水面连线从节点的哪个位置出发很多绘图工具直接连矩形中心点导致连线穿过其他图元。简单有效的改进方案是连线从出边的方向侧发出进入下一层节点时从对应方向侧进入。diagram-design 里我做了一组“锚点选择”逻辑。节点四个边各留一个接线区渲染之前先查一下当前边连接的是哪个邻居节点取两者中心的相对方向选择出口和入口。这样大部分连线就是一条直线或一个直角折线不需要额外做绕障。如果图中真的出现了需要绕障的情况我建议你克制一点不要在一版里去实现完整的全局路径规划先让用户手动指定两个路由途经点绝大多数时候都能解决问题。快速总结锚点选择的优先顺序如果目标在下层优先底部出口如果目标在上层优先顶部出口如果目标在同一层优先左右侧出口如果节点宽度明显大于高度优先左右侧减少线条穿过矩形正面的概率。4. 交互编辑的隐性成本偏移坐标、命中测试与导出缩放有人会觉得既然都“代码生成图”了那就让用户纯写代码不需要交互。真实用下来不行。架构评审的时候你指着屏幕说“这里应该加一条调用链”结果还得开文本编辑器改完再刷新整个讨论节奏就断了。所以 diagram-design 还是需要支持轻量交互编辑交互的坑比自动布局还隐蔽。4.1 偏移坐标系与缩放补偿点击位置为什么不准第一个坑是缩放后点不准。画面有 pan 和 zoom鼠标事件返回的是屏幕坐标如果直接拿屏幕坐标去找节点会发现怎么点都不中。我加了一个全局 viewport 状态把所有鼠标事件先做一次坐标变换def screen_to_world(client_x: float, client_y: float) - Point: # viewport 包含 translate_x / translate_y / zoom world_x (client_x - viewport.translate_x) / viewport.zoom world_y (client_y - viewport.translate_y) / viewport.zoom return Point(world_x, world_y)这个变换顺序不能反先减偏移再除缩放。我曾经写反一次在 zoom0.5 的时候点哪都差一倍的距离排查了半天才意识到是变换没做对。这个函数是所有交互的基础后面接的选中、拖拽、右键菜单全都要经过它。4.2 命中测试不要遍历所有图元交互还需要快速判断鼠标落在了哪个节点或连线上。最笨的办法是遍历所有图形做几何包含判断几百个节点时还行图形多了就卡。折中的方案是做两件事维护一套空间索引只查当前 viewport 可见范围内的候选集合先做矩形粗筛再做精确判断避免每条连接线都做“点到折线距离”计算。精确判断连接线的时候有个小技巧不要只探测单像素线给连线加一个“虚拟线宽”比如 8 像素这样用户不必精准地抓到那条 1px 细线。这个细节对体验提升非常明显视觉上是 1 像素的线实际上可点击范围是它的 8 倍。4.3 导出图片打印尺寸和模糊问题另一个容易翻车的是导出 PNG。SVG 在浏览器里显示正常导出时如果不按目标设备的分辨率做超采样在 Retina 屏上或者文档插图里就会发虚。我当时就踩了一次导出的 1920*1080 架构图在 PPT 里一拉大就糊了。处理方案很机械但在工程上很实用导出前把 SVG 的 width 和 height 按 scale 放大再让绘图上下文等比缩放导出后把外部容器的 CSS 尺寸恢复原值。这样不会影响屏幕显示导出的位图又足够细腻。如果有条件优先导出 SVG 而不是位图放大缩小永远不糊这也是我推荐大家做架构图时优先考虑的交付格式。5. 性能优化分层渲染、增量更新与可见区裁剪新上手做绘图工具的人一开始对节点数量没概念画到五百个节点、一千条连线的时候页面开始明显卡顿这才开始焦虑。性能优化的方向其实很清晰核心思路不是“让单次渲染更快”而是“尽量少渲染、渲染可见的东西”。5.1 分层渲染固定层、交互层、标注层diagram-design 把 SVG 内部拆成了好几个g组最底下一层画连线第二层画节点形状和文本最上面一层只画选中框、拖拽手柄和 hover 效果。听起来很常规但真正做到位之后交互层本身非常轻量重绘只发生在需要变化的那一层不会动不动就把整张图重绘一遍。拿拖动节点举例拖动时只需要重绘交互层和相关连线节点层本身位置变动我得更新对应 transform 属性但并没有重新执行完整的 render 流程。分层带来的另外一个好处是 z-index 关系非常稳定不会出现连线盖住节点文本这种问题。5.2 增量更新与防抖合并不是每一次数据变化都需要立刻渲染。文本框输入里连续敲几个字符如果每敲一个字符就重排版浏览器压力很大。我的方案是给重布局渲染加防抖大概 150~200 毫秒。也就是说用户停笔超过 150 毫秒才触发重新布局。如果数据变化幅度比较大还可以做“保留现有位置只增量修改新增节点”的策略。这个策略看起来效果不如完全重新布局但胜在稳定在已经调好样式的大图上用户只是加了一个节点你会希望整个图都动一遍吗多数时候不想。以稳定性优先把“完全重新布局”留给用户手动触发体验会更好。5.3 可见区裁剪看不见的就不画当图特别大的时候大量图元在屏幕外根本看不到渲染它们纯属浪费。我加了一个可见区裁剪逻辑拿到 viewport 的四个角坐标在布局阶段结束之后、渲染阶段开始之前先把完全落在裁剪区外的节点和连线过滤掉只有与裁剪区相交或可见的图元才被渲染。这个逻辑对性能的改善非常显著。我之前测试过一个 2000 节点的大图缩放到只显示局部 10% 的区域渲染时间从几百毫秒下降到几十毫秒。对架构图这类应用来说你很少有需要“一眼看全 2000 节点”的场景反而“快速定位到某一块子图”才是刚需。6. 从“能画”到“好用”主题系统、模板复用与只读分享画图工具做到“能画”只是及格真正让人觉得好用的是主题、模板和分享链路。6.1 主题系统把视觉偏好从数据里剥离diagram-design 支持一套轻量主题机制。主题是一份包含颜色、字体、间距、连线样式的配置和 Model 数据解耦。渲染时节点和连线会从主题里按kind服务、数据库、队列等查找对应样式没有指定就落到默认样式。这里有个看起来很玄但很重要的点主题配置里尽量使用“语义名称”不要直接写死颜色值。比如用accent表示主角色用danger表示错误链路而不是在每一处写#ff4d4f。因为换主题的时候你不希望把所有颜色值一个个找出来改而是换一整个调色板就能改变全局观感。这套语义化思路和 CSS 变量、设计令牌的做法完全一致。给一个主题简化的例子{ themeName: light, palette: { bg: #ffffff, primary: #2563eb, line: #94a3b8, danger: #dc2626 }, nodeStyle: { service: { fill: #eff6ff, stroke: #2563eb }, database: { fill: #f8fafc, stroke: #0f172a }, queue: { fill: #fef3c7, stroke: #d97706 } } }6.2 模板复用把常见架构沉淀成配置仓库做了一段时间后我意识到很多图的结构高度相似——微服务架构无非就是网关、服务、数据库、缓存、消息队列这几种角色来回排列。与其每次从零开始写节点不如做一套模板仓库把常见架构的骨架预先定义好。模板不是静态文件而是“生成器函数”的返回值。比如microservice_template(service_names: list[str])会接受服务列表自动生成一组带层次关系的节点和连线得到的是一个可以直接交给布局引擎渲染的 Diagram 对象。这让团队新成员画图门槛大幅降低不需要理解渲染细节只要填自己服务的名字架构图就出来了。6.3 只读分享交互式而不是图片式最后聊分享。最初导出 PNG 分享到群里大家看图说话还行但想近距离看细节顶多放大没法查看某个节点是谁、有什么依赖。后来我改成了导出“自包含 HTML”把 SVG 数据和少量 JS 交互逻辑一起打包接收方用浏览器打开就能平移、缩放、点击节点查看详情不需要安装任何依赖。这个方案的启发其实很实际当图是“数据”而不是“图片”时分送到别人手里的也可以是一份可交互的数据而不是一张像素图。当前我还想给自包含 HTML 加时间轴把架构的演进过程做成可拖动的历史视图架构评审和交接的时候会非常直观。7. 项目落地时的一些经验与心得最后说几点从项目里踩出来的实际经验希望能帮你少走弯路。第一架构图的数据模型一定要保持最小化。能推出来的信息比如连线端点、所属层级就不要存进原始数据里独立“坐标”和“数据”不然以后加自动布局能力会后悔。第二布局算法先跑通最朴素的版本再一点一点优化。一开始就追求复杂算法容易把自己绕晕而且业务场景里绝大多数图根本没有特别严重的交叉问题先让图看起来有序就赢了 80%。第三交互开发的顺序建议是选中、拖拽、缩放、连线、右键菜单。选中和拖拽是所有交互的基础没有它们后面的东西都无从谈起。右键菜单是各个交互里最容易被忽略但影响非常大的一个“能改样式”是用户最常期待的能力。diagram-design 到现在已经覆盖了我在团队内部大部分架构图、时序图、拓扑图的绘制场景。这个东西的边界我还在不断扩展但核心思路一直没变图是一种可以直接被代码管理、被逻辑驱动、被团队维护的资产而不是某个文件里永远只能靠手工修修补补的“死图片”。如果你也在做或者打算做类似方向欢迎拿去参考也欢迎一起聊聊里面那些还没解决好的布局细节。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进