用 “流水线” 设计拆解复杂处理流程

建议横屏阅读代码

  • 本文的主要价值:提供一种抽象复杂逻辑,达成功能复用的思路
  • 关键词:语义提炼、动态具名
  • 本文约 4000 字,建议阅读时间 12 分钟。

引子

在软件开发中,我们常常会遇到一种场景:随着产品功能的扩展,出现了多个具备高度相似性的功能单元。作为功能单元,它们可能有着相似的交互逻辑,提供同类的输入数据和输出数据。并且,对于用户来说,它们都在处理同一个的东西。

举个例子,比如一款修图 app ,它包含了一组编辑功能,每个功能都作用于一张图片,处理之后的图片,还可以作为其他功能的输入。作为编辑工具,在每个功能内部,可能还需要支持撤销和重做这样的用户操作。我们容易想到的是,这些功能间存在着许多可以进行复用设计的代码。

本文基于一次回顾发起,出于记录和分享的目的:一次代码重构,一款之前我参与开发的图像处理应用。


重构的具体背景

请看下面这幅图:

某个图像功能模块的结构图

元素不多,让我解释一下。图中的 “内存图像管理 + 效果处理” 是一个 “黑盒子”。“逻辑黑盒” 有的时候是好事,有的时候是坏事。那这里的黑盒子算好事还是坏事呢? 既然对这种设计做了重构,多半是有痛点了。这里,我们重点探讨一下它的负面效应。

在具体业务场景下,这个黑盒子有两个问题:

  • 图像处理接口粒度太大,难以复用代码;
  • 图像 管理 和图像 效果处理 被绑定在一起。使得外部难以灵活的接触和使用图像。强调一下,管理和效果处理是两件事。前者是站在用户的角度,后者是站在服务提供者的角度。更高层应用逻辑的开发者,对于更底层支撑 API 的开发者来说,也是用户。

“管道” 概念的提炼

黑盒子的两个问题在重构时都得到了解决,但第一个问题与本文要分享的设计思想关联不大,不做展开。

为了说明我们是如何解决第二个问题的,这里先引入两个概念:“流水线” 和 “例程”。相信对于从事编程类工作的读者来说,这两个词不会陌生。

流水线 pipeline,[计] 又称管道,管线。
例程 routine,[计] 程序;日常工作;例行公事

在我们的案例中,Pipeline 相当于内存中的图像状态机,提供了基本的图像管理功能,例如加入图像,删除图像,复制图像,移动图像,等等。Routine 相当于各个图像功能单元中的通用事务,比如说,对于每个图像功能单元,都需要在其开始运作时从某处获得一份初始的图像,并在其结束运作时输出一份 最终的 图像到另一处。我们还约定,Routine 中的事务会基于 Pipeline 来完成。可以具体解释成这样:每个 Routine 都会包含一组典型的图像处理动作,这些处理动作借助一个或者多个 Pipeline 的通用操作,以及每个 Pipeline 的差异化操作来完成(后面会具体说明这个 差异化的图像处理步骤)。

从这里开始,我们不妨把 “流水线” 的叫法直接替换成 “管道”,因为后面会用到一些比喻性的描述,我个人它们觉得基于 “管道” 一词衍生出来,会比用 “流水线” 来得更自然。接下来,我们对 “管道” 这个意象再做进一步的挖掘,可以设计出下面这些对应关系(表格中左侧的概念只是一种比喻,读者可自行体会,这里不会详细解读)

比喻 原对象
“管道” 图像状态机
“流体” 图像
“节点” 图像状态
“流动” 图像状态流转
“锋面”(流体的最前端) 当前正在处理的图像状态
“连通性” 状态机内的图像以及图像状态机之间都是可串联的

“流体” 是一个名词,它对应的是图像,涉及到存储模型。根据 “流体” 的特性,我们可以想象,或者说推断,管道里的图像存储模型应该会被设计为平行结构。

请读者联想一个类比, <__化妆 / 整容 VS 软件上美化照片上的人脸__> ,再思考一下,两者在存储模型和工序这两个方面有什么异同?

