Мышление в JavaScript promises (Bluebird в этом случае) - программирование

Мышление в JavaScript promises (Bluebird в этом случае)

Я пытаюсь разгадать некоторые не совсем тривиальные обещания/асинхронные варианты использования. В примере, с которым я сейчас сражаюсь, у меня есть массив книг, возвращенных из запроса knex (thenable array), который я хочу вставить в базу данных:

books.map(function(book) {

  // Insert into DB

});

Каждый элемент книги выглядит следующим образом:

var book = {
    title: 'Book title',
    author: 'Author name'
};

Однако, прежде чем вставлять каждую книгу, мне нужно получить идентификатор автора из отдельной таблицы, поскольку эти данные нормализованы. Автор может существовать или не существовать, поэтому мне нужно:

  • Проверьте, присутствует ли автор в DB
  • Если это так, используйте этот идентификатор
  • В противном случае вставьте автора и используйте новый идентификатор

Однако вышеупомянутые операции также являются асинхронными.

Я могу просто использовать обещание в исходной карте (выборка и/или вставка идентификатора) в качестве предпосылки операции вставки. Но проблема заключается в том, что, поскольку все выполняется асинхронно, код вполне может вставить дубликаты авторов, поскольку исходный файл check-if-author-exists отделен от блока insert-a-new-author.

Я могу придумать несколько способов достижения вышеуказанного, но все они связаны с разделением цепочки обещаний и, как правило, кажутся немного беспорядочными. Это похоже на проблему, которая должна возникать довольно часто. Я уверен, что мне не хватает чего-то фундаментального здесь!

Любые советы?

4b9b3361

Ответ 1

Предположим, что вы можете обрабатывать каждую книгу параллельно. Тогда все довольно просто (используя только 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'))

Проблема заключается в том, что между созданием автора и созданием нового автора существует условие гонки. Рассмотрим следующий порядок событий:

  • мы пытаемся получить автора A для книги B;
  • Получение авторизатора A завершается неудачей;
  • мы запрашиваем создание автора A, но он еще не создан;
  • мы пытаемся получить автора A для книги C;
  • Получение авторизатора A завершается неудачей;
  • мы запрашиваем создание автора A (снова!);
  • завершается первый запрос;
  • завершается второй запрос;

Теперь у нас есть два экземпляра A в таблице автора. Это плохо! Для решения этой проблемы мы можем использовать традиционный подход: блокировка. Нам нужно хранить таблицу для каждого автора. Когда мы отправляем запрос на создание, мы блокируем соответствующий замок. По завершении запроса мы разблокируем его. Все другие операции с одним и тем же автором должны сначала получить блокировку, прежде чем что-либо сделать.

Это кажется сложным, но в нашем случае может быть упрощено, так как мы можем использовать наш запрос promises вместо блокировок:

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'))

Что это! Теперь, если запрос для автора является потоком, будет возвращено то же обещание.

Ответ 2

Вот как я его реализую. Я думаю, что некоторые важные требования:

  • Никаких дублирующих авторов не создаются (это тоже должно быть ограничение в самой базе данных).
  • Если сервер не отвечает посередине - не вставлены противоречивые данные.
  • Возможность ввода нескольких авторов.
  • Не делайте n запросов к базе данных для n вещей - избегайте классической проблемы "n + 1".

Я бы использовал транзакцию, чтобы убедиться, что обновления являются атомарными - то есть, если операция выполняется, а клиент умирает посередине - авторы не создаются без книг. Также важно, чтобы временный сбой не вызывал утечки памяти (например, в ответ на карту авторов, которая не сработала promises).

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!"));

Это имеет то преимущество, что только фиксирует количество запросов и, вероятно, будет более безопасным и будет работать лучше. Кроме того, ваша база данных не находится в согласованном состоянии (хотя вам может потребоваться повторить операцию для нескольких экземпляров).