普通视图

发现新文章,点击刷新页面。
昨天以前首页

从 SQL DDL 到 ER 图:前端如何优雅地实现数据库可视化

作者 码云之上
2026年4月10日 15:12

在数据分析平台越来越卷的今天,各家都在琢磨怎么让用户更直观地理解自己的数据。
笔者所在团队维护着一个数据分析平台(技术栈:React18 + Vite + TypeScript + Ant Design),产品同学提了一个需求:用户导入数据库后,希望能以可视化的方式展示表结构及表之间的关系,就像数据库设计工具里的 ER 图那样。

听起来不难是吧?然而后端同学给的数据是——数据库的 DDL 语句。

就这?

好吧,SQL 解析这活儿看来得前端自己想办法了。

需求分析

先捋一下需求:

  1. 输入:用户导入的数据库 DDL 语句(CREATE TABLE 语句)
  2. 输出:可视化的 ER 图,展示表结构、字段信息、表之间的关联关系
  3. 交互:支持拖拽、缩放、节点展开/收起等常见操作

核心问题有两个:

  • SQL 解析:如何把 DDL 语句解析成结构化的表数据?
  • 图渲染:如何把表数据渲染成好看的 ER 图?

技术选型

SQL 解析库

在 GitHub 上一顿搜索,找到了几个候选方案:

库名 Stars 特点
sql.js 13k+ SQLite 的 WebAssembly 版本,偏重执行而非解析
pgsql-ast-parser 200+ 只支持 PostgreSQL
node-sql-parser 1k+ 支持多种数据库,解析成标准 AST

最终选择了 node-sql-parser,原因很简单:

  1. 支持 11 种数据库方言(MySQL、PostgreSQL、SQLite、MariaDB、SQL Server 等)
  2. 解析结果是标准的 AST,方便提取表结构和外键信息
  3. 文档清晰,API 简洁
import { Parser } from "node-sql-parser";

const parser = new Parser();
const ast = parser.astify(`
  CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50)
  )
`, { database: "MySQL" });

console.log(ast);
// 输出完整的 AST 结构

可视化组件

图可视化这块,首先想到的是 D3.js,但 D3 太底层了,画个节点连线都得从头写,不太划算。
继续调研,发现了 React Flow,这个库专门为 React 设计,API 友好,自带很多交互能力:

  • 节点拖拽
  • 画布缩放
  • 小地图导航
  • 连线动画
  • 自定义节点样式

配合 Dagre 布局算法,可以实现节点的自动排列,不用手动调整位置。

import { ReactFlow, Background, MiniMap, Controls } from "@xyflow/react";

function ERDiagram({ nodes, edges }) {
  return (
    <ReactFlow nodes={nodes} edges={edges}>
      <Background />
      <MiniMap />
      <Controls />
    </ReactFlow>
  );
}

整体设计

确定了技术选型,接下来设计整体架构。

数据流

用户输入 SQL DDL
      ↓
node-sql-parser 解析
      ↓
TableData[] + RelationshipData
      ↓
转换为 React Flow 节点和边
      ↓
Dagre 自动布局
      ↓
React Flow 渲染 ER 图

项目结构

sql-to-er-table/
├── client/
│   ├── pages/SqlToER/
│   │   └── SqlToERPage.tsx      # 主页面
│   ├── components/ERDiagram/
│   │   ├── ERDiagram.tsx        # 图容器
│   │   ├── ERNode.tsx           # 自定义表节点
│   │   ├── ERDiagramParser.ts   # 数据转换
│   │   └── utils.ts             # 布局算法
│   └── utils/
│       └── sqlParser.ts         # SQL 解析(API 调用)
│
├── server/
│   ├── services/
│   │   └── sqlParser.ts         # SQL 解析服务
│   └── middleware/
│       └── serveApi.ts          # API 路由
│
└── shared/
    ├── types.ts                 # 共享类型
    └── crypto.ts                # 加密工具