回到正题,我配了五幅图来描述管道在具体实现中的五个特性:

  1. “流体” 由一系列 “节点” 组成。“节点”,即图像的状态,它的含义构成了我们对某一个图像的本征性认知。通俗地讲,图像状态能够帮助我们在特定的场景下把不同的图像区分开来。举个例子,有协同开发的两位程序员,对于 “美颜” 和 “滤镜” 这两个步骤的认知达成了共识。于是,我们就可以建立两个节点:“美颜”、“滤镜”,然后在开发过程中使用这两个节点来 协作 。注意,图像状态不是图像本身。对于图像状态的代码实现,我们可以使用一个极轻量的数据结构 —— 字符串。它体现的是 占位符思想,而我想要强调占位符的三个重要好处:它们是 可预见的(基于认知共识)、可预置的(很轻量)、可固化的(可复用代码的一个内在要求)。

节点及同位节点

  1. “管道” 通过衔接 “节点” 构成 “连通”。在 “节点” 中,有必要特别介绍的是 “同位节点”。它指的是:几个步骤在 同一个图像 上先后发生。在时间上有先后,但在空间上始终操作同一份存储。我后面会再用到这个描述。

连通性

  1. “流动” 的 “流体” 会有一个 最前部 ,就好像水流的最前端,又称为 “锋面” (Waterfront),对应着这样一个事实:“管道” 中所有的图像,在同一时间里,只会有 唯一的 图像处于 可操作 的状态,这个状态代表着 图像的变化趋势 。具体到代码实现,可能会是一组带有 同步关键字 的方法,加上一个唯一的指向当前状态的指针。我们通过 引导操刀 这个趋势,把图像 “引向” 最终要呈现出来的样子。在图示中,我有意使用了绿色代表原始的、最初的,使用红色代表成熟的、完全体的。Pipeline 专注于做一件事:把图像从一种状态转化为另外一种状态。这期间,可能要经历多个 “节点”,而 “锋面” 的意义就在于,它保证了一件事。那就是 Pipeline 的操刀者可以确信,这一刻只有他自己在引导图像的 “流向”,没有人会干扰到他。

    锋面

  2. “流动” 可以是双向的(相比生产车间的 “流水线”,“管道” 之所以更贴切,在于后者可以实现双向的流动,对应到图像,相当于实现反向编辑,或者说撤销到一个处理步骤之前的状态)

  3. “流体” 如果 “分流”,则可以出现多个 “锋面”,对应着图像的 并行处理

    分流

“管道” 的具体实现

如前所述,“流体” 其实就是图像,简单封装即可。我们主要实现的是 “节点”、“锋面”、 “流动” 和 “连通”。

节点的实现方案和意义

我们先来看一种典型的图像处理过程中可能会采用的写法,代码为 swift 实现:

1
2
3
4
5
6
// 图像 xyz
var xyz: MyImage
// 图像 ijk
var ijk: MyImage
// 图像 abc
var abc: MyImage

当然,现代编程语言的语法特性,可以让你省去写各种 getter/setter 的样板代码,从而节省代码量。但这不是重点,重点在于 —— 上述这种代码无法复用。因为每一个图像的引用都被赋予了 具体 的含义:同样的写法不太可能完全地适用于另外一个图像处理场景。比如说,另外那个图像处理场景很可能不会用到描述为 ijk 的图像,可能会用到描述是 uvw 的图像。因此,采取这种写法会遇到的一个典型问题是:每新增一个图像处理场景,我们都需要新增若干个特定描述的图像声明。在编码层面,这无疑是一项繁冗的工作。

上面说的图像引用,其实正是我们的图像 “管道” 里的某个 “节点”。思考一个问题,如果要对 “节点” 实现代码复用,你会怎么做?稍微提示一下,关键在于 “具体” 这两个字。

是的,如果我们能想到,上面的写法中代码之所以不能复用,根源在于图像引用的用途已经被 具体定义(同时也是被具体 约束),那么我们就更有可能往这样一个方向思考问题的解决方案:能不能把图像引用 “去具体化” ,让它的含义在具体场景到来时才被赋予呢?

讲到这一层,有些读者可能已经想到一种数据结构 —— 字典。是的,没有什么奇淫巧技,只用字典,就能实现 “去具体化”,解决这个代码复用问题中的最大障碍 —— 既然无法预知我们可能需要处理什么样的图像,可能需要处理多少份图像,并且这些未知数总是易变的,那为什么不让具体场景的使用者来 动态添加 这些图像引用,并且为它们具名呢?图像部分被复用的代码,这里只声明了一样东西,就是从图像状态表述到图像引用的映射表。它提供了一个之前的写法不具备的特点,而这个特点是达成复用的必要前提:图像存取的方式是 统一的有限的,从而是 可固化的

1
var stateTagToImageMap = [String:MyImage]()

