勇士枪战少年
92.41M · 2026-04-12
在数据分析平台越来越卷的今天,各家都在琢磨怎么让用户更直观地理解自己的数据。
笔者所在团队维护着一个数据分析平台(技术栈:React18 + Vite + TypeScript + Ant Design),产品同学提了一个需求:用户导入数据库后,希望能以可视化的方式展示表结构及表之间的关系,就像数据库设计工具里的 ER 图那样。
听起来不难是吧?然而后端同学给的数据是——数据库的 DDL 语句。
好吧,SQL 解析这活儿看来得前端自己想办法了。
先捋一下需求:
CREATE TABLE 语句)核心问题有两个:
在 GitHub 上一顿搜索,找到了几个候选方案:
| 库名 | Stars | 特点 |
|---|---|---|
| sql.js | 13k+ | SQLite 的 WebAssembly 版本,偏重执行而非解析 |
| pgsql-ast-parser | 200+ | 只支持 PostgreSQL |
| node-sql-parser | 1k+ | 支持多种数据库,解析成标准 AST |
最终选择了 node-sql-parser,原因很简单:
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 解析直接在浏览器端做。跑起来之后发现一个问题:node-sql-parser 打包后有 410KB+,直接把 client bundle 撑大了一圈。
对于一个工具页面来说,这个体积有点夸张。而且 SQL 解析本身是纯计算任务,放在服务端更合理。
/api/parse-sql 接口,接收 SQL 语句,返回解析结果node-sql-parser服务端实现:
// 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,效果显著。
需求评审的时候,安全同学提了一个问题:SQL 语句里可能包含敏感信息(表名、字段名、注释等),明文传输不太合适。
好吧,那就加个密。
考虑到是内部系统,不需要非常复杂的加密体系,选择了 AES-256-GCM 对称加密:
客户端使用 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 图的完整流程:
node-sql-parser 对一些复杂语法(如存储过程、触发器)支持有限,部分非标准写法可能解析失败FOREIGN KEY 约束识别表关系,实际业务中很多表并没有显式定义外键user_id -> users.id)智能识别表关系项目代码已开源(脱敏处理),欢迎查看 sql-to-er-table