类型定义

定义好数据结构,前后端共享:

// shared/types.ts
export interface ColumnSchema {
  type: string;
  nullable: boolean;
  comment: string;
}

export interface TableData {
  table_name: string;
  name: string;
  comment: string | null;
  schema: Record<string, ColumnSchema>;
  index_info?: {
    primary_key?: string[];
  };
}

export interface RelationshipData {
  relationships: string[][];  // [["orders.user_id", "users.id"], ...]
}

优化点一:SQL 解析放服务端

一开始图省事,SQL 解析直接在浏览器端做。跑起来之后发现一个问题:node-sql-parser 打包后有 410KB+,直接把 client bundle 撑大了一圈。

client未优化体积.png

对于一个工具页面来说,这个体积有点夸张。而且 SQL 解析本身是纯计算任务,放在服务端更合理。

改造思路

  1. 服务端新增 /api/parse-sql 接口,接收 SQL 语句,返回解析结果
  2. 客户端改为调用 API,不再直接依赖 node-sql-parser
  3. 前端 bundle 瞬间瘦身

服务端实现:

// server/services/sqlParser.ts
import nodeSqlParser from "node-sql-parser";
import type { TableData, RelationshipData, DatabaseType } from "../../shared/types";

const { Parser } = nodeSqlParser;
const sqlParser = new Parser();

export function parseSqlToERData(sql: string, database: DatabaseType = "MySQL") {
  const errors: string[] = [];
  const tables: TableData[] = [];
  
  try {
    const result = sqlParser.astify(sql, { database });
    const astList = Array.isArray(result) ? result : [result];
    
    for (const ast of astList) {
      if (ast?.type !== "create" || ast?.keyword !== "table") continue;
      const tableData = parseCreateTableAST(ast);
      tables.push(tableData);
    }
  } catch (err: any) {
    errors.push(`SQL 解析失败:${err?.message}`);
  }
  
  return { tables, relationships, errors };
}

客户端调用:

// client/utils/sqlParser.ts
export async function parseSqlToERData(sql: string, database: DatabaseType = "MySQL") {
  const response = await fetch("/api/parse-sql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sql, database }),
  });
  
  return response.json();
}

改造完成后,client bundle 下降了 400KB,效果显著。

  • 改造前:

vite构建未优化前.png

  • 改造后:

vite 优化构建后.png

优化点二:SQL 加密传输

需求评审的时候,安全同学提了一个问题:SQL 语句里可能包含敏感信息(表名、字段名、注释等),明文传输不太合适。

好吧,那就加个密。

加密方案

考虑到是内部系统,不需要非常复杂的加密体系,选择了 AES-256-GCM 对称加密:

  • 加密强度足够
  • 自带认证标签(AuthTag),可以防止数据被篡改
  • 前后端都有成熟的实现

客户端使用 Web Crypto API:

// client/utils/crypto.ts
const ENCRYPTION_KEY = "sql-er-diagram-secret-key-32byte!";

async function getEncryptionKey(): Promise<CryptoKey> {
  const keyData = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(ENCRYPTION_KEY)
  );
  return crypto.subtle.importKey(
    "raw",
    keyData,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt"]
  );
}

export async function encryptSql(sql: string): Promise<string> {
  const key = await getEncryptionKey();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  
  const encrypted = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    new TextEncoder().encode(sql)
  );
  
  // 组合格式: iv:authTag:ciphertext (均为 base64)
  const encryptedArray = new Uint8Array(encrypted);
  const ciphertext = encryptedArray.slice(0, -16);
  const authTag = encryptedArray.slice(-16);
  
  return `${btoa(iv)}:${btoa(authTag)}:${btoa(ciphertext)}`;
}

服务端使用 Node.js crypto 模块解密:

// shared/crypto.ts
import crypto from "crypto";

