更好的“循环”承诺的方式

问题描述 投票:3回答:4

这篇文章很可能是概念性的,因为我首先从很多伪代码开始。 - 最后你会看到这个问题的用例,虽然解决方案将是一个“我可以添加到我的工具带中的有用编程技术工具”。

The problem

有时可能需要创建多个承诺,并在所有承诺结束后执行某些操作。或者根据先前承诺的结果,可以创建多个承诺。可以类推,创建一个值数组而不是单个值。 有两种基本情况需要考虑,其中承诺的数量与所述承诺的结果不一致,以及它是依赖的情况。什么“可以”完成的简单伪代码。

for (let i=0; i<10; i++) {
    promise(...)
        .then(...)
        .catch(...);
}.then(new function(result) {
    //All promises finished execute this code now.
})

基本上创建了n(10)个promise,最终的代码将在所有promise完成后执行。当然语法在javascript中不起作用,但它显示了这个想法。这个问题相对简单,可以称为完全异步。

现在第二个问题是:

while (continueFn()) {
    promise(...)
        .then(.. potentially changing outcome of continueFn ..)
        .catch(.. potentially changing outcome of continueFn ..)
}.then(new function(result) {
    //All promises finished execute this code now.
})

这要复杂得多,因为人们不能只是开始所有的承诺,然后等待它们完成:最终你必须“承诺承诺”。第二种情况是我想弄清楚的(如果可以做第二种情况,你也可以做第一种情况)。

The (bad) solution

我确实有一个有效的“解决方案”。这不是一个很好的解决方案,因为可能很快就会看到,在代码之后我将讨论为什么我不喜欢这种方法。基本上它不使用循环而是使用递归 - 所以“promise”(或承诺的包装器是一个承诺)在代码中实现时会自动调用:

function promiseFunction(state_obj) {
    return new Promise((resolve, reject) => {
        //initialize fields here
        let InnerFn = (stateObj) => {
            if (!stateObj.checkContinue()) {
                return resolve(state_obj);
            }
            ActualPromise(...)
                .then(new function(result) {
                    newState = stateObj.cloneMe(); //we'll have to clone to prevent asynchronous write problems
                    newState.changeStateBasedOnResult(result);
                    return InnerFn(newState);
                })
                .catch(new function(err) {
                    return reject(err); //forward error handling (must be done manually?)
                });
        }
        InnerFn(initialState); //kickstart
    });
}

需要注意的重要一点是,stateObj在其生命周期内不应该改变,但它可以非常简单。在我真正的问题中(我将在最后解释),stateObj只是一个计数器(数字),而if (!stateObj.checkContinue())只是if (counter < maxNumber)

现在这个解决方案非常糟糕;它很丑陋,复杂,容易出错,最终无法扩展。 丑陋,因为实际的业务逻辑埋藏在一堆乱七八糟的代码中。它没有显示“在罐头上”实际上只是做上面的while循环所做的事情。 因为执行流程无法遵循而变得复杂。首先,递归代码永远不会“容易”遵循,但更重要的是,您还必须牢记使用状态对象的线程安全性。 (也可能有另一个对象的引用,比如存储结果列表以供以后处理)。 由于存在冗余而非严格必要,因此容易出错;你必须明确转发拒绝。诸如堆栈跟踪之类的调试工具也很快变得难以查看。 可伸缩性在某些方面也是一个问题:这是一个递归函数,因此在某一点上它将创建一个stackoverflow /遇到最大递归深度。通常,可以通过尾递归进行优化,或者更常见的是,在堆上创建虚拟堆栈,并使用手动堆栈将函数转换为循环。但是,在这种情况下,无法将递归调用更改为带有手动堆栈的循环;仅仅是因为承诺语法如何工作。

The alternative (bad) solution

一位同事提出了解决这个问题的另一种方法,这种方法最初看起来不那么成问题,但是我放弃了最终的方法,因为它违背了所有承诺的意图。

他所建议的基本上是按照上面的承诺循环。但是不是让循环继续,而是会有一个变量“finished”和一个不断检查这个变量的内部循环;所以在代码中它会是这样的:

function promiseFunction(state_obj) {
    return new Promise((resolve, reject) => {
        while (stateObj.checkContinue())  {
            let finished = false;
            let err = false;
            let res = null;
            actualPromise(...)
                .then(new function(result) {
                    res = result;
                    finished = true;
                })
                .catch(new function(err) {
                    res = err;
                    err = true;
                    finished = true;
                });
            while(!finished) {
                sleep(100); //to not burn our cpu
            }
            if (err) {
                return reject(err);
            }
            stateObj.changeStateBasedOnResult(result);
        }
    });
}

