Эта статья направлена ​​на то, чтобы представить правильные шаблоны проектирования для операций, где важна последовательная итерация, но прежде чем мы углубимся в эту конкретную область node.js, важно объяснить несколько шаблонов, которые мы увидим в этой статье, а также правильный способ сделать что-то. .

Евангелие асинхронности

Одним из наиболее важных факторов, делающих node.js таким хорошим инструментом для серверного программирования, является его асинхронный характер. Чтобы все было предсказуемо, функция должна быть либо полностью асинхронной, либо полностью синхронной, а не смесью того и другого.

// Synchronous function
function syncFunction() {
    return "Hello, World!";
}

// Asynchronous function
function asyncFunction(callback) {
    setImmediate(() => {
        callback(null, "Hello, World!");
    });
}

Ошибка первых обратных вызовов

В node.js обратные вызовы идут последними, а ошибки внутри обратных вызовов идут первыми. Универсальное соглашение состоит в том, что ошибки должны быть первым параметром, чтобы обеспечить единый способ обработки ошибок для разных обратных вызовов.

fs.readFile('/path/to/file', (err, data) => {
    if (err) {
        console.error('There was an error reading the file:', err);
    } else {
        console.log('File data:', data);
    }
});

Правильное распространение ошибок

В асинхронном стиле передачи продолжения (CPS) очень важно правильно распространять ошибки через обратные вызовы. Если возникает ошибка, она должна быть передана следующему обратному вызову в цепочке. Важно отметить, что ошибки всегда должны быть экземплярами класса Error, а это означает, что простые строки или числа никогда не должны передаваться как объекты ошибок. Например:

function asyncFunction(callback) {
    setImmediate(() => {
        let err = null;
        let data = "Hello, World!";
        if (/* some error condition */) {
            err = new Error('An error occurred');
        }
        callback(err, data);
    });
}

Методы отсрочки

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

console.log('Hello,');
setImmediate(() => {
    console.log('World!');
});

Помните, что эти шаблоны проектирования — не просто тонкости. Они составляют суть написания элегантного, удобочитаемого и эффективного кода Node.js. В последующих разделах мы рассмотрим, как эти шаблоны проектирования можно комбинировать для создания оптимального шаблона проектирования для последовательных итераторов в Node.js.

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

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

function iterate(index) {
    if (index === tasks.length) {
        return finish()
    }
    const task = tasks[index]
    task(() => iterate(index + 1))
}

function finish() {
    // iteration completed
}
iterate(0)
  • Шаблон начинается с определения функции итератора, названной здесь iterate. Он принимает параметр индекса, который отслеживает выполнение текущей задачи.
  • Внутри итератора мы сначала проверяем, достиг ли индекс длины нашего массива задач. Если это так, мы вызываем функцию finish(), сигнализирующую о завершении нашей итерации.
  • Если индекс не достиг конца массива задач, мы выполняем задачу по текущему индексу. Предполагается, что каждая задача является асинхронной операцией, которая принимает функцию обратного вызова. Когда задача завершена, она вызывает этот обратный вызов.
  • Обратный вызов, предоставленный задаче, представляет собой функцию, которая снова вызывает iterate, но с увеличенным индексом. Здесь начинается рекурсия.
  • Делая это, мы гарантируем, что следующая задача не будет запущена, пока текущая не завершится, достигнув желаемой последовательности операций.

Этот шаблон, каким бы простым он ни казался, обеспечивает основу для обработки ряда асинхронных операций в Node.js. Этот шаблон проектирования имеет множество приложений и может использоваться для:

  • Сопоставьте значения массива асинхронно
  • Реализовать асинхронную версию алгоритма сокращения
  • Преждевременный выход из цикла, если выполняется определенное условие, аналогично помощнику Array.some().
  • Перебирать бесконечное количество элементов
  • Выполнение списка задач последовательно

Теперь давайте начнем с первого применения этого шаблона — асинхронного отображения значений массива. Для иллюстрации воспользуемся следующим фрагментом кода:

import axios from "axios";

function asyncMap(array, asyncFn, callback) {
    function iterate(index) {
        if (index === array.length) {
            return callback(null)
        }

        asyncFn(array[index], function (err, result) {
            if(err) {
                if(!(err instanceof Error)) {
                    err = new Error(err)
                }
                return callback(err)
            }
            array[index] = result
            iterate(index + 1)
        })
    }
    iterate(0)
}

const fetchUser = (id, cb) => {
    axios.get(`https://jsonplaceholder.typicode.com/users/${id}`)
        .then(response => {
            setImmediate(() => cb(null, response.data))
        })
        .catch(error => {
            setImmediate(() => cb(error));
        })
}

asyncMap([1, 2, 3, 4, 5], fetchUser, (err) => {
    if(err) {
        console.error(err)
    } else {
        console.log("All tasks completed")
    }
})

В приведенном выше коде мы извлекаем пользовательские данные из API с помощью функции asyncMap, которая использует наш шаблон проектирования для последовательного извлечения каждого пользователя. Функция iterate рекурсивно вызывает себя, каждый раз передавая следующий индекс, пока мы не обработаем всех пользователей. На каждой итерации он вызывает asyncFn (в данном случае это fetchUser), передавая текущий идентификатор пользователя из массива и обратный вызов. Этот обратный вызов вызывается после завершения асинхронной операции (выборки пользовательских данных). Если есть ошибка, она распространяется на окончательный обратный вызов через callback(err). Если нет, результат (пользовательские данные) сохраняется в массиве по текущему индексу, и мы переходим к следующему индексу с iterate(index + 1).

Теперь давайте сравним эту асинхронную функцию карты с ее синхронным аналогом:

import axios from "axios";

let ids = [1, 2, 3, 4, 5];

for (let id of ids) {
    let user = axios.get(`https://jsonplaceholder.typicode.com/users/${id}`)
        .then(response => {
            console.log(response.data);
        })
        .catch(error => {
            console.log(error);
        });
}

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

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

Давайте рассмотрим еще один вариант использования нашего шаблона асинхронного итератора — реализацию асинхронной версии популярного метода reduce, обычно используемого с массивами JavaScript.

Чтобы проиллюстрировать это, мы рассмотрим реальный сценарий, в котором мы получаем несколько веб-страниц и подсчитываем общее количество слов на всех этих страницах. Вот фрагмент кода, который выполняет это:

import axios from "axios";
import cheerio from "cheerio";

function fetchAndCountWords(currentCount, url, callback) {
    axios.get(url).then(response => {
        const $ = cheerio.load(response.data)
        const words = $('body').text().split(/\s+/).length;
        callback(null, currentCount + words)
    }).catch(callback)
}

function asyncReduce(array, reducer, initialValue, callback) {
    function iterate(index, value) {
        if (index === array.length) {
            return callback(null, value)
        }

        reducer(value, array[index], (err, newValue) => {
            if (err) {
                return callback(err);
            }

            iterate(index + 1, newValue)
        })
    }
    iterate(0, initialValue)
}

const urls = [
    'https://example.com',
    'https://wikipedia.org',
]

asyncReduce(urls, fetchAndCountWords, 0, (err, totalWords) => {
    if (err) {
        console.error(err);
    } else {
        console.log(`Total words: ${totalWords}`);
    }
});

