和少妇白洁一起Thinking in Spec

第一讲写在了微博上,篇幅不长,讲了一个api或function call返回error时要保证检查顺序的问题。

这件事情看起来不重要,也很难打动你;所以需要讲一个positive case来改变你对spec和测试的看法。

这也是我这两天要写的代码,一个在服务器端copy/move一组文件或文件夹的功能,我是做nas的,在2018年书写在1998年各大操作系统就完成的功能,不同之处在于,现在咱们restful和microservice了。

事实上我们在过去的两年写了海量的类似的业务,各种异步并发过程组合,各种流的mux/demux,各种lazy和各种在并发情况下的错误处理,随着对并发状态机组合的了解越来越深入,常见的应用场景都没什么问题;直到遇到这个copy功能的实现。

这个功能不同在哪里呢?象文件批量上传这样的功能,系统对客户端而言是blackbox,你内部有多少并发或串行组合状态机,外部是看不见的,最多是遇到错误时内部要优雅的停下来。

但是递归式的复制文件和文件夹不一样,客户端那里蹲着一个充满好奇心的灵长目动物,它想看见你的内部过程,或者至少是一部分;同时这个操作还不可避免的会遇到各种冲突和错误,同名的文件和文件夹,因为系统其他用户的并发操作导致的一些失败,甚至是这个用户突然被管理员删除了,诸如此类。

理论上它等价于一个状态机把状态暴露出来,但是这里有个问题,外部的观察其实是异步的,内部的很多状态存在自发迁移,黑盒测试的代码在polling和拿到api结果时再想去assert系统数据(文件系统上的数据,不是服务器内部的状
态),可能已经发生了变化。

但是业务上说,如果因此把服务端的行为设计成串行和单步的,虽然获得了良好的可测试性,缺没有了实用价值,效率牺牲太多。

所以这是一个非常好的如何specify系统行为的问题;之所以用specify这个词,是因为我们要指定的仍然是黑盒意义上客户端和服务端的合约(contract),而不是服务端内部的状态机实现,如果是后者,我们就是在说design了。

我们的目的是在系统的实用性和黑盒可测试性上找到一个平衡。没有一个好的合约,就像一个定理缺乏证明,你只好随机的找一大堆test data和test case来试行为和结果,俗称quality assurance。


测试一个函数的基本逻辑是y = f(x),我们先找到一个数学函数f,给定x就能计算出y,然后我们去测代码实现的f',检查对于同一个x,它是否和我们定义的数学函数给出的结果一致。

在这里我们剥离了测试数据x和系统行为定义f,f即spec。

对于api测试,我们把参数拓展为[a,b,c,d]理论:

a: 系统预置状态
b: 调用api的参数
c: api返回结果
d: 系统结束状态

参数多了点,但本质是一样的,系统的spec是存在f: (a,b) => (c,d),这是确定的。


我们来脑部一下加入程序跑一下应该得到什么结果。

系统的初始状态是存在一个源文件夹src,一个目标文件夹dst,以及用户需要从src中copy到dst中的一些entries,可能是子文件夹,可能是文件,这个列表不应该为空,否则没有业务意义。

对于测试程序而言,[src, dst, entries]是参数,即上面所述的b;a呢?a可以理解为在开始时src和dst的整个hierarchy。

好了我们有了a和b,我们期待什么样的c和d?

假如被测程序没有任何限制,即遇到多少个命名冲突整个任务应该停下来的情况,再假如没有遇到文件系统访问错误,这个操作到最后是完成了尽可能多的可以成功复制的文件和文件夹,但是也找到了所有遇到冲突无法继续的情况,等待用户决策。

在这个假设下我们是能够得到c和d的定义的,c可以定义为全部冲突情况的列表,d是最终任务停下来时的结果。

这是一个spec吗?我们说是的,因为abcd有无歧义的定义,而且是可以实现这个函数的,在src和dst两个tree上visit一遍就能根据a/b计算出c/d。

但是这个spec有两个问题:

  1. 它的粒度太粗了,只有开始和结束;
  2. 它有不确定性;

如果你在任务中间去polling服务器,即使服务器只暴露出现命名冲突的文件夹和文件给客户端,这个列表中的内容是稳定的,客户端不操作其中的条目不会自行消失;但是条目的出现顺序可以是随机的;这种随机性在实现角度看没有任何问题,完全不至于因为乱序复制一个文件夹中的内容影响执行结果的正确性。

但是在合约角度看,它难以assert,一方面因为随机性,另一方面因为服务器不会停下来等待客户端去assert。

去除随机性很简单,例如我们可以约定服务器端的执行必须按照depth first/previsit的顺序进行,这对服务器满足功能正确性而言是不必要的spec,但是对于test它会让测试代码方便很多,只要没有显著的性能影响,我们不介意把这样一条rule写到spec里去。

但另一方面会比较麻烦,如何让服务器停下来允许客户端assert?

熟悉生产者消费者模式的人一眼就能看出这里需要一个调度器;如果不熟悉这个模式可以这样理解,在一个子任务完成后,代码中会有继续执行下一个任务的逻辑,只要在这里插入过程,即可让continuation中断和恢复。