虽然这不那么复杂,但现在很容易遵循执行流程。这有它自己的问题:至少不清楚这个功能什么时候结束;这对于表现来说真的很糟糕。

Conclusion

那么这个结论还不多,我真的很喜欢上面第一个伪代码中的简单内容。也许是另一种看待事物的方式,这样就不会有深度递归函数的麻烦。

那么你如何重写作为循环一部分的承诺呢?

The real problem used as motivation

现在这个问题源于我必须创造的真实事物。虽然现在已经解决了这个问题(通过应用上面的递归方法),但知道产生这个问题的内容可能会很有趣;然而,真正的问题不是关于这个具体的案例,而是关于如何在任何承诺下做到这一点。

在sails应用程序中,我必须检查一个数据库,该数据库包含订单ID。我必须找到第一个N“不存在的订单 - ID”。我的解决方案是从数据库中获取“第一”M产品,找到其中缺少的数字。然后,如果缺失的数量小于N,则获得下一批M产品。

现在要从数据库中获取项目,使用promise(或回调),因此代码不会等待数据库数据返回。 - 所以我基本上处于“第二个问题:”

function GenerateEmptySpots(maxNum) {
    return new Promise((resolve, reject) => {
        //initialize fields
        let InnerFn = (counter, r) => {
            if (r > 0) {
                return resolve(true);
            }
            let query = {
                orderNr: {'>=': counter, '<': (counter + maxNum)}
            };
            Order.find({
                where: query,
                sort: 'orderNr ASC'})
                .then(new function(result) {
                    n = findNumberOfMissingSpotsAndStoreThemInThis();
                    return InnerFn(newState, r - n);
                }.bind(this))
                .catch(new function(err) {
                    return reject(err); 
                });
        }
        InnerFn(maxNum); //kickstart
    });
}


EDIT: Small post scriptus: the sleep function in the alternative is just from another library which provided a non-blocking-sleep. (not that it matters).
Also, should've indicated I'm using es2015.
javascript recursion ecmascript-6 promise
4个回答
4
投票

替代(坏)解决方案

...实际上并不起作用,因为JavaScript中没有sleep函数。 (如果你有一个提供非阻塞睡眠的运行时库,你可以使用while循环和非阻塞 - 使用相同的样式在其中等待承诺)。

糟糕的解决方案是丑陋,复杂,容易出错,最终无法扩展。

不。递归方法确实是执行此操作的正确方法。

丑陋,因为实际的业务逻辑埋藏在一堆乱七八糟的代码中。并且容易出错,因为您必须明确转发拒绝。

这只是由Promise constructor antipattern造成的!躲开它。

因为执行流程无法遵循而变得复杂。递归代码永远不会“容易”遵循

我会质疑这个说法。你必须习惯它。

您还必须记住状态对象的线程安全性。

不。在JavaScript中没有多线程和共享内存访问,如果你担心并发,其他东西会影响你的状态对象,而循环运行会导致任何方法出现问题。

可扩展性在某些方面也是一个问题:这是一个递归函数,所以在某一点上它会创建一个stackoverflow

不,这是异步的!回调将在一个新的堆栈上运行,它实际上并不是在函数调用期间递归调用的,并且不会携带这些堆栈帧。异步事件循环已经提供了trampoline来实现这种尾递归。

The good solution

function promiseFunction(state) {
    const initialState = state.cloneMe(); // clone once for this run
    // initialize fields here
    return (function recurse(localState) {
        if (!localState.checkContinue())
            return Promise.resolve(localState);
        else
            return actualPromise(…).then(result =>
                recurse(localState.changeStateBasedOnResult(result))
            );
    }(initialState)); // kickstart
}

The modern solution

你知道,async / await可用于实现ES6的每个环境,因为它们现在都实现了ES8!

async function promiseFunction(state) {
    const localState = state.cloneMe(); // clone once for this run
    // initialize fields here
    while (!localState.checkContinue()) {
        const result = await actualPromise(…);
        localState = localState.changeStateBasedOnResult(result);
    }
    return localState;
}

1
投票

让我们从简单的案例开始:你有N个承诺,所有人都做了一些工作,并且你想在所有承诺完成后做点什么。实际上有一种内置方式可以做到这一点:Promise.all。有了它,代码将如下所示:

let promises = [];
for (let i=0; i<10; i++) {
    promises.push(doSomethingAsynchronously());
}

Promise.all(promises).then(arrayOfResults => {
    // all promises finished
});

现在,第二次调用是一种情况,当你想要继续异步地执行某些操作时,这取决于先前的异步结果。一个常见的例子(有点不那么抽象)就是简单地获取页面,直到你结束。

