使用antv/x6构建SQL数据血缘web页面

1,006 阅读9分钟

最终展示效果

2024-10-2608.42.45-ezgif.com-video-to-gif-converter.gif

github

github.com/SorryToPers…

1、ER图选型

流程图的插件有很多,看了很多插件,还是觉得antv/x6的更容易上手,并且对于小白,做一些定制化功能比较简单。 @projectstorm/react-diagrams这个插件也比较不错,针对这次需求,它天生自带输入输出节点,功能比较简单,如果需要定制化功能的话,工作量比较多。 React-Flow也是比较简单的流程图插件,同样太简单,不便于很多定制开发。 GoJSjsPlumb功能强大,但是上手难度比较高,学习起来比较困难,高级功能收费,当然,一般的功能社区版就足够了。由于1024的时间问题,就不去花时间研究了。

2、react-ace实现代码编辑器,并同步高亮词汇

CodeEditor

    import React, { useEffect, useRef, useState } from 'react';
    import { Select, Space, Button, message } from 'antd';
    import AceEditor, { IAceEditorProps } from 'react-ace';
    import * as sqlFormatter from 'sql-formatter';
    import { useSqlStore } from '@/models/sql';
    import { parse } from '@/services/common';
    import { Range } from 'ace-builds';
    import 'ace-builds/webpack-resolver';
    import 'ace-builds/src-noconflict/theme-github';
    import 'ace-builds/src-noconflict/theme-chrome';
    import 'ace-builds/src-noconflict/theme-tomorrow';
    import 'ace-builds/src-noconflict/mode-mysql';
    import './index.less';
    
    interface ICodeEditor extends IAceEditorProps {
      disabled?: boolean;
    }
    
    export default React.forwardRef((props: ICodeEditor) => {
      const { mode, height, width, name, theme, placeholder, value, onChange, disabled = false, ...rest } = props;
      const editorRef = useRef<AceEditor>(null);
      const highlightWords = useSqlStore((state) => state.highlightWords);
      const highlightTableWords = useSqlStore((state) => state.highlightTableWords);
      const highlightFeildWords = useSqlStore((state) => state.highlightFeildWords);
      const mockData = useSqlStore((state) => state.mockData);
      const loading = useSqlStore((state) => state.loading);
      const curMockData = useSqlStore((state) => state.curMockData);
      const [sqlValue, setSqlValue] = useState(curMockData.data.sql);
    
      const handleClick = async () => {
        if (mockData.find((item) => item.data.sql === sqlValue)) {
          useSqlStore.setState({
            loading: true,
          });
          const obj = mockData.find((item) => item.data.sql === sqlValue);
          setTimeout(() => {
            useSqlStore.setState({
              curMockData: obj,
              sqlInfo: {
                desc: '',
                graph: undefined,
                lineage: undefined,
                sql: '',
              },
            });
            obj?.data?.sql && setSqlValue(obj.data.sql);
            useSqlStore.setState({
              loading: false,
            });
          }, 2000);
        } else {
          useSqlStore.setState({
            loading: true,
          });
          const res = await parse({
            originalSql: sqlValue,
          }).finally(() =>
            useSqlStore.setState({
              loading: false,
            }),
          );
    
          if (res.code === '0') {
            console.log(res.data);
            useSqlStore.setState({
              sqlInfo: res.data,
            });
          } else {
            message.error('服务端异常,请稍后再试');
          }
        }
      };
    
      const handleChange = (v: any) => {
        setSqlValue(v);
      };
    
      const handleHighlightWords = (words: string[], tableWords: string[], feildWords: string[]) => {
        const editor = editorRef.current?.editor;
        if (!editor) return;
    
        const session = editor.getSession();
    
        // 清除现有的 markers
        const markers = session.getMarkers();
        Object.keys(markers).forEach((key: any) => {
          if (['highlight-marker', 'highlight-table', 'highlight-feild'].includes(markers[key].clazz)) {
            session.removeMarker(markers[key].id);
          }
        });
    
        // 为每个单词添加新的 marker
        feildWords.forEach((word) => {
          const content = session.getValue();
          let match;
          const regex = new RegExp(word, 'g');
    
          while ((match = regex.exec(content)) !== null) {
            const startPosition = session.doc.indexToPosition(match.index, 0);
            const endPosition = session.doc.indexToPosition(match.index + word.length, 0);
            const range = new Range(startPosition.row, startPosition.column, endPosition.row, endPosition.column);
            session.addMarker(range, 'highlight-feild', 'text');
          }
        });
    
        // 为每个单词添加新的 marker
        tableWords.forEach((word) => {
          const content = session.getValue();
          let match;
          const regex = new RegExp(word, 'g');
    
          while ((match = regex.exec(content)) !== null) {
            const startPosition = session.doc.indexToPosition(match.index, 0);
            const endPosition = session.doc.indexToPosition(match.index + word.length, 0);
            const range = new Range(startPosition.row, startPosition.column, endPosition.row, endPosition.column);
            session.addMarker(range, 'highlight-table', 'text');
          }
        });
    
        // 为每个单词添加新的 marker
        words.forEach((word) => {
          const content = session.getValue();
          let match;
          const regex = new RegExp(word, 'g');
          while ((match = regex.exec(content)) !== null) {
            const startPosition = session.doc.indexToPosition(match.index, 0);
            const endPosition = session.doc.indexToPosition(match.index + word.length, 0);
            const range = new Range(startPosition.row, startPosition.column, endPosition.row, endPosition.column);
            session.addMarker(range, 'highlight-marker', 'text');
          }
        });
      };
    
      const handleSelectChange = (v: string) => {
        const obj = mockData.find((item) => item.name === v);
        if (obj) {
          setSqlValue(obj?.data.sql ?? '');
          useSqlStore.setState({
            curMockData: {
              name: obj?.name,
              data: {
                desc: '',
                graph: undefined,
                lineage: undefined,
                sql: '',
              },
            },
          });
        }
      };
    
      useEffect(() => {
        handleHighlightWords(highlightWords, highlightTableWords, highlightFeildWords);
      }, [highlightWords, highlightTableWords, highlightFeildWords]);
    
      return (
        <div className="code-editor">
          <div className="code-editor-tool">
            <Space>
              <Select
                style={{
                  width: 200,
                }}
                options={mockData.map((item) => ({
                  label: item.name,
                  value: item.name,
                }))}
                value={curMockData.name}
                onChange={handleSelectChange}
              />
              <Button loading={loading} onClick={handleClick}>
                执行
              </Button>
            </Space>
          </div>
          <AceEditor
            ref={editorRef}
            width="100%"
            mode="mysql"
            theme="tomorrow"
            placeholder=""
            onChange={handleChange}
            name="ace-editor"
            value={sqlValue}
            editorProps={{ $blockScrolling: true }}
            fontSize={14}
            showGutter // 显示行号
            highlightActiveLine
            showPrintMargin={false}
            setOptions={{
              enableBasicAutocompletion: true, // 启用基本自动完成功能
              enableLiveAutocompletion: true, // 启用实时自动完成功能比如智能代码提示)
              enableSnippets: true, // 启用代码段
              showLineNumbers: true,
              showGutter: true,
              tabSize: 2,
              useWorker: false,
            }}
            readOnly={disabled}
            debounceChangePeriod={500} // 防抖时间
            {...rest}
          />
        </div>
      );
    });
    