如果没有额外的限制,这个continuation的实现方法很多:

  1. 你可以pre-visit src tree,产生一个dst任务的列表,这个列表用调度器调度执行;
  2. 你也可以为src -> dst构造一个任务tree,这个continuation体现在了父子对象之间的event handler上;
  3. 你也可以几构造task tree,然后把调度器写成visitor,不用队列结构,满足前面加入的pre-visitor规则;

都OK,但是谁该win design呢?

使用全局调度器的1和3都是可以容易满足stop任务执行的要求的。如果没有一些错误处理要求1是OK的,如果遇到父文件夹发生文件夹丢失之类的上下文相关错误,使用tree结构处理更方便一些。

具体怎么实现不是重点,这里想强调的是:如何break复杂过程,让更细粒度的testing/verification可行。

好了,有了这两个新规则我们再看一下问题:

首先,它应该提供一个stepping模式;在任务初始创建时处于stopped状态;

对于创建任务的需求,我们的a和之前一样,b是创建任务的参数,c是返回的任务结果,这个时候什么实质性的工作也没有做,只是server端有个任务描述,处于stopped状态;而d和a一样。

然后我们有了第二个客户端动作,姑且称为step;step的意思是server端可以根据调度规则选择几个任务开始,一直执行到这些任务完成,但是调度器不工作,所以不会有新的任务产生;step在完成时立刻返回内部任务状态,它可以列入spec,也可以不列入,取决于你想在多大程度上assert系统行为,但本质上这是灰盒的。

第三个客户端动作是watch;服务器在收到客户端的watch调用时,如果已经完成了一个step的所有操作,进入stopped状态,则立刻返回状态描述;如果没有,它应该等到step完成时再返回,这样对客户端来说比较容易使用。

step-watch构成了一个cycle,如果不想观察step之后哪些任务在执行的状态,step-watch可以合并成一个api调用。不管怎样,我们得到了一个细粒度的assert能力,在每次step-watch结束时,可以assert c和d了。因为server停下来了,d可以assert。


那么在这个设计下,服务器端的每次并发多少,是一个参数,或者说policy,如果不区分文件和文件夹并发数设置为1,它就退化成了顺序执行;实际使用中会用到的并发限制,对node.js而言,创建文件夹可以是个很大的值甚至不做限制,如果你不介意在任务彻底失败时预先创建了海量的空文件夹的话;复制文件高并发没有意义,瓶颈在磁盘io那里,一般2个并发就够了。

所以现在你能脑补出来的执行过程是:每次step,调度器要按照previsit原则填充指定的任务,当他们结束时,无论是遇到冲突还是成功完成任务,结果都是容易预先计算的。

但是还有一个问题:这样一步一步执行的结果,真的和实际使用时,每个子任务结束时都kick调度器的结果一样吗?能保证这一点吗?

这需要把调度器的要求再提高一点:即使系统不处于stopped的状态,仍然可以调度,离并发限制差多少就该填充多少任务进去。在这种情况下,你可以连续step多次最终只watch一次。每次step的结果可以assert c,最终的watch结果可以assert c和d。

这是final的保证吗?也不是,除非你真的能坐下来给一个数学上的proof,否则都不算能证明其正确性。我们仍然或多或少的需要不那么可靠的直觉。

但是把spec强化到这个程度,对我来说,它的每次执行可以算是很容易预测(计算)出来的,这意味着step by step的spec函数可以书写、易读、且状态打印结果是比较易懂的。在工程上,这已经很好了。


说一个小问题。

比如在SRS文档上写了一个限制,在整个任务遇到5个冲突时应该停下来。

这个限制可能是灵长目客户出了钱要一定实现的;实现也不难,调度器调度的时候要考虑还剩多少个可以冲突的并发可用,而不是预设的并发数限制,换句话说,如果已经有4个冲突了,这个程序就只能堕落成one by one的顺序执行了,因为如果并发了两个,两个都冲突,这条愚蠢的限制就没法meet了。

但是如果这个限制是伪装成灵长目客户的另一个灵长目同事提出的(俗称产品经理),你最好跟他商量商量,手里要拎着一个棒子,上面刻着persuader。因为这种需求就是在没有推敲细节时拍脑袋想出来的,它没考虑对性能的影响。严格遵守这个5没有什么意义,除非团队用易经指导编程。


说完了。小结一下:

  1. spec和design是紧密相关的;
  2. design的自由度是很大的,对它增加限制条件,以获得spec意义上的可测试性,是考量设计好坏的重要指标;
  3. 要让spec函数可以写出来和容易写出来,即使你现在代码忙得没空写,但是不要搞成未来需要写的时候全部重写生产代码;

Matias Duarte说:

Design is all about finding solutions within constraints. If there were no constraints, it is not design - it's art.

对于代码来怎么理解这句话呢?就是不要把代码怎么写都是可以的挂在嘴边,找到那些你还没发现的constraint,才是区分好的design和坏的design的关键;在这篇文章里,将的就是从spec/testing的角度去通过加入更多的constraint,让design变得更容易test/verification。

Tony Hoare说:

There are two ways of constructing a software design: one way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies. The first method is far more difficult.

Hoare说的so simple that there are obviously no deficiencies怎么理解呢?如何做到呢?就是你的spec合约如此的简单,即使对于复杂行为实现,你仍然可以break it down,用归纳法得到足够简单的验证方法 - 尤其是在遇到复杂问题,在实现层面难以简化的时候。

相关推荐