Рекомендация к прочтению: "Node.js Design Patterns" by Mario Casciaro, Luciano Mammino.
Значительная часть философии Node.js зафиксировал её создатель - Ryan Dahl. Зафиксирована вот здесь: https://en.wikipedia.org/wiki/List_of_software_development_philosophies
Основные идеи:
Маленькое ядро имеет наименьшее из возможных множество функциональных возможностей, оставляя остальное т.н. userland/userspace - экосистеме модулей, живущих вне ядра. Именно этот принцип оказывает огромное влияние на культуру Node.js, предоставляя сообществу свободу экспериментирования и быстрого развития экосистемы. Сохранение функциональности ядра на самом минимуме, является не только удобным для сопровождения продукта, но и оказывает положительное культурное влияние на эволюцию всей экосистемы.
Маленькие модули. Модуль имеет фундаментальное згачение в структуре кода программы. Это кирпичик для содания приложений и повторно используемых ,библиотек, называемых packages. Один из главных принципов дизайна в Node.js состоит в том, чтобы создавать маленькие модули, не только в терминах размера, но и в терминах scope. Принцип повторяет ключевой принцип философии Unix:
- Small is beautiful
- Make each program do one thing well
В npm каждый установленный package имеет своё собственное подмножество зависимостей (set of dependencies), что позволят избегать конфликтов модулей. Благодаря этому возникает беспрецендентный уровень повторного использования компонентов. Хотя такой подход кажется непрактичным из-за большого объёма зависимостей, на практике это приводит к тому, что сложно найти npm package, содержащий более 100 строк кода.
Вот почему важно сохранять маленький размер модулей:
- проще понимать и использовать
- проще тестировать и сопровождать
- отлично можно использовать и в браузере
Всё это выводит принцип Don't repeat yourself (DRY) на новый уровень.
Small surface area: модули в Node.js обычно имеют минимальный набор функций. Это приводит к тому, что гораздо проще понять, как использовать такой модуль.
Типовой pattern в Node.js состоит в том, что модуль реализует только одну часть функциональности, например, функцию, или конструктор, предоставляя другим модулям возможность реализовать другие особенности. Это помогает пользователям понимать, что важно, а что не очень важно в конкретной задаче.
Ещё одна характеристика Node.js состоит в том, что модули создаются для использования, а не для расширения. Тот факт, что по сути, в Node.js возможность расширения является заблокированной, кажется очень не гибким, но на практике это приводит к уменьшению прецедентов использования, упрощению реализации и споровождении, а также к увеличению usability.
Простота и прагматизм: ключевой принцип - Keep It Simple, Stupid (KISS).
"Simplicity is the ultimate sophistication" - Leonardo da Vinci.
Интересный документальный фильм Node.js. The Documentary об истории создания Node.js от Honeypot.
Ключевой компонент Node.js - synchronous event demultiplexer (также известный как event notification interface). Он следит за выполнением блокирующих операций ввода/вывода и, когда какая-нибудь из них (или несколько) завершается, управление передаётся из event demultiplexer в вызывающие его код, который обрабатывает события завершения операций ввода/вывода. После отработки событий (это называется event loop), управление снова передаётся demultiplexer-у, который снова отслеживает завершение не блокирующих операций ввода-вывода.
Этот подход позволяет использовать только один поток исполнения (thread) для выполнения параллельных задач, связанных с использованием I/O ресурсов. Но самое главное, этот подход позволяет реализовать модель программирования, в которой не требуется тратить вычислительные ресурсы на межпоточную синхронизацию, которая обычно обходится очень дорого.
Идея the reactor pattern состоит в том, что для каждой операции ввода/вывода есть обработчик, который называется callback. Соответственно, задача состоит в том, чтобы реагировать на завершение операции ввода/вывода передачей сообщения в связанный с этой операцией обработчик.
Связанные термины: Event Loop и Event Queue.
Главное достижение The Reactor Pattern состоит в том, что отсутствуют издержки на создание потоков, переключение между ними и на межпоточную синхронизацию.
В каждой операционной системе есть свой собственный механизм для event demultiplexer: epoll в Linux, kqueue в macOS и I/O completion port (IOCP) API в Windows. Однако, в разных операционных системах существуют фундаментальные различия в реализации операций ввода/вывода. Так, например, обычная файловая система в Linux не поддерживает non-blocking операции и, как результат, non-blocking поведение приходится реализовывать в отдельном потоке вне event loop.
Для устранения подобных различий потребовалось реализовать выcокоуровневую абстрацию для event demultiplexer. Именно эту задачу и решила команда Node.js разработав библиотеку libuv, портированную на все основные операционные системы. Пожалуй, именно эта библиотека является наиболее важной особенностью Node.js.
Рекомендуется к прочтению книга An Introduction to libuv by Nikhil Marathe.
Существует огромное количество packages, включая net (TCP/IP), dgram (UDP), http и https, crypto (OpenSSL).
При необходимости, мы можем подключать native code, разработанный на C/C++ посредством N-API interface.
Многие современные JavaScript VM поддерживают WASM, что позволяет использовать код разработанные на других языках программирования, включая Rust и С++ в общей среде, совместно с кодом JavaScript.
Модульная система помогает с решение фундаментальных проблем программной инженерии:
- обеспечивает возможность разделить код на множество файлов
- позволяет повторно использовать код в разных проектах
- скрывает детали реализации (инкапсуляция)
- позволяет управлять зависимостями
В JavaScript долгое время не было встроенной системы модулей. В браузере проблема отчасти решалась использованием разных включаемых библиотек, через тэг Script. Со временем появились asynchronous module definition (AMD), которая реализовывалась в рамках RequireJS и Universal Module Definition (UMD).
В Node.js тэга Script не было и было принято решение реализовать модульную систему прямо в коде. Это решение привело к появлению CommonJS (CJS). Этот подход был поддержан и в браузерах, в частности, в проектах Browserify и WebPack.
В 2015 году был выпущен ECMAScript 6 (также называемый ECMAScript 2015), в котором появился ESM, или ECMA Script modules.
Считается, что со временем ESM полностью заменить CJS, но пока этого не произошло - до сих пор во многих проектах используется CJS. Однако, замечу, что многие современные packages требуют обязательного использования ESM. Один из примеров такого package node-fetch.
Одной из самых больших проблем JavaScript является использование глобальной области видимости (global scope). Разные переменные в разных файлах могут называться одинаково и если они оказываются в глобальной области видимости это приводит к очень сложно идентифицируемым сторонним эффектам.
Чтобы минимизировать вероятность подобных ситуаций разработчики начали использовать The Module Pattern. Пример кода:
const myModule = (() => {
const privateFoo = () => {}
const privatebar = []
const exported = {
publicFoo: () => {},
publicBar: () => {}
}
return exported
})()
console.log(myModule)
console.log(myModule.privateFoo, module.privateBar)
В приведённом выше примере используется Immediately Invoked Function Expressio (IIFE) - когда JS разбирает завершающие круглые скобки, вся функция вызывается. Этот подход также часто именуют как самовызывающиеся функции.
В приведённом выше примере, внешнему коду будут доступны только экспортируемые функции publicFoo() и publicBar(), а приватные функции будут скрыты в реализации. Доступ к экспортируемым функциям будет доступен только через переменную myModule. Именно это и позволяет устранить коллизию в глобальном пространстве имён.
Приведённый подход лёг в основу модульной системы CommonJS.
Важно отметить, что CommonJS является синхронным и это гарантирует заданную последовательность загрузки модулей. В момент появления CJS, был реализован и асинхронный вариант модульной системы, но его быстро запретили, т.к. функция require() возвращала не до конца проинициализированный модуль (он выполнял асинхронную инициализацию) и это приводило к массовым ошибкам и сбоям.
Node.js учитывает версии зависимостей при их загрузке и если два packages используют одну и ту же зависимость, но разных версий, то npm разместит две копии зависимости в разных node_modules, на разных уровнях иерерхии проекта. Соответственно, при загрузке зависимости в ОЗУ, при выполнении приложения, Node.js будет пытаться загрузить нужную зависимость с самого низкого уровня в дереве зависимостей и будет подниматься по дереву к его корню (root), если зависимость не будет найдена. Соответственно, для оптимизации размера занимамой памяти, следует обращать пристольное внимание на использование разных версий dependencies.
При загрузке модулей используется кэширование, что обеспечивает важные функциональные особенности: это гарантирует, что при выполнении require() для одного и того же модуля будет возвращена одна и тоже копия (instance) package. Кэширование позволяет драматически увеличить производительность приложения.
Ещё один недостаток CommonJS - в этой модульной системе возможно возникновение циклических зависимостей. Этот тип ошибок свойственен сложным приложениям и является трудноуловимым.
В CommonJS модуль должен вернуть вызывающему модулю объект, через который можно обращаться к функциям и атрибутам этого модуля. Передача осуществляется через exports. Например, широко распространённый подход named exports может выглядеть следующим образом:
exports.info = (message) => {
console.log(`info: ${message}`);
}
exports.verbose = (message) => {
console.log(`verbose: ${message}`);
}
Таким образом, через exports будут доступны две функции: info() и verbose(). Соотвественно, обратиться к этим функциям модуля можно следующим образом:
const logger = require(`./logger`);
logger.info('Это информационное сообщение');
logger.verbose('Это многословное сообщение');
Существует подход, в которой модуль возвращает одну функцию - это называется substack pattern (автор James Halliday):
module.exports = (message) => {
console.log(`Info: ${message}`);
}
Вызов:
const logger = require(`./logger`);
logger('Это информационное сообщение');
Идея подхода состоит в том, что модуль реализует единственную функцию (хотя, на практике, могут быть определены и дополнительные функции). Этот подход отражает принцип single-responsibility (SRP), т.е. модуль реализует только одну функцию.
Также, модуль может возвращать определение класса, например:
class Logger {
constructor (name) {
this.name = name;
}
log (message) {
console.log(`[${this.name}] ${message}`)
}
info (message) {
this.log(`info: ${message}`)
}
verbose (message) {
this.verbose(`verbose: ${message}`)
}
}
module.exports = Logger
Такой экспорт позволяет создать экземпляр класса:
const Logger = require(`./logger`);
const dbLogger = new Logger('DB');
dbLogger.info('Это информационное сообщение');
const accessLogger = new Logger('ACCESS');
accessLogger.verbose('Это сообщение обрабатывается в другом экземпляре объекта');
Модификацией этого подхода является Exporting an instance, в котором модуль возвращает экземпляр класса, а не его описание:
...
module.exports = new Logger('DEFAULT');
Этот подход похож на шаблон проектирования Singleton, он достаточно гибкий, но у него есть множество сторнних эффектов и он не рекомендуется к использованию. Гибкость проявляется, например в том, что можно создать другой экземпляр класса:
const customLogger = new logger.constructor('CUSTOM');
customLogger.log(`Информационное сообщение для второго экземпляра модуля`);
JavaScript позволяет вноить изменения в другие модули, или в global scope. Этот подход называется monkey patching и рассматривается как исключительно опасный. Однако, он может быть полезен, например, для mocking-а при разработке тестов. Выглядит подход таким образом:
// Это реализация модуля, который выполняет patch. Файл: patcher.js
require('./logger').customMessage = function() {
console.log('Это новый добавленный метод');
}
Пример применения patch-а из некоторого прикладного кода:
require('./patcher');
const logger = require('./logger');
logger.customMessage();
В приведённом выше примере мы добавили в чужой модуль logger дополнительную функцию customMessage().
Ещё раз заметим, что в этом подходе есть side-effects, т.е. может возникнуть борьба patcher-ов с непредсказуемыми эффектами, но его реально применяют, например, он используется в package nock.
По умолчанию, в Node.js используется CommonJS, а не ESM. Чтобы активировать ESM нужно либо использовать у JavaScript-файлов расширение ".mjs", либо добавить в "package.json" строку:
{
"type": "module"
}
Следует заметить, что в ESM вместо plural word exports, используется singular word export:
export function log (message) {
console.log(message);
}
export const LEVELS = {
error: 0,
debug: 1,
warn: 2,
data: 3,
info: 4,
verbose: 5
}
По умолчанию, все функции/переменные модуля для которых не указано ключевое слово export считаются private, т.е. не видимы снаружи.
Чтобы испортировать сущности, следует использовать ключевое слово import:
import * as loggerModule from './logger.js';
console.log(loggerModule);
Заметим, что в случае ESM следует обязательно использовать расширение импортируемого модуля.
ESM позволяет импортировть отдельные сущности из модуля, например:
import { log, Logger } from './logger.js';
const logger = new Logger('DEFAULT');
logger.log('Здравствуй МИР!');
Также можно назначать синонимы (aliases) для импортируемых сущностей, например:
import { log as Log2 } from './logger.js';
Если мы хотим, чтобы модуль экспортировал одеу функцию, или описание класса, то следует использовать ключевое слово default:
export default class Logger {
constructor (name) {
this.name = name;
}
log (message) {
console.log(`[${this.name}] ${message}`)
}
}
Импорт модуля может выглядеть следующим образом:
import MyLogger from './logger.js';
const logger = new MyLogger('info');
logger.log('Hello World!');
Default-ный экспорт эквивалентен следующему коду:
import * as loggerModule from './logger.js';
console.log(loggerModule);
ESM позволяет смешить named exports и default export, что используется, например, в React.
Следует заметить, что CommonJS и ESM не обладают взаимозаменяемостью, т.е. разработчик явно должен выбрать модульную систему, которая будет использована в проекте. Так же заметим, что при выборе ESM автоматически активируется strict-mode для всех JS-файлов.
Если мы хотим загрузить объект из JSON-файла, это можно сделать посредством заменителя:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const data = require('./data.json');
console.log(data);
Критически важным в JavaScript является механизм Closures (замыкания). Почитать: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
Рекомендуется для прочтения пост Вячеслава Егорова, входящего в группу по разработке V8 в Google.
Фундаментальная проблема closures состоит в том, что для доступа к переменным из внешних областей видимости создаётся специальный объект Context, в котором сохраняются ссылки на эти переменные. При этом, счётчик ссылок на каждую переменную увеличивается на единицу и до те пор, пока не будет удалена функция, содержащая замыкание, переменные из внешней области видимости не будут удалены. По сути, возникает ситуация схожая с memory leaks - т.е. память занятая внешними переменными может не освобождаться длительное время. Для того, чтобы избежать возникновения closures, вместо анонимных callback-ов рекомендуется использовать именованные обработчики событий. Именованные не используют доступа к внешним облостям видимости и механизм closures не применяется к ним. При этом, необходимые данные должны поступать в обработчик, через параметры вызова именованной callback-функции.
Наличие callback-функции не говорит о том, что функция является асинхронной. Синхронная функция также может использовать callback. Признак асинхронной функции - callback вызывается после завершения обработки текущего события в Event Loop. Часто, для того, чтобы организовать асинхронность используют setTimeout():
function additionAsync (a, b, callback) {
setTimeout(() => callback(a + b), 100);
}
Одна из ловушек JavaScript связана со смешением асинхронного/синхронного поведения в коде. Такое смешение может приводить (и приводит) к неопределённому поведению функции. Ниже приведён пример такой функции:
import { readFile } from 'fs';
const cache = new Map();
function inconsistentRead (filename, cb) {
if (cache.has(filename)) {
// Вызов завершается синхронно, т.к. содержимое файла уже есть в кэше
cb(cache.get(filename));
}
else
{
// Вызывается асинхронная функция readFile(), т.е. поведение асинхронное
readFile(filename, 'utf8', (err, data) => {
cache.set(filename, data);
cb(data);
});
}
}
Для большинства стандартных функций JavaScript есть синхронные аналоги. Синхронное поведение является более предсказуемым, но может приводить к потере производительности приложения, т.к. синхронные вызовы блокируют работу Event Loop. Также следует принять во внимание, что в не стандартных модулях JavaScript далеко не всегда есть синхронные аналоги асинхронным функциям.
Гарантировать асинхронность посредством отложенного выполнения (deffered execution) можно используя функцию process.nextTick(). Например, мы можем заменить setTimeout() в приведённом выше примере на вот такой код:
process.nextTick(() => cb(cache.get(filename)));
Функция nextTick() работает быстрее, чем setTimeout(), т.к. не требует создания таймера. Также может быть использована функция setImmediate(), которая также отличается высокой скоростью выполнения.
Существуют принципы, в соответствии с которыми следует оформлять код, в котором используются callback-функции:
- В списке параметров callback-функция указывается последней
- В списке параметров callback-функции всегда первым параметром идёт ошибка
- Уведомление об ошибке всегда передаётся через ту же callback-функцию, через которую передаётся успешный результат
- Аккуратно обрабатываются исключения, а там, где это невозможно, используется обработчик события uncaughtException
Пример API, в котором используется callback-функция:
readFile(filename, [options], callback);
В реализации callback-функции всегда сначала обрабатывается ошибка, например:
readFile('foo.txt', 'utf8', (err, date) => {
if (err) {
handleError(err);
} else {
processData(data);
}
});
В Node.js существует встроенная реализация шаблона проектирования The Observer (Publish/Subscribe). Вся работа осуществляется через экземпляр класса EventEmitter:
import { EventEmitter } from 'events';
const emitter = new EventEmitter();
В классе реализованы следующие базовые методы:
- on(event, listener): метод позволяет зарегистрировать нового подписчика конкретного события
- once(event, listener): подписчик автоматически удаляется сразу после получения события
- emit(event, [arg1], [...]): метод создаёт новое событие и доставляет его подписчикам
- removeListener(event, listener): удаляет подписчика из списка
Пример генерации событий, посредством EventEmitter:
import { EventEmitter } from 'events';
import { fileRead } from 'fs';
function findRegex (files, regex) {
const emitter = new EventEmitter();
for (const file of files) {
readFile(file, 'utf8', (err, content) => {
if (err) {
return emitter.emit('error', err);
}
emitter.emit('fileread', file);
const match = content.match(regex);
if (match) {
match.forEach(elem => emitter.emit('found', file, elem));
}
});
}
return emitter;
}
Легко увидеть, что функция findRegex() возвращает экземпляр класса EventEmitter. Через этот instance мы может оформить подписку на все три типа событий, генерируемых функцией, используя chaining methods:
findRegex(['fileA.txt', 'fileB.json'],
/hello \w+/g
)
.on('fileread', file => console.log(`${file} was read`))
.on('found', (file, match) => console.log( `Matched "${match}" in ${file}`))
.on('error', err => console.error(`Error emitted ${err.message}`));
Следует заметить, что чаще всего EventEmitter не используется сам по себе, а встраивается внутрь некоторого класса, предоставляя пользователям этого класса более гибкий механизм, чем callback-функции. Гибкость, в частности, состоит в том, что можно подписываться только на интересующие пользователя события, игнорируя остальные.
Однако, использование EventEmitter потенциально приводит к memory leaks, т.е. утечке памяти. Утечка памяти - наиболее неприятная проблема в использовании Node.js, т.к. их (утечки) тяжело находить, но они могут приводить к падению приложения. Чтобы решить эту проблему, следует своевременно отписываться от событий, используя метод removeListener(). Ещё одна важная рекомендация - для получения одноразовых событий, следует использовать функцию once(), вместо on(), хотя и в этом случае может возникнуть memory leak, если ожидаемое событие не возникнет.
Проблема утечки памяти имеет место не только в Node.js, но и в client-side applications, особенно в SPA, таких как React. Здесь следует заметить, что не смотря на то, что Redux (State Management) использует шаблон проектирования The Observer (Publish/Subscribe), его реализация не использует EventEmitter. Redux - очень компактная, прекрасно задокументированная, ООП библиотека на TypeScript, которая использует более примитивную и прямолинейную реализацию с хранением подписчиков в Map-контейнере. Чтобы минимизировать утечки памяти, метод подписки на событие - subscribe(), возвращает метод unsubscribe(), который должен быть применён в пользовательском коде в соответствии с conventions.
ВАЖНЫЙ ТЕРМИН: shallow copy - быстрое копирование благодаря использованию копирования только на один уровень вниз. Для практических целей в JavaScript такого копирования оказывается вполне достаточно. В случае, если это не так, то следует использовать deep copy - "глубокое" копирование объектов. Глубокое копирование является ресурсоёмкой операцией.
Для реализации различных алгоритмов, требующих выполнения асинхронных параллельно, или последовательно, рекомендуется рассмотреть возможность использования библиотеки Async.
Promises вошли в стандарт ECMASCript 2015 (ES6).
Promise - это объект, который включает результат (или ошибки) асинхронной операции. Существует жаргон, используемые при использование Promises:
- pending: асинхронная операция ещё не закончилась
- fulfilled: когда операция успешно завершается
- rejected: когда операция прерывается с ошибкой
- settled: асинхронная операция завершена и результаты (включая ошибки обработаны)
Ценность Promises состоит в том, что с их помощью можно выполнить цепочку асинхронных операций, но весь код будет выстроен в цепочку (chained), т.е. будет выглядеть как последовательный.
Пример кода:
asyncOperationPromise(arg)
.then(result1 => {
// Выполняем ещё одну асинхронную операцию и создаём ещё один Promise
return asyncOperationPromise(arg2);
})
.then(result2 => {
// Возвращаем успешный код завершения
return 'done';
})
.then(undefined, err => {
// этот код будет вызван только в том случае, если в цепочке выполнения возникнет ошибка (исключение)
});
Важно обратить внимание на синтаксический сахар - вместо .then(undefined, err => {})
можно написать вот такой код:
promise.catch(onRejected)
Ещё один полезный обработчик, позволяет выполнить код в любом случае, вне зависимости от того, как завершилась операция - успешно, или не успешно:
promise.finally(onFinally)
Пример реализации функции с использованием Promises:
function delay (milliseconds) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(new Date());
}, milliseconds);
})
}
Использовать такую функцию можно следующим образом:
console.log(`Delaying...${new Date().getSeconds()}s`);
delay(1000)
.then(newDate => {
console.log(`Done ${new Date().getSeconds()}s`);
});
Следует заметить, что прикладные разработчики обычно не разрабатывают функции, которые выполняют resolve() и reject() - за них это делают разработчики системных библиотек. Однако, может возникнуть задача в которой уже существующий устаревший код, построенный на callback-ах, необходимо (для единообразия используемых подходов) переработать, чтобы поддержать Promises. Такой процесс называется Promisification.
В "рецептах" разработки функционала web spider из книги "Node.js Design Patterns" by Mario Casciaro и Luciano Mammino, приводятся разные примеры использования библиотеки promises, включая параллельную загрузку документов по ссылкам и последовательную. При параллельной загруке используется функция Promise.all(), которая создаёт новый Promise, он становится resolved, когда выполняются все асинхронные запросы:
function spiderLinks (currentUrl, content, nesting) {
if (nesting == 0) {
return Promise.resolve();
}
const links = getPageLinks(currentUrl, content);
const promises = links.map(link => spider(link, nesting - 1));
return Promise.all(promises); // <--
}
Ещё один очень интересный класс - TaskQueue, позволяет создать очередь асинхронных задач с ограничением максимального количество выполняемых задач одновременно.
В книге есть ссылка на ready-to-use, production-ready реализацию функции map() поддерживающую promises и ограниченную конкурентность: p-map package
Также в книге есть ссылка на p-limit package. который реализует шаблон limit the concurrency of a set of tasks.
Полное название механизма: async functions and the await expressions
Async-функции являются специальным типом функций, в которых возможно использование await-выражения для приостановки (pause) выполнения до момента, пока Promise не будет resolved.
Пример реализации async-функции:
async function playingWithDelays() {
console.log('Delaying...', new Date());
const dateAfterIneSecond = await delay(1000);
console.log(dateAfterOneSecond);
const dateAfterThreeSeconds = await delay(3000);
console.log(dateAfterThreeSeconds);
return 'done';
}
Вызвать асинхронную функцию из синхронного кода можно с помощью Promises:
playingWithDelays().then(result => {
console.log(`After 4 seconds: ${result}`)
});
Если мы хотим запустить несколько асинхронных задач в цикле и дождаться их выполнения, то используя функцию map() сделать это можно следующим образом:
async function spiderLinks (currentIrl, content, nesting) {
const links = getPageLinks(currentUrl, content);
const promises = links.map(link => spider(link, nesting - 1));
return Promise.all(promises);
}
Эта фундаментальная проблема может приводить к критичным утечкам памяти и падению приложения. Пример кода:
function leakingLoop () {
return delay(1)
.then(() => {
console.log(`Tick ${Date.now()}`);
return leakingLoop();
});
}
Для того, чтобы вызвать развали приложения (app crash), достаточно выполнить следующий код:
for(let i = 0; i < 1e6; i++) {
leakingLoop();
}
Проблема состоит в том, что на каждой итерации мы добавляем в цепочку Promises ещё один Promise. Как результат, цепочка критически разрастается и это приводит к гибели приложения. Если заменить return leakingLoop();
на leakingLoop();
то код будет работать нормально, но отключится механизм обработки исключений по цепочке, т.е. это тоже будет ошибочным поведением, но без падения приложения. Чтобы обойти проблему требуется реализовать код без return, но с дополнительное обработкой исключение и повторным сбросом reject() в ручном режиме.
Подробно почтиать об этой проблеме можно в bug report 6673 и bug report 179.
Предположим, что нам нужно получить по интенет некоторый файл, обработать его несколькими последовательными алгоритмами, сжать архиватором, а затем сохранить как файл в локальной файловой системе. Наиболее простой вариант - разбить задачу на этапы и полноценно поэтапно отработать. В этом варианте, мы бы сначала получили весь файл через интернет и сохранили бы его в буфере. Потом передали буфер в первый алгоритм обработки, который сохранил бы результат в другой буфер и передал бы другой буфер на обработку дальше. Этот вариант плох как с точки зрения расхода памяти, так и утилизации вычислительных ресурсов. Что если размер входного файла 16 Гб, а у нас есть только 8 Гб физической памяти? Если говорить о производительности, то разные операции выполняются с разной скоростью и, пока мы ждём загрузку очередного блока данных по сети, мы могли бы уже выполнить обработку всех операций для предыдущего блока.
Streams - это набор API, которые позволяют выполнять обработку частично полученных данных, увеличивая как скорость работы алгоритмов, так и уменьшая расход оперативной памяти.
Пример использования:
import { createReadStrem, createWriteStream } from 'fs';
import { createGzip } from 'zlib';
const filename = process.args[2];
createReadStream(filename)
.pipe( createGzip() )
.pipe( createWriteStream(`${filename}.gz`))
.o('finish', () => console.log(`File successfully compressed'));
Пример реализации "читающего" потока (Readable stream) в non-floweing mode:
process.stdin
.on('readable', () => {
let chunk;
console.log('New data available');
while ((chunk = process.stdin.read()) !== null) {
console.log(`Chunk read (${chunk.length} bytes): "${chunk.toString()}"`);
}
})
.on('end', () => console.log('End of stream'));
Пример реализации Readable stream:
import { Readable } from 'stream';
import Chance from 'chance';
const chance = new Chance();
export class RandomStream extends Readable {
constructor (options) {
supre (options);
this.emittedBytes = 0;
}
_read (size) {
const chunk = chance.string({length: size});
this.push(chunk, 'utf8');
this.emittedBytes += chunk.length;
if (chance.bool({likelihood: 5})) {
this.push(null);
}
}
}
Библиотека Chance генерирует различные типы случайных данных.
С потоками в JavaScript (и вообще с потоками) связана проблема возникновения bottle neck - бутылочное горлышко, которая вознкает в том случае, если компоненты пишут данные быстрее, чем следующие за ними компоненты обрабатывают эти данные. Механизм streams в JavaScript содержит инструменты, которые позволяются отрабатывать возникновение bottle neck, но этот механизм носит рекомендательный, а не обязательный характер. Разрабатывая компоненты для работы с потоками следует помнить о существовании подобной проблемы и проверять возможность её возникновения в реальных программных продуктах.
Одной из особенностей реализации streams является частичная обработка поступивших данных. Например, в поток передаётся CSV-файл, который проходит через несколько фильтров и компонентов обсчёта. Предположим, что в очередной фрейме данным мы получим несколько завершённых и несколько не завершённых CSV-строк. Завершённые строки будут обработаны и переданы далее по пайпу (pipe), а не обработанная строка будет помещена в накопительный буфер (tail) и будет объединена со следующим фреймом, когда он будет получен.
Ещё одна функция, которую следует принять во внимание - pipeline(). Эта функция позволяет создавать pipes (потоки обработки данных), которые обладают надёжной обработкой ошибок. В общем случае, вызов функции осуществляется следующим образом:
pipeline(stream1, stream2, stream3, ..., cb);
Функционал работы с потоками в Node.js развит весьма сильно. Мы можем, например, объединить несколько потоков в один, чтобы улучшить читаемость кода. Например, мы можем взять поток, который умеет сжимать данные и шифровать их и объединив, сделать "черный ящик", который и сжимает, и шифрует в поточной модели. Удобнее всего это сделать используя библиотеку pump:
const combinedStream = pumpify(streamA, streamB, streamC);
Потоки в JavaScript можно разделять на части (forking streams), или объединять несколько потоков в один (merging streams). Как обычно, на npm есть библиотека, которая упрощает разработку кода, выполняющего forking/merging потоков данных. Эта библиотека называется multistream
Ещё один тип задач, для которого часто используют потоки данных - это мультиплексирование и демультиплексирование. Часто у нас есть несколько каналов данных, которые нужно поместить в один общий канал (shared), передать комбинированные данные, а затем восстановить их на несколько оригинальных потоков. Операция помещения каналов данных в общий канал называется multiplexer (mux), а восстановления отдельных каналов из shared называется demultiplexer (demux). Node.js умеет прекрасно справляться с подобными задачами.
Простейший вариант мультиплексирования - упаковывать данные каждого канала в структуру, в которой есть поле "идентификатор канала", "длина данных" и "данные". По идентификатору канала на приёмной стороне осуществляется демультиплексирование. Пример упаковки данных в структуру:
const outBuff = Buffer.alloc(1 + 4 + chunl.length);
outBuff.writeUInt8(i, 0);
outBuff.writeUInt32BE(chunk.length, 1);
chunk.copy(outBuff, 5);
console.log(`Sending packet to channel: ${i}`);
destination.write(outBuff);
Часто используется библиотека ternary-stream.
Pino - очень интересная система логирования для JavaScript. Прекрасно работает с Express, Koa и Fastify.