JavaScript / «Лапша» из callback-ов — будьте проще

в 5:18, , рубрики: javascript, node.js, лапша, метки: , ,

По следам недавних топиков, а также постоянных рассказов в стиле «мой стартап не взлетел, потому что его зохавала лапша из callback-ов».
Как раз недавно я закончил небольшой проект (ссылку не даю, чтобы не заподозрили — кому надо см. профиль), полностью и на всех этапах написанном только на JS, и притом полностью асинхронный. Разумеется, я столкнулся с пресловутой проблемой «лапши». И, вы не поверите, совершенно спокойно решил её без всяких там фреймворков и хитрых приемов.
Итак, допустим, у нас есть задача: асинхронно выбрать из базы количество книг, потом асинхронно же выбрать из базы нужную пачку книг, потом асинхронно же выбрать из базы метаданные по книгам, а потом свести всё это в один dataset и отрендерить шаблон. Как это обычно выглядит?
exports.processRequest = function (request, response) {
db.query('SELECT COUNT(id) FROM books', function (res1) {
// do something
db.query('SELECT * FROM books LIMIT ' + limit + ' OFFSET' + offset, function (res2) {
// do something 2
db.query('SELECT * FROM bookData WHERE bookId IN (' + ids.join(', ') + ')', function (res3) {
// И вот наконец формируем как-то dataset
response.write(render(dataset));
});
});
});
}

Если накинуть ещё пару-тройку промежуточных шагов, то всё станет совсем плохо.
Теперь зададим себе простой вопрос: зачем мы написали эту лапшу? Действительно ли нам необходимы здесь три вложенных замыкания?
Нет, конечно. У нас нет ровно никакой нужды из третьей анонимной функции иметь доступ к замыканиям второй и первой. Перепишем немного код:
exports.processRequest = function (request, response) {
var dataset = {};

getBookCount();

function getBookCount () {
db.query('SELECT COUNT(id) FROM books', onBookCountReady);
}

function onBookCountReady (res) {
// ...заполняем dataset
getBooks();
}

function getBooks () {
db.query('SELECT * FROM books LIMIT ' + dataset.limit + ' OFFSET' + dataset.offset, onBooksReady);
}

function onBooksReady (res) {
// ... заполняем dataset
getMetaData();
}

function getMetaData () {
db.query('SELECT * FROM bookData WHERE bookId IN (' + dataset.ids.join(', ') + ')', onMetaDataReady);
}

function onMetaDataReady (res) {
// ... заполняем dataset
finish();
}

function finish () {
response.write(render(dataset));
}
}

Пожалуйста. Код стал полностью линейным, и, что немаловажно, более структурированным; весь program flow у вас перед глазами, логические блоки кода оформлены отдельными функциями. Никаких фреймворков и хитрых синтаксисов. И никаких подводных камней — dataset замкнут в контексте обработки пары request-response, случайно залезть в какие-то разделяемые между request-ами данные не получится.
Всё немного усложняется, если нужно что-то запараллелить. У меня такой задачи не было, но если бы была (допустим, есть два набора метаданных), то я решал бы её так:
function getMetaData () {
var parallelExecutor = new ParallelExecutor({
meta1: getMetaData1,
meta2: getMetaData2
});

function getMetaData1 () {
db.query('smthng', onMetaData1Ready);
}

function getMetaData2 () {
db.query('smthng', onMetaData2Ready);
}

function onMetaData1Ready (res) {
// заполняем dataset
parallelExecutor.ready('meta1');
}

function onMetaData2Ready (res) {
// заполняем dataset
parallelExecutor.ready('meta2');
}

parallelExecutor.start(onMetaDataReady);
}

function onMetaDataReady () {
}

Смысл всё тот же — создать отдельное замыкание для набора функций, который обычно объединяют в «лапшу», и расписать их последовательно.
Кажется, что в таком формате асинхронные callback-и не только не захламляют код, но и, напротив, делают его более структурированным и читабельным.

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js