pipeline 设计

Why Pipeline?
  1. 团队内前辈们的选择
  2. Pipeline的优势, 可以让VSeed独立控制每一个图表类型的执行流程, 通过良好的设计, 让每个图表类型的实现解耦的同时又可以局部复用, 每一类图表类型都可以对任何细节的进行完美掌控, 这是 Pipeline 带来的, 也是VSeed最需要的.
  3. 与之比起来, Pipeline模式的缺点都是可以在设计时避免的, 只要在设计Pipe时, 降低单个Pipe的规模, 减少Pipe之间的依赖, 就可以极大的避免这套模式带来的缺点
  4. 经过四代的Pipeline的设计与优化, 到VSeed这里已经是第五个版本, 该踩的坑已经踩过了.

什么是 Pipeline?

Pipeline 是一种强大的抽象和工程实践, 旨在将一项复杂任务分解为一系列相互连接、按顺序执行的较小步骤, 其设计理念和实现方式深受函数式编程(FP)核心思想的深刻影响。

Pipeline 的优势:

  • 模块化: 原子化实现, 通过组合原子得到模块
  • 自动化: 只需确定输入, 即可自动得到输出, 而无需关注内部实现。
  • 纯函数: 指定输入, 一定得到预期输出, 是纯函数的特征。
  • 并行性: 天然支持并发。
  • 可重用性: 每一个模块, 均可复用。
  • 可测试性: 理论上, 每个模块都是独立的, 可以单独测试, 确保质量。
  • 可追踪性: 每个阶段的输入输出清晰,便于定位问题和监控流程状态。
  • 可缓存性: 理论上, 可以单独缓存单个Pipe的输出, 所以可以避免重复计算, 提高效率。

Pipeline 的缺点:

  • 先后依赖: 当Pipe之间存在先后依赖时, 会使得理解成本增加, 因为你需要先理解前面的阶段, 才能理解后面的阶段。需要对整体流程有较深入的理解, 才能快速定位问题。
  • 调试成本: 由于 Pipeline 是按顺序执行的, 一旦某个阶段失败, 就会导致整个 Pipeline 失败。 这使得调试变得困难, 因为你需要定位失败的阶段, 并修复它。
  • 性能问题: 由于 Pipeline 是按顺序执行的, 每个阶段的输出都需要等待前一个阶段完成, 这会导致性能问题。 特别是当某个阶段的执行时间较长时, 会影响整个 Pipeline 的执行效率。
  • 函数式编程: 要理解全新的概念, 有一定的学习成本. 也是因此, 设计原理和实现细节需要写在贡献指南里, 方便其他开发者理解和使用, 弥补劣势.

VSeed内应该如何编写Pipeline?

Pipe 组合模式

多个功能Pipe, 可以组合成一个更大的功能Pipe, 也可以组合成一个更复杂的Pipeline.

在VSeed中, 一个完整的Pipeline, 对应着一个图表类型的实现;通过描述Pipe的组合关系, 就能做出不同的图表类型. 在Pipeline组合阶段, 无需关注每个pipe的具体实现.

组合差异

举个例子:

折线图和面积图有大量功能可以复用, 例如标签、图例、坐标轴等, 但折线图没有面图元样式, 因此pipeline就通过组合功能Pipe, 解决上述差异, 整个过程中没有任何if语句.

1const lineChartPipeline = [
2  label,
3  legend,
4  xAxis,
5  yAxis,
6  lineStyle,
7  pointStyle,
8]
9
10const areaChartPipeline = [
11  label,
12  legend,
13  xAxis,
14  yAxis,
15  lineStyle,
16  pointStyle,
17
18  // 仅面积图有面图元样式
19  areaStyle,
20]

Pipe 适配器模式

除了组合模式外, Pipe的构建往往有一定的条件, 为了满足不同条件下的Pipe组合, VSeed内大量使用了Pipe适配器

组合条件

举个例子:

折线图有透视功能, 无透视时由VChart渲染, 输出VChart spec, 有透视时由VTable渲染, 输出VTable spec.

透视折线图有基本上需要复用折线图的基本功能, 例如标签、图例、坐标轴等, 因此需要通过适配器模式, 将折线图的Pipe, 适配成透视折线图的Pipe.

1const pivotLineChartPipeline = [
2  initPivotChart,
3  pivotIndicators([
4    label,
5    xAxis,
6    yAxis,
7    lineStyle,
8    pointStyle,
9  ]),
10  pivotChartLegend,
11] 
12
13const commonLineChartPipeline = [
14  label,
15  legend,
16  xAxis,
17  yAxis,
18  lineStyle,
19  pointStyle,
20]
21
22const lineChartPipeline = [
23  pivotAdapter(commonLineChartPipeline, pivotLineChartPipeline)
24]