Давайте немного распакуем этот код:

  1. Начнем с определения функции fetchAndCountWords, которая извлекает веб-страницу с помощью axios, подсчитывает количество слов в теле веб-страницы и добавляет это значение к текущему общему количеству слов (currentCount). Если во время операции выборки возникает какая-либо ошибка, она передается обратному вызову, в противном случае передается обновленный счетчик.
  2. Затем мы определяем нашу функцию asyncReduce, которая принимает массив (в нашем случае URL-адресов), функцию-редуктор (fetchAndCountWords), начальное значение (0 в нашем случае для количества слов) и обратный вызов.
  3. Внутри asyncReduce мы объявляем нашу функцию итератора iterate. Как обычно, он проверяет, достиг ли текущий индекс длины массива. Если это так, он вызывает обратный вызов с окончательным накопленным значением.
  4. Если текущий индекс все еще находится в границах массива, он вызывает функцию редуктора с текущим накопленным значением и текущим элементом массива. Функция редуктора, в данном случае fetchAndCountWords, выполняет асинхронную операцию и в конечном итоге возвращает либо сообщение об ошибке, либо обновленное накопленное значение.
  5. Если получена ошибка, она передается основному обратному вызову, завершая цикл. В противном случае функция итератора вызывается снова со следующим индексом и новым накопленным значением.
  6. Наконец, мы используем asyncReduce с массивом URL-адресов, fetchAndCountWords в качестве редуктора, 0 в качестве начального значения и обратный вызов, который регистрирует либо ошибку, либо окончательное общее количество слов.

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

Большой! Теперь мы рассмотрим другое практическое применение нашего паттерна «Асинхронный итератор» — мы создадим асинхронный эквивалент метода some массива, который проверяет, проходит ли хотя бы один элемент в массиве проверку, реализованную предоставленной функцией.

В этом сценарии мы проверим, отвечает ли хотя бы один URL-адрес из списка URL-адресов кодом состояния 200. Вот как мы можем этого добиться:

import axios from "axios";

function checkStatus(url, statusCode, callback) {
    axios.get(url)
        .then(response => {
            callback(null, response.status === statusCode)
        })
        .catch(callback)
}

function asyncSome(array, checker, callback) {
    function iterate(index) {
        if (index === array.length) {
            return callback(null, false)
        }

        checker(array[index], (err, result) => {
            if (err) {
                return callback(err)
            }

            if (result === true) {
                return callback(null, true)
            }

            iterate(index + 1)
        })
    }

    iterate(0)
}

const urls = [
    'https://example.com',
    'https://wikipedia.org'
]

asyncSome(urls, (url, cb) => checkStatus(url, 200, cb), (err, result) => {
    if (err) {
        console.error(err);
    } else {
        console.log(`Any URL responded with 200? ${result}`);
    }
})

Разберем код:

  1. У нас есть функция checkStatus, которая делает запрос GET к URL-адресу и проверяет, соответствует ли код состояния HTTP заданному statusCode. Любая ошибка во время запроса передается обратному вызову, в противном случае передается результат проверки кода состояния.
  2. Здесь основное внимание уделяется функции asyncSome. Подобно функциям asyncMap и asyncReduce, которые мы обсуждали ранее, он перебирает массив и выполняет асинхронную операцию (checker) над каждым элементом.
  3. Внутри функции итератора iterate мы проверяем, достигли ли мы конца массива. Если это так, мы возвращаем false через обратный вызов, указывая, что ни один элемент не прошел проверку.
  4. Если мы все еще в границах массива, мы вызываем функцию checker с текущим элементом. Если получена ошибка, она передается основному обратному вызову, завершая цикл. Если результат функции проверки равен true, мы немедленно вызываем основной обратный вызов с true, указывая, что элемент прошел проверку и, следовательно, преждевременно завершает цикл. Если результат равен false, мы переходим к следующему элементу.
  5. Наконец, мы используем asyncSome с массивом URL-адресов, checkStatus в качестве средства проверки и обратный вызов, который регистрирует, ответил ли какой-либо URL со статусом 200.

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

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

function infiniteSequence(start = 0) {
    let index = start;
    while (true) {
        yield index++;
    }
}

function isNotDivisibleBy123456(value) {
    return value % 123456 !== 0;
}

function asyncLoop(generator, predicate, callback) {
    function iterate(value) {
        if (!predicate(value)) {
            return callback(null, value)
        }

        setImmediate(() => {
            iterate(generator.next().value)
        })
    }
    iterate(generator.next().value);
}

const sequence = infiniteSequence(1);

