eFusion 06 мая 2020

Секреты создания производительных веб-приложений на 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 секунд. Вы можете изменить это в зависимости от требований.

Заключение

Идея этой статьи состояла в том, чтобы охватить лучшие практики, которых вы должны придерживаться и те, от которых следует держаться подальше.

Надеемся, что вам понравилось. Удачи в обучении!

Источники

РУБРИКИ В СТАТЬЕ

МЕРОПРИЯТИЯ

Комментарии 0

ВАКАНСИИ

Programmer UE4
Краснодар, по итогам собеседования
Java-разработчик
Москва, от 180000 RUB до 230000 RUB
Unity3D Developer
по итогам собеседования
Middle\Senior .Net разработчик
от 120000 RUB до 165000 RUB

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

BUG