C момента появления Node.js его и критикуют, и превозносят. Споры о достоинствах и недостатках этого инструмента не утихают и, вероятно, не утихнут в ближайшее время. Однако часто мы упускаем из виду, что критика любого языка или платформы основывается на возникающих проблемах, зависящих от того, как мы эти платформы используем. Вне зависимости от того, насколько Node.js усложняет написание безопасного кода и облегчает его распараллеливание, платформа существует уже довольно давно, и на ней создано огромное количество надёжных и сложных веб-сервисов. Все они хорошо масштабируются и на практике доказали свою устойчивость.
Но, как и любая платформа, Node.js не застрахован от ошибок самих разработчиков. В одних случаях падает производительность, в других — система становится практически непригодной к использованию. И в этом посте я хотел бы рассмотреть 10 наиболее частых ошибок, которые делают разработчики с недостаточным опытом работы с Node.js.
Ошибка первая: блокирование цикла событий
JavaScript в Node.js (как и в браузере) обеспечивает однопоточную среду. Это означает, что две или больше частей вашего приложения одновременно выполняться не могут. Распараллеливание осуществляется за счет асинхронной обработки операций ввода/вывода. Например, запрос Node.js к базе данных за каким-либо документом дает возможность Node.js уделить внимание другой части приложения:
// Пытаемся извлечь данные пользователя из базы данных. С этого момента Node.js свободен для выполнения других частей кода..
db.User.get(userId, function(err, user) {
// .. до тех пор, пока данные пользователя не будут извлечены здесь
})
Однако кусок занимающего процессор кода способен заблокировать цикл событий, заставив тысячи подключённых клиентов ожидать завершения своего выполнения. В качестве примера подобного кода можно привести попытку сортировки очень большого массива:
function sortUsersByAge(users) {
users.sort(function(a, b) {
return a.age < b.age ? -1 : 1
})
}
Вызов функции sortUsersByAge вряд ли создаст проблемы в случае с небольшим массивом. Но при работе с большим массивом это катастрофически снизит общую производительность. Проблем может и не возникнуть, если данная операция крайне необходима, и вы уверены, что никто другой не ожидает цикла событий (скажем, если вы делаете инструмент, запускаемый из командной строки, и асинхронность выполнения не нужна). Но для Node.js-сервера, обслуживающего тысячи клиентов одновременно, такой подход недопустим. Если этот массив пользователей извлекается непосредственно из базы данных, то лучшим решением было бы извлечь его уже отсортированным. Если цикл событий блокируется циклом вычисления общего результата большого количества финансовых транзакций, то эту работу можно делегировать какому-нибудь внешнему исполнителю, чтобы не блокировать цикл событий.
К сожалению, не существует серебряной пули для решения проблем этого типа, и в каждом случае нужен индивидуальный подход. Главное — не перегружать процессор в рамках выполнения инстанса Node.js, который параллельно работает с несколькими клиентами.
Ошибка вторая: вызов колбэка более одного раза
Работа JavaScript базируется на колбэках. В браузерах события обрабатываются путём передачи ссылок на функции (зачастую, анонимные), которые действуют как колбэки. Раньше в Node.js колбэки были единственным способом связи асинхронных частей кода между собой, пока не были внедрены обещания (promises). Однако колбэки всё ещё используются, и многие разработчики пакетов по-прежнему обращаются к ним при проектировании своих API. Частая ошибка — вызов колбэка более одного раза. Обычно метод, который делает что-то асинхронно, последним аргументом ожидает функцию, которую он вызовет после завершения своей асинхронной задачи:
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”, то передача этой функции пароля, который не является строкой, приведет к вызову “computeHash”. И в зависимости от дальнейшего сценария работы “computeHash”, “done” может вызываться многократно. Любой посторонний пользователь, воспользовавшийся этой функцией, может быть застигнут врасплох вызовом колбэка несколько раз.
Чтобы избежать этой ошибки, достаточно проявлять бдительность. Некоторые разработчики взяли за правило добавлять ключевое слово “return” перед каждым вызовом колбэка:
if(err) {
return done(err)
}
Во многих асинхронных функциях возвращаемое значение не важно, так что этот подход зачастую позволяет избежать многократного вызова колбэка.
Ошибка третья: глубоко вложенные колбэки
Это проблему часто называют «Callback Hell». Хотя само по себе это не является ошибкой, но может стать причиной того, что код быстро выйдет из-под контроля:
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’)
})
})
})
}
Чем сложнее задача, тем глубже может быть вложенность. Это приводит к неустойчивому и трудночитаемому коду, плохо поддающемуся сопровождению. Один из способов решения этой проблемы — выделить каждую задачу в отдельную функцию, а затем связать их. В то же время, многие считают, что лучше всего использовать модули, реализующие паттерны асинхронного JavaScript, такие как Async.js:
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 содержится и ряд других функций, обеспечивающих асинхронное выполнение JavaScript. Для краткости здесь представлен довольно простой пример, но в реальности, зачастую, все гораздо хуже.
Ошибка четвёртая: рассчитывать, что колбэки будут выполняться синхронно
Асинхронное программирование с колбэками не являются чем-то необычным для JavaScript и Node.js. Другие языки приучили нас к предсказуемости порядка выполнения, когда два выражения выполняются последовательно, одно за другим, если нет никаких особых инструкций для перехода между ними. Но даже в этом случае мы зачастую ограничены условными операторами, циклами и вызовами функций.
Однако в JavaScript колбэки позволяют сделать так, что некая функция может не выполняться до тех пор, пока не будет завершена какая-то задача. Здесь функция будет выполняться без остановки:
function testTimeout() {
console.log(“Begin”)
setTimeout(function() {
console.log(“Done!”)
}, duration * 1000)
console.log(“Waiting..”)
}
При вызове функции “testTimeout” сначала будет выведено “Begin”, затем “Waitng”, а примерно через секунду — “Done!”. Если что-то должно быть сделано после вызова колбэка, то оно должно быть вызвано в самом колбэке.
Ошибка пятая: присвоение “exports” вместо “module.exports”
Node.js работает с каждым файлом как с маленьким изолированным модулем. Допустим, ваш пакет содержит два файл a.js и b.js. Чтобы b.js мог получить доступ к функциональности из a.js, последний должен экспортировать эту функциональность путём добавления свойств объекту “exports”:
// a.js
exports.verifyPassword = function(user, password, done) { ... }
Если это сделано, то любой запрос a.js, вернет объект с функцией “verifyPassword” в свойствах:
// b.js
require(‘a.js’) // { verifyPassword: function(user, password, done) { ... } }
А если нам нужно экспортировать эту функцию напрямую, а не как свойство какого-либо объекта? Мы можем сделать это, переопределив переменную “exports”, но главное не обращаться к ней, как к глобальной переменной:
/ a.js
module.exports = function(user, password, done) { ... }
Обратите внимание на “exports” в качестве свойства объекта “module”. Разница между “module.exports” и “exports” очень велика, и непонимание этого приводит к затруднениям у начинающих Node.js-разработчиков.
Ошибка шестая: генерация ошибок внутри колбэков
В JavaScript есть такое понятие, как исключение. Подражая синтаксису почти всех традиционных языков программирования, в которых тоже есть обработка исключений, JavaScript может генерировать и перехватывать исключения с помощью try-catch-блоков:
function slugifyUsername(username) {
if(typeof username === ‘string’) {
throw new TypeError(‘expected a string username, got '+(typeof username))
}
// ...
}
try {
var usernameSlug = slugifyUsername(username)
} catch(e) {
console.log(‘Oh no!’)
}
Однако в случаях асинхронного выполнения try-catch будет работать не так, как вы ожидаете. Например, если с помощью большого try-catch-блока вы попытаетесь защитить внушительный кусок кода с многочисленными асинхронными сегментами, то это может и не сработать:
try {
db.User.get(userId, function(err, user) {
if(err) {
throw err
}
// ...
usernameSlug = slugifyUsername(user.username)
// ...
})
} catch(e) {
console.log(‘Oh no!’)
}
Если колбэк, переданный в “db.User.get”, будет вызван асинхронно, то try-catch-блок не сможет перехватить генерируемые в колбэке ошибки, поскольку он будет выполнен в другом контексте, отличном от контекста try-catch. Ошибки в Node.js можно обрабатывать по-разному, но необходимо придерживаться одного шаблона для аргументов всех колбэков function (err, …) — первым аргументом в каждом колбэке нужно ожидать ошибку, если таковая произойдет.
Ошибка седьмая: предполагать, что все числа целочисленные
В JavaScript нет целочисленного типа данных, здесь все числа — с плавающей запятой. Вы можете посчитать, что это не проблема, поскольку не так часто встречаются числа достаточно большие, чтобы возникли проблемы из-за ограничений плавающей запятой. Это заблуждение. Поскольку числа с плавающей запятой могут содержать целочисленные представления только до определённого значения, его превышение при любом вычислении сразу приводит к проблемам. Как ни странно, это выражение в Node.js расценивается как верное:
Math.pow(2, 53)+1 === Math.pow(2, 53)
Странности с числами в JavaScript на этом не заканчиваются. Несмотря на то, что это числа с плавающей запятой, с ними работают операторы, предназначенные для целочисленных данных:
5 % 2 === 1 // true
5 >> 1 === 2 // true
Однако в отличие от арифметических, побитовые операторы и операторы сдвига работают только с последними 32 битами подобных больших «целочисленных». Например, если сдвинуть “Math.pow(2, 53)” на 1, то результат всегда будет равен 0. Если применить поразрядное ИЛИ, то тоже будет 0.
Math.pow(2, 53) / 2 === Math.pow(2, 52) // true
Math.pow(2, 53) >> 1 === 0 // true
Math.pow(2, 53) | 1 === 0 // true
Скорее всего, вы редко сталкиваетесь с большими числами, но когда такое случится, воспользуйтесь одной из многочисленных библиотек, выполняющих точные математические операции с большими числами. Например, node-bigint.
Ошибка восьмая: игнорирование преимуществ потоковых API
Допустим, вам нужно создать небольшой прокси-сервер, который обрабатывает ответы при запросе каких-либо данных с другого сервера. Скажем, для работы с изображениями с Gravatar:
var http = require('http')
var crypto = require('crypto')
http.createServer()
.on('request', function(req, res) {
var email = req.url.substr(req.url.lastIndexOf('/')+1)
if(!email) {
res.writeHead(404)
return res.end()
}
var buf = new Buffer(1024*1024)
http.get('http://www.gravatar.com/avatar/'+crypto.createHash('md5').update(email).digest('hex'), function(resp) {
var size = 0
resp.on('data', function(chunk) {
chunk.copy(buf, size)
size += chunk.length
})
.on('end', function() {
res.write(buf.slice(0, size))
res.end()
})
})
})
.listen(8080)
В данном примере мы берём изображение с Gravatar, читаем его в Buffer и отправляем в качестве ответа на запрос. Не самая плохая схема, поскольку эти изображения невелики. А если нужно проксировать контент гигабайтных размеров? Лучше использовать такой метод:
http.createServer()
.on('request', function(req, res) {
var email = req.url.substr(req.url.lastIndexOf('/')+1)
if(!email) {
res.writeHead(404)
return res.end()
}
http.get('http://www.gravatar.com/avatar/'+crypto.createHash('md5').update(email).digest('hex'), function(resp) {
resp.pipe(res)
})
})
.listen(8080)
Здесь мы берём изображение и просто транслируем в качестве ответа клиенту, без считывания целиком в буфер.
Ошибка девятая: использование Console.log для отладки
Console.log позволяет выводить в консоль что угодно. Передайте ему объект, и он выведет в консоль литерал JavaScript-объекта. Console.log принимает любое количество аргументов и выводит их, аккуратно разделив пробелами. Многие разработчики с удовольствием пользуются этим инструментом для отладки, однако рекомендуется не использовать “console.log” в реальном коде. Избегайте “console.log” даже в закомментированных строках. Лучше воспользуйтесь какой-нибудь специально написанной для этого библиотекой вроде debug. С помощью таких библиотек можно легко включать и отключать режим отладки при запуске приложения. Например, при использовании “debug”, если вы не установите соответствующую переменную окружения DEBUG, то отладочная информация не попадет в терминал:
// app.js
var debug = require(‘debug’)(‘app’)
debug(’Hello, %s!’, ‘world’)
Чтобы включить режим отладки, достаточно просто запустить этот код, присвоив переменной окружения DEBUG значение “app” или “*”:
DEBUG=app node app.js
Ошибка десятая: не использование программ-диспетчеров
Вне зависимости от того, выполняется ли ваш код в продакшене или в вашем локальном окружении, крайне рекомендуется использовать программу-диспетчер. Многие опытные разработчики считают, что код должен «падать» быстро. Если возникает неожиданная ошибка, не пытайтесь обрабатывать ее, позвольте программе упасть, чтобы диспетчер перезапустил ее в течение нескольких секунд. Конечно, это далеко не всё, что умеют делать диспетчеры. Например, можно настроить перезапуск программы в случае изменения каких-то файлов, и многое другое. Это существенно облегчает процесс разработки на Node.js. Можно посоветовать следующие диспетчеры:
У каждого из них свои плюсы и минусы. Кто-то хорошо работает одновременно с несколькими приложениями на одной машине, какие-то лучше справляются с логированием. Но если вы хотите начать использовать диспетчер, то выбирайте любой из предложенных.
Заключение
Некоторые из описанных ошибок могут иметь разрушительный эффект для вашей программы, некоторые могут стать причиной разочарований при реализации простейших вещей. Хоть Node.js достаточно прост для того, чтобы новичок смог начать с ним работать, есть много моментов, в которых легко напортачить. Если вы знакомы с другими языками программирования, то какие-то из этих ошибок вам могут быть известны. Но именно эти 10 ошибок характерны для начинающих Node.js-разработчиков. К счастью, их довольно легко избежать. Я надеюсь, что эта небольшая статья поможет начинающим разработчикам писать стабильные и эффективные приложения для всех нас.