[前端漫谈]关于复杂多任务编排的思考

3,319 阅读9分钟

0x000 导读

在前端开发中,多个任务串联、并发、多任务条件触发的场景非常常见,本篇文章是我对这方面的思考和总结。

0x001 多任务

这里的多任务指的是什么呢?我们假设以下几个场景:

  1. 开发一个商城应用,首页需要加载 banner 信息、商品信息、商品分类信息。假设这需要请求三个接口,那么请求这三个接口就是三个任务,而这三个接口的之间是没有相互关联的,我们可以说他们是“并发”的任务。(注:这里所谓的并发只是说不等待其他异步请求完成就开始下一个请求,非真实的并发)

  2. 发布一个商品的时候分为两部,第一步保存接口,第二步上架。假设保存和上架是两个接口,那么这两个接口就是两个任务,而这上架之前必须保存,因此这两个任务是“串联”的,因为发布接口必须等待保存接口完成之后调用。

  3. 用户登陆之后需要检测是否绑定了邮箱,如果绑定了,就跳转首页;如果没有绑定,就跳转接口绑定页面。这里的两个跳转就是两个任务,因为他是根据登陆接口的返回结果来判断行为,所以,可以称之为条件触发。

定义:

  • 任务:一系列业务操作的集合,本身就可以完成一个业务,在代码中的体现可以是一个简单的函数,比如请求登陆接口,跳转某个页面。(注意:任务不局限于一个网络请求,一个弹窗、一个跳转都可以是一个任务)
  • 多任务:就是多个任务
  • 多任务编排:就是将多个任务按照一定的逻辑排序,这里的逻辑可以是上一个任务的返回值,比如登陆的成功和失败决定下一个任务;也可以是线性逻辑,上一个任务完成就执行下一个任务。

0x002 串联

串联

任务的串联处理很简单,可以直接像日常一样编写代码:

await saveGoods()
await publishGoods()

但是多了就有点不太优雅了,因此我们可以使用一个reduce来处理

[saveGoods, publishGoods]
    .reduce(
        (prev, next) => prev.then(prevResult => next(prevResult)),
        Promise.resolve()
    )

优雅一点,封装一下:

function stepFlow(taskList, initialData) {
    taskList.reduce(
        (prev, next) => prev.then(prevResult => next(prevResult)),
        Promise.resolve(initialData)
    )
}

然后我们就可以这么使用了:

stepFlow([saveGoods,publishGoods])

0x003 并发

并发

并发的处理的日常写法:

getBanner()
getGoodsList()
getGoodsGroupList()

因为这三个方法是接口请求,本身就是异步的,所以其实不做任何处理,就是并发。但是我们还有优雅一点,可以使用Promise.all()

Promise.all([getBanner, getGoodsList, getGoodsGroupList])

0x004 条件触发

条件触发

真正复杂的是这种情况,串联和并发并不会造成太大的业务复杂度。在各种条件中才容易迷失方向。比如,前面的场景3(用户登陆之后需要检测是否绑定了邮箱,如果绑定了,就跳转首页;如果没有绑定,就跳转接口绑定页面),我们日常的编写方式是:

const result = await login()

if (result.hasBindEmail) {
    window.location.href = '#/index'
} else {
    window.location.href = '#/bind'
}

如果条件简单,则无所谓,但是如果有多层的逻辑嵌套。最后形成一个逻辑树,那么我们就会在条件逻辑中迷失,对未来的开发维护造成极大的成本损耗。

复杂逻辑图

在未来,我们回顾这段逻辑,或者向新成员介绍这段业务的时候,如何最快的解释清楚这中间的逻辑关系呢?

为了解决这个问题,我尝试引入状态机来解决。

0x005 状态机

具体状态机的概念可以自行了解(阮一峰 -JavaScript与有限状态机 ),我这里进行压缩,只抽取必要的逻辑:

  1. 状态:每个时刻,应用都处于某个状态,比如,未登录状态,已登陆状态。状态可以随着业务进行而转变,比如未登录变成已登陆状态。
  2. 任务:一系列业务操作的操作,表现为一个函数。任务的执行可以为状态变化准备数据。
  3. 转化器:状态机根据转化器将状态从一个状态转化到另一个状态。

注意:任务和转化器其实可以合并为一个,但是这里为了分离任务和任务编排,而独立出来。之后会解释为什么要分离成两个概念。

0x006 状态机实现复杂条件多任务调度和编排

以场景3(用户登陆之后需要检测是否绑定了邮箱,如果绑定了,就跳转首页;如果没有绑定,就跳转接口绑定页面)来做例子,从场景3中我们可以分离出三个任务:

  • 登陆
  • 跳转首页
  • 跳转绑定页面

因此我们可以定义三个任务:

function taskLogin(){}
function taskJumpToIndex(){}
function taskJumpToBind(){}

然后定义三个状态,分别是loginjumpToIndexjumpToBind,接着我们编排状态图:

const stateMap = {
    'login': taskLogin,
    'jumpToIndex': taskJumpToIndex,
    'jumpToBind': taskJumpToBind
}

