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

在数据分析平台越来越卷的今天,各家都在琢磨怎么让用户更直观地理解自己的数据。

笔者所在团队维护着一个数据分析平台(技术栈: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 简洁
typescript 复制代码
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 布局算法,可以实现节点的自动排列,不用手动调整位置。

typescript 复制代码
import { ReactFlow, Background, MiniMap, Controls } from "@xyflow/react";

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

整体设计

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

数据流

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

项目结构

bash 复制代码
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                # 加密工具

类型定义

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

typescript 复制代码
// 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 撑大了一圈。

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

改造思路

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

服务端实现:

typescript 复制代码
// 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 };
}

客户端调用:

typescript 复制代码
// 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 加密传输

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

好吧,那就加个密。

加密方案

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

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

客户端使用 Web Crypto API:

typescript 复制代码
// 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 模块解密:

typescript 复制代码
// 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 复制代码
客户端输入 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. 支持拖拽、缩放、小地图导航

总结

技术选型优点

  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

相关推荐
cat10month3 小时前
react坑点记录
前端·javascript·react.js
AKA__Zas3 小时前
SQL查询技巧全 Strategy Guide
数据库·sql·学习方法
luoganttcc3 小时前
华为 的 npu 架构如何 进行 flash attention
数据库·华为
Chasing__Dreams3 小时前
Mysql--基础知识点--94.1--嵌套子查询转关联查询
数据库·mysql
qq_283720053 小时前
Python 操作 MySQL 数据库全解:增删改查、事务、连接池与性能优化
数据库·python·mysql
爱码小白3 小时前
MySQL 系统函数专项练习题
数据库·python·mysql
Wenweno0o3 小时前
Ubuntu 系统配置 VS Code C++ 开发环境
数据库·c++·ubuntu
ayt0073 小时前
Netty NioEventLoopGroup源码深度剖析:高性能网络编程的核心引擎
服务器·前端·数据库
Chasing__Dreams3 小时前
Mysql--基础知识点--97--UNION ALL VS UNION
数据库·mysql