我们用一个 字符串标签 来表示图像的状态。对于图像 “管道” 的使用者来说,他只需要理解每个标签的含义,通过标签来存取图像并进行处理。在这些标签中,我们再提炼出几个具有通用含义的代表性标签:比如,original 代表 “最初的”,processed 代表 “加工完成的”,这正是前文提到的 占位符 。容易理解,在一份可复用的代码库中,你可以声明并且预置许多 占位符 。但你不会在这个代码库里声明同样数量的图像引用 —— 这样很奇怪对吧?哪怕从程序实现的角度来说,没有分配实际空间的引用并不一定会占据更多的内存。在后面列举的代码范例中,我们将会经常地用到 originalprocessed 这样的标签。

不妨阅读下面这段代码,这就是一种使用标签来操作对应图像的写法。

1
2
3
4
5
6
7
// 显示两个处理步骤之后的图像 
pipeline.from (.original) // 从原始的图像开始
.copyTo (.processed) // 拷贝出一份图像用于处理,对应标签 processed
.doProcess (tag: .processed, specificProcess1) // 在 processed 上执行处理 1
.doProcess (tag: .processed, specificProcess2) // 在 processed 上执行处理 2

showImage (pipeline.fetch (.processed)) // 取得 processed 标签代表的图像并且展示

锋面的实现方案和意义

解决了 “节点” 的设计,我们再来看基于 “节点” 提炼出来的 “锋面” 要怎么设计。容易理解,“锋面” 是最前面的那个 “节点” ,具有 唯一性,对应具体的图像处理代码中就是 “当前正在被处理的那个图像”。在设计图像管道对外提供的处理 API 时,我们约定处理动作一定只能发生在这个 “当前的” 图像上,这样就能够保证我们的 “图像流” 总是按照我们想要的方向流动,并且在这个过程中,“图像流” 是不会被篡改的。这也是我们的图像编辑功能要实现撤销和重做功能的基本前提。

还是上面那段代码,现在可以去掉实际处理步骤的标签参数。因为我们约束了处理只能发生在 唯一的当前的 图像上。

1
2
3
4
5
6
7
// 显示一个处理步骤之后的图像 
pipeline.from (.original) // 从原始的图像开始
.copyTo (.processed) // 拷贝出一份图像用于处理,对应标签 processed
.doProcess (specificProcess1) // 隐含了在 processed 上执行处理 1
.doProcess (specificProcess2) // 隐含了在 processed 上执行处理 2

showImage (pipeline.fetch (.processed)) // 取得 processed 标签代表的图像并且展示

如果要求能够回撤到第一个处理步骤之后的状态,再做第二个处理步骤,并且第二个处理步骤的参数是可以改变的。可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 显示一个处理步骤之后的图像,但我们在过程中保留了第一个步骤的状态 
pipeline.from (.original)
.copyTo ("specificProcess1") // 相比一步到位,这里多存储了第一个步骤的状态
.doProcess (specificProcess1)
.copyTo (.processed)
.doProcess (specificProcess2.setParams (params1))

showImage (pipeline.fetch (.processed))

// 调整第二个步骤的某些参数,重新显示图像
pipeline.from ("specificProcess1") // 之前存储了第一个步骤的状态,直接从这个步骤开始
.copyTo (.processed)
.doProcess (specificProcess2.setParams (params2))

showImage (pipeline.fetch (.processed))

流动和连通性的实现方案

有了 “节点” 和 “锋面”,“流动” 和 “连通” 就有了作用的主体。对应到图像编辑功能,“流动” 其实就是图像从一个状态变成另外一个状态的过程。“连通” 则更好理解,一个管道出来的图像可以被另外一个管道接纳,由此构成管道之间的连接。连接在一起的每一节 “小管道” 各司其职,灵活组合,再构成更长跨度的 “大管道” 或者 “管道网络”,从而协同完成复杂的业务流程。

