news 2026/10/21 10:09:47

Diagram as Code:用Graphviz代码化自动生成架构图与拓扑图

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Diagram as Code:用Graphviz代码化自动生成架构图与拓扑图

做技术方案最烦的一件事,就是画架构图。业务那边催着要评审文档,开发这边代码一天改八遍,图也跟着改八遍;等你终于把架构图调好,下个迭代一来,整个拓扑又变了。时间一长,图就成了摆设,文档过期比牛奶还快。这个项目“diagram-design”就是冲着这个痛点去的:把架构图、流程图、拓扑图的“绘制”变成“编写”,用一套代码和配置来驱动图表生成,让图和源码保持同步,改架构像改代码一样简单,还能走 review、走版本管理,彻底告别手动拖拽和反复截图。这篇文章我会从方案选型、核心实现、真实场景到排坑心得,完整拆解这个项目,适合后端工程师、DevOps、架构设计岗,以及任何一个被“文档维护”折磨过的人。

1. 整体设计思路与方案选型

1.1 为什么不做拖拽工具,非要“代码画图”

先说需求核心:diagram-design定位是“Diagram as Code”,图表即代码。拖拽工具(比如各种在线白板、桌面绘图软件)确实上手快,但有两个致命伤:一是不可追溯,你没法对一张 PNG 做代码评审,也没法轻松看出两张图之间改了什么;二是不可复用,下一个项目画类似的拓扑,你还得重新拖一遍,耗时且容易走样。

换成代码驱动之后,图变成了一种文本资产。可以放进 Git 仓库,每次修改都有 diff;可以绑到 CI 里自动渲染预览;可以对数据进行批量生成——从云环境拉一批实例清单,脚本一跑,几十个节点的拓扑图就出来了,手动画要一个下午,代码画图只需要几秒钟。这种“生成式”的优势在节点多、变化频繁的场景里尤其明显。

1.2 核心候选方案对比:Graphviz、Mermaid、PlantUML

diagram-design 的第一项技术决策就是选底层渲染引擎。当时圈子里主流的有三套:Graphviz、Mermaid、PlantUML。我挨个试过,简单列下感受:

方案布局能力样式控制适用范围上手成本
Graphviz极强,多引擎可选(dot/neato/fdp)精细到节点边框、字体、端口、集群架构图、拓扑图、状态机、依赖关系需理解 DOT 语法,学习曲线略陡
Mermaid一般,适合常规流程图和时序图中等,主题定制有限Web 文档、Markdown 内嵌很低,几乎零学习成本
PlantUML偏 UML,时序图效果好中等UML 类图、时序图很低,语法结构化

最终选 Graphviz,原因是 diagram-design 的核心场景偏向“系统架构图”和“链路拓扑”,这类图的重点是大规模节点下的自动布局。Graphviz 的图布局引擎最强,几十上百个节点它依然能排出相对清晰的层次;而且它通过 Python 的 graphviz 库绑定之后,可以非常方便地与数据源、脚本结合,属于“既能画得复杂,也能自动化”的那类工具。

1.3 分层设计:基础层、生成层、定制层

整个项目我没有把逻辑塞在一个脚本里,而是分了三层:

  • 基础层:围绕 Graphviz 的 Python 绑定做封装,统一节点样式、颜色、字体、输出格式,避免每次调用都重复写一堆 styling。
  • 生成层:负责把结构化的数据(元数据、配置、JSON、YAML)转换成图谱对象。比如你给我一份“服务名、调用方、被调用方、协议、环境”的表,我直接生成一张微服务调用图。
  • 定制层:暴露 template 和 hook 机制,允许对某些特殊节点加图标、加边框、分区展示,保持灵活性。

分层的好处是“大多数人只改生成层的数据就能干活”,而不用碰到底层渲染细节。这个设计思路直接决定了整个项目实施过程各方打交道的成本,也让 diagram-design 从“一个脚本”扩展成“一套可维护的工具”。

2. 核心细节解析与实操要点

2.1 环境搭建与依赖安装

先搭基础环境。我当时的操作系统是 Linux,macOS 和 Windows 也支持,流程基本一致:

# 安装 graphviz 二进制 sudo apt update && sudo apt install -y graphviz # 安装 python 绑定 pip install graphviz

装完之后先跑一条命令确认没有问题:

dot -V

Graphviz 的核心是一个命令行工具集,python 绑定只是帮你生成 dot 文本再调用这条命令,所以二进制必须装好。常见入门者会漏装二进制只装 pip 包,结果一运行就报“Executable not found”,这个问题我后面专门排查过,遇到概率非常高。

2.2 DOT 语法与核心概念