使用现代JavaScript,幸运的是以一种非常易读的方式编写它:使用asynchronous functionsawait

async function readFromAllPages() {
    let shouldContinue = true;
    let pageId = 0;
    let items = [];

    while (shouldContinue) {
        // fetch the next page
        let result = await fetchSinglePage(pageId);

        // store items
        items.push.apply(items, result.items);

        // evaluate whether we want to continue
        if (!result.items.length) {
            shouldContinue = false;
        }
        pageId++;
    }

    return items;
}

readFromAllPages().then(allItems => {
    // items have been read from all pages
});

如果没有async / await,这看起来会有点复杂,因为你需要自己管理所有这些。但除非你试图让它超级通用,否则看起来不应该那么糟糕。例如,分页可能如下所示:

function readFromAllPages() {
    let items = [];
    function readNextPage(pageId) {
        return fetchSinglePage(pageId).then(result => {
            items.push.apply(items, result.items);

            if (!result.items.length) {
                return Promise.resolve(null);
            }
            return readNextPage(pageId + 1);
        });
    }
    return readNextPage(0).then(() => items);
}

首先,递归代码永远不会“容易”遵循

我认为代码很好读。正如我所说:除非你试图让它超级通用,否则你可以保持简单。命名也有很大帮助。

但更重要的是,您还必须牢记使用状态对象的线程安全性

不,JavaScript是单线程的。你是异步做事但这并不一定意味着事情同时发生。 JavaScript使用事件循环来处理异步进程,其中一次只运行一个代码块。

可伸缩性在某些方面也是一个问题:这是一个递归函数,因此在某一点上它将创建一个stackoverflow /遇到最大递归深度。

也没有。在函数引用自身的意义上,这是递归的。但它不会直接称自己为自己。相反,当异步进程完成时,它会将自身注册为回调。因此,函数的当前执行将首先完成,然后在某个时刻异步进程完成,然后回调最终将运行。这些是(至少)与事件循环分开的三个步骤,它们都独立于另一个循环运行,所以这里没有递归深度的问题。


0
投票

事情的关键似乎是“实际的业务逻辑被埋在一堆乱码”中。

是的,在两种解决方案中都是......

事情可以分开:

  • 有一个asyncRecursor函数,只知道如何(异步)递归。
  • 允许recursor的调用者指定业务逻辑(要应用的终端测试,以及要执行的工作)。

允许调用者负责克隆原始对象而不是resolver(),假设克隆始终是必要的。在这方面,来电者确实需要负责。

function asyncRecursor(subject, testFn, workFn) {
    // asyncRecursor orchestrates the recursion
    if(testFn(subject)) {
        return Promise.resolve(workFn(subject)).then(result => asyncRecursor(result, testFn, workFn));
        // the `Promise.resolve()` wrapper safeguards against workFn() not being thenable.
    } else {
        return Promise.resolve(subject);
        // the `Promise.resolve()` wrapper safeguards against `testFn(subject)` failing at the first call of asyncRecursor().
    }
}

现在您可以按如下方式编写调用者:

// example caller
function someBusinessOrientedCallerFn(state_obj) { 
    // ... preamble ...
    return asyncRecursor(
        state_obj, // or state_obj.cloneMe() if necessary
        (obj) => obj.checkContinue(), // testFn
        (obj) => somethingAsync(...).then((result) => { // workFn
            obj.changeStateBasedOnResult(result);
            return obj; // return `obj` or anything you like providing it makes a valid parameter to be passed to `testFn()` and `workFn()` at next recursion.
        });
    );
}

理论上你可以将你的终端测试整合到workFn中,但保持它们分开将有助于在业务逻辑的编写者中强制执行纪律,以记住包含测试。否则他们会认为它是可选的,你可以随心所欲,他们会把它留下来!


0
投票

对不起,这不使用Promises,但有时抽象只是妨碍了。

这个例子是根据@ poke的答案构建的,简短易懂。

function readFromAllPages(done=function(){}, pageId=0, res=[]) {
    fetchSinglePage(pageId, res => {
        if (res.items.length) {
            readFromAllPages(done, ++pageId, items.concat(res.items));
        } else {
            done(items);
        }
    });
}

readFromAllPages(allItems => {
    // items have been read from all pages
});

这只有一个深度的嵌套函数。通常,您可以解决嵌套回调问题,而无需使用为您管理事物的子系统。


如果我们删除参数默认值并更改箭头函数,我们将获得在旧版ES3浏览器中运行的代码。

© www.soinside.com 2019 - 2024. All rights reserved.