10 типичных ошибок Node.js разработчиков

Часть 1

| Категории: Node.js, Javascript
Eleonora Pavlova


Иллюстрация блокнота


С момента выхода Node.js, многие разработчики удостаивали его высоких похвал, многие жёстко критиковали. Подобные «холивары», вероятно, не прекратятся никогда. Важно в этих спорах то, что любую платформу и любой язык программирования критикуют за определённые слабые места, которые всегда обусловлены тем, как мы используем данный инструмент.

На Node.js непросто писать «традиционный» код, зато легко писать код асинхронный, платформа существует уже достаточно долгое время, и на ней создано огромное количество надёжных, высокотехнологичных, легко масштабируемых веб-сервисов, демонстрирующих стабильность в работе на протяжении всего времени своего существования.

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

ОШИБКА 1: Блокирование цикла обработки событий

JavaScript в Node.js (как и в браузере) создаёт однопоточную среду. Это значит, никакие компоненты приложения не выполняются одновременно; вместо этого, параллелизм достигается за счет асинхронной обработки операций ввода/вывода . Например, сделав запрос к ядру СУБД, чтобы извлечь какой-нибудь документ, в ожидании ответа Node.js может параллельно работать с другой частью приложения.

1
2
3
4
5
// Извлекая из базы данных объект «пользователь», Node.js может выполнять другие части кода с того момента, как начнёт выполняться данная функция
db.User.get(userId, function(err, user) {
// до момента, пока объект «пользователь» будет загружен сюда
})
}

Однако, чтобы блокировать цикл обработки событий, достаточно добавить немного нагружающего процессор кода с большим количеством подключенных клиентов — и все клиенты будут ждать в очереди. Проблемы с производительностью CPU обеспечены при попытках сортировки больших массивов, запуске очень долгого цикла, и всё в таком духе. Пример:

1
2
3
4
5
function sortUsersByAge(users) {
users.sort(function(a, b) {
return a.age < b.age ? -1 : 1
})
}

Вызов функции sortUsersByAge при работе с маленьким массивом пользователей сработает на отлично, но при большом количестве юзеров снижение общей производительности может быть ужасающим. Если это абсолютно необходимо сделать и вы уверены, что при этом ничего не будет висеть в очереди (допустим, вы пишете какую-нибудь командную строку, и не страшно, если всё будет выполняться синхронно), данная проблема вам не страшна. Но стоит проделать нечто подобное на сервере Node.js с тысячами пользователей, и блокирование потока вам гарантировано.

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

Идеальных решений в таких ситуациях не существует, в каждом случае всё индивидуально. Главная мысль — не совершать операций с высокой нагрузкой на ЦП на инстансах Node.js, к которым клиенты подключаются параллельно.

ОШИБКА 2: Вызов колбека несколько раз

Колбеки в Jacascript используются со времён Куликовской битвы. В веб-браузерах события обрабатываются путём передачи в функции (часто анонимные) параметров по ссылке, где функции ведут себя как колбеки. До недавнего времени колбеки в Node.js были единственным способом взаимодействия асинхронных элементов кода друг с другом — пока не появились промисы. Но колбеки никуда не делись, многие разработчики пакетов по-прежнему выстраивают API на колбеках. И типичная ошибка здесь — вызов колбека несколько раз. Обычно функция, выполняющая что-то асинхронно, ожидает в качестве последнего аргумента другую функцию, которая вызывается, когда асинхронная операция завершена.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports.verifyPassword = function(user, password, done) {
if(typeof password !== ‘string’) {
done(new Error(‘password should be a string’))
return
}
computeHash(password, user.passwordHashOpts, function(err, hash) {
if(err) {
done(err)
return
}
done(null, hash === user.passwordHash)
})
}