DOT 是 Graphviz 的领域描述语言。简单说它描述的是图的对象:节点、边、子图。最容易被忽略但无比重要的是三个概念:rank、cluster、port。

  • rank 控制垂直方向的层级。rankdir=LR 可以让图从左往右延伸,rank=same 可以让几个节点排在同一水平线。
  • cluster 是视觉上的“组”,可以实现“微服务内部一组,数据库一组,外部服务一组”的分区效果。cluster 子图的命名必须以 cluster 开头才能被识别。
  • port 是节点的“端口”,用于精确控制连线的出入口位置,比如 A 的出口和 B 的入口分别打在哪一侧,避免交叉线。

这三个概念直接决定一张架构图是“能看”还是“看得舒服”。很多人用 Graphviz 画出的第一张图很乱,不是引擎不好,是没理解 rank 和 cluster 的用法。

2.3 样式定制的关键参数

Graphviz 的默认样式是学术味很浓的黑白图,放在技术文档里稍显生硬,所以 diagram-design 里我自定义了一套视觉参数。最重要的几个:

  • bgcolor="transparent",让图整体透明,方便嵌入各类文档背景。
  • node 的 shape=box、style="rounded,filled"、fixedsize=true 可以让节点变成精致圆角卡片。
  • fontname 必须设置,尤其是处理中文标签时,不设置字体很容易变成方块乱码。
  • splines=ortho 让边变成正交折线,适合架构图里那类“从左侧进、从右侧出”的连线,视觉效果整齐很多。

还有一个非常实用但容易漏的参数:dpi。网页里展示的图一般 96dpi 就够,但如果要拿去打印或者放进 PPT,需要提高 DPI 保证放大不糊。直接在渲染参数里调dpi="300"即可,不需要手动用 PS 放大。

3. 实操过程与核心环节实现

3.1 模型层的接口设计

先定义生成图时需要的数据模型。我建议不要直接把杂乱的 dict 给渲染层,而是定义两个基础数据结构:Node 和 Edge。看起来好像多写了几个类,但后面扩展属性和改样式时省很多事。

from graphviz import Digraph from typing import List, Optional class DiagramNode: def __init__(self, name: str, label: str, shape: str = "box", style: str = "rounded,filled", fillcolor: str = "#E6F0FA"): self.name = name self.label = label self.shape = shape self.style = style self.fillcolor = fillcolor class DiagramEdge: def __init__(self, tail: str, head: str, label: Optional[str] = None, style: str = "solid", color: str = "#333333"): self.tail = tail self.head = head self.label = label self.style = style self.color = color

设计要点是默认值要“好看且通用”。shape 默认给 rounded box,fillcolor 默认给浅蓝,这样即使调用方什么样式都不写,渲染出来也不丑。Edge 的 label 用 Optional[str],因为不是每条边都需要说明,空标签时不显示文字,避免图上挂一堆多余信息。

3.2 渲染引擎封装

这一步是把 graphviz 对象再包一层,让调用方完全不感知 DOT 语法。封装时记得把图方向、节点默认样式、边默认样式都集中处理,hack 技巧在这里:

class DiagramRenderer: def __init__(self, rankdir: str = "LR"): self.graph = Digraph(comment="diagram-design") self.graph.attr(rankdir=rankdir) self.graph.attr(bgcolor="transparent") self.graph.attr(dpi="300") self.graph.attr(splines="ortho") self.graph.node_attr.update(shape="box", style="rounded,filled", fontname="Arial", fillcolor="#E6F0FA") self.graph.edge_attr.update(fontname="Arial", fontsize="10")

单独抽一个 renderer 的收益在后续会慢慢显现:如果你有 50 张图要统一改配色,只需改这里,不用逐张图去调参数。样式统一、更换成本低,这在“文档维护”项目里是决定生死的特性。

然后实现 add_node 和 add_edge 两个方法,负责往 graph 对象里填充:

def add_node(self, node: DiagramNode): self.graph.node(node.name, label=node.label, shape=node.shape, style=node.style, fillcolor=node.fillcolor) def add_edge(self, edge: DiagramEdge): self.graph.edge(edge.tail, edge.head, label=edge.label or "", style=edge.style, color=edge.color) def render_to_file(self, output_path: str): self.graph.render(output_path, format="png", cleanup=True)

注意 render_to_file 里我传了 cleanup=True。这个参数的作用是渲染完成后自动删除中间生成的 .dot 源文件。第一次跑的时候我还不清楚这个参数,后来发现工作目录里散落着大量编译缓存文件,全是这类中间产物,设置 cleanup 后干净多了。