<!---->

    .code-editor {
      height: 100%;
      display: flex;
      flex-direction: column;
      .code-editor-tool {
        height: 46px;
        display: flex;
        background-color: #7f56d9;
        align-items: center;
        padding: 0 20px 0 50px;
      }
      .ace_editor {
        flex: 1;
        height: 0;
        .highlight-marker {
          position: absolute;
          background-color: rgb(255, 132, 152);
          z-index: 20;
        }
    
        .highlight-table {
          position: absolute;
          background-color: rgb(194, 215, 255);
          z-index: 20;
        }
    
        .highlight-feild {
          position: absolute;
          background-color: rgb(191, 255, 189);
          z-index: 20;
        }
      }
    }
    

注意点

如果需要高亮多组词汇,需要单独设置不同的样式。 然后要么对词汇进行重新分割排列优先级,确保多组词汇中并无重复。 如果不想做这份工作,那么注意高亮词组的设置顺序,后设置的会覆盖前面的词组。

起初使用vite构建,但是打包构建的时候,ace-builds这个插件始终构建不了,换了umi之后没问题

3、使用antv/x6实现数据血缘关系图

SQL Flow

    // @ts-nocheck
    import { Graph, Cell } from '@antv/x6';
    import { DagreLayout } from '@antv/layout';
    import { useCallback, useEffect, useRef } from 'react';
    import { initGraph } from './logic';
    import { useSqlStore } from '@/models/sql';
    import { debounce, throttle } from 'lodash';
    
    export const LINE_HEIGHT = 24;
    export const NODE_WIDTH = 220;
    export const COLOR_MAP = {
      fontColor: '#ffffff',
      hoverBg: '#e5c2ff',
      activeBg: '#1febf6',
      defaultBg: '#ebfdec',
      defaultEdge: '#A2B1C3',
      sourceTable: {
        primary: '#fa541c',
        bg: '#fff2e8',
      },
      targetTable: {
        primary: '#a0d911',
        bg: '#fcffe6',
      },
      selectColumns: {
        primary: '#1677ff',
        bg: '#e6f4ff',
      },
      viewTable: {
        primary: '#eb2f96',
        bg: '#fff0f6',
      },
    };
    
    // 初始化画布
    initGraph();
    
    function SqlFlowView2() {
      const graphRef = useRef<Graph>();
      const sqlInfo = useSqlStore((state) => state.sqlInfo);
      const curMockData = useSqlStore((state) => state.curMockData);
    
      const init = () => {
        graphRef.current = new Graph({
          container: document.getElementById('container')!,
          autoResize: true,
          panning: true,
          mousewheel: true,
          // background: {
          //   color: '#eee',
          // },
          // grid: {
          //   visible: true,
          //   type: 'doubleMesh',
          //   args: [
          //     {
          //       color: '#eee', // 主网格线颜色
          //       thickness: 1, // 主网格线宽度
          //     },
          //     {
          //       color: '#ddd', // 次网格线颜色
          //       thickness: 1, // 次网格线宽度
          //       factor: 4, // 主次网格线间隔
          //     },
          //   ],
          // },
    
          interacting: {
            nodeMovable: true, // 节点是否可以被移动
            vertexAddable: false, // 边的路径点是否可以被删除
            vertexDeletable: false, // 是否可以添加边的路径点
            vertexMovable: false, // 边的路径点是否可以被移动
            arrowheadMovable: false, // 边的起始/终止箭头(在使用 arrowhead 工具后)是否可以被移动
            edgeLabelMovable: false, // 边的标签是否可以被移动
            edgeMovable: false, // 边是否可以被移动
            magnetConnectable: false, // 当在具有 magnet 属性的元素上按下鼠标开始拖动时,是否触发连线交互。
          },
          connecting: {
            router: {
              name: 'er',
              args: {
                offset: 25,
                direction: 'H',
              },
            },
          },
        });
      };
    
      // 处理数据绘制图表
      const generate = (data: any) => {
        const cells: Cell[] = [];
    
        let num = 1;
    
        const res1 = data.source.map((item, index) => {
          if (num < item?.columns?.length) {
            num = item?.columns?.length;
          }
          return {
            id: item.name,
            shape: item.type,
            label: item.name,
            width: NODE_WIDTH,
            height: LINE_HEIGHT,
            ports: item?.columns?.map((_item) => ({
              id: item.name + '&' + _item,
              group: 'list',
              attrs: {
                portNameLabel: {
                  text: _item,
                },
              },
            })),
          };
        });
        const res2 = data.statements.map((item, index) => {
          if (item?.mappings?.length > 0) {
            return [
              ...item?.mappings.map((_item, _index) => {
                return {
                  id: index + '-' + _index,
                  shape: 'edge',
                  source: {
                    cell: item.source,
                    port: item.source + '&' + _item.sourceColumn,
                  },
                  target: {
                    cell: item.target,
                    port: item.target + '&' + _item.targetColumn,
                  },
                  connector: { name: 'rounded' },
                  attrs: {
                    line: {
                      stroke: COLOR_MAP.defaultEdge,
                      strokeWidth: 1,
                      // sourceMarker: {
                      //   name: 'circle',
                      //   size: 2,
                      // },
                      targetMarker: {
                        name: 'classic',
                        size: 6,
                      },
                    },
                  },
                  zIndex: 0,
                };
              }),
            ];
          } else {
            return [
              {
                id: index,
                shape: 'edge',
                source: {
                  cell: item.source,
                },
                target: {
                  cell: item.target,
                },
                connector: { name: 'rounded' },
                attrs: {
                  line: {
                    stroke: COLOR_MAP.defaultEdge,
                    strokeWidth: 1,
                    // sourceMarker: {
                    //   name: 'circle',
                    //   size: 2,
                    // },
                    targetMarker: {
                      name: 'classic',
                      size: 6,
                    },
                  },
                },
                zIndex: 0,
              },
            ];
          }
        });
    
        [...res1, ...res2.flat(Infinity)].forEach((item: any) => {
          if (item.shape === 'edge') {
            cells.push(graphRef.current?.createEdge(item));
          } else {
            cells.push(graphRef.current?.createNode(item));
          }
        });
    
        console.log('renderCells', cells);
    
        // 渲染图形
        graphRef.current?.clearCells();
        graphRef.current?.resetCells(cells);
        graphRef.current?.centerPoint();
    
        applyDagreLayout(graphRef.current, num);
      };
    
      // 自动排布
      const applyDagreLayout = (graph: Graph, num: number) => {
        const nodes = graph.getNodes();
        const edges = graph.getEdges();
    
        const dagreLayout = new DagreLayout({
          type: 'dagre',
          rankdir: 'LR',
          align: 'UL',
          ranksep: 80,
          nodesep: num * 8,
          controlPoints: true,
        });
    
        const model = {
          nodes: nodes.map((node) => ({
            id: node.id,
            width: node.size().width,
            height: node.size().height,
          })),
          edges: edges.map((edge) => ({
            source: edge.getSourceCellId(),
            target: edge.getTargetCellId(),
          })),
        };
    
        const newPositions = dagreLayout.layout(model);
    
        newPositions.nodes.forEach((node) => {
          const cell = graph.getCellById(node.id);
          if (cell.isNode()) {
            // @ts-ignore
            cell.setPosition(node.x, node.y);
          }
        });
    ​
        graph.centerPoint();
      };
    ​
      // 往前找关联节点
      const highlightRelatedCellPrev = (cell, port, arr) => {
        cell.setPortProp(port, 'attrs/rect', {
          fill: COLOR_MAP.hoverBg,
        });
        const connectedEdges = graphRef.current?.getConnectedEdges(cell, { incoming: true }).filter((edge) => edge.getSourcePortId() === port || edge.getTargetPortId() === port);
        const edge = connectedEdges[0];
        if (!edge) return;
        // 高亮边
        edge.attr('line/stroke', COLOR_MAP.hoverBg);
        edge.attr('line/strokeWidth', 4);
    ​
        // 高亮边的源端口和目标端口
        const sourceCell = edge.getSourceCell();
        const sourcePort = edge.getSourcePortId();
    ​
        if (sourceCell && sourcePort && sourceCell.isNode()) {
          highlightRelatedCellPrev(sourceCell, sourcePort, arr);
        }
    ​
        let str1 = sourcePort;
        const arr2 = str1.split('&');
    ​
        arr.push([
          {
            type: 'table',
            value: arr2[0],
          },
          {
            type: 'feild',
            value: arr2[1],
          },
        ]);
      };
    ​
      // 往后找关联节点
      const highlightRelatedCellNext = (cell, port, arr) => {
        cell.setPortProp(port, 'attrs/rect', {
          fill: COLOR_MAP.hoverBg,
        });
        const connectedEdges = graphRef.current?.getConnectedEdges(cell, { outgoing: true }).filter((edge) => edge.getSourcePortId() === port || edge.getTargetPortId() === port);
        const edge = connectedEdges[0];
        if (!edge) return;
    ​
        // 高亮边
        edge.attr('line/stroke', COLOR_MAP.hoverBg);
        edge.attr('line/strokeWidth', 4);
    ​
        // 高亮边的源端口和目标端口
        const targetCell = edge.getTargetCell();
        const targetPort = edge.getTargetPortId();
    ​
        if (targetCell && targetPort && targetCell.isNode()) {
          highlightRelatedCellNext(targetCell, targetPort, arr);
        }
    ​
        let str2 = targetPort;
        const arr2 = str2.split('&');
    ​
        arr.push([
          {
            type: 'table',
            value: arr2[0],
          },
          {
            type: 'feild',
            value: arr2[1],
          },
        ]);
      };
    ​
      const highlightRef = useRef('');
      const handlePortMouseEnter = ({ node, view, cell, port }) => {
        if (highlightRef.current === port) {
          console.log('=======================>', highlightRef.current);
          return;
        }
    ​
        highlightRef.current = port;
    ​
        handleGraphMouseEnter();
    ​
        console.log('+++++++++++++++++++++++>', highlightRef.current);
    ​
        let str3 = port;
        const arr2 = str3?.split('&');
    ​
        let arr = [{ type: 'table', value: arr2[0] }];
    ​
        highlightRelatedCellPrev(cell, port, arr);
        highlightRelatedCellNext(cell, port, arr);
    ​
        useSqlStore.setState({
          highlightWords: [arr2[1]],
          highlightTableWords: arr
            .flat(Infinity)
            .filter((item) => item.type === 'table')
            .map((item) => item.value),
          highlightFeildWords: arr
            .flat(Infinity)
            .filter((item) => item.type === 'feild')
            .map((item) => item.value),
        });
      };
    ​
      const handleGraphMouseEnter = () => {
        console.log('handleGraphMouseEnter');
    ​
        useSqlStore.setState({
          highlightWords: [],
          highlightTableWords: [],
          highlightFeildWords: [],
        });
        const cells = graphRef.current?.getCells();
        const edges = graphRef.current?.getEdges();
        cells?.forEach((c) => {
          if (c.isNode()) {
            const ports = c.getPorts();
            ports.forEach((p) => {
              c.setPortProp(p.id, 'attrs/rect', {
                fill: COLOR_MAP[c.shape].bg,
              });
            });
          }
        });
        edges?.forEach((e) => {
          e.attr('line/stroke', COLOR_MAP.defaultEdge);
          e.attr('line/strokeWidth', 1);
        });
      };
    ​
      const handleNodeMouseLeave = () => {
        highlightRef.current = '';
        handleGraphMouseEnter();
      };
    ​
      useEffect(() => {
        if (graphRef.current) {
          if (sqlInfo.graph && Object.keys(sqlInfo.graph).length > 0) {
            console.log('render sqlInfo', sqlInfo);
            generate(sqlInfo.graph);
          } else {
            if (curMockData?.data?.graph) {
              console.log('render curMockData', curMockData);
              generate(curMockData.data.graph);
            }
          }
    ​
          graphRef.current?.on('node:port:mouseenter', handlePortMouseEnter);
          graphRef.current?.on('graph:mouseenter', handleGraphMouseEnter);
          graphRef.current?.on('node:mouseleave', handleNodeMouseLeave);
          return () => {
            graphRef.current?.off('node:port:mouseenter', handlePortMouseEnter);
            graphRef.current?.off('graph:mouseenter', handleGraphMouseEnter);
            graphRef.current?.off('node:mouseleave', handleNodeMouseLeave);
          };
        }
      }, [sqlInfo, curMockData]);
    ​
      useEffect(() => {
        init();
      }, []);
    ​
      return (
        <div
          style={{
            width: '100%',
            height: '100%',
          }}
        >
          {(sqlInfo.desc || curMockData?.data.desc) && (
            <div
              className="desc"
              style={{
                padding: 16,
                fontSize: 16,
              }}
            >
              {sqlInfo.desc || curMockData?.data.desc}
            </div>
          )}
          <div id="container" />
        </div>
      );
    }
    ​
    export default SqlFlowView2;

