以 JavaScript Promise 为中心思考(以 Bluebird 为例)

12

我正在努力理解一些不那么琐碎的 Promise/异步用例。目前我在处理的一个示例中,我有一个由 Knex 查询返回的书籍数组(thenable 数组),我希望将其插入到数据库中:

books.map(function(book) {

  // Insert into DB

});
每个图书项目看起来像:
var book = {
    title: 'Book title',
    author: 'Author name'
};

然而,在我插入每本书之前,我需要从一个单独的表中检索作者的ID,因为这些数据已经被规范化了。作者可能存在,也可能不存在,所以我需要:

  • 检查数据库中是否存在该作者
  • 如果存在,则使用该ID
  • 否则,插入该作者并使用新ID

但是,上述操作也都是异步的。

我可以在原始的map(获取和/或插入ID)中使用promise作为插入操作的先决条件。但是问题在于,由于所有操作都是异步运行的,代码可能会插入重复的作者,因为初始的检查作者是否存在与插入新作者的块是分离的。

我能想到几种方法来实现上述目标,但它们都涉及拆分promise链,并且通常看起来有点混乱。这似乎是一个很常见的问题。我确定我在这里漏掉了一些基本的东西!

有什么建议吗?


1
你不想要一个“Upsert”类型的方法吗?也就是说,如果它不存在,它将始终返回作者的ID并创建它,如果它已经存在,则只会返回现有的作者ID。这样可以减少在检查和插入之间出现问题的可能性。你可能还想在这里使用某种锁定机制。 - Liam
你要插入多少本书,你的数组有多大?最好先返回所有作者ID的集合,然后在客户端合并这些数据。此外,很多逻辑最好在数据库中执行而不是在JavaScript中执行。 - vol7ron
说实话,正确的做法是使用upsert并让数据库为您处理。问题在于upserts是非常新的(5天前在Pg中实现),所以knex还没有跟上。 - Benjamin Gruenbaum
另外,与其批处理 n 个插入操作,如果可能的话,最好发送单个查询。在客户端实现这种逻辑和并发管理对我来说似乎非常愚蠢。使用事务可能会更简单。 - Benjamin Gruenbaum
2个回答

8

假设您可以并行处理每本书。那么使用ES6 API非常简单:

Promise
  .all(books.map(book => {
    return getAuthor(book.author)
          .catch(createAuthor.bind(null, book.author));
          .then(author => Object.assign(book, { author: author.id }))
          .then(saveBook);
  }))
  .then(() => console.log('All done'))

问题在于获取作者和创建新作者之间存在竞争条件。考虑以下事件顺序:
  • 我们尝试获取书籍B的作者A;
  • 获取作者A失败;
  • 我们请求创建作者A,但它尚未被创建;
  • 我们尝试获取书籍C的作者A;
  • 获取作者A失败;
  • 我们再次请求创建作者A;
  • 第一个请求完成;
  • 第二个请求完成;
现在我们在作者表中有两个A实例。这很糟糕! 为解决此问题,我们可以使用传统方法:锁定。我们需要保留每个作者锁的表。当我们发送创建请求时,我们锁定相应的锁。请求完成后,我们将其解锁。所有涉及相同作者的其他操作都需要先获取锁,然后再执行任何操作。
这似乎很难,但在我们的情况下可以大大简化,因为我们可以使用我们的请求承诺代替锁:
const authorPromises = {};

function getAuthor(authorName) {

  if (authorPromises[authorName]) {
    return authorPromises[authorName];
  }

  const promise = getAuthorFromDatabase(authorName)
    .catch(createAuthor.bind(null, authorName))
    .then(author => {
      delete authorPromises[authorName];
      return author;
    });

  authorPromises[author] = promise;

  return promise;
}

Promise
  .all(books.map(book => {
    return getAuthor(book.author)
          .then(author => Object.assign(book, { author: author.id }))
          .then(saveBook);
  }))
  .then(() => console.log('All done'))

就是这样!如果一个请求正在处理“作者”,将返回相同的承诺。


很高兴在这里见到你!我可以用bluebird sugar来美化你的代码吗? - Benjamin Gruenbaum
有意使用仅限ES6的API。可以添加bluebird风格的示例进行比较! - vkurchatkin
仅供记录,这个回答虽然不错,但实际上并没有真正解决OP所面临的问题。防止多个作者应该在数据库层面上进行控制,而不是在代码层面上进行控制——如果节点的多个实例运行,或者服务器关闭...更不用说如果createAuthor由于网络故障而抛出的情况了。 - Benjamin Gruenbaum
没错!更像是脚本/数据引导解决方案。 - vkurchatkin
我也很想看看Benjamin的一个漂亮的Bluebird版本! - russx2

3
这是我的实现方式。我认为一些重要的要求如下:
  • 不会创建重复的作者(这也应该是数据库本身的限制)。
  • 如果服务器在中途没有响应 - 不会插入不一致的数据。
  • 可以输入多个作者。
  • 不要对于n个事物,向数据库进行n个查询 - 避免经典的“n+1”问题。

我将使用事务来确保更新是原子性的 - 如果操作在运行时客户端死亡,没有书籍就不会创建作者。同样重要的是临时故障不会导致内存泄漏(如在作者映射中保留失败的Promise的答案中所示)。

knex.transaction(Promise.coroutine(function*(t) {
    //get books inside the transaction
    var authors = yield books.map(x => x.author);
    // name should be indexed, this is a single query
    var inDb = yield t.select("authors").whereIn("name", authors);
    var notIn = authors.filter(author => !inDb.includes("author"));
    // now, perform a single multi row insert on the transaction
    // I'm assuming PostgreSQL here (return IDs), this is a bit different for SQLite
    var ids = yield t("authors").insert(notIn.map(name => {authorName: name });
    // update books _inside the transaction_ now with the IDs array
})).then(() => console.log("All done!"));

这样做的好处是只进行固定数量的查询,可能会更安全且性能更佳。此外,您的数据库不处于一致状态(虽然您可能需要为多个实例重试操作)。


我想我在这里要表达的观点是,你应该先考虑“数据库”,而不是“承诺”。 - Benjamin Gruenbaum

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接