3.3 一个完整可运行的架构图示例

用上面这些基础组件就可以搭建一个微服务调用的完整示例。模拟项目X 有三部分:网关、业务服务、数据层。我现在用 diagram-design 生成它的总体架构图:

from graphviz import Digraph # 构建一个有向图,rankdir 控制方向 dot = Digraph(comment="Architecture Overview") dot.attr(rankdir="LR") # 全局默认样式 dot.attr(bgcolor="white") dot.node_attr.update(shape="box", style="rounded,filled", fillcolor="#E6F0FA", fontname="Arial") dot.edge_attr.update(fontname="Arial") # 核心节点 dot.node("gateway", "API Gateway") dot.node("order_svc", "Order Service") dot.node("pay_svc", "Payment Service") dot.node("auth_svc", "Auth Service") dot.node("order_db", "Order DB\n(PostgreSQL)") dot.node("pay_db", "Payment DB\n(PostgreSQL)") dot.node("cache", "Redis Cache") # 边 dot.edge("gateway", "auth_svc", label="JWT 校验") dot.edge("gateway", "order_svc", label="POST /orders") dot.edge("gateway", "pay_svc", label="POST /payments") dot.edge("order_svc", "order_db", label="SQL") dot.edge("pay_svc", "pay_db", label="SQL") dot.edge("order_svc", "cache", label="GET /SET") dot.edge("pay_svc", "cache", label="GET /SET") dot.render("example_architecture", format="png", cleanup=True) print("generated example_architecture.png")

这段代码跑完,当前目录下会得到一张 example_architecture.png。七个节点和七条边的关系清晰可读,已经能达到项目评审文档的标准。如果想插入到某个 Markdown 文档,直接把图片路径引入即可,而且因为用的是确定的 renderer,每次跑出来的图视觉风格都一样,批量文档拼接出来观感非常统一。

3.4 集群分组实现(cluster 用法)

单层的节点图只能表达“服务与关系”,表达不了“系统边界”。架构评审时大家关心的是:哪些服务属于同一个子系统?哪些依赖属于外部?这种信息需要用 cluster 来分组解决。

继续扩展刚才的例子,把“核心业务域”和“基础设施域”分开:

with dot.subgraph(name="cluster_core") as c: c.attr(style="rounded,filled", fillcolor="#F0F8F0", label="核心业务域", fontname="Arial") c.node("order_svc", "Order Service") c.node("pay_svc", "Payment Service") with dot.subgraph(name="cluster_infra") as c: c.attr(style="rounded,filled", fillcolor="#F8F0F0", label="基础设施域", fontname="Arial") c.node("order_db", "Order DB") c.node("pay_db", "Payment DB") c.node("cache", "Redis Cache")

这里有个非常关键的命名规则:subgraph 的 name 参数必须以 cluster_ 开头,否则 Graphviz 不会把它当成分区,视觉上就是普通节点。相当多第一次用 Graphviz 的开发者在这里踩坑,图怎么画都不分组,最后发现只是命名不对。设置 fillcolor 加 style 后,分组区块会有淡色底纹,一眼看得出边界。

3.5 批量生成与自动化管线

diagram-design 最后能坚持下来,靠的是批量能力。实际使用中很少只画一张图,往往是几十个服务在一起的完整拓扑。手写每张节点和边不现实,于是我写了一个 JSON schema 的数据源映射工具,已知服务清单后自动生成图并导出缩略图:

import json def generate_from_json(path: str): with open(path, "r", encoding="utf-8") as fp: data = json.load(fp) dot = Digraph(comment="Topology from JSON") dot.attr(rankdir="TB") dot.attr(bgcolor="white") dot.node_attr.update(shape="box", style="rounded,filled", fillcolor="#E6F0FA", fontname="Arial") for s in data["services"]: dot.node(s["name"], s["label"], fillcolor=s.get("color", "#E6F0FA")) for e in data["edges"]: dot.edge(e["from"], e["to"], label=e.get("label", "")) out_name = data.get("output_name", "generated_topology") dot.render(out_name, format="png", cleanup=True) print("generate done:", out_name + ".png") data_example = { "services": [ {"name": "web", "label": "Web Frontend", "color": "#E0F7FA"}, {"name": "api", "label": "API Service", "color": "#E0F0FF"}, {"name": "worker", "label": "Worker", "color": "#FFF3E0"} ], "edges": [ {"from": "web", "to": "api", "label": "HTTPS"}, {"from": "api", "to": "worker", "label": "Queue"} ], "output_name": "auto_topology" } with open("topology.json", "w", encoding="utf-8") as fp: json.dump(data_example, fp, ensure_ascii=False, indent=2) generate_from_json("topology.json")