export function decryptSql(payload: string): string {
  const [ivBase64, authTagBase64, encrypted] = payload.split(":");
  const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
  const iv = Buffer.from(ivBase64, "base64");
  const authTag = Buffer.from(authTagBase64, "base64");
  
  const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(authTag);
  
  let decrypted = decipher.update(encrypted, "base64", "utf8");
  decrypted += decipher.final("utf8");
  
  return decrypted;
}

现在 SQL 传输流程变成了:

客户端输入 SQL
      ↓
AES-256-GCM 加密
      ↓
POST /api/parse-sql { payload: "加密后的字符串" }
      ↓
服务端解密
      ↓
node-sql-parser 解析
      ↓
返回解析结果

最终效果

经过一番折腾,终于实现了从 SQL DDL 到 ER 图的完整流程:

  1. 用户在输入框粘贴 SQL 语句
  2. 选择数据库类型(支持 MySQL、PostgreSQL 等 11 种)
  3. 点击「生成 ER 图」
  4. 自动渲染出带关系连线的 ER 图
  5. 支持拖拽、缩放、小地图导航

ER效果图.png

总结

技术选型优点

  1. node-sql-parser:支持多种数据库方言,解析结果标准化,满足大部分 DDL 解析需求
  2. React Flow:专为 React 设计的图可视化库,开箱即用,交互体验好
  3. Dagre:经典的图布局算法,自动排列节点位置,省去手动调整的麻烦
  4. AES-256-GCM:加密强度足够,自带完整性校验,前后端都有成熟实现

不足之处

  1. SQL 解析的局限性:node-sql-parser 对一些复杂语法(如存储过程、触发器)支持有限,部分非标准写法可能解析失败
  2. 关系识别依赖外键:目前只能通过 FOREIGN KEY 约束识别表关系,实际业务中很多表并没有显式定义外键
  3. 布局算法的局限:Dagre 是基于层次结构的布局,对于复杂的网状关系,布局效果可能不太理想
  4. 客户端加密的安全性:密钥硬编码在前端代码中,安全性有限,仅适用于内部系统

后续优化方向

  1. 支持通过字段命名规则(如 user_id -> users.id)智能识别表关系
  2. 支持导出 ER 图为图片或 PDF
  3. 考虑使用 WebAssembly 方案,在保证性能的同时减少服务端依赖

项目代码已开源(脱敏处理),欢迎查看 👉 sql-to-er-table

从一个截图函数到一个 npm 包——pdf-snapshot 的诞生记

作者 码云之上
2026年4月7日 19:35

一个 PDF 文档页面截图工具的渐进式演化之路

背景

事情要从一个内部知识库项目说起。

产品同学提了一个需求:知识库里存了大量 PDF 文档,在预览列表页希望能展示文档的缩略图,用户点击缩略图后再打开完整的 PDF 文件。听起来很简单对吧?但问题是——库里只有 PDF 文件,没有缩略图。

于是摆在我面前的问题就很清晰了:如何从 PDF 文件中生成缩略图

一番调研后发现,Node.js 生态里虽然有一些 PDF 相关的库,但要么功能太重(整个 PDF 编辑器级别)、要么只能跑在浏览器端、要么 API 设计不太友好。最后决定基于 pdf-parse 封装一个轻量级的截图工具。

本以为写个工具函数就完事了,没想到这个小需求最终演变成了一个完整的 npm 包。下面就来聊聊这个渐进式的演化过程。

渐进式方案演进

阶段一:一个 utils 函数

最初的需求很简单——给知识库用,能生成缩略图就行。

于是我在项目里写了个 utils/pdfSnapshot.ts,核心逻辑大概长这样:

import { PDFParse } from 'pdf-parse';

