editflow系列(零):简介

分类: 开发笔记

背景

随着AI的兴起,工作流也逐渐成为一个热点——通过工作流将数据收集、预处理,模型训练、调优、评测、发布等等流程简单直观地串起来,可以极大方便数据科学家们进行模型迭代,直到最终发布。

KubeFlow是业界比较具有知名度的相关开源项目: kubeflow.png

不过,在KubeFlow中,工作流是通过代码借助SDK编写创建的。上图只是工作流的执行界面,用于直观展示工作流执行的步骤,但并不能允许用户通过界面来创建、编辑等。

对于极客来说,这似乎不是什么大问题;但对于很多有志于做数据分析实验但缺乏一定的编程技能的用户来说,这就是个不小的门槛了。更何况,能够直接使用UI操作,对于想先尝鲜试用一下的用户,以及仅做一点点修改来复用工作流的用户来说也是极为轻松便捷的。

相关产品

当然了,市面上也有不少商业产品实现了类似的功能,在这里随便列举几个:

相关开源组件

除了具体的产品之外,提供相关功能的开源组件库也有不少:

都不好用

在体验了一些开源组件,也试用了一些商业产品之后,可以说,他们的工作流编辑器用起来都多多少少不那么高效:或者是在基本的操作上有所缺失,或者是界面交互过于复杂,或者是性能不足以支撑大量节点等……

各家有各家的亮点和缺点,当然也有一些通病,总归是不够用户友好。既然如此,不如自己重新实现一个。

计划萌芽

重新实现一个工作流编辑器,是一个相当重的工程,轻易不敢开始。
React在三大前端组件库中算是生态最好的,再加上我之前有过使用经历,我仍然打算选择它。可之前写组件的经历其实不算美好,这也让我很犹豫。

正巧我后来发现早在2019年2月,React就发布了Hooks,提供了一种全新的编写组件的方式,很符合我的心意。另外,最近我也在学习Haskell,对纯函数式编程有了些许认识和思考,正想进行相关的实践。相信采用Hooks也会成为本项目的一大亮点。

于是,那就做吧!

为了博采众长,我也总结了一份特性列表,用于指导自己的开发:

  • 节点
    • 拖拽式创建
    • 支持多选
    • 可移动
    • 可缩放(修改大小)
    • 上下文菜单
    • 自定义主题
  • 输入输出端点
    • 动态增删改
    • 悬浮提示
    • 上下文菜单
    • 从输出端点拖拽创建连接边
    • 从输入端点拖拽可重连接边
    • 标签
    • 上下文菜单
    • 自定义主题
      • 直线/曲线
  • 画布
    • 可缩放
      • 适应宽度
      • 适应视图
      • 自定义倍数
    • 可平移
      • 定位到中点
      • 回到原点
    • 辅助对齐
      • 参考线
    • 支持框选
    • 上下文菜单
    • 自动布局
    • 缩略图
    • 高性能支持(10K节点)

前期开发

该项目自2020年1月启动至今已整整4个月,基本的节点移动和缩放、画布平移和缩放等功能均已完成,其间项目进行过一次针对性能优化的大型重构和一次针对Hooks封装性优化的局部重构。

项目整体开发思路已经基本定型,我将在下一节中进行介绍。

这里先放一些Demo:

简单便捷的基本操作

easy-control.gif

高性能 - 懒加载

下图中包含10K节点

lazy-loading.gif

一些显示特效

blur-unselected.gif

项目的主程序

最后,虽然本系列会讲解项目实现细节,但本项目短期内暂不开源(目前是GitHub的私有仓库)。

下面是主程序的真实代码,可以看到使用本库还是很简单的。

const App: React.FC = () => {
  const [flow, setFlow] = useState(genFlow()); // raw flow model
  const [flowState, dispatch] = useFlowState(flow); // flow canvas state

  return (
    <div className='App' style={{ display: 'flex' }}>
      <CanvasStyleProvider>
        <FlowProvider flowState={flowState} dispatch={dispatch}>
          <Canvas width='80%' height='600' />
          <Toolbar>
            <button onClick={() => { setFlow(genFlow()) }}>change</button>
          </Toolbar>
        </FlowProvider>
      </CanvasStyleProvider>

      <textarea readOnly style={{ width: '20%', marginTop: 48, marginLeft: 10 }} value={
        'First 10 Nodes:\n' +
        JSON.stringify(Object.values(flowState.raw.nodes).slice(0, 10), null, ' ')
      } />
    </div >
  );
}

const W = 120;
const H = 40;
const Space = 10;
const RowSize = 10;
const OffsetX = 0;
const OffsetY = 48;
const NodeCount = 1000;
const EdgeCount = 50;

const generatePorts = (namePrefix: string, n: number) => {
  return Array.from(Array(n).keys()).map((_, i) => ({
    name: `${namePrefix} ${i}`,
    type: 'null',
  }));
}

const genFlow = (): Flow => {
  return {
    nodes: Array.from(Array(NodeCount).keys()).reduce((o, i) => {
      o[`node-${i}`] = {
        layout: {
          x: OffsetX + Space + (W + Space) * (i % RowSize),
          y: OffsetY + Space + (H + Space) * Math.trunc(i / RowSize),
          w: W,
          h: H,
        },
        title: `Component ${i}`,
        input: generatePorts("In", Math.trunc(Math.random() * 8) + 2),
        output: generatePorts("Out", Math.trunc(Math.random() * 8) + 2),
      };
      return o;
    }, {} as NodeMap),
    edges: Array.from(Array(EdgeCount).keys()).reduce((o, i) => {
      o[`edge-${i}`] = {
        start: {
          nodeId: `node-${Math.trunc(Math.random() * NodeCount)}`,
          portName: `Out ${Math.trunc(Math.random() * 2)}`,
        },
        end: {
          nodeId: `node-${Math.trunc(Math.random() * NodeCount)}`,
          portName: `In ${Math.trunc(Math.random() * 2)}`,
        },
      };
      return o;
    }, {} as EdgeMap),
  };
}

export default App;