我们将所有的状态和对应的任务都使用一个对象保存了,那么如何指定状态之间的关系呢?

我们可以让每一个状态的任务返回下一个状态,如果没有返回状态,就代表完结了:

async function taskLogin(){
    const result = await login()
    if (result.hasBindEmail) {
        return Promise.resolve('jumpToIndex')
    } else {
        return Promise.resolve('jumpToBind')
    }
}

这样我们就可以让状态机跑起来了:

async function run(stateMap, state){
    if(!state) return;
    const task = await stateMap[state]
    const nextState = await task()
    run(stateMap, nextState)
}

run(stateMap, login)

但是这里存在3个问题:

  1. 无法将上一个任务的结果传递给下一个任务。比如上面的场景中,如果login接口的数据需要在绑定页面使用。我们必须向下传递。我们可以通过改造任务的返回值来解决:
async function taskLogin(){
    const result = await login()
    if (result.hasBindEmail) {
        return Promise.resolve({
            state: 'jumpToIndex',
            payload: result
        })
    } else {
        return Promise.resolve({
            state: 'jumpToBind',
            payload: result
        })
    }
}

我们将任务的返回值从直接返回状态改为返回一个{state, payload}对象,其中state就是下一个状态,而paylod是这次任务的结果。因此run函数需要同步修改:

async function run(stateMap, state, payload){
    if(!state) return;
    const task = await stateMap[state]
    const nextState = await task(payload)
    run(stateMap, nextState?.state, nextState?.payload)
}

run(stateMap, login)
  1. 这里存在封装入侵(这里应该有专有名词,但是我忘记了),我们的任务定义必须满足要求,即(改造前)需要返回下一个状态,或者(改造后)返回结果必须是{action, payload}形式,会导致任务函数无法在其他场景复用,或者存在重构风险。

  2. 将对逻辑的判断封装到任务函数中,对于整个逻辑的表达没有帮助,或者相比于之前,反倒是绕了一圈,更乱了。

不使用状态机:

const result = await login()

if (result.hasBindEmail) {
    window.location.href = '#/index'
} else {
    window.location.href = '#/bind'
}

使用状态机:

    const result = await login()
    if (result.hasBindEmail) {
        return Promise.resolve({
            state: 'jumpToIndex',
            payload: result
        })
    } else {
        return Promise.resolve({
            state: 'jumpToBind',
            payload: result
        })
    }

对于以上 2、3 问题的解决方案都是一样的那就是将对状态的判断独立出来,任务只负责处理任务自身的逻辑,对于状态的判断交由转换器来完成:

  1. 任务:一系列业务操作的集合,无所谓是否返回数据。(注意:任务不局限于一个网络请求,一个弹窗、一个跳转都可以是一个任务)
  2. 转换器:状态的转换处理器,可以直接配置或者根据上一个任务的执行结果来决定下一个状态。和具体任务无关。

现在让我们修改一下状态图:

const stateMap = {
    'login': {
        task: taskLogin,
        next: (result)=>{
            if(result.hasBindEmail){
                return {
                    state: 'jumpToIndex',
                    payload: result
                }
            } else {
                return {
                    state: 'jumpToBind',
                    payload: result
                }
            }
        }
    },
    'jumpToIndex': taskJumpToIndex,
    'jumpToBind': taskJumpToBind
}

原先,状态对应的直接是任务,而现在,状态对应的是一个{task, next}的配置对象。其中,task是状态对应的任务,next就是转换器。它可以是一个函数,该函数返回一个{state, payload}对象,表示下一个状态需要的信息。

好处:

  • taskLogin的封装没有侵入性,他就是一个普通的函数,对于测试也非常的友好。

  • 任务的流更加的清晰,代码即业务。当我们关心的是业务流程的时候,通过状态图就可以看出。如果我们关心的是具体实现,就可以看具体的任务函数。甚至,如果任务的返回值是有限的几个,我们可以直接使用一个映射表来表示:{1: taskJumpToIndex, 2: taskJumpToBind}

坏处:

  • 有一定的学习成本
  • 对于大部分场景大材小用,过度设计

这里还不算完,状态图改造之后,响应的run函数也要进行一定的改造。具体实现已经在下面的 github 仓库中完成,并发布到npm:

0x007 总结

“人们面临权衡取舍”。

使用哪种方式来编写代码千人千面,对于每一种编码方式的决策都是基于每个人的思考的。而我以上所思考的就是如何在未来--一个礼拜,一个月,一年后,快速的让我,或者一个团队的新人快速的了解这里的业务。

因此我将代码切分成任务和转化器,使用状态图将两者结合。其中:

  • 任务负责执行具体的业务
  • 转化器负责状态变化
  • 状态图负责编排任务和转化器

其背后的思想就是状态机,而灵感来自 Docker 和 k8s。

0x008 资源

文章内提及的资源:

我的其他文章:

0x009 带货

最近发现一个好玩的库,作者是个大佬啊--基于 React 的现象级微场景编辑器