export async function snapshotPdf(filePath: string, pages: number[]) {
  const pdfBuffer = await readFile(filePath);
  const pdfParser = new PDFParse({ data: pdfBuffer });
  
  const result = await pdfParser.getScreenshot({
    partial: pages,
    scale: 1.5,
    imageBuffer: true,
  });
  
  return result.pages.map(page => ({
    page: page.pageNumber,
    data: Buffer.from(page.data),
  }));
}

嗯,几十行代码,需求搞定,下班!

阶段二:抽成独立模块

好景不长,没过多久,隔壁组的同事找过来了:

"嘿,听说你写了个 PDF 截图的工具?我们这边有个文档预处理服务也需要这个功能,能不能给我们用用?"

于是我把这个函数从业务项目里抽出来,放到了一个独立的内部模块里。

但抽离的过程中发现了一些问题:

  1. 内存泄漏风险pdfjs-distpdf-parse 的底层依赖)会在内存里缓存解析结果,大量 PDF 处理后内存蹭蹭往上涨
  2. 缺少取消机制:处理几百页的大文件时,用户等不及想取消,但没有中断的能力
  3. 输入格式单一:只支持文件路径,不支持 Buffer 和流式输入

既然要给其他模块用了,这些问题就得解决。于是开始了第一次重构:

  • 引入子进程隔离,PDF 渲染跑在独立进程里,进程退出后内存自动释放
  • 支持 AbortController 取消操作
  • 支持文件路径 / Buffer / ReadableStream 三种输入格式

阶段三:发布为 npm 包

又过了一段时间,其他团队的同事也找过来了:

"你们那个 PDF 截图工具挺好用的,我们想在另一个项目里用,能不能发个 npm 包?" "对了,我们有个批量处理的场景,能不能加个进度回调?" "还有,我们运维同学想在脚本里用,能不能支持命令行?"

好家伙,需求越来越多了。

