系统架构图的布局会绘制思路分享

362 阅读16分钟

需求背景

最近接到一个需求,针对系统部署的环境进行全链路检测,并最终将检测的链路以及结果以图的形式做前端展示。大概如下图: 需求图.png 类似的架构图还是比较常见的,只是通常都是通过 XMind 等软件绘制而成,然后作为演示内容。在 Web 系统中以前端页面的形式呈现相对较少。此类图的绘制难度主要体现在数据结构的不可描述性以及低抽象性,但相对的,一些内部关系比较固定,也能适当的布局绘制难度。

可描述性 & 可抽象性

可描述性可以简单的理解为是不是可以用常用的数据结构来描述。 对于数据可视化,可描述性是至关重要的一个点。举例而言,将一个可视化,很容易想到一个布局策略:绘制的过程是一个层序遍历,可以提前算出每一个子树的最大宽度来作为辅助判断每个子节点的相对位置。但如果是一组离散的点,那么就会陷入无从下手的困境。 可抽象性是对可描述性的一个降级和补充。假设一组节点整体不满足任何一种常见或易于描述的数据形态,但是可以通过对数据的分组、拆解和变形,转化为一个较为容易进行可视化的数据结构,那么它就是可抽象性比较好的。比如一个环不好绘制,但去掉一条边就可以将其转变为 DAG,而 DAG 是非常容易可视化的一种数据结构,然后再去单独处理那一条边。

问题分析

基本诉求

最终将架构图绘制完成交付自然是首要目标,但设计、开发的过程中也有几个基本的准则需要注意:

  1. 布局不能是写死的坐标
  2. 支持多种交互

架构关系只是相对固定,所以图的布局一定要是弹性可扩展的。再者,本身项目中一直在用 @antv/g6,良好的封装性和丰富的 API 也可以节省很多开发成本。二者综合考虑,基本上就放弃了自己通过 canvas 逐像素绘制的想法。 但使用 G6 也有一个问题,就是 G6 本身是绘制关系图的,所以图中所有的元素都应该被抽象为节点和边。而文章开始的需求图中存在许多辅助用户查看的边框以及文本,这在实际服务端提供的数据中是无意义且不存在的,虽然 G6 也有 Hull、Combo 等能力,但整体来看不能满足需求。这个点上后续的设计开发也需要额外注意。

方案设计

前面提到,本身图的数据的可描述性较差的,服务端将每个服务作为一个节点Node,放到一个拍平的一维数组返回。而服务之间的关系通过一条带有起点和终点的边Edge来表示,也是放在一维数组中返回。 但仔细观察不难看出,如果将图按区域划分一下,可以得到几个相对而言更容易描述的结构。 比如顶部和底部,都是平铺的布局。上、下方的数据流可以看做一个横置的树或者链表。左侧部分可以看做纵向的列表。只有右侧的结构相对复杂,可以单独处理,比如写死…… 基于此,方案的雏形基本确定,为更直观的阐述,先上一个布局方案示意图: 环境全链路布局.jpeg 大概思路如下:

  1. 人为的将图划分为几个块Layout,每个Layout作为一个矩形,相应的也有自己的坐标信息
  2. Layout按照自己的规则对其内部节点进行布局,内部布局以 Layout 的左上角坐标(x, y)作为基准点,内部布局结束之后计算出Layout的右下角坐标(ex, ey)
  3. Layout之间互不重叠(当然也可以重叠,但为了让整体布局更清晰直观,在人为划分的时候应避免这种情况),位置相互依赖。如 B 在 A 的右下方,则先对 A 进行布局,而后利用 A 的坐标信息得出 B 的左上角,继而对 B 进行布局
  4. Layout内部由若干个网格Grid组成,每个Grid内包含一个节点Node,为方便布局管理,我们设定Grid相互紧挨,尺寸一致,基于分组内节点的最大尺寸设定
  5. Node作为最终绘制的节点,其坐标(x, y)就是应用于 G6 绘制的节点坐标,在这里我们以左上角作为绘制起点,以保持与矩形网格的布局方案的统一,同时省去一些额外的计算成本
  6. Node基于自己所属的 Grid 进行位置计算,比如尺寸大的节点离 Grid(x, y) 近一些,尺寸小的远一些,以此来实现节点之间的对齐与间距
  7. Shape是 G6 真正绘制的形状,其坐标信息基于 Node