В примере мы видим, что в каждом вызове done прописан оператор return, за исключением последнего вызова. А всё потому, что вызов колбека не означает автоматического завершения выполнения текущей функции. Если закомментировать первый return, передача не строкового пароля данной функции всё равно вызовет computeHash. В зависимости от того, как computeHash сработает в данном случае, колбек «done» может вызываться нескольк раз. Для кого-то это будет неприятным сюрпризом.
Во избежание сюрпризов, нужно просто быть аккуратным. Некоторые разработчики выработали привычку добавлять return перед каждым вызовом колбека:

1
2
3
if(err) {
return done(err)
}

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

ОШИБКА 3: Глубокая вложенность колбеков

Само по себе это явление, именуемое в интернетах «лапшой из колбеков», не обязательно влечёт за собой катастрофу. Но новичок может быстро потерять контроль над собственым кодом.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function handleLogin(..., done) {
db.User.get(..., function(..., user) {
if(!user) {
return done(null, ‘failed to log in’)
}
utils.verifyPassword(..., function(..., okay) {
if(okay) {
return done(null, ‘failed to log in’)
}
session.login(..., function() {
done(null, ‘logged in’)
})
})
})
}

И чем сложнее решаемая задача, тем запутаннее код — его крайне трудно читать и поддерживать. Один из способов решения - разбить задачки на микро-функции и пошагово их соединить. Хотя, наиболее простым (субъективно) решением будет использование пакетной утилиты Node.js для асинхронных шаблонов Javascript – Async.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function handleLogin(done) {
async.waterfall([
function(done) {
db.User.get(..., done)
},
function(user, done) {
if(!user) {
return done(null, ‘failed to log in’)
}
utils.verifyPassword(..., function(..., okay) {
done(null, user, okay)
})
},
function(user, okay, done) {
if(okay) {
return done(null, ‘failed to log in’)
}
session.login(..., function() {
done(null, ‘logged in’)
})
}
], function() {
// ...
})
}

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

ОШИБКА 4: Ожидание синхронных колбеков

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

Но в Javascript, функция с колбелками, ожидающая завершения какой-то операции, может срабатывать не так, как было задумано. В примере ниже функция будет выполняться до конца, без остановок:

1
2
3
4
5
6
7
function testTimeout() {
console.log(“Begin”)
setTimeout(function() {
console.log(“Done!”)
}, duration * 1000)
console.log(“Waiting..”)
}

Вызов функции testTimeout() сначала напечатает «Begin», затем - «Waiting..» и лишь через секунду сообщение «Done!»

Всё, что должно происходить после вызова колбека, должно быть прописано внутри него.

ОШИБКА 5: Использование exports вперемешку с module.exports

Каждый файл в Node.js – небольшой изолированный модуль. Если в вашем проекте два файла, скажем, «a.js» и «b.js», то для того, чтобы файл «b.js» получил доступ к функционалу файла «a.js», значения последнего нужно экспортировать. Присвоим их параметрам объекта exports:

1
2
3
// a.js
exports.verifyPassword = function(user, password, done) { ... }
module.exports.verifyPassword = function(...)

В результате по запросу «a.js» получим объект с функцией verifyPassword.

1
2
// b.js
require(‘a.js’) // { verifyPassword: function(user, password, done) { ... } }

Но что, если нам нужно экспортировать именно функцию, не как объектное значение? Для этого нужно переопределить exports, но как локальную, а не глобальную переменную:

1
2
3
4
// a.js
module.exports = function(user, password, done) { ... }
// а могу ли я здесь написать module.exports.verifyPassword = function(...) - да
//module.exports является node.js расширением, которое позволяет разработчикам экспортировать не объектные значения.

На самом деле, изначально exports и module.exports всегда ссылаются на один и тот же объект: var exports = module.exports = {}; — и наш модуль, по сути, всегда возвращает именно module.exports. Но если мы в процессе присваиваем переменной exports другое значение, она уже не будет ссылаться на module.exports, и наш модуль ничего не вернёт. Простой рецепт от возможной путаницы — всегда используйте что-то одно; безопаснее и удобнее - module.exports.

Вторая порция ошибок и способов лечения — в нашей следующей статье :)

По мотивам Mahmud Ridwan