既然要发 npm 包,那就得认真对待了。于是有了这次比较彻底的重构:

  • 完善的 TypeScript 类型定义
  • 进度回调机制(onProgress
  • CLI 工具支持,方便脚本调用和 AI Agent 集成
  • 多种输出格式:Buffer / Base64 / 文件路径
  • 超时控制,避免子进程卡死

最终,这个工具从一个几十行的函数,演变成了一个结构完整的 npm 包——@guangmingz/pdf-snapshot

设计思路与实现框架

聊完演化过程,来深入剖析一下 pdf-snapshot 的设计思路。

核心设计原则

在设计这个工具时,我遵循了几个核心原则:

  1. 主进程零污染:PDF 渲染是内存大户,不能污染主进程
  2. 输入输出灵活:支持多种输入格式和输出格式,适应不同场景
  3. 可控性强:支持取消、超时、进度回调
  4. API 简洁:一个函数搞定,不需要复杂的初始化流程

模块架构

整个项目的目录结构如下:

src/
├── core/
│   ├── snapshot.ts      # 核心截图函数(主进程)
│   ├── pdf-info.ts      # 获取 PDF 信息
│   └── worker.ts        # 子进程 Worker(实际渲染)
├── utils/
│   ├── input-normalizer.ts   # 输入归一化
│   ├── page-resolver.ts      # 页码解析
│   ├── output-formatter.ts   # 输出格式化
│   └── worker-manager.ts     # 子进程管理
├── cli/
│   └── index.ts         # 命令行入口
├── types.ts             # 类型定义
├── errors.ts            # 错误类
├── constants.ts         # 常量
└── index.ts             # 导出入口

可以看到,模块划分还是比较清晰的:

  • core:核心逻辑,包括主进程入口和子进程 Worker
  • utils:工具函数,处理输入输出和子进程管理
  • cli:命令行接口

子进程隔离:内存泄漏的终极解法

这是整个设计中最关键的一环。

为什么要用子进程?因为 pdfjs-dist 在解析 PDF 时会在 V8 堆上分配大量内存,即使调用了 destroy() 方法,也很难完全释放。如果在主进程里处理大量 PDF,内存会越积越多,最终 OOM。

解法很简单也很粗暴——用子进程。子进程退出后,操作系统会自动回收它占用的所有内存,干净利落。

整个流程如下:

┌─────────────────────────────────────────────────────────────────┐
│                        主进程 (Main Process)                     │
├─────────────────────────────────────────────────────────────────┤
│  1. 接收输入 (文件路径 / Buffer / Stream)                         │
│  2. 归一化为临时文件路径                                          │
│  3. 解析页码参数                                                  │
│  4. Fork 子进程,传递任务参数                                      │
│  5. 等待子进程完成,接收结果文件路径                                │
│  6. 根据 output 参数格式化输出                                    │
│  7. 清理临时文件                                                  │
└───────────────────────────┬─────────────────────────────────────┘
                            │ IPC 通信(传递路径,不传 Buffer)
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                        子进程 (Worker Process)                   │
├─────────────────────────────────────────────────────────────────┤
│  1. 读取 PDF 文件                                                │
│  2. 调用 pdf-parse 渲染指定页面                                   │
│  3. 将截图写入临时目录                                            │
│  4. 返回文件路径 + 元数据                                         │
│  5. 退出进程(内存自动释放)                                       │
└─────────────────────────────────────────────────────────────────┘

这里有个细节值得一提:IPC 通信只传文件路径,不传 Buffer

为什么?因为 IPC 传输大数据很慢,一张截图可能有几 MB,如果通过 IPC 传 Buffer,性能会很差。所以我们让子进程把截图写到临时目录,IPC 只传路径和元数据(宽高、大小),主进程再按需读取。

子进程的核心代码:

process.on('message', async (msg: WorkerRequest) => {
  const { pdfPath, pages, scale, outputDir } = msg;
  let pdfParser: PDFParse | null = null;

  try {
    const pdfBuffer = await readFile(pdfPath);
    pdfParser = new PDFParse({ data: pdfBuffer });

    // 一次性传入所有页码,避免重复解析 PDF
    const screenshotResult = await pdfParser.getScreenshot({
      partial: pages,
      scale,
      imageBuffer: true,
    });

    const results: PageInfo[] = [];
    for (const page of screenshotResult.pages) {
      const filePath = join(outputDir, `page-${page.pageNumber}.png`);
      await writeFile(filePath, Buffer.from(page.data));
      results.push({ pageNumber: page.pageNumber, filePath, width: page.width, height: page.height });
    }

    process.send!({ success: true, pages: results });
  } catch (error) {
    process.send!({ success: false, error: error.message });
  } finally {
    await pdfParser?.destroy();
    process.exit(0);  // 退出进程,内存自动释放
  }
});

输入归一化:统一处理多种输入格式

为了支持文件路径、Buffer、ReadableStream 三种输入格式,我设计了一个「输入归一化」层:

export async function normalizeInput(input: PdfInput): Promise<{ path: string; isTempFile: boolean }> {
  // 文件路径:直接使用
  if (typeof input === 'string') {
    return { path: input, isTempFile: false };
  }
  
  // Buffer / Stream:写入临时文件
  const tempPath = join(tmpdir(), `pdf-${randomUUID()}.pdf`);
  
  if (Buffer.isBuffer(input)) {
    await writeFile(tempPath, input);
  } else {
    // Stream
    const chunks: Buffer[] = [];
    for await (const chunk of input) {
      chunks.push(chunk);
    }
    await writeFile(tempPath, Buffer.concat(chunks));
  }
  
  return { path: tempPath, isTempFile: true };
}

不管用户传什么格式,最终都归一化为文件路径,后续逻辑只需要处理文件路径即可。这种「归一化」的设计模式在很多场景下都很实用。

取消与超时:让操作可控

处理大文件时,用户可能等不及想取消;或者子进程卡死了需要超时兜底。这两个能力是生产环境必备的。

取消能力基于标准的 AbortController

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);  // 5 秒后取消