开发执行

准备工作

由于数据是服务端返回,所以需要提前和服务端协定好一些我们需要的额外的信息。我个人的习惯是尽可能不要让服务端去增加一些单纯是为了满足前端开发需求而没有实际业务含义的字段,因为这些字段在一个不了解前后端约定的人看来是不明所以的。 具体到这个场景,我们需要一个标识来确定一个节点属于哪个分块。如果 Layout 的前端划分而后端在元数据中一个个填充,就会出现前面提到的问题。这里我们按照业务实际情况,在横、纵两个方向各定义了一个可以区分且有实际意义的字段,在二维图中两个方向纬度便足以唯一定位一个点。 还有一个需要服务端增加的标识是节点类型,业务与绘图中,对于 Node 的类型区分标准可能并不一致,前端绘图只关心样子,这里需要按需增加一个节点绘图类型字段。 目前来看数据准备工作只有这两个需要注意的点。

代码设计

进入到编码阶段,在我看来就比较容易了。就是一个按照方案一步步执行,不断补充完善的过程。个人的原则是不要担心写出烂代码,首先** make it work**,然后再考虑优化与整合。当然前提是已经有一个明确的架构设计,而不是埋头就是写。 前面的方案中提到了四个概念:Layout->Grid->Node->Shape,依次包含的关系。按照功能模块又可以划分为两部分,Shape是绘制相关,LayoutGrid是布局相关,Node是连接布局与绘制的纽带。

节点绘制

节点的绘制本身没有太多的难度,值得注意的是我们需要提前定义好每一种类型**Node**的尺寸,以便布局的时候基于此来统筹计算,这个后续详细说明。 前面提到,Node坐标信息是提供给 G6 绘制的基准点,具体节点包含的Shape可以按需调整位置。以图中较为特殊的 KAFKA 节点为例:

// 首先定义好 KAFKA 节点的尺寸信息
NODE_CONFIG[KAFKA] = {
  width: 100,
  height: 40,
  // r, p 是绘制节点形状的波浪线, 即贝塞尔曲线的辅助信息
  r: 5,
  p: 10,
}
// G6 注册节点
G6.registerNode(
  'KAFKA',
  {
    draw(cfg, group) {
      // 以 Node 的左上角作为绘制基准点
      const x = 0,
            y = 0
      const { width, height, r, p } = NODE_CONFIG[KAFKA]
      // keyShape 的概念可以参考 G6 官方文档
      const keyShape = group.addShape('rect', {
        attrs: {          
          x,
          y,
          width,
          height,
          // 由于后续的形状都是 path, 建议设置填充以避免出现形状无法选中的情况
          fill: '#fff',
        },
        name: 'key-shape',
      })
      // KAFKA 节点曲线边框
      const ex = x + width,
            ey = y + height
      group.addShape('path', {
        attrs: {
          path: [
            ['M', x, y],
            ['V', ey],
            // 下方波浪
            ['C', x + width / 3, ey + p, x + (width * 2) / 3, ey - p, ex, ey],
            ['V', y],
            // 上方波浪
            ['C', x + (width * 2) / 3, y - p, x + width / 3, y + p, x, y],
          ],
          stroke: '#ccc',
        },
        name: 'node-path',
      });
      
      return keyShape
    },
    getAnchorPoints() {
      // 定义四个连线锚点, [上, 右, 下, 左]
      return [
        [0.5, 0],
        [1, 0.5],
        [0.5, 1],
        [0, 0.5],
      ]
    },
  },
  // 继承 G6 内置节点
  'rect',
)

子布局

Layout相对复杂一点,他由Grid组成,而GridNode决定,与此同时Layout还有自己的属性,影响着内部节点的排布。所以这部分的设计不必急于一步到位,可以根据方案思路一点一点实现,查缺补漏逐步完善。 首先根据设计定义一下Layout的类型,有利于理清思路。

