R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

编写状态驱动的业务代码

什么是状态驱动的业务代码

别看概念似乎很高大上,其实是我胡扯出来的……简单来说,就是把业务按照一个个状态进行拆分,然后通过状态来计算当前要执行的操作(后端)或当前要展示的界面(前端)。

虽然这个做法很早就有了,而且绝对有很多人实践过了,但似乎一直没有一个专门的名词(也可能是我不知道),于是我就将其描述为“状态驱动的业务代码”了。

几个业务场景

多个步骤的页面

我们的一个业务场景是(部分逻辑已修改):提供一个拣货的页面,操作员首先扫描订单上的条形码,页面上显示这个订单所需的货物列表,然后操作员再依次扫描货物上的货位条形码和货物标记条形码,后端确认无误(该货位上有这个货物)之后相当于拣完了一个货物,然后操作员开始扫下一个货物……直到扫完所有需要的货物后,显示一个弹框提示成功,按下回车后清空界面的所有内容,操作员可以扫描下一个订单。

因为扫描枪本质是极快的输入一串字符然后按个回车,因此之前的界面逻辑是:页面载入后,光标先聚焦在订单输入框,在输入框的回车事件中将光标聚焦到货位输入框,以此类推。这样容易出的问题就是,万一操作员不小心点到了鼠标,或者其它原因导致了输入框失焦,整个页面的逻辑就会乱掉。

我们的新系统用了 Vue,因此可以更方便的使用数据来描述页面,这也使得用状态驱动业务成为了可能。我先为扫描枪封装一个专门的库:

import scanner from '~/utils/scanner';

export default {
    methods: {
        scanHandler(code) {
            // ...
        }
    },
    mounted() {
        scanner.on('scan', this.scanHandler);
    },
    beforeDestroy() {
        scanner.off('scan', this.scanHandler);
    }
}

至于 scanner 内部如何实现,方法有很多,主要的思路就是监听键盘事件然后做处理。用 debounce 也好,setTimeout 也好,甚至单纯的监听回车事件也可以(前提是扫描枪最后必须输入一个回车)。接下来就是如何编写 scanHandler 函数了。

我把业务逻辑分为了如下几个阶段:

  1. 等待扫描订单
  2. 等待扫描货位
  3. 等待扫描货物标记
  4. 弹框,等待用户按下回车

1 中获取的 code 只会被用于 orderId,向后端请求一下所需的货物列表,如果没有待拣货物则转 4,否则转 2;2 中获取的 code 只会被用于 location,然后转 3;3 中获取的 code 只会被用于 tag,然后向后端提交一个拣货请求判断是否都拣完了,如果是则转 4,否则转 2;4 则不会处理扫描枪。那么 scanHandler 的一种可能的写法是这样:

scanHandler(code) {
    switch (this.step) {
        case 1: this.scanOrderId(code); break;
        case 2: this.scanLocation(code); break;
        case 3: this.scanTag(code); break;
    }
}

里面的函数都是简单的逻辑。而且由于是严格通过状态来控制,所以我可以在 scanLocation 函数中专心处理 location 相关的事情。然后修改 this.step 即可,不需要考虑任何其它的状态和风险。

对于界面的显示,1 时只显示 orderId 的输入框,可以用 <input v-model="orderId" :disabled="step !== 1"> 来实现。其它状态同理。

格斗游戏中的出招表和连击系统

《鬼泣》系列和《拳皇》系列大家应该比较熟悉,这其中的出招表和连击系统的逻辑就可以通过状态驱动来实现。定义状态为:[角色当前正在释放的技能, 目前的连击, 可以释放下一技能的时间],状态转移条件为 玩家按下的按键,这样每次玩家按下按键时,先判断是否超时(连击断了),如果断了则释放该按键可以触发的第一个技能;否则根据按键和当前释放的技能来计算角色下一个释放的技能。

至于界面,则可以监视“角色当前释放的技能”这个变量的修改,然后播放动画即可,具体的流程都在状态转移中描述清楚了,不需要在写界面时考虑这些。

不过要注意的是,对于格斗游戏来说,两个技能之间可能需要一些动作来连接,否则会显得很僵硬。

利用状态转移图来明确思路

废话不多说,先上图,例如拣货页面的状态转移图大概是这样:

0

扩展一下:这张图片是用 Graphviz 生成的,可以通过下面这段 DOT 代码来生成,生成方式是执行 dot xxx.dot -Tsvg -o xxx.svg

digraph example1 {
    1 -> 2 [label = "scan orderId, has item"]
    2 -> 3 [label = "scan location"]
    3 -> 4 [label = "scan tag, all finished"]
    4 -> 1 [label = "press enter"]
    3 -> 2 [label = "scan tag, not finished"]
    1 -> 4 [label = "scan orderId, no item"]
}

状态转移图是个很有用的东西,可以让你一目了然的了解有哪些状态,以及每个状态接受了某个事件之后该如何做。其实,这又涉及到了自动机的概念。

格斗游戏的那个例子就不画图了,毕竟《鬼泣》中的丁叔有几十种组合技,这么复杂的状态和状态转移,在大家的屏幕上可能画不开……

优点、缺陷与优化方法

优点

在编写代码的时候,只需要分别考虑某个状态与业务逻辑之间的对应关系,这是个一次性问题,与流程无关,因此写出来的代码将更加不容易出错。

此外,由于这样写出来的函数与纯函数十分相似(可能会多一些外部的数据依赖),所以特别方便编写测试代码,甚至是为某个状态单独编写测试代码。

缺陷

缺陷还是很明显的,由于每次都是根据一个状态值来重新计算当前的各种数据,运算量必然是很大的,这也是受控组件一般要比非受控组件慢的原因之一(一个最典型的例子是树形组件,每次重新生成的效率显然不如动态增删节点的效率)。

优化方法

使用纯函数 Memoize(常用)

嗯我没拼错,这个词确实是 Memoize:“通过储存大计算量函数的返回值,当这个结果再次被需要时将其从缓存提取,而不用再次计算来节省计算时间”。

如果通过一些方法,把根据状态计算的函数(或其中的一部分)写成真·纯函数(不依赖外部的任何状态),就可以使用 Memoize 这个对于纯函数的通用优化方法。当然,这个方法使用的前提是重新计算要比查找的开销大,并且状态的总数比较少(否则存不下)。

化简状态转移图(不推荐)

既然涉及到了自动机,那么一个可能的方法就是通过化简这个自动机来减少状态的总数。但目前我还没在项目中看到过这种优化方式(正则表达式引擎除外),以及如果调用不频繁或者状态数量本身就很少,那么化简的效果将是微乎其微的(甚至无法化简)。所以如果遇到了性能瓶颈,最先想的不应该是做这样的优化。

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。

这是我们共同度过的

第 1114 天