try {
  await snapshotPdf('./large.pdf', { signal: controller.signal });
} catch (error) {
  if (error instanceof SnapshotAbortedError) {
    console.log('操作被取消');
  }
}

超时控制在子进程管理器里实现:

const timer = setTimeout(() => {
  child.kill('SIGKILL');  // 强制杀死子进程
  reject(new SnapshotTimeoutError(timeout));
}, timeout);

进度回调:让等待不再焦虑

批量处理时,用户需要知道当前进度。虽然子进程是一次性处理所有页面的,但我们至少可以在「开始」和「完成」两个时机通知用户:

await snapshotPdf('./document.pdf', {
  pageRange: [1, 100],
  onProgress: (progress) => {
    // progress.stage: 'preparing' | 'completed'
    // progress.percent: 0 | 100
    console.log(`[${progress.stage}] ${progress.percent}%`);
  },
});

为什么不支持逐页进度?因为 pdf-parsegetScreenshot 是一次性处理所有页面的,中间没有回调钩子。如果要实现逐页进度,需要改成逐页调用,但这样会有性能问题(每次调用都要重新解析 PDF)。权衡之下,选择了「阶段进度」的方案。

CLI 工具:让 AI 也能用

最后聊聊 CLI 工具。

为什么要做 CLI?除了方便运维同学写脚本,还有一个重要原因——方便 AI Agent 调用

现在各种 AI 编程助手越来越流行,它们通常通过命令行来调用工具。如果你的工具只有 API 没有 CLI,AI 就很难直接使用。

pdf-snapshot 的 CLI 使用起来很简单:

# 截取第 1-10 页
pdf-snapshot -r 1-10 -o ./output document.pdf

# 截取指定页
pdf-snapshot -p 1,5,10 document.pdf

# 从标准输入读取(支持管道)
cat document.pdf | pdf-snapshot -o ./output -r 1-5 -

# 仅查看 PDF 信息
pdf-snapshot --info document.pdf

CLI 的实现基于 commander,核心是把命令行参数映射到 snapshotPdf 的 options:

program
  .argument('<input>', 'PDF 文件路径')
  .option('-o, --output <dir>', '输出目录', './pdf-screenshots')
  .option('-p, --pages <pages>', '离散页码')
  .option('-r, --range <range>', '页码范围')
  .option('-s, --scale <number>', '缩放比例', '1.5')
  .action(async (input, opts) => {
    const results = await snapshotPdf(input, {
      output: 'file',
      outputDir: opts.output,
      pageRange: parseRange(opts.range),
      pages: parsePages(opts.pages),
      scale: parseFloat(opts.scale),
    });
    console.log(`✅ 完成!已保存 ${results.length} 张截图`);
  });

还贴心地加了进度条:

⏳ 正在截图...
  [████████████████████████████████████████] 100% | 50/50 页

✅ 完成!已保存 50 张截图到 ./pdf-screenshots

总结

回顾 pdf-snapshot 的演化过程:

  1. 阶段一:一个 utils 函数,解决单点需求
  2. 阶段二:抽成独立模块,解决内存泄漏、支持取消和多种输入格式
  3. 阶段三:发布 npm 包,增加进度回调、CLI 工具、完善类型定义

这个过程其实挺有代表性的。很多时候我们写的工具函数,一开始只是为了解决眼前的问题,但随着需求的增加和使用场景的扩展,它会逐渐演化成一个更通用、更健壮的模块。

关键是要在演化过程中保持代码的可维护性可扩展性。子进程隔离、输入归一化、取消超时机制……这些设计不是一开始就有的,而是在实际使用中逐步发现问题、解决问题后沉淀下来的。

最后,如果你也有 PDF 截图的需求,欢迎试试 pdf-snapshot!

GitHub 地址:pdf-snapshot

有问题欢迎提 Issue,有改进想法欢迎 PR!

❌
❌