综上, 每一个adapter就是一条if else, 可以将pipe内隐藏的条件, 抽象成一个adapter, 因此if else前置到了最顶层, 从而获得依赖关系更清晰的Pipeline, 减少维护成本.

Pipeline 的最基本单元: 功能 Pipe

VSeed期望所有的图表类型, 都以功能为最基本的单元, 提供足够的复用与扩展能力; 自底向上构建一个图表类型的 pipeline; 每个功能Pipe, 都应该是一个独立的、 可测试的、 可复用的模块;

其中最关键的是, 应该以功能差异抽象出不同的Pipe(即少写if else), 而非写一个大而全的Pipe.

扁平化功能Pipe

举个例子:

条形图、柱状图、折线图、面积图、散点图都有X轴与Y轴, 它们相似而又略有不同, 如果写一个大而全的 axes pipe, 可能会变成这样

1const lineChartPipeline = [
2  axes
3]
4const barChartPipeline = [
5  axes
6]
7const areaChartPipeline = [
8  axes
9]
10const scatterChartPipeline = [
11  axes
12]
13const axes = (spec, context) => {
14  if (isLine || isArea || isColumn){
15    // 折线图、面积图、柱状图有一个离散的轴, 一个连续的轴
16    return xy(spec, context) 
17  }
18  if (isScatter){
19    // 散点图有2个连续的轴
20    return yy(spec, context) 
21  }
22  if (isBar){
23    // 条形图有一个离散的轴, 一个连续的轴, 但与折线图、面积图、柱状图的轴方向不同
24    return yx(spec, context) 
25  }
26}
27
28const xy = (spec, context) => {
29  linearAxis(spec, context, {orient: 'left'})
30  bandAxis(spec, context, {orient: 'bottom'})
31}
32
33const yx = (spec, context) => {
34  linearAxis(spec, context, {orient: 'bottom'})
35  bandAxis(spec, context, {orient: 'left'})
36}
37
38const yy = (spec, context) => {
39  linearAxis(spec, context, {orient: 'bottom'})
40  linearAxis(spec, context, {orient: 'left'})
41}

上述逻辑, 在一个功能Pipe内实现了根据图表类型, 选择不同的子功能pipe, 引发的问题是

  1. xy、yx、yy内重复的功能又该如何复用? 大量的相似而又不同的子函数, 需要在不同的子功能pipe中, 被重复调用. 依赖关系容易变得错综复杂, 导致维护成本增加.
  2. 修改折线图、面积图的功能, 容易遗漏条形图, 因为逻辑出现了分叉, 因此实现新功能时要考虑差异.

当整个spec pipeline的规模扩大到几百个pipe时, 这样的编写逻辑会带来非常高昂的维护成本, 因此, 我们需要一种更简单的方式, 来实现根据图表类型, 选择不同的子功能pipe.

继续上述的例子, 将差异抽象成不同的Pipe, 在更细粒度的功能上封装的差异, 最后在pipeline内直接组合, 就可以避免上述问题

1const lineChartPipeline = [
2  xBandAxis,
3  yLinearAxis,
4]
5const barChartPipeline = [
6  yBandAxis,
7  xLinearAxis,
8]
9const areaChartPipeline = [
10  xBandAxis,
11  yLinearAxis,
12]
13const scatterChartPipeline = [
14  xLinearAxis,
15  yLinearAxis,
16]
17
18const xBandAxis = (spec, context) => {
19}
20const yBandAxis = (spec, context) => {
21}
22const xLinearAxis = (spec, context) => {
23}
24const yLinearAxis = (spec, context) => {
25}

上述例子中, 没有实现axes pipe, 而是直接组合了xBandAxis、yBandAxis、xLinearAxis、yLinearAxis这4个pipe, 这样就避免了在axes pipe内根据图表类型, 选择不同的子功能pipe的问题, 从而避免了根据图表类型, 做出不同的判断, 从而减少了if else的使用.

所以的图表类型差异的分叉, 应该是在Pipeline之上, 除非迫不得已, Pipeline内无需根据图表类型, 选择不同的子功能pipe.

这样的组合方式, 符合VSeed的设计哲学, 即使用更扁平的功能Pipe的组合, 而不是if else条件判断做一个大而全的功能Pipe.