interface ILayout {
  /** Layout 命名方便后续查取 */
  name: string;
  /** Layout 内部的节点与边 */
  nodes: INode[];
  edges: IEdge[];
  /** 过滤函数, 对外部传入的数据集合进行过滤从而得出 nodes 和 edges */
  nodeFilter: (node: INode) => boolean;
  /** 默认筛选 target 和 source 都在内部 nodes 中的边 */
  edgeFilter: (edge: IEdge) => boolean;
  /** Layout 坐标信息, 参考 */
  x: number;
  y: number;
  ex: number;
  ey: number;
  /** 内部 Grid 的尺寸 */
  gridWidth: number;
  gridHeight: number;
  /** 执行布局, 计算内部节点的位置以及 Layout 自身的坐标信息 */
  render(): void;
  /** 其他辅助字段 */
  [customKey: string]: any;
}

这里需要注意的是,坐标信息在创建声明Layout的时候设置一个初始值,在布局的过程中可以随时外部修改,最终执行Layout.render()的时候以最新值作为基准。而Grid尺寸前面提到过,为了方便布局,设置为一个固定值,值的选取可以有特定的策略。至于不同的节点,在Grid内部所处的相对位置,在render()的过程中按需调整。以右下方的基础资源信息为例:

// 定义单个 Layout
const layoutResource: ILayout = {
  // ...default options
  nodes: [],
  nodeFilter: (node) => node.deploy_type === 'platform_resource',
  // Node 与 Grid 保留内边距, 来实现 Node 之间的间距
  gridWidth: NODE_CONFIG[COMMON].width + padding * 2,
  gridHeight: NODE_CONFIG[COMMON].height + padding * 2,
  // 平铺布局, 可以预设每一行的最大节点个数,
  rowGridCount: 5,
  render() {
    const { nodes, x, y, gridWidth, gridHeight, rowGridCount } = this
    // 对内部节点坐标信息挨个赋值
    nodes.forEach((node, i) => {
      // 可以将 Grid 与 Node 的关系与排布看做一个 css 盒模型, 参考方案图
      node.x = x + (i % rowGridCount) * gridWidth + padding
      node.y = y + Math.floor(i / rowGridCount) * gridHeight + padding
    });
    // 所有节点布局完成之后, 重新计算当前 Layout 的结束位置坐标
    this.ex = x + gridWidth * Math.min(rowGridCount, nodes.length)
    this.ey = y + gridHeight * Math.ceil(nodes.length / rowGridCount)
  },
}

const layoutA: ILayout = { /** ... */ }

// Layout 依次执行, 假设 layoutResource 在 layoutA 的正下方左对齐
layoutA.render()
// 基于 layoutA 布局之后的最新坐标信息, 重置 layoutResource 的坐标信息
layoutResource.x = layoutA.x
layoutResource.y = layoutA.ey + margin
layoutResource.render()

这里可以看到,所有的Layout是一个依次执行、相互依赖的过程,所以可以将整个布局的流程放到一个有序列表中:

// 定义布局顺序, 可以重复多次执行某一个 Layout 以满足前后依赖关系的补充
const orders = [
  {
    // 布局模板
    tpl: layoutA,
    // 返回其坐标信息的最新计算值 [x, y]
    pos: () => [0, 0],
  }, {
    tpl: layoutB,
    pos: () => [layoutA.ex + margin, 0],
  }, {
    tpl: layoutRecource,
    pos: () => [
      layoutA.x,
      // 取 A 和 B 的最下沿, 保证 layout 之间不重叠
      Math.max(layoutA.ey, layoutB.ey) + margin,
    ],
  },
]

// 执行
orders.forEach(({ tpl, pos }) => {
  // 过滤
  tpl.nodes = allNodes.filter(tpl.nodeFilter)
  tpl.edges = allEdges.filter(tpl.edgeFilter)
  // 获取最新坐标信息
  const [x, y] = tpl.pos()
  tpl.x = x
  tpl.y = y
  
  tpl.render()
})

至此,此前的方案设计基本验证是行之有效的。通过调整Layout``Grid``Node``Shape各自的预设值、位置依赖关系等参数,即可实现整体布局的对齐、间距、不重叠等要求。 接下来要做的就是针对图中几个较为无规则的布局特殊处理,比如右侧的类似于电路图的关系网图。

网图