注意点

  1. 在做鼠标hover效果的时候,需要高亮关联的sourcetargetnode以及edge,仅仅高亮左右邻近的节点还不够,需要高亮整条链路的节点跟线,这就要求要递归寻找出所有节点跟线。 在递归查找的时候,注意区分方向,建议往前跟往后的方法分开,并及时return,不然会出现死循环。
  2. 在做鼠标hover效果的时候,注册了node:port:mouseenter事件,起初我的做法是在node:port:mouseenter事件中高亮相关节点,在node:port:mouseleave事件中取消掉所有高亮效果。但是发现node:port:mouseleave事件的触发有问题,鼠标移动过快的话不会触发leave事件,直接触发enter事件。 尝试过在许多事件中去处理这个事情,效果都不是很理想,所以决定在node:port:mouseenter事件触发的时候,取消掉页面中所有节点的高亮,再重新设置,这也是常规的做法。 这时候就遇到了一个问题,在进行取消页面中所有节点高亮的时候,会无限触发node:port:mouseenter事件,导致页面不断的重复渲染,进而卡顿。 解决办法:使用一个highlightRef.current存储当前hover的节点id,在重新触发的时候进行对比是否相同,如果相同的话就直接return。用了这个方法后解决了这个问题,并且也不是说一直触发,只是在这个节点return掉了。根据观察,应该第二或者第三次就不会触发了,因为没有继续重置所有节点的缘故。