回归到代码,我们来看一组步骤稍多的图片处理工序,看它是如何体现出管道的 “流动性” 和 “连通性”。刨去内部的实现细节,整合或者忽略一些与管道设计思想关联不大的逻辑,以下代码在流程上算是比较接近实际生产环境了。虽然采用的是伪代码,相信读者可以看懂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/** 主功能区,不妨将它的例程称为 Main
* 基本功能:
* 1. 展示图像
* 2. 可以从这里进入各子功能处理图片再回到这里展示新的图片
* 3. 撤销到经过某个步骤处理之前的图像或者重做出之前做过但是被撤销掉的某个步骤的图像
*/
RoutineMain.startFrom (imageFile) {
RoutineMain.pipeline.loadFrom (imageFile, .original) // 从图片中加载初始的图像
}
RoutineMain.showCurrent () {
showImage (RoutineMain.pipeline.front () // 显示 “锋面”
}

/** 进入到一个叫 “美型” 的功能区,对应的例程称为 FaceLift
* 基本功能:
* 1. 展示图像
* 2. 针对图像中的人脸轮廓,五官进行形状调整
* 3. 输出处理后的图像到主功能区
*/
RoutineFaceLift.startFrom (RoutineMain.pipeline.front ().copy ())
RoutineFaceLift.process () {
RoutineFaceLift.pipeline
// 这个过程用户无法干预,不会有 “重做”,因此我们可以直接在原稿上操作
.from (.original)
.doProcess (faceLift_step_1_process)
.doProcess (faceLift_step_2_process)
.doProcess (faceLift_step_3_process)
...
}
// 把子功能 “美型” 处理好的图像提交给主功能
RoutineMain.accept (RoutineFaceLift.commit ())

/** 进入到一个叫 “滤镜” 的功能区,对应的例程称为 Filter
* 基本功能:
* 1. 展示图像
* 2. 滤镜化处理图像
* 3. 输出处理后的图像到主功能区
*/
RoutineFilter.startFrom (RoutineMain.pipeline.front ().copy ());
RoutineFilter.process () {
RoutineFilter.pipeline
// 这个过程中用户决定要选用哪个具体的滤镜,因此每次都需要基于原稿复制一份再滤镜化
.from (.original).copyTo (.processed)
.doProcess (filterProcess (pickFilter ("awful")))
... // 皱眉,这个不好,换一个!
.from (.original).copyTo (.processed)
.doProcess (filterProcess (pickFilter ("notbad")))
... // 托腮,这个还行,再换个试试~
.from (.original).copyTo (.processed)
.doProcess (filterProcess (pickFilter ("perfect")))
... // 完美~
...
}
// 把子功能 “滤镜” 处理好的图像提交给主功能
RoutineMain.accept (RoutineFilter.commit ())

/** 进入到一个叫 “美颜 ” 的功能区,对应的例程称为 SkinBeauty
* 基本功能:
* 1. 展示图像
* 2. 针对图像中的人脸皮肤进行色相调整
* 3. 输出处理后的图像给 Main 功能
*/
RoutineSkinBeauty.startFrom (RoutineMain.pipeline.front ().copy ());
RoutineSkinBeauty.process () {
RoutineSkinBeauty.pipeline
// 这个过程用户可以调节一个滑竿来控制色相参数,每次都基于原稿复制一份再调色相
.from (.original).copyTo (.processed)
.doProcess (skinBeautyProcess (level_too_weak))
... // 托腮,效果好像不明显,加强一点
.from (.original).copyTo (.processed)
.doProcess (skinBeautyProcess (level_too_much)))
... // 皱眉,好像有点过头了,往回调一点
.from (.original).copyTo (.processed)
.doProcess (skinBeautyProcess (level_just_right)
... // 完美~
...
}
// 把子功能 “美颜” 处理好的图像提交给主功能
RoutineMain.accept (RoutineSkinBeauty.commit ())

// 纠结一下。。
// 犹豫,要不还是不美颜了吧?
RoutineMain.undo ();
// 迟疑,滤镜也不要了?
RoutineMain.undo ();
// 思考中。。。
//... 不行,还是都加回来吧
RoutineMain.redo ().redo ();
// 端详 5 分钟。。。完美~
save (); // 收工,准备发朋友圈

总结

关于 “管道” 的设计思路和实现方案介绍到此。我们可以回顾一下,本文开始所提到 “黑盒子” 设计的第二个问题:“图像 管理 和图像 效果处理 被绑定在一起。”,在管道方案中是不是已经解决了呢?

“管道” 设计的基石是 无差别地管理图像 ,被管理的每一个图像,由最初将其投入管道的创建者为其定义标签。最初的创建者和后来的协同者,只需要对这个标签的含义达成 共识 便可以进行协作。“管道” 的思想是模拟 “流体” 的运行方式来实现图像处理过程,通过 “节点” 的设定来 分解 处理步骤,通过 “锋面” 的操控来 聚焦 每个单步的操作,通过 连通性 来将 分治 的逻辑重新 串联 起来完成复杂的功能。