使用G6绘制服务依赖关系图

3,968 阅读10分钟

一套业务系统通常会依赖多个中台提供的不同服务,如搜索服务、文件上传服务、推荐服务等。而各个中台服务又依赖不同的基础引擎服务。其依赖服务众多,关系复杂,就会很难维护管理。为了便于管理和直观的显示依赖服务之间的关系,需要以图的形式呈现依赖关系图。

G6

G6 是一个图可视化引擎,它提供了图的绘制、布局、分析、交互、动画等图可视化的基础能力。

Combo

Combo 是 G6 提供的用于节点分组的绘图机制,它允许将一些具有相似特征的节点放到一个分组中,便于进行特征分析,例如风控、反洗钱、保险骗保、网络诈骗、信用卡诈骗等场景下的团伙分析。

image.png

绘制Combo

绘制 Combo 前,需要对G6源数据的数据结构进行修改:

1. 新增 combos 数组,用于定义图上所有的 Combo 及其配置。

combos 数组中一个数据项如下所示:

{
  id: 'comboA',
  label: 'comboA',
  parentId: 'comboC'
}
  1. 在 nodes 数组中的数据项内加入 comboId 属性,表示该节点与某个 Combo 的从属关系。

    { nodes: [ { id: 'node1', comboId: 'comboA' // node1 属于 comboA }, { id: 'node2', comboId: 'comboB' // node2 属于 comboB }, { id: 'node3' // node3 不属于任何 combo }, // ... ], edges: [ // ... ], combos: [ { // 定义 comboA id: 'comboA', label: 'comboA', parentId: 'comboC' }, { // 定义 comboB id: 'comboB', parentId: 'comboB' }, { // 定义 comboC,这是一个空的 combo id: 'comboC' }, // ... ] }

Combo 交互

Combo 支持一系列的交互操作,只需要配置 drag-combocollapse-expand-combodrag-node 三个Behavior,就可以与Combo进行交互。

drag-compo

drag-combo Behavior,支持拖动 Combo 。在拖动 Combo 的过程中,会动态改变 Combo 中节点和边的位置,在拖拽完成后,保持 Combo 和节点的相对位置不变。还可以通过拖拽改变 Combo 的从属关系。

drag-combo.gif

drag-node

如果配置了drag-node,那么在拖拽节点的过程中,会动态地改变节点与父 Combo 的从属关系。

drag-node.gif

collapse-expand-combo

配置了collapse-expand-combo Behavior,在双击 Combo 时可以收起或展开 Combo 。收起 Combo 后,会隐藏 Combo 中的所有节点,外部节点和 Combo 中节点有连线的情况下,所有连接会连接到 Combo 上面。

drag-expand-combo.gif

图形分组 Group

图形分组 Group 是针对 图形 Shape 层次的分组,类似于 SVG 中的 <g> 标签:元素 g 是用来组合图形对象的容器。在自定义节点、自定义边时会使用到图形分组Group 。图形分组Group 方便了用户对节点或边上元素的组合和管理。

例如,下图中的节点 A 有一个包含节点 A 中所有图形的 group,该 group 中包含了一个 circle 图形和一个文本图形。节点 B 是一个自定义节点,有一个包含节点 B 中所有图形的 group,该 group 包含了 circle 图形、rect 图形和文本图形。

image image

获取元素的 group

// 获取元素(节点/边/Combo)的图形对象的容器
const group = item.getContainer();
// 等价于
const group = item.get('group');

实例方法

  • addGroup(cfgs) 向分组中添加新的分组

    const subGroup = group.addGroup({ id: 'rect', });

  • addShape(type, cfgs) 向分组中添加新的图形

    const keyShape = group.addShape('rect', { attrs: { stroke: 'red', }, // must be assigned in G6 3.3 and later versions. it can be any value you want name: 'rect-shape', });

Combo 和 Group 选谁?

我们的项目需求是需要将一套业务系统所依赖的所有服务以依赖图的形式直观的呈现出来,可以很方便的看出所使用的依赖服务以及各个服务之间的依赖关系。并且可以通过拖动来改变节点在图中的位置,但节点内部的子节点也要随着该节点一起移动,不能改变子节点与父节点的从属关系。如下图:WechatIMG29.jpg

在G6中,有两种方案可以绘制这种关系图,它们是 Combo 和 图形分组Group 。在上面,我们已经简单地介绍了Combo 和 图形分组Group。接下来,我们分析一下在我们的项目中,应该使用哪个方案最合适。

方案一:使用 Combo 绘制

  1. 使用Combo,首先需要在G6的源数据的数据结构中添加combos数组,用于定义图上的所有Combo及配置;

  2. 然后在数据结构的 nodes 数组中的数据项内加入 coboId 属性,表示该节点与某个Combo的从属关系;

  3. 设置了 drag-cmopo Behavior,可以拖动 Combo,在拖动 Combo 的过程中,会动态地改变 Combo 中节点和边的位置,也会改变 Combo 的从属关系;

  4. 如果用于绘制 Combo 的数据结构十分复杂,那么 Combo 之间的组合会变得很复杂;

方案二:使用 图形分组Group 绘制

使用 图形分组Group,无需修改G6源数据的数据结构,只需要使用 group实例的 addGroup 方法添加一个新的分组,然后使用 addShape 方法向分组中添加新的图形。无论多么复杂的数据结构,都可以轻松地绘制出我们想要的图形,并且可以很方便的控制图中各个节点的位置以及样式。

对比方案一和方案二,只有图形分组Group 符合我们的绘制要求,因此最终选择了 图形Group 来绘制依赖关系图。

绘制图形

1、绘制父容器

使用 addShape 方法绘制一个 rect 类型的图形,作为一个节点的父容器,然后通过父容器展示的title的文字长度来预先计算容器的宽度,在绘制子元素时再根据子元素展示的title的文字长度来重新计算父容器的宽度。而父容器的高度则通过它的所有子元素所占用的实际高度来计算。

计算父容器宽度

// 根据文字的长度 计算 容器 的宽度
const containerTitleWidth = cfg.label.length * config.textLengthThreshold;
const width = containerTitleWidth + widthOffset;

计算父容器高度

// 根据容器中的子元素计算容器的高度
let height = containerDefaultHeight;
if (propKeysData.length > 0) {
  height += propKeysData.length * heightOffset;
}

if (instanceKeysData.length > 0) {
  height += instanceKeysData.length * heightOffset;
}

if (height !== containerDefaultHeight) {
  height += instanceKeysData.length > 0 && propKeysData.length === 0 ? 25 : 65;
}

绘制父容器

// Container box
const container = drawContainer(
  cfg,
  group,
  { x: 0, y: 0 },
  { width, height }
);

function drawContainer(cfg, group, initPosition, initSize) {
  const container = group.addShape("rect", {
    attrs: {
      width: initSize.width,
      height: initSize.height,
      x: initPosition.x,
      y: initPosition.y,
      ...style.container,
      shapeGroupId: "instance-group"
    },
    name: "instance-container",
    draggable: true // 设置容器可拖动
  });

  return container;
}

记录坐标信息

记录当前父容器的坐标信息,方便后续子元素的坐标位置计算

// 记录坐标信息,方便后续计算使用
let currentX = container.getBBox().x;
let currentY = container.getBBox().y;

绘制父容器的title

如果我们需要在一个图形中显示文字,可以使用 addShape 方法绘制一个 text 类型的图形。

// 绘制父容器的 Title
drawTitle(cfg, group, {
  x: cfg.data.DeployStatusCfg ? currentX + 14 : (currentX += 20),
  y: cfg.data.DeployStatusCfg ? currentY : (currentY += 20)
});

// 绘制文本
function drawTitle(cfg, group, initPosition) {
  const titleShape = group.addShape("text", {
    attrs: {
      text: cfg.label,
      x: initPosition.x,
      y: initPosition.y,
      ...style.title,
      shapeGroupId: "instance-group"
    },
    name: "instance-title",
    draggable: true
  });

  return titleShape;
}

2、绘制子元素

在绘制子元素时,也是使用 addShape 方法绘制一个图形,如果当前绘制的子元素是一个矩形,则绘制 rect 类型的图形,如果当前需要绘制的子元素是一个圆,则绘制 circle 类型的图形。子元素的宽度也是根据其展示的title的文字长度来计算,子元素的高度同样也是通过它的所有子元素所占用的实际高度来计算。在绘制完之后,同样需要记录当前子元素的坐标位置,以便于后续其它元素的绘制。

// 绘制子容器
let propContainer = null;
if (propKeysData.length > 0) {
  // Properties container
  propContainer = drawPropContainer(
    cfg,
    group,
    { x: currentX, y: (currentY += 26) },
    {
      width: width - 40,
      height:
        containerDefaultHeight + propKeysData.length * heightOffset - 10
    }
  );

  currentX += 20;
  
 
  
 function drawPropContainer(cfg, group, initPosition, initSize) {
  const propContainer = group.addShape("rect", {
    attrs: {
      width: initSize.width,
      height: initSize.height,
      x: initPosition.x,
      y: initPosition.y,
      ...style.prop_container
    },
    name: "props-container",
    draggable: true
  });

  return propContainer;
}

3、计算节点的可连接点

要想清晰地呈现各个服务之间的依赖关系,我们需要连接线( 边 )将各个节点连接起来。节点的连接点 anchorPoint 是边连入节点的相对位置,即节点与其相关边的交点位置。节点中有了 anchorPoints 之后,相关边就可以分别选择连入起始点、结束点的哪一个 anchorPoint 。

节点的连接点 anchorPoint

// 计算top、right、bottom、left的连接点
tmpPoint = anchorPointCal.getTopAnchorPoint(element, container);
cfg.anchorPoints.push([tmpPoint.x, tmpPoint.y]);

tmpPoint = anchorPointCal.getRightAnchorPoint(element, container);
cfg.anchorPoints.push([tmpPoint.x, tmpPoint.y]);

tmpPoint = anchorPointCal.getBottomAnchorPoint(element, container);
cfg.anchorPoints.push([tmpPoint.x, tmpPoint.y]);

tmpPoint = anchorPointCal.getLeftAnchorPoint(element, container);
cfg.anchorPoints.push([tmpPoint.x, tmpPoint.y]);

// 记录point的数据下标值
element.get("attrs").selfPointIndex = cfg.anchorPoints.length - 3;

4、绘制边

G6 提供了 9 种内置边,G6 内置的边都有 source、target、sourceAnchor、targetAnchor、style、labelCfg等通用属性。我们给需要绘制的每一条边都设置它的 source、target、sourceAnchor、targetAnchor、style、labelCfg 等属性,然后使用 addItem 实例方法将边绘制到视图中,从而通过边的关系来展现服务的依赖关系。

const allNodes = graphInstance.getNodes();
let group = null;
let shapeArray = null;
let newEdge = null;

// 绘制连接线
for (const node of allNodes) {
  group = node.getContainer();

  shapeArray = group.get("children");
  for (const shapeInfo of shapeArray) {
    const shapeName = shapeInfo.get("name");
    if (
      [
        "prop-item",
        "prop-child-item",
        "instance-item",
        "depend-item"
      ].includes(shapeName)
    ) {
      const shapeAttrs = shapeInfo.get("attrs");

      if (shapeAttrs.refInfo && shapeAttrs.refInfo.length > 0) {
        newEdge = {
          source: shapeAttrs.belongNodeId,
          target: `${shapeAttrs.rootId}_instance_${shapeAttrs.refInfo[0]}`
        };

        if (shapeAttrs.isDependOn) {
          newEdge.isDependOn = true;
          // 设置边的 style 属性
          newEdge.style = {
            ...graphInstance.cfg.defaultEdge.style,
            strokeOpacity: 0.7,
            lineDash: [15, 10],
            lineWidth: 2
          };
          // 设置边的 label 属性
          newEdge.label = "Depend on";
        } else {
          newEdge.label = "Ref";
          // 设置边的 labelCfg 属性
          newEdge.labelCfg = {
            ...graphInstance.cfg.defaultEdge.labelCfg,
            refX: -20,
            refY: -20
          };
        }

        let targetNode = graphInstance.findById(newEdge.target);
        // 设置边的 targe(结束点id) 属性
        if (!targetNode) {
          newEdge.target = `${shapeAttrs.rootId}_properties`;
          targetNode = graphInstance.findById(newEdge.target);
        }

        if (targetNode) {
          if (shapeInfo.get("attrs").selfPointIndex) {
            // 设置边的 sourcAnchor(边的起始节点上的锚点的索引值) 属性
            newEdge.sourceAnchor =
              shapeInfo.get("attrs").selfPointIndex - 1;

            const targetGroup = targetNode.get("group");
            let targetShapeId = `${shapeAttrs.rootId}_#_${shapeAttrs.refInfo[0]}`;

            if (shapeAttrs.refInfo.length > 1) {
              targetShapeId = `${shapeAttrs.rootId}_#_${
                shapeAttrs.refInfo[0]
              }_#_${shapeAttrs.refInfo[shapeAttrs.refInfo.length - 1]}`;
            }

            // 设置边的 targetAnchor(边的终止节点上的锚点的索引值) 属性
            targetGroup.find((element) => {
              if (element.attrs.shapeItemId) {
                if (element.attrs.shapeItemId === targetShapeId) {
                  newEdge.targetAnchor =
                    element.get("attrs").selfPointIndex - 1;
                }
              }
              return null;
            });
          }

          // 使用 graph 实例的 addItem 方法给 graph 实例添加新的元素
          if (newEdge.targetAnchor !== undefined) {
            graphInstance.addItem("edge", newEdge);
          } else {
            if (!newEdge.target.endsWith("_properties")) {
              newEdge.targetAnchor =
                targetNode.getModel().anchorPoints.length - 1;
              graphInstance.addItem("edge", newEdge);
            }
          }
        }
      }
    }
  }
  //node.toFront();
}

graphInstance.layout();

5、配置 Tooltip 展示详细信息

我们的绘图仅仅用来呈现各个服务之间的依赖关系,各个服务的详细信息是无法一一绘制到图上面的,我们可以使用 G6 的 ToolTip 插件来展示这些详细信息。

// 定义 Tooltip 展示的内容
export const tooltipHandle = {
  type: "tooltip",
  formatText: function formatText(model) {
    return "";
  },
  offset: 20,
  shouldBegin: (e) => {
    const div = document.getElementsByClassName("g6-tooltip")[0];
    if (div) div.style.display = "none";
    return true;
  },
  shouldUpdate: (e) => {
    const targetName = e.target.get("name") || e.target.attrs.name;

    if (activeTooltipShapes.includes(targetName)) {
      const div = document.getElementsByClassName("g6-tooltip")[0];

      if (div) {
        if (["instance-title", "instance-container"].includes(targetName)) {
          div.innerHTML = formatInstanceContainerText(e);
        } else if (
          [
            "prop-child-item",
            "prop-child-text",
            "prop-item",
            "prop-text"
          ].includes(targetName)
        ) {
          div.innerHTML = formatPropChildItemText(e);
        } else if (["instance-item", "instance-text"].includes(targetName)) {
          div.innerHTML = formatInstanceItemText(e);
        } else if (
          ["props-container", "props-type-title"].includes(targetName)
        ) {
          div.innerHTML = formatPropContainerText(e);
        } else if (["deploy-status"].includes(targetName)) {
          div.innerHTML = formatDeployStatusText(e);
        } else if ("instance-detail-btn" === targetName) {
          div.innerHTML = formatBtnInstanceDetailText(e);
        }
      }

      if (div.innerHTML) {
        div.style.display = "block";

        return true;
      }
    }
    return false;
  }
};

// 实例化 Tooltip
const tooltip = new G6.Tooltip(tooltipHandle);

// 配置 Tooltip 插件
const graph = new G6.Graph({
  //... 其他配置项
  plugins: [tooltip], // 配置 Tooltip 插件
});

6、给节点和边添加事件监听

有时我们希望通过交互可以清晰的知道我们当前查看的是哪个节点,以及该节点与其它节点的依赖关系。例如鼠标 hover 节点、点击节点、hover 边、点击边时样式发生了变化。要想达到交互更改元素样式,需要设置各状态下的元素样式以及监听事件并切换元素状态。

设置各状态下的元素样式

export const graphInitializeConfig = {
  // ...             // 其它配置项
  
  
  // 节点在除默认状态外,其他状态下的样式属性(style)。例如鼠标放置(hover)、选中(select)等状态
  nodeStateStyles: {
    // 鼠标 hover 上节点,即 hover 状态为 true 时的样式
    hover: {
      lineWidth: 2
    },
    select: {},
    // 鼠标点击节点,即 click 状态为 true 时的样式
    click: {},
  },
  // 边在除默认状态外,其他状态下的样式属性(style)。例如鼠标放置(hover)、选中(select)等状态
  edgeStateStyles: {
    // 鼠标 hover 边,即 hover 状态为 true 时的样式
    hover: {
      stroke: "#f0db7b",
      strokeOpacity: 0.8,
      lineWidth: 10,
      endArrow: {
        path: G6.Arrow.vee(),
        fill: "#f0db7b"
      }
    },
    select: {},
    // 鼠标点击边,即 click 状态为 true 时的样式
    click: {},
  }
};

监听事件并切换元素状态

graphInstance.on("node:mouseenter", (evt) => {
  const node = evt.item;
  graphInstance.setItemState(node, "hover", true);
});

graphInstance.on("node:mouseleave", (evt) => {
  const node = evt.item;
  graphInstance.setItemState(node, "hover", false);
});

graphInstance.on("edge:mouseenter", (evt) => {
  graphInstance.setItemState(evt.item, "hover", true);
});
graphInstance.on("edge:mouseleave", (evt) => {
  graphInstance.setItemState(evt.item, "hover", false);
});

7、最终效果

下载.png

ps: 项目效果地址

传送门:使用G6绘制服务依赖关系图