Это руководство ориентировано на разработчиков, которые знают JavaScript, но еще не очень хорошо владеют Node.
Вам, вероятно, уже приходится работать с Node.js: npm scripts, конфигурацией webpack, задачами gulp, автоматической сборкой и запуском тестов. Несмотря на то, что для работы не требуется глубокое понимание всех этих действий, иногда они могут сбивать с толку.
Познакомившись с Node.js поближе, вы сможете во всем разобраться, а заодно автоматизировать некоторые вещи, которые до сих пор делаете вручную. Вы почувствуете себя увереннее и сможете писать более сложные сценарии.
Версия Node
Самое разительное отличие серверного программирования от клиентского заключается в том, что вы сами определяете свою среду выполнения и можете быть абсолютно уверены в поддерживаемых функциях. Именно вы выбираете, какую версию программного обеспечения использовать, в зависимости от потребностей проекта и возможностей сервера.
У Node.js есть публичный график релизов, из которого видно, что нечетные версии не имеют долгосрочной поддержки. Текущая LTS-версия (long-term support) будет активно разрабатываться до апреля 2019 года, а затем поддерживаться до 31 декабря 2019 года.
В свежих версиях Node появляется много новых функций, обновлений безопасности и улучшений производительности, поэтому очень полезно использовать текущую активную версию. Тем не менее, никто не заставляет вас делать это, нет ничего плохого в том, чтобы использовать старую версию и игнорировать обновления, если они вам не нужны.
Node.js широко применяется в современном фронтенде – трудно найти проект проект, который не использует инструменты этой платформы. Скорее всего, вы уже знакомы с nvm (node version manager), который позволяет установить несколько версий Node одновременно для разных проектов. Это полезно, ведь если разные приложения используют разные релизы, было бы сложно синхронизировать и тестировать их на одной машине. Такие инструменты существуют и для многих других языков, например, virtualenv для Python, rbenv для Ruby.
Babel не нужен
Так как вы можете выбирать любую LTS-версию Node.js, то ничего не мешает использовать ту, в которой поддерживаются практически все современные возможности языка (ES 2015), за исключением хвостовой рекурсии.
Следовательно, Babel вам нужен, только если вы застряли с очень старой версией, или используете JSX-синтаксис, или хотите применять суперновые возможности, которые еще не вошли в стандарт.
Также нет необходимости в использовании webpack или browserify, и поэтому у нас нет инструмента для перезагрузки кода – вы можете использовать nodemon для перезапуска приложения после внесения изменений.
И поскольку мы никуда не отправляем написанный код, нет необходимости его минимизировать – на один шаг меньше в рабочем процессе. Вы просто используете свой код как есть! Очень непривычно.
Стиль коллбэков
Исторически сложилось так, что асинхронные функции в Node.js принимают обратные вызовы с сигнатурой (err, data)
, где первый аргумент представляет ошибку. Если он равен null
, то все хорошо, иначе придется что-то предпринимать. Эти обработчики вызываются после получения ответа, например, при попытке прочитать файл:
const fs = require('fs');
fs.readFile('myFile.js', (err, file) => {
if (err) {
console.error('There was an error reading file :(');
// process - это глобальный объект в Node
// https://nodejs.org/api/process.html#process_process_exit_code
process.exit(1);
}
// какие-то действия с полученным содержимым файла
});
Вскоре выяснилось, что этот стиль крайне затрудняет написание читаемого и поддерживаемого кода, именно с ним связано callback hell. Позже появился асинхронный Promise – он был стандартизирован в ECMAScript 2015 (это глобальный объект как в браузере, так и в Node.js). Недавно ECMAScript 2017 ввела синтаксис async / await, доступный в Node.js 7.6+, который вы уже можете использовать.
С промисами мы избегаем «ада коллбэков», но появляется другая проблема: старый код и многие встроенные модули до сих пор используют обратные вызовы. Однако их можно преобразовать в обещания. Для примера давайте конвертируем метод fs.readFile:
const fs = require('fs');
function readFile(...arguments) {
return new Promise((resolve, reject) => {
fs.readFile(...arguments, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
Этот шаблон может быть легко расширен на любую функцию. Кроме того, существует специальный метод utils.promisify. Вот пример из официальной документации:
const util = require('util');
const fs = require('fs');
const stat = util.promisify(fs.stat);
stat('.').then((stats) => {
// Какие-то манипуляции со `stats`
}).catch((error) => {
// Обработка ошибки
});
Разработчики Node.js понимают, что нужно переходить от старого стиля к новому, поэтому они пытаются внедрить промисифицированную версию встроенных модулей – например, уже есть промисифицированный модуль файловой системы, хотя он пока экспериментальный.
Вы все еще можете столкнуться со старым кодом с обратными вызовами. Рекомендуется обернуть его с помощью utils.promisify
.
Цикл событий
Цикл событий почти такой же, как и в браузере, но с некоторыми расширениями.

Некоторые методы JavaScript позволяют извлечь код из основной очереди и выполнить его асинхронно:
Промисы, вроде Promise.resolve выполняются в той же итерации цикла обработки событий, но после остального синхронного кода.
Это специфическая для Node.js операция, которая ведет себя как microtask, но с приоритетом. Такой код будет выполняться сразу после всего синхронного кода, даже если ранее были введены другие микрозадачи. Это опасно, и может привести к бесконечным циклам. Название метода неудачно и может вводить в заблуждение, так как код выполняется во время текущей итерации, а не на следующем тике, но из-за соображений совместимости оно, вероятно, останется прежним.
Этот метод уже существует в некоторых браузерах, но еще не стандартизирован, и его следует использовать с осторожностью. Он похож на setTimeout(0)
, но иногда имеет приоритет над ним. Именование здесь также не самое подходящее – мы говорим о следующей итерации цикла событий, а это не совсем immediate.
Таймеры ведут себя одинаково и в Node, и в браузере. Важно понимать, что установленная задержка не является гарантированной. Код будет выполнен по истечении времени задержки, но только после того, как основной цикл завершит все операции (включая микрозадачи) и если не будет других таймеров с более высоким приоритетом.
Давайте посмотрим на пример со всем вышеперечисленным:
Правильный вывод скрипта размещен ниже, но если хотите, попробуйте определить его самостоятельно:
const fs = require('fs');
console.log('beginning of the program');
const promise = new Promise(resolve => {
// функция, переданная в конструктор Promise
// выполняется синхронно!
console.log('I am in the promise function!');
resolve('resolved message');
});
promise.then(() => {
console.log('I am in the first resolved promise');
}).then(() => {
console.log('I am in the second resolved promise');
});
process.nextTick(() => {
console.log('I am in the process next tick now');
});
fs.readFile('index.html', () => {
console.log('==================');
setTimeout(() => {
console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);
setImmediate(() => {
console.log('I am from setImmediate callback');
});
});
setTimeout(() => {
console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);
setImmediate(() => {
console.log('I am from setImmediate callback');
});
Вот правильный вывод:
> node event-loop.js
beginning of the program
I am in the promise function!
I am in the process next tick now
I am in the first resolved promise
I am in the second resolved promise
I am in the callback from setTimeout with 0ms delay
I am from setImmediate callback
==================
I am from setImmediate callback
I am in the callback from setTimeout with 0ms delay
Вы можете узнать больше о цикле событий и методе process.nextTick
в официальной документации Node.js.
Event Emitters
Многие встроенные модули Node.js создают или получают различные события. Платформа реализует EventEmitter по шаблону публикации-подписки. Это очень похоже на события в браузере, но с немного другим синтаксисом. Чтобы разобраться, проще всего реализовать все самостоятельно:
class EventEmitter {
constructor() {
this.events = {};
}
checkExistence(event) {
if (!this.events[event]) {
this.events[event] = [];
}
}
once(event, cb) {
this.checkExistence(event);
const cbWithRemove = (...args) => {
cb(...args);
this.off(event, cbWithRemove);
};
this.events[event].push(cbWithRemove);
}
on(event, cb) {
this.checkExistence(event);
this.events[event].push(cb);
}
off(event, cb) {
this.checkExistence(event);
this.events[event] = this.events[event].filter(
registeredCallback => registeredCallback !== cb
);
}
emit(event, ...args) {
this.checkExistence(event);
this.events[event].forEach(cb => cb(...args));
}
}
Эта реализация просто показывает принципы работы шаблона. Она не точная, не используйте ее в своем коде!
Этот код позволяет подписаться на события, отписаться от них позже позже, а также создавать. Объект ответа, объект запроса, потоки в Node – все они фактически расширяют или реализуют EventEmitter!
Эта простая концепция реализована во многих npm-пакетах: 1, 2, 3 и ряде других.
Потоки
Потоки в Node – самая лучшая и непонятная идея.
Потоки позволяют обрабатывать данные по частям, не дожидаясь конца операции (например, чтения файла). Допустим, мы хотим вернуть пользователю запрошенный файл произвольного размера. Наш код может выглядеть следующим образом:
function (req, res) {
const filename = req.url.slice(1);
fs.readFile(filename, (err, data) => {
if (err) {
res.statusCode = 500;
res.end('Something went wrong');
} else {
res.end(data);
}
});
}
Этот код будет работать, особенно на локальной машине, но вы видите в нем проблему? Мы помещаем читаемый файл в память, и если он слишком велик, то это не будет работать. Это также не сработает, если у нас много параллельных запросов.
Но этот файл нам совсем не нужен – мы просто возвращаем его, даже не заглядывая внутрь. Поэтому можно прочитать какую-то его часть, немедленно отдать ее, освободить память и повторить все это снова, пока не закончим. Это упрощенное описание потоков – у нас есть механизм получения данных в чанках (кусками), и мы можем решать, что с ними делать. Например, сделаем то же самое:
function (req, res) {
const filename = req.url.slice(1);
const filestream = fs.createReadStream(filename, { encoding: 'utf-8' });
let result = '';
filestream.on('data', chunk => {
result += chunk;
});
filestream.on('end', () => {
res.end(result);
});
// если файл не существует, сработает коллбэк
filestream.on('error', () => {
res.statusCode = 500;
res.end('Something went wrong');
});
}
Здесь мы создаем поток для чтения из файла. Этот поток реализует класс EventEmitter. По событию data
мы получаем следующий чанк, а событие end
сигнализирует, что поток закончился. Эта реализация работает так же, как и раньше – мы ждем, пока весь файл будет прочитан, а затем возвращаем егопользователю. Проблема сохранения данных в памяти никуда не делась.
Объект ответа сам является записываемым потоком, это значит, что мы можем отдавать ему информацию напрямую, не сохраняя ее:
function (req, res) {
const filename = req.url.slice(1);
const filestream = fs.createReadStream(filename, { encoding: 'utf-8' });
filestream.on('data', chunk => {
res.write(chunk);
});
filestream.on('end', () => {
res.end();
});
filestream.on('error', () => {
res.statusCode = 500;
res.end('Something went wrong');
});
}
Объект ответа – это записываемый поток, а fs.createReadStream создает читаемый поток. Они оба являются дуплексными и трансформируемыми. Узнать больше о потоках в Node вы можете здесь.
Теперь нам больше не нужна переменная result
, мы просто записываем уже прочитанные чанки сразу в ответ! Это означает, что можно читать даже большие файлы и не беспокоиться о параллельных запросах – память не будет исчерпана.
Осталась еще одна проблема: эти потоки имеют разные задержки. Через некоторое время поток ответа будет перегружен, так как он намного медленнее. У Node.js есть решение для этой проблемы: каждый читаемый поток имеет метод pipe, который контролирует загрузку и управляет подачей данных, приостанавливая и возобновляя ее по необходимости. Используя этот метод, можно упростить код:
function (req, res) {
const filename = req.url.slice(1);
const filestream = fs.createReadStream(filename, { encoding: 'utf-8' });
filestream.pipe(res);
filestream.on('error', () => {
res.statusCode = 500;
res.end('Something went wrong');
});
}
Потоки в Node менялись несколько раз, поэтому будьте особенно внимательны при чтении старых руководств и всегда проверяйте официальную документацию!
Система модулей
Node.js использует модули commonjs. Каждый раз, когда вы используете require
,чтобы получить какой-то модуль внутри конфигурации webpack или когда объявляете module.exports
, вы фактически применяете этот механизм. Возможно, также видели что-то вроде exports.something = {}
, без слова module
. Давайте разберемся, как это работает.
Прежде всего, речь пойдет о модулях commonjs с обычным расширением
.js
, а не о.esm
/.mjs
-файлах (модули ECMAScript), которые позволяют использовать синтаксисimport
/export
. Кроме того, важно понимать, что webpack и browserify (и другие инструменты сборки) используют свою собственную функциюrequire
, которая немного отличается.
Итак, откуда мы на самом деле получаем эти «глобальные» объекты module
, require
и exports
? Их добавляет сама платформа – вместо того, чтобы просто выполнять файл javascript, она фактически обертывает его в функцию со всеми этими переменными:
function (exports, require, module, __filename, __dirname) {
// код модуля
}
Чтобы увидеть эту оболочку, нужно выполнить следующий фрагмент кода в командной строке:
node -e "console.log(require('module').wrapper)"
Эти переменные вводятся в модуль и доступны в нем как «глобальные», хотя на самом деле таковыми не являются. Обязательно взгляните на них внутри модуля и в «главном «файле – можно просто вызвать console.log(module)
– и сравните.
Далее рассмотрим объект exports
и тонкости работы с ним:
exports.name = 'our name'; // это работает
exports = { name: 'our name' }; // это не работает
module.exports = { name: 'our name' }; // это работает!
Приведенный выше пример может озадачить вас. exports
— это аргумент, передаваемый функции. Если мы присваиваем ему новый объект, то просто переписываем эту переменную, а старая ссылка исчезает. А module.exports ссылается на тот же объект:
module.exports === exports; // true
Наконец, require
– это функция, которая берет имя модуля и возвращает его объект exports
. Как именно она находит модуль? Существует довольно простая последовательность действий:
- сравнить модули ядра с указанным именем;
- если путь начинается с
./
или../
, попробовать найти такой файл; - если файл не найден, попробовать найти каталог с таким именем с файлом
index.js
- если путь не начинается с
./
или./
, зайти вnode_modules/
и проверить наличие там папки/ файла:- в той директории, где запустился скрипт
- на один уровень выше, пока не найдется
/node_modules
Есть и некоторые другие места поиска, которые в основном предназначены для совместимости. Также вы можете указать свой путь для поиска с помощью переменной NODE_PATH. Если вы хотите видеть точный порядок поиска модулей, просто выведите объект модуля на консоль и найдите свойство paths
. Оно выглядит примерно так:
tmp node test.js
=> Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/seva.zaikov/tmp/test.js',
loaded: false,
children: [],
paths:
[ '/Users/seva.zaikov/tmp/node_modules',
'/Users/seva.zaikov/node_modules',
'/Users/node_modules',
'/node_modules' ] }
Также важно знать, что после первого вызова require
объект модуля кэшируется. При последующих вызовах система не будет его искать, а просто вернет из кэша. Это означает, что код при инициализации модуля будет выполнен только один раз. Однако можно удалить идентификатор модуля из кэша и перезагрузить его, если требуется.
Переменные окружения
Как указано в 12-factor-app, рекомендуется хранить конфигурацию в переменных среды. Можно настроить переменные для сеанса терминала:
export MY_VARIABLE="some variable value"
Node – это кросс-платформенный движок, и в идеале ваше приложение должно запускаться на любой платформе. Примеры в статье охватывают только MacOS / Linux и не будут работать для Windows. Синтаксис переменных среды в Windows отличается, вы можете использовать что-то вроде cross-env, но имейте это ввиду и в других случаях.
Вы можете добавить эту строку в свой профиль bash/zsh, чтобы она была настроена в любой новой сессии.
Однако обычно вы просто запускаете приложение с этими переменными:
APP_DB_URI="....." SECRET_KEY="secret key value" node server.js
Доступ к ним можно получить с помощью объекта process.env:
const CONFIG = {
db: process.env.APP_DB_URI,
secret: process.env.SECRET_KEY
}
Все вместе
В этом примере мы создадим простой http-сервер, который вернет файл с именем, указанным в URL после символа /
. Если файл не существует, вернется ошибка 404 Not Found
. Если пользователь попытается использовать относительный или вложенный путь, мы отправим ему ошибку 403
.
На этот раз задокументируем код как следует:
// мы подключаем только встроенные модули,
// поэтому Node.js не ищет в папках `node_modules`
// https://nodejs.org/api/http.html#http_http_createserver_options_requestlistener
const { createServer } = require("http");
const fs = require("fs");
const url = require("url");
const path = require("path");
// устанавливаем переменную окружения с именем папки с файлами
// мы можем использовать другую папку локально
const FOLDER_NAME = process.env.FOLDER_NAME;
const PORT = process.env.PORT || 8080;
const server = createServer((req, res) => {
// req.url содержит полный адрес со строкой запроса
// раньше мы это игнорировали, но теперь нужно убедиться
// что мы получаем только pathname без querystring
// https://nodejs.org/api/http.html#http_message_url
const parsedURL = url.parse(req.url);
// убираем первый символ `/`
const pathname = parsedURL.pathname.slice(1);
// чтобы вернуть ответ, вызываем `res.end()`
// https://nodejs.org/api/http.html#http_response_end_data_encoding_callback
//
// > Метод response.end() должен вызываться при каждом ответе.
// если не вызвать его, то соединение не закроется и клиент
// будет ждать, пока не закончится тайм-аут соединения
//
// по умолчанию возвращается ответ с кодом 200
// (https://en.wikipedia.org/wiki/List_of_HTTP_status_codes)
// если что-то пойдет не так, нужно вернуть корректный код состояния
// используя свойство `res.statusCode = ...`:
// https://nodejs.org/api/http.html#http_response_statuscode
if (pathname.startsWith(".")) {
res.statusCode = 403;
res.end("Relative paths are not allowed");
} else if (pathname.includes("/")) {
res.statusCode = 403;
res.end("Nested paths are not allowed");
} else {
// https://nodejs.org/en/docs/guides/working-with-different-filesystems/
// чтобы сохранять кроссплатформенность, нельзя просто создать путь
// нужно использовать специфичный для платформы разделитель
// это делает метод path.join():
// https://nodejs.org/api/path.html#path_path_join_paths
const filePath = path.join(__dirname, FOLDER_NAME, pathname);
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
fileStream.on("error", e => {
// обрабатываем только несуществующие файлы,
// но есть много возможных кодов ошибок
// распространенные коды вы можете найти здесь
// https://nodejs.org/api/errors.html#errors_common_system_errors
if (e.code === "ENOENT") {
res.statusCode = 404;
res.end("This file does not exist.");
} else {
res.statusCode = 500;
res.end("Internal server error");
}
});
}
});
server.listen(PORT, () => {
console.log(`application is listening at the port ${PORT}`);
});
Заключение
В этом руководстве рассмотрены фундаментальные принципы Node.js. Вы уже понимаете, где могут возникнуть ошибки, какие интерфейсы используют встроенные модули и чего ожидать от встроенных объектов. Мы не углублялись в конкретные API и определенно пропустили некоторые важные вещи, но теперь вы будете чувствовать себя увереннее и сможете легко разобраться в документации.
0 комментариев