难点

一般来说,这个ER图的节点大小都不太好确定,我们即使可以通过计算port的数量来算出大小,但是我们仍然很难对节点进行排布,不大好做到完美的位置排布,由后端设置好放数据里是最好的结果。 但是后端说,他们也不清楚这个坐标位置,只能提供关联关系。那只能通过前端来进行排布。 关于这个图形的排布算法,我找到了两种解决方案:

因为只是简单的试用,并没有深入的了解,所以对dagre使用不熟悉,初步使用发现,它只能保证节点之间不重叠,排布上面不能做到数据血缘关系的前后顺序。

使用dagre的效果:

使用@antv/layout的效果:

ReactJSON

github.com/vah13/react…

    import ReactJson from 'react-json-view';
    import { useSqlStore } from '@/models/sql';
    ​
    function ReactJsonView() {
      const curMockData = useSqlStore((state) => state.curMockData);
      const sqlInfo = useSqlStore((state) => state.sqlInfo);
      return <ReactJson src={sqlInfo.lineage || curMockData.data.lineage} collapsed={3} iconStyle="square" theme="monokai" />;
    }
    ​
    export default ReactJsonView;
    ​

### 技术栈

    // package.json
    {
      "name": "react",
      "private": true,
      "version": "0.0.0",
      "scripts": {
        "start": "node server.js",
        "dev": "umi dev",
        "build": "umi build",
        "postinstall": "umi setup",
        "setup": "umi setup"
      },
      "dependencies": {
        "@ant-design/pro-components": "^2.7.9",
        "@antv/layout": "0.3.25",
        "@antv/x6": "^2.18.1",
        "@projectstorm/react-diagrams": "^7.0.4",
        "@types/lodash": "^4.17.12",
        "ace-builds": "^1.36.2",
        "antd": "^5.18.0",
        "axios": "^1.7.2",
        "dayjs": "^1.11.11",
        "file-loader": "^6.2.0",
        "js-cookie": "^3.0.5",
        "lodash": "^4.17.21",
        "react": "^18.2.0",
        "react-ace": "^12.0.0",
        "react-dom": "^18.2.0",
        "react-json-view": "^1.21.3",
        "react-router-dom": "^6.23.1",
        "sql-formatter": "^15.4.5",
        "umi": "^4.3.27",
        "zustand": "^4.5.2"
      },
      "devDependencies": {
        "@types/js-cookie": "^3.0.6",
        "@types/node": "^20.14.0",
        "@types/react": "^18.2.66",
        "@types/react-dom": "^18.2.22",
        "@typescript-eslint/eslint-plugin": "^7.2.0",
        "@typescript-eslint/parser": "^7.2.0",
        "cookie-parser": "^1.4.6",
        "eslint": "^8.57.0",
        "eslint-plugin-react-hooks": "^4.6.0",
        "eslint-plugin-react-refresh": "^0.4.6",
        "express": "^4.19.2",
        "http-proxy-middleware": "^3.0.0",
        "less": "^4.2.0",
        "typescript": "^5.2.2"
      }
    }