image.png 这部分的布局,应该是可以找出一个策略来完美布局的,但个人水平以及精力有限,采用了较为偷懒的方式。我一直人为布局的过程,就是以人的视角去排版每一个节点相对位置,然后再将这些步骤描述成程序可以理解的语言的过程。所以在我看来,这里就像电路图一样,虽然节点之间的关系是可描述的,但要想最终排版符合产品需求预期,一定是经过许多人为调整的。考虑到前面已经设计了一个较为健全的Grid布局模式,不如直接将每个节点人工填充到对应的位置。这样虽然也是写死的布局,与初衷相违背,但设计得当还是可以保留一定的灵活扩展性。话不多说直接上代码:

/**
 *	以 node_id 作为 key, 节点在网格中的 [xIndex, yIndex] 作为 value
 *	定义网格数据的时候最好也以一个固定排版的顺序, 比如 左 -> 右, 上 -> 下
 *	这样后续维护的时候可以较为容易的找到某个节点的对应位置
 */
const GRID_MAP: Record<string, [number, number]> = {
  // row 0
  'node_a': [0, 0],
  // row 1
  'node_b': [0, 1],
  'node_c': [2, 1],
  // row 2
  'node_d': [1, 2],
  // ...
}

/** 网格的 render 方法 */
const layoutGrids: ILayout = {
  // ...
  render() {
    const { nodes, x, y, gridWidth, gridHeight } = this
    nodes.forEach(node => {
      const grid = GRID_MAP[node.id]
      // 这里可以根据不同 Node 类型来区别定义其在 Grid 内部的相对位置
      node.x = x + grid[0] * gridWidth + padding
      node.y = y + grid[1] * gridHeight + padding
    })
    // 计算 layout 结束位置
    const grids = Object.values(GRID_MAP)
    this.ex = x + (Math.max.apply(0, grids.map(g => g[0])) + 1) * gridWidth
    this.ey = y + (Math.max.apply(0, grids.map(g => g[1])) + 1) * gridHeight
  },
}

执行完上面的布局,会发现虽然节点的位置都按照预期排布,但是连线却一团乱麻。所以就需要对布局内的连线也做特殊处理,分为如下步骤:

  1. 采用折线
  2. 根据起点和终点的相对位置设置折线的拐点
  3. 根据起点和终点的相对位置设置折线的锚点,即Edge.anchorIndex

因为折线的绘制是最终 G6 根据我们自定义边的方式以及边数据实现的,所以要让折线按预期转折,需要设置一个字段来标识拐点或者说转折的方式。而anchorIndex则直接修改边数据即可。 这里我设计了一个dir: <'L' | 'R' | 'T' | 'B'>[]字段来标识折现的转折方式,经常做绘图的朋友应该很容易看出来,其实就是描述一条线段从起点到终点,绘制的方向,分为上下左右四个值。 由于依赖于内部节点的排版,所以对边数据的修改应该在Layout.render()之后,即每个节点的位置信息已经确认之后。

// 新增一个 edgeMap 方法
interface ILayout {
  // ...
  edgeMap(): void;
}

const layoutGrids: ILayout {
  // ...
  edgeMap() {
    this.edges.forEach(edge => {
      // 定义 edge 类型
      edge.type = 'custom-polyline'
      // 起点 终点在预定义的 GRID_MAP 中的位置
      const source = GRID_MAP[edge.source]
      const target = GRID_MAP[edge.target]
      // 根据 sourceX, sourceY, targetX, targetY 的相互位置关系来计算折线的方向与锚点
      const [sx, sy] = source
      const [ex, ey] = target
      // 水平或垂直方向较为容易处理
      if (sx === tx || sy === ty) {
        // 水平向下
        if (sy < ty) {
          // anchorIndex, 前面定义的节点锚点顺序是 [上, 右, 下, 左]
          edge.sourceAnchor = 2;
          edge.targetAnchor = 0;
          // 走线方向
          edge.dir.push('B');
        }
        // 其他三个方向同理
        return
      }
      // target 在 source 右侧
      if (sx < tx) {
        // 我们定义默认从水平方向连出, 如果水平方向有阻挡, 则从垂直方向连出
        const horizonBlock = checkHorizonBlock(source, target);
        if (horizonBlock) {
          // 水平有阻挡, 从垂直方向出边
          if (sy < ty) {
            // 下 -> 右
            edge.sourceAnchor = 2
            edge.dir.push('B')
          } else {
            // 上 -> 右
            edge.sourceAnchor = 0
            edge.dir.push('T')
          }
          edge.targetAnchor = 3
          edge.dir.push('R')
        } else {
          edge.sourceAnchor = 1
          edge.dir.push('R')
          if (sy < ty) {
            // 右 -> 下
            edge.targetAnchor = 0
            edge.dir.push('B')
          } else {
            // 右 -> 上
            edge.targetAnchor = 2
            edge.dir.push('T')
          }
        }
      }
      // target 在 source 左侧同理
    })
  }
}

