Секреты создания производительных веб-приложений на Express.js
Как структурировать веб-приложение на Express.js, повысить его производительность и надёжность, в том числе с помощью DevOps-инструментов, балансировки нагрузки и обратного прокси 🚂 →🚅
Как структурировать приложение c Express.js
Наличие интуитивно понятной файловой структуры играет огромную роль – легче добавлять новый функционал и рефакторить код. Подходящий способ структурирования выглядит так:
src/ config/ - конфиги controllers/ - маршруты и обратные вызовы providers/ - логика для контроллера маршрутов services/ - общая бизнес-логика, используемая в функциях провайдера models/ - модели БД routes.js - все маршруты db.js - все модели app.js - загрузка всего вышеперечисленного test/ unit/ - unit тесты integration/ - интеграционные тесты server.js - загрузка app.js и прослушивание на порту (cluster.js) - загрузка app.js и создание кластер test.js - главный тестовый файл, который будет запускать все в каталоге test/
Такая организация позволяет ограничить размер файла примерно до 100 строк, что делает ревью и траблшутинг менее кошмарным делом.
Возьмите за правило отделять и выносить логику в отдельный файл – вы ограничите переключение контекста, которое происходит при чтении одного файла. Это также полезно при мерже в мастер – будет меньше конфликтов слияния.
Чтобы обеспечить соблюдение правил в команде, вы можете настроить линтер, который сообщит, когда вы переходите через установленный лимит строк в файле или если одна строка имеет длину более 100 символов.
1. Как повысить производительность и надёжность Express.js
1.1. Переменная окружения NODE_ENV
Установив переменную
окружения NODE_ENV
, вы примерно трёхкратно увеличите производительность. В терминале это можно
сделать следующим образом:
export NODE_ENV=production
Если вы используйте server.js
,
добавьте следующее:
NODE_ENV=production node server.js
1.2. Включаем Gzip-сжатие
Установите npm-пакет компрессии:
npm i compression
Затем добавьте следующий фрагмент в свой код:
const compression = require('compression') const express = require('express') const app = express() app.use(compression())
1.3. Асинхронные функции
Не блокируйте поток выполнения и не используйте синхронные функции. Вместо этого используйте функции Promises и Async/Await. Если у вас есть доступ только к синхронным функциям, оберните их в async-функцию, которая будет выполняться вне основного потока.
(async () => { const foo = () => { ...some sync code return val } async const asyncWrapper = (syncFun) => { const val = syncFun() return val } // значение будет возвращено извне основного потока выполнения const val = await asyncWrapper(foo) })()
Если не избежать использования синхронных функций – запустите их в отдельном потоке. Чтобы избежать блокировки основного потока и проседания CPU, создайте дочерние процессы для обработки интенсивных задач процессора.
1.4. Система логов
Чтобы унифицировать
журналы по всему Express.js-приложению, а не использовать console.log()
, используйте агент для централизованного ведения, структурирования и сбора журналов.
Можно использовать любой инструмент управления журналами, например, Sematext, Logz.io или Datadog. Практически все агенты базируются на Winston и Morgan. Они отслеживают трафик запросов API с помощью промежуточного программного обеспечения. Это сразу же даст вам журналы и данные по каждому параметру, что важно для отслеживания производительности.
Вот так добавляется логгер и промежуточное ПО:
/const { stLogger, stHttpLoggerMiddleware } = require('sematext-agent-express') // В верхней части ваших маршрутов добавьте stHttpLoggerMiddleware для отправки журналов const express = require('express') const app = express() app.use(stHttpLoggerMiddleware) // Используйте stLogger для отправки всех типов журналов непосредственно в Sematext app.get('/api', (req, res, next) => { stLogger.info('An info log.') stLogger.debug('A debug log.') stLogger.warn('A warning log.') stLogger.error('An error log.') res.status(200).send('Hello World.') })
1.5. Обработка ошибок и исключений
При использовании в
коде Async/Await
для обработки ошибок и исключений рекомендуется применять операторы try-catch
, а также использовать для ведения журнала ошибок Express logger.
async function foo() { try { const baz = await bar() return baz } catch (err) { stLogger.error('Function \'bar\' threw an exception.', err); } }
Кроме того,
рекомендуется настроить catch-all error
:
function errorHandler(err, req, res, next) { stLogger.error('Catch-All error handler.', err) res.status(err.status || 500).send(err.message) } router.use(errorHandler) module.exports = router
Здесь будет поймана любая ошибка, выброшенная в контроллере. А ещё можно добавить слушателей в сам процесс:
process.on('uncaughtException', (err) => { stLogger.error('Uncaught exception', err) throw err }) process.on('unhandledRejection', (err) => { stLogger.error('unhandled rejection', err) })
1.6. Следите за утечками памяти
Вы не сможете поймать ошибки до того, как они произойдут. Некоторые проблемы не обусловлены только «тематикой» исключения, вываливающегося при падении приложения. Все решения сводятся к тому, чтобы упредить любую возможность утечки памяти.
Заметить утечку гораздо проще, чем вы думаете. Если память процесса продолжает неуклонно расти, а не периодически сокращается в результате сборки мусора – скорее всего это она и есть. В идеале вы должны сосредоточиться на предотвращении утечек, а не на их устранении и отладке.
Добавьте в Express-приложение сборщик метрик, который будет хранить их в одном месте. Это поможет проанализировать данные и добраться до основной причины.
В чем прелесть – это
всего лишь одна строка кода. Добавьте ее в файлик app.js
.
const { stMonitor, stLogger, stHttpLoggerMiddleware } = require('sematext-agent-express') stMonitor.start() // запуск метода .start в stMonitor // В верхней части ваших маршрутов добавьте stHttpLoggerMiddleware для отправки журналов const express = require('express') const app = express() app.use(stHttpLoggerMiddleware) ...
Благодаря этому вы получите доступ к нескольким информационным панелям, дающим ключевое представление о том, что происходит с вашим Express-приложением. Данные можно фильтровать и группировать для визуализации процессов, памяти, использования CPU и HTTP-запросов/ответов. Что вы должны сделать сразу же – настроить оповещения об изменениях в работе софта.
Двигаемся дальше от Express.js к конкретным советам и рекомендациям по JavaScript и о том, как его использовать оптимизированным и надежным способом.
2. Как настроить окружение JavaScript
2.1. Чистые функции
Чистые функции – это функции, которые не изменяют внешнее состояние. Они принимают параметры, что-то делают с ними и возвращают значение.
Вместо использования
var
, применяйте только const
и полагайтесь на чистые функции для создания новых
объектов вместо изменения существующих. Это связано с использованием функций высокого
порядка в JavaScript, например .map()
, .reduce()
, .filter()
и т. д.
2.2. Параметры объекта
JavaScript – слабо типизированный язык. В вызов функции может быть передан один или несколько параметров. Даже если объявление функции имеет фиксированное число определенных аргументов. Этот огрех можно решить, используя объекты в качестве параметров функции.
const foo = ({ param1, param2, param3 }) => { if (!(param1 && param2 && param3)) { throw Error('Invalid parameters in function: foo.') } const sum = param1 + param2 + param3 return sum } foo({ param1: 5, param2: 345, param3: 98 }) foo({ param2: 45, param3: 57, param1: 81 })
Все эти вызовы функций будут работать одинаково. Вы можете принудительно указать имена параметров, при этом вы не связаны порядком, что значительно упрощает управление.
2.3. Тестирование
Используйте что-нибудь простое, например, Mocha и Chai. Mocha – это фреймворк для тестирования, а Chai – assertion библиотека.
Установите npm пакеты:
npm i mocha chai
Давайте потестим
функцию. В test.js
добавьте следующее:
const chai = require('chai') const expect = chai.expect const foo = require('./src/foo') describe('foo', function () { it('should be a function', function () { expect(foo).to.be.a('function') }) it('should take one parameter', function () { expect( foo.bind(null, { param1: 5, param2: 345, param3: 98 })) .to.not.throw(Error) }) it('should throw error if the parameter is missing', function () { expect(foo.bind(null, {})).to.throw(Error) }) it('should throw error if the parameter does not have 3 values', function () { expect(foo.bind(null, { param1: 4, param2: 1 })).to.throw(Error) }) it('should return the sum of three values', function () { expect(foo({ param1: 1, param2: 2, param3: 3 })).to.equal(6) }) })
Добавьте это в package.json
:
"scripts": { "test": "mocha" }
Теперь запустим тесты, выполнив следующую команду в терминале:
npm test
Выведется примерно такое:
> test-mocha@1.0.0 test /path/to/your/expressjs/project > mocha foo ✓ should be a function ✓ should take one parameter ✓ should throw error if the parameter is missing ✓ should throw error if the parameter does not have 3 values ✓ should return the sum of three values 5 passing (6ms)
3. Использование DevOps-инструментов
3.1. Управление переменными среды в Node.js с dotenv
Dotenv – это модуль
npm, позволяющий загружать переменные среды в любое Node.js приложение. В корне вашего проекта
создайте .env
файл. Здесь вы добавите все необходимые переменные окружения.
NODE_ENV=production DEBUG=false LOGS_TOKEN=xxx-yyy-zzz MONITORING_TOKEN=xxx-yyy-zzz INFRA_TOKEN=xxx-yyy-zzz ...
Загрузка файл проста. В верхней части app.js
разместите dotenv:
// dotenv вверху require('dotenv').config() // другие агенты const { stLogger, stHttpLoggerMiddleware } = require('sematext-agent-express') // требуется express и создать экземпляр приложения const express = require('express') const app = express() app.use(stHttpLoggerMiddleware) ...
Dotenv по умолчанию загружает файл с
именем .env
. При необходимости прочитайте руководство по настройке нескольких dotenv-файлов.
3.2. Перезапуск приложения с помощью Systemd
Systemd – часть строительных блоков ОС Linux. Он запускает и управляет системными процессами. Вам нужно запустить Node.js процесс, как системную службу, чтобы он восстанавливался после сбоев.
На виртуальной машине
или сервере создайте новый файл в разделе /lib/systemd/system/
:
# /lib/systemd/system/fooapp.service [Unit] Description=Node.js as a system service. Documentation=https://example.com After=network.target [Service] Type=simple User=ubuntu ExecStart=/usr/bin/node /path/to/your/express/project/server.js Restart=on-failure [Install] WantedBy=multi-user.target
Две важные строки в
этом файле – ExecStart
и Restart. ExecStart
запустит ваш server.js
с помощью
бинарника /usr/bin/node
(обязательно проверяйте абсолютный путь к файлу server.js
).
Функция Restart=on-failure
перезапустит приложение, если оно «обвалится».
После сохранения fooapp.service
,
перезагрузите демона и запустите скрипт.
systemctl daemon-reload systemctl start fooapp systemctl enable fooapp systemctl status fooapp
3.3. Перезапуск приложения с помощью PM2
PM2
существует уже несколько лет. Эти ребята используют специальный кастомный скрипт, управляющий
и запускающий server.js
. Он проще в настройке, но обременён другим Node.js- процессом, выступающим в качестве Master-процесса
и менеджера для вашей Express.js.
Сначала нужно установить PM2:
npm i -g pm2
Затем запустите приложение, выполнив следующую команду в корневом каталоге Express-проекта:
pm2 start server.js -i max
Флаг -I max
гарантирует, что приложение будет запущено в кластерном режиме, создавая
столько воркеров, сколько есть ядер у CPU.
4. Балансировка нагрузки и обратный прокси
4.1. Балансировка нагрузки с помощью кластерного модуля
Встроенный модуль Node.js
позволяет создавать рабочие процессы, обслуживающие ваше приложение. Он основан
на реализации child_process
и прост в настройке. Вам нужно лишь добавить файл cluster.js
и вставить в него
следующий код:
const cluster = require('cluster') const numCPUs = require('os').cpus().length const app = require('./src/app') const port = process.env.PORT || 3000 const masterProcess = () => Array.from(Array(numCPUs)).map(cluster.fork) const childProcess = () => app.listen(port) if (cluster.isMaster) { masterProcess() } else { childProcess() } cluster.on('exit', () => cluster.fork())
Когда вы запустите
cluster.js
c нодой cluster.js
, модуль кластера обнаружит, что он работает как
Master-процесс и вызовет функцию masterProcess()
. Она
подсчитает, сколько процессорных ядер имеет сервер, и вызовет cluster.fork()
. Все
эти процессы выполняются на одном и том же порту. Подробнее об этом читайте в официальном хелпе.
Слушатель событий
cluster.on('exit')
перезапустит рабочий процесс, если он завершится неудачей.
Теперь отредактируем
поле ExecStart
в приложении fooapp.service
. Замените:
ExecStart=/usr/bin/node /path/to/your/express/project/server.js
На следующее:
ExecStart=/usr/bin/node /path/to/your/express/project/cluster.js
Перезагрузите Systemd и
приложение fooapp.service
:
systemctl daemon-reload systemctl restart fooapp
Вы добавили балансировку нагрузки в Express-приложение. Это будет работать только для single-server. Если необходимо несколько серверов – используйте Nginx.
4.2. Добавление обратного прокси с помощью Nginx
Одно из основных правил в работе с приложениями Node.js – не вешайте их на порты 80 и 443. Для перенаправления трафика используйте обратный прокси. Nginx – самый распространённый инструмент для достижения этой цели.
Установка Nginx довольно проста, для Ubuntu это будет выглядеть так:
apt update apt install nginx
Если у вас другая ОС, ознакомьтесь с инструкциями по установке Nginx.
Nginx должен стартовать сразу же, но на всякий случай проверьте:
systemctl status nginx [Output] nginx.service - A high performance web server and a reverse proxy server Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled) Active: active (running) since Fri 2018-04-20 16:08:19 UTC; 3 days ago Docs: man:nginx(8) Main PID: 2369 (nginx) Tasks: 2 (limit: 1153) CGroup: /system.slice/nginx.service ├─2369 nginx: master process /usr/sbin/nginx -g daemon on; master_process on; └─2380 nginx: worker process
Если не запустился – выполните эту команду:
systemctl start nginx
После запуска Nginx необходимо отредактировать конфиг, чтобы включить обратный прокси. Конфиг Nginx
находится в каталоге /etc/nginx/
. Основной конфигурационный файл называется
nginx.conf
, но есть разные дополнения в каталоге etc/nginx/sites-available/
.
Конфигурация сервера по умолчанию находится здесь и называется default.
Чтобы включить обратный прокси-сервер, откройте файл конфигурации по умолчанию и отредактируйте его следующим образом:
server { listen 80; location / { proxy_pass http://localhost:3000; #change the port if needed } }
Сохранитесь и перезапустите службу Nginx:
systemctl restart nginx
Эта настройка будет роутить весь трафик с порта 80 на ваше Express-приложение.
4.3. Кэширование в nginx
Кэширование важно для сокращения времени отклика ресурсов, которые редко изменяются.
Отредактируйте nginx.conf
:
http { upstream fooapp { server localhost:3000; server domain2; server domain3; ... } ... }
Откройте дефолтный конфиг и добавьте эти строки кода:
server { listen 80; location / { proxy_pass http://fooapp; } }
4.4. Gzip
В серверном блоке конфига добавьте следующие строки:
server { gzip on; gzip_types text/plain application/xml; gzip_proxied no-cache no-store private expired auth; gzip_min_length 1000; ... }
Если нужно больше информации – читайте официальный хелп.
4.5. Включение кэширования на Redis
Redis – in-memory хранилище, которое часто используется в качестве кэша.
Установка на Ubuntu проста:
apt update apt install redis-server
Откройте файл /etc/redis/redis.conf
и измените одну
важную строку:
supervised no
на следующее:
supervised systemd
Перезапустите службу Redis:
systemctl restart redis systemctl status redis [Output] ● redis-server.service - Advanced key-value store Loaded: loaded (/lib/systemd/system/redis-server.service; enabled; vendor preset: enabled) Active: active (running) since Wed 2018-06-27 18:48:52 UTC; 12s ago Docs: http://redis.io/documentation, man:redis-server(1) Process: 2421 ExecStop=/bin/kill -s TERM $MAINPID (code=exited, status=0/SUCCESS) Process: 2424 ExecStart=/usr/bin/redis-server /etc/redis/redis.conf (code=exited, status=0/SUCCESS) Main PID: 2445 (redis-server) Tasks: 4 (limit: 4704) CGroup: /system.slice/redis-server.service └─2445 /usr/bin/redis-server 127.0.0.1:6379
Затем установите модуль redis для доступа к Redis из приложения:
npm i redis
Теперь вы можете начать кэшировать запросы. Рассмотрим пример:
const express = require('express') const app = express() const redis = require('redis') const redisClient = redis.createClient(6379) async function getSomethingFromDatabase (req, res, next) { try { const { id } = req.params; const data = await database.query() // Установим данные в Redis redisClient.setex(id, 3600, JSON.stringify(data)) res.status(200).send(data) } catch (err) { console.error(err) res.status(500) } } function cache (req, res, next) { const { id } = req.params redisClient.get(id, (err, data) => { if (err) { return res.status(500).send(err) } // Если данные существуют вернем кэшированное значение if (data != null) { return res.status(200).send(data) } // Если данные не существуют, идем в функцию getSomethingFromDatabase next() }) } app.get('/data/:id', cache, getSomethingFromDatabase) app.listen(3000, () => console.log(`Server running on Port ${port}`))
Этот код будет кэшировать ответ из БД в виде строки JSON в Redis в течение 3600 секунд. Вы можете изменить это в зависимости от требований.
Заключение
Идея этой статьи состояла в том, чтобы охватить лучшие практики, которых вы должны придерживаться и те, от которых следует держаться подальше.
Надеемся, что вам понравилось. Удачи в обучении!