这段代码把“图和数据的耦合”彻底解开了。日常维护只改 JSON,不改 Python。如果服务数量暴涨,还可以从配置中心拉数据直接灌进这个 JSON 再渲染,实现拓扑图每天自动更新。真实架构演进最怕什么?最怕文档更新不及时。自动生成管线直接把“人为更新”这件事去掉了。

4. 常见问题与排查技巧实录

4.1 中文字体显示为方块

Graphviz 默认字体不包含中文字形,不配置 fontname 的话标签全是方块。解决办法分两步:第一步系统里装好中文字体(Linux 下确认 fonts-noto-cjk 已安装);第二步在 DOT 里给 node_attr 和 graph 都指定 fontname。比较典型的配置:

dot.attr(fontname="Noto Sans CJK SC") dot.node_attr.update(fontname="Noto Sans CJK SC") dot.edge_attr.update(fontname="Noto Sans CJK SC")

三个地方都要设置,不能只设置 node_attr。graph 属性控制的是 cluster 标签和标题字体,edge 控制连线标签,漏掉哪个哪个就乱码。

4.2 节点过多导致布局拥挤

画依赖关系图时一旦超过 30 个节点,默认引擎输出往往变成一团毛线。我的经验有三条路线:先加 cluster 做分组,让图按系统域分块;再把非核心节点聚合掉,比如把三个缓存节点合并成一个“Cache Cluster”节点;最后调整 rankdir,节点层次多时用 TB(从上到下),链路长时用 LR(从左到右),试几次选观感最合适的。

还有一个低成本的必杀技:调overlap=false和sep="+20"。前者强制节点不重叠,后者控制节点间最小间距,很多时候只是这两个参数就能让图从“没法看”变成“还行”。

4.3 连线交叉严重,怎么都排不干净

Graphviz 的自动布局倾向于优化边的长度,但不保证完全不交叉。如果交叉严重,先检查是否所有连接都“必须”存在;再考虑用splines=ortho让所有边折行,视觉上会整齐很多。最后利用 port 机制,给节点指定出口和入口,例如:

dot.node("a", "A") dot.node("b", "B") dot.node("c", "C") dot.edge("a:e", "b:w", label="1") dot.edge("a:e", "c:w", label="2")

a:e 的意思是节点 A 的东侧出边,b:w 是节点 B 的西侧入边。这样控制后,A 向 B 和 C 的连线从同侧出发,图上交叉概率大幅下降,也符合人工绘图习惯。

4.4 中文文档导出 PNG 后不清晰

架构图如果要贴进 Word 或者打印,屏幕显示的 96dpi 完全不够,文字边缘会虚。正确做法是渲染时调高 dpi:

dot.attr(dpi="300")

同样一张图,300dpi 输出体积会大一些,但无论放大还是缩小,观感都更专业。尤其评审文档打印成 PDF,文字依然锐利。

4.5 直连边重叠在同一个节点上

多个边同时指向一个节点时,如果都从同一个方向进来,入口处会糊成一团。建议在边上加不同的端口位置,让一个从北侧进、一个从南侧进,避免全挤在一起。配合 color 区分不同协议也会进一步降低识别成本。

4.6 render 时报 Executable not found

这是最常见的入门级报错,本质是图库二进制没有安装。pip 装的 graphviz 只是 Python 绑定,真正干活的是系统里的 dot 命令。遇到这种报错先跑dot -V,如果提示 command not found,就回到 2.1 安装 graphviz 二进制。macOS 上用brew install graphviz,Windows 需要去官网下载安装包并加入 PATH。很多时候明明“装过”,重启终端之后 PATH 还是没加载,需要确认安装路径已加入环境变量。

5. 实战项目:模拟某跨平台系统的自动化拓扑生成

5.1 场景设定

模拟项目X,一个跨平台系统,线上环境有 20 个服务节点、一个网关、三种存储方案、两条外部依赖链路。传统手动维护拓扑图方式每周至少消耗半天,且版本之间差异难以追踪。diagram-design 在这个项目中的目标很简单:从账号体系拉取服务列表后一键生成最新拓扑,免人工绘图。

5.2 数据准备与映射

我先写了一段清单映射脚本,把服务模型转成渲染器需要的节点和边:

services_data = [ {"name": "gateway", "label": "网关", "color": "#FDE9E9"}, {"name": "auth", "label": "认证服务", "color": "#E8F5E9"}, {"name": "order", "label": "订单服务", "color": "#E8F5E9"}, {"name": "inventory", "label": "库存服务", "color": "#E8F5E9"}, ] edges_data = [ {"from": "gateway", "to": "auth", "label": "REST"}, {"from": "gateway", "to": "order", "label": "REST"}, {"from": "order", "to": "inventory", "label": "内部 RPC"}, ]