这里又出现两个新的问题:

  1. 如何判断水平/垂直是否有阻挡,即checkHorizonBlock的实现
  2. 如果水平和垂直都有阻挡,如何处理

问题 1 比较好解决,水平阻挡只要判断GRID_MAP中是否存在与 source 的 y 坐标相同且 x 坐标在 source 与 target 之间即可,大概实现如下:

const checkHorizonBlock = (source: [number, number], target: [number, number]) => {
  return !!Object.values(GRID_MAP).find(
    (pos) =>
      pos[1] === source[1]   // y 坐标相同
      && isInRange(pos[0], source[0], target[0])  // x 坐标在 (source.x, target.x] 范围内, 注意开闭区间
      && !isSame(pos, target)   // 且不是 target 节点
  )
}

整体而言,对于一个预设的网格来说,判断两个节点之间的路径是否有阻挡是清晰且容易的。 针对问题 2,虽然项目中我没有去考虑,因为整体布局是人为设定的,我们完全可以提前做到排版中不存在这种情况。但如果真的遇到,就体现了我们方案中Grid设计的优势。参考前面提到的方案图,Grid之间是紧挨的,节点之间的间距通过NodeGrid的内边距来实现。那么在网格中的两个节点之间的连线,我们完全可以让其沿着**Grid**的边缘走线,从而保证不与其他节点重叠。但如此一来,可能一个dir字段就不够标识准确的拐点了,需要根据实际情况扩展。

补充布局

基本上编码内容就是上述这些,可以看一下实际效果: image.png 整体而言是符合预期的,但相较于需求图,还差一些辅助看图的信息,边边框框之类的。前面提到,由于 G6 将图中所有元素都抽象为节点和边(这里不考虑聚类轮廓包裹,样式上无法满足定制需求),所以这些辅助信息也需要造一些特殊节点来实现,只要最后不要绑定交互事件并且全部toBack()置底即可。 这里又体现了我们子布局方案的优势,因为辅助信息一定是对某一个或多个子布局的补充标识,所以在创造视图辅助节点的时候,其坐标与尺寸信息完全可以依赖于之前每一个Layout的位置信息。可以分为两种:

// 针对需要框起来的单个子布局, 可以补充一个 getContainer() 方法
const layoutA = {
  // ...
  getContainer() {
    const { x, y, ex, ey } = this
    return {
      x,
      y,
      width: ex - x,
      height: ey - y,
      label: '分组 A',
    }
  },
}
// 根据 layout.getContainer 创造视图辅助节点
allNodes.unshift({
  type: 'layout-helper',
  ...layoutA.getContainer(),
})

// 针对多个子布局需要框在一起, 根据多个子布局的位置信息计算出最大包围框
allNodes.unshift({
  type: 'layout-helper',
  x: Math.min(layoutA.x, layoutB.x),
  width: Math.max(layoutA.ex, layoutB.ex) - Maht.min(layoutA.x, layoutB.x),
  // ...
})

看一下最终效果: image.png 还是比较符合预期的。

优化提升

整体做下来,从结果来看是符合预期的,并且也具有相当的灵活性,除了部分特殊布局采用了相对固定的布局方式之外,其余的均是可以根据数据扩展的。但实现的过程中还是有许多值得深入研究优化的地方。 比如现在每个子布局内部的Grid是统一尺寸的,这个在数据不规整的情况下,可能会导致最终的排版有许多空间浪费或者看起来不够整齐的情况。如果一个子布局内部的节点类型较多,尺寸差异较大,Grid尺寸完全可以设计为可变的,可以看到Grid的尺寸只在Layout.render()中影响节点之间的排布,所以这部分是可以按需灵活调整、动态计算的。