asyncLoop(sequence, isNotDivisibleBy123456, (err, result) => {
    if (err) {
        console.error(err)
    } else {
        console.log(`First number in sequence divisible by 123456 is ${result}`)
    }
})
  1. У нас есть функция генератора infiniteSequence, которая создает бесконечную последовательность чисел, начиная с заданного значения start.
  2. Мы определяем предикатную функцию isNotDivisibleBy123456, которая проверяет, не делится ли значение на 123456.
  3. Функция asyncLoop — это сердце нашего кода, в котором мы используем шаблон асинхронного итератора. Он перебирает значения, созданные данным generator, и применяет predicate к каждому значению.
  4. Внутри функции iterate, если predicate возвращает false для значения, мы немедленно передаем это значение в основной обратный вызов и останавливаем итерацию. В противном случае мы используем setImmediate для асинхронного вызова iterate со следующим значением из генератора.
  5. Мы используем asyncLoop с нашим генератором бесконечной последовательности, нашим предикатом и обратным вызовом, который регистрирует первое число в последовательности, которое делится на 123456.

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

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

Рассмотрим следующую реализацию:

const tasks = [
    callback => setTimeout(() => {
        console.log('Task 1 completed');
        callback(null)
    }, Math.random() * 1000),

    callback => setTimeout(() => {
        console.log('Task 2 completed');
        callback(null);
    }, Math.random() * 1000),

    callback => setTimeout(() => {
        console.log('Task 3 completed');
        callback(null)
    }, Math.random() * 1000)
];

function asyncSequence(tasks, callback) {
    function iterate(index) {
        if (index === tasks.length) {
            return callback(null)
        }

        tasks[index](function(err) {
            if (err) {
                if (!(err instanceof Error)) {
                    err = new Error(err)
                }
                return callback(err)
            }
            iterate(index + 1)
        })
    }
    iterate(0)
}

asyncSequence(tasks, err => {
    if (err) {
        console.error('A task failed', err)
    } else {
        console.log('All tasks completed successfully')
    }
})

Вот что происходит в коде:

  1. У нас есть массив tasks, где каждая задача представляет собой асинхронную функцию, которая принимает обратный вызов и завершается после случайной задержки. В иллюстративных целях мы используем setTimeout для имитации асинхронных задач, которые выполняются в разное время.
  2. Наша asyncSequence функция является движущей силой операции. Он принимает список tasks и callback для вызова, когда все задачи выполнены или когда возникает ошибка.
  3. Функция iterate вызывается с index для указания текущей задачи. Если index равно длине tasks, значит все задачи выполнены, и мы вызываем основную callback с ошибкой null.
  4. В противном случае мы вызываем текущую задачу, передавая ей функцию обратного вызова. Если задача приводит к ошибке, мы преобразуем ее в экземпляр Error, если это еще не так, и немедленно вызываем основную callback с ошибкой, завершая последовательность.
  5. Если задача завершается без ошибок, мы рекурсивно вызываем iterate со следующим index, фактически переходя к следующей задаче в последовательности.
  6. Наконец, мы запускаем нашу последовательность, используя asyncSequence с нашим tasks и callback, который регистрирует, все ли задачи выполнены успешно или задача не удалась.

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

Последовательное использование структуры обратного вызова, разделение синхронных и асинхронных процессов, распространение ошибок и упорядоченное использование setImmediate() и setTimeout() — все это неотъемлемые части этого шаблона проектирования. Приведенные примеры иллюстрируют эти концепции, показывая, как их можно применять в реальных ситуациях.

Мы рекомендуем вам поэкспериментировать с этим шаблоном и адаптировать его к вашим потребностям. Это надежный и универсальный подход, который может значительно улучшить читабельность и удобство сопровождения вашего асинхронного кода в Node.js.

Применяя эти концепции и адаптируя их к уникальным задачам ваших проектов, вы не только улучшите качество своего кода, но и углубите свое понимание асинхронного программирования в Node.js. Итак, идите и повторяйте! Удачного кодирования.