这里我用“接口模型映射源码”的思路生成这些 Python 字典,保证服务和线上环境实时一致。接着调用封装好的 generate_from_list 函数:

def generate_from_list(services, edges, output_name="system_topology"): dot = Digraph(comment="System Topology") dot.attr(rankdir="LR", bgcolor="white") dot.node_attr.update(shape="box", style="rounded,filled", fontname="Arial") for s in services: dot.node(s["name"], s["label"], fillcolor=s.get("color", "#E6F0FA")) for e in edges: dot.edge(e["from"], e["to"], label=e.get("label", "")) dot.render(output_name, format="png", cleanup=True) generate_from_list(services_data, edges_data)

整个操作过程耗时不超过 15 秒,网络拓扑图就刷新了。相较于传统手段,这个流程最大的优势是:无论服务增至 50 还是 100,我都不需要重新画图,只改列表数据即可。

5.3 与 CI 文档流程的集成

后续我把这个生成步骤嵌进了文档构建流程。每次构建,项目配置先构建数据映射,再调用 generate_from_list,最后把 PNG 发布到静态文档目录。这样团队每次看到的架构图都是当次构建的真实状态,彻底杜绝了“图跟代码不一致”的争议。这个经验后来被我应用到多个项目里,评价普遍不错。

6. 体验总结与后续扩展方向

6.1 对“图即代码”的落地体会

diagram-design 最核心的价值不是画图本身,而是把“图”这种产物拉进了软件工程的版本管理体系中。我在实际操作中的体会是:这个方案敲开了一扇门,它让我第一次看到“图也变得可评审、可追溯”的样子。大家评审代码时可以指出某个函数实现对不对,现在评审架构图时也可以直接指出某条链路是否多余、某个依赖方向是否合理,这是拖拽绘图工具无法提供的核心优势。

6.2 值得尝试的扩展方向

这套项目后续还有两个我觉得值得尝试的方向,供参考:

  • 动态数据源接入:从服务注册中心直接拉实例列表,生成监督大屏类的实时拓扑图。图会随着服务发现的变化自动刷新。
  • 导出更多格式:目前默认输出 PNG,但 Graphviz 底层支持 SVG、PDF、PostScript 等。SVG 特别适合嵌入 Web 端,可以局部交互、动态高亮节点。
  • 多语言渲染:已经跑通过用统一数据源同时输出中文和英文版架构图,对外文档直接剥离翻译工序。
  • 图数据库联动:把节点和边的关系同步到图数据库,用于后续做链路追踪、故障根因定位。

最终这项目沉淀下来的不是某张图,而是一整套“把图当代码维护”的工作习惯。强烈建议还在被文档维护逼疯的人,下一个迭代从 Diagram as Code 开始。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 10:09:45

开放式代码审查实战:从流程设计到工具链落地的完整指南

代码审查这件事,我做了十几年,见过团队把 Review 做成走流程的摆设,也见过靠 Review 把线上事故率实实在在压下去的团队。前阵子和几个朋友聊到一个挺火的话题:open-code-review,说白了就是把审查从“两个人之间的私聊…

作者头像 李华
网站建设 2026/10/21 10:09:18

OpenClaw集成飞书实战:从权限配置到办公智能体落地

OpenClaw 第3章:实战集成飞书,构建自动化办公智能体这个系列写到第3章,终于到了我踩坑最多、也最值得拿出来分享的环节——把 OpenClaw 接进飞书。前面两章分别解决了"本地跑通 OpenClaw"和"让它具备基本工具调用能力"的…

作者头像 李华
网站建设 2026/10/21 10:07:28

接收机扫描能力深度解析:核心参数、方案与选型指南

做接收机相关的工作久了,我发现一个现象:大家讨论一款接收机的扫描能力时,最常甩出来的一句话是“扫描速度多少”。可真把两台机子放在同一个频段上去扫,结果经常对不上——有的标称每秒几个吉赫,实测连一半都跑不出来…

作者头像 李华
网站建设 2026/10/21 10:06:45

火星数据:构建球员-俱乐部-国家队三维足球评估体系

1. 为什么叫"火星数据":先聊聊这套评估体系的起名逻辑如果你也常年泡在各类球员评分、俱乐部榜单、国家队实力讨论里,大概会有和我一样的感受:数据不少,口径不一,结论更是互相打架。有的榜单偏爱进攻数据&am…

作者头像 李华