[Перевод] О структуре и масштабировании сложных приложений для Node.JS

Структура программных проектов — это важно. От решений, принятых в самом начале работы, зависит то, какой будет эта работа в течение всего жизненного цикла продукта.

f55055a1e2d5415882364df22ab69b37.jpg

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

Вот основные темы, которые мы здесь раскроем:

  • Разработка хорошо масштабируемых приложений, которые легко поддерживать.
  • Качественное разделение конфигурационных данных и основного кода приложения.
  • Использование в Node.js-приложениях процессов различных типов.

Здесь мы, иллюстрируя различные концепции, будем пользоваться приложением-примером, полный код которого можно найти на GitHub.

Обзор демонстрационного проекта


Наше приложение получает данные из Твиттера, подписавшись на обновления по определённым ключевым словам. Подходящие твиты передаются в очередь RabbitMQ. Содержимое очереди обрабатывается и сохраняется в базе данных Redis. Кроме того, в приложении имеется REST API, предоставляющее доступ к сохранённым твитам.

Структура файлов проекта выглядит так:

.
|-- config
|   |-- components
|   |   |-- common.js
|   |   |-- logger.js
|   |   |-- rabbitmq.js
|   |   |-- redis.js
|   |   |-- server.js
|   |   `-- twitter.js
|   |-- index.js
|   |-- social-preprocessor-worker.js
|   |-- twitter-stream-worker.js
|   `-- web.js
|-- models
|   |-- redis
|   |   |-- index.js
|   |   `-- redis.js
|   |-- tortoise
|   |   |-- index.js
|   |   `-- tortoise.js
|   `-- twitter
|       |-- index.js
|       `-- twitter.js
|-- scripts
|-- test
|   `-- setup.js
|-- web
|   |-- middleware
|   |   |-- index.js
|   |   `-- parseQuery.js
|   |-- router
|   |   |-- api
|   |   |   |-- tweets
|   |   |   |   |-- get.js
|   |   |   |   |-- get.spec.js
|   |   |   |   `-- index.js
|   |   |   `-- index.js
|   |   `-- index.js
|   |-- index.js
|   `-- server.js
|-- worker
|   |-- social-preprocessor
|   |   |-- index.js
|   |   `-- worker.js
|   `-- twitter-stream
|       |-- index.js
|       `-- worker.js
|-- index.js
`-- package.json

В проекте имеется 3 процесса:
  • Процесс twitter-stream-worker взаимодействует с Твиттером, используя потоковое API. Он получает твиты, содержащие определённые ключевые слова, после чего отправляет их в очередь RabbitMQ.
  • Процесс social-preprocessor-worker работает с очередью RabbitMQ. А именно — записывает твиты из неё в хранилище Redis и удаляет старые данные.
  • Процесс web обслуживает REST API с одной конечной точкой: GET /api/v1/tweets?limit&offset.

На том, чем различаются процессы web и worker, мы остановимся позже, а сейчас поговорим о конфигурационных данных решения.

Поддержка различных сред выполнения и конфигураций приложения


Конфигурационные данные для конкретного экземпляра приложения следует загружать из переменных окружения. Их не надо добавлять в код как константы.

Речь идёт о параметрах, которые могут не совпадать в различных вариантах развёртывания приложения и в разных средах выполнения. Например, это может быть запуск в среде разработки, на билд-сервере, в среде, максимально приближенной к рабочей, и, наконец, в продакшн-окружении. Такой подход позволяет иметь единую кодовую базу приложения, способную работать в любых условиях.

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

Доступ к переменным окружения можно получить с помощью объекта process.env. В объекте хранятся только строковые значения, поэтому тут может понадобиться конверсия типов.

// config/config.js
'use strict'

// необходимые переменные окружения
[
  'NODE_ENV',
  'PORT'
].forEach((name) => {
  if (!process.env[name]) {
    throw new Error(`Environment variable ${name} is missing`)
  }
})

const config = {  
  env: process.env.NODE_ENV,
  logger: {
    level: process.env.LOG_LEVEL || 'info',
    enabled: process.env.BOOLEAN ? process.env.BOOLEAN.toLowerCase() === 'true' : false
  },
  server: {
    port: Number(process.env.PORT)
  }
  // ...
}

module.exports = config

Проверка конфигурационных данных


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

Вот как мы улучшили файл config.js, добавив в него проверку данных с использованием валидатора joi.

// config/config.js
'use strict'

const joi = require('joi')

const envVarsSchema = joi.object({  
  NODE_ENV: joi.string()
    .allow(['development', 'production', 'test', 'provision'])
    .required(),
  PORT: joi.number()
    .required(),
  LOGGER_LEVEL: joi.string()
    .allow(['error', 'warn', 'info', 'verbose', 'debug', 'silly'])
    .default('info'),
  LOGGER_ENABLED: joi.boolean()
    .truthy('TRUE')
    .truthy('true')
    .falsy('FALSE')
    .falsy('false')
    .default(true)
}).unknown()
  .required()

const { error, value: envVars } = joi.validate(process.env, envVarsSchema)  
if (error) {  
  throw new Error(`Config validation error: ${error.message}`)
}

const config = {  
  env: envVars.NODE_ENV,
  isTest: envVars.NODE_ENV === 'test',
  isDevelopment: envVars.NODE_ENV === 'development',
  logger: {
    level: envVars.LOGGER_LEVEL,
    enabled: envVars.LOGGER_ENABLED
  },
  server: {
    port: envVars.PORT
  }
  // ...
}

module.exports = config

Разделение конфигурационных данных


Все конфигурационные данные можно держать в одном файле, но, в ходе роста и развития проекта, такой файл будет увеличиваться в размерах, работать с ним будет неудобно. Для того, чтобы этих проблем избежать, настройки имеет смысл разделить, основываясь, например, на компонентах приложения. В нашем примере это выглядит так:
// config/components/logger.js
'use strict'

const joi = require('joi')

const envVarsSchema = joi.object({  
  LOGGER_LEVEL: joi.string()
    .allow(['error', 'warn', 'info', 'verbose', 'debug', 'silly'])
    .default('info'),
  LOGGER_ENABLED: joi.boolean()
    .truthy('TRUE')
    .truthy('true')
    .falsy('FALSE')
    .falsy('false')
    .default(true)
}).unknown()
  .required()

const { error, value: envVars } = joi.validate(process.env, envVarsSchema)  
if (error) {  
  throw new Error(`Config validation error: ${error.message}`)
}

const config = {  
  logger: {
    level: envVars.LOGGER_LEVEL,
    enabled: envVars.LOGGER_ENABLED
  }
}

module.exports = config

После этого в основном файле config.js нужно лишь скомбинировать параметры компонентов.
// config/config.js
'use strict'

const common = require('./components/common')  
const logger = require('./components/logger')  
const redis = require('./components/redis')  
const server = require('./components/server')

module.exports = Object.assign({}, common, logger, redis, server)

Обратите внимание на то, что не следует группировать конфигурационные данные по признаку рабочего окружения, то есть, скажем, держать в файле config/production.js настройки для продакшн-версии приложения. Такой подход препятствует масштабируемости приложения, например, в ситуации, когда со временем ту же продакшн-версию надо будет развёртывать в различных средах.

Организация многопроцессного приложения


Процесс — это основной строительный блок современных приложений. Программный продукт может состоять из множества процессов, которые не отслеживают собственное состояние. В нашем примере используются именно такие процессы. Так, HTTP-запросы может обработать процесс web, а делать что-то в соответствии с расписанием, или выполнять некие операции, которые занимают много времени, могут процессы worker. Информация, которую надо хранить, записана в базу данных. Благодаря такой архитектуре, решение хорошо поддаётся масштабированию за счёт запуска параллельно исполняющихся процессов. Критериями необходимости увеличения числа процессов могут быть различные метрики, например, нагрузка на приложение.

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

В файле config/index.js:

// config/index.js
'use strict'

const processType = process.env.PROCESS_TYPE

let config  
try {  
  config = require(`./${processType}`)
} catch (ex) {
  if (ex.code === 'MODULE_NOT_FOUND') {
    throw new Error(`No config for process type: ${processType}`)
  }

  throw ex
}

module.exports = config  

В корневом файле index.js запускаем нужный процесс с переменной окружения PROCESS_TYPE:
// index.js
'use strict'

const processType = process.env.PROCESS_TYPE

if (processType === 'web') {  
  require('./web')
} else if (processType === 'twitter-stream-worker') {
  require('./worker/twitter-stream')
} else if (processType === 'social-preprocessor-worker') {
  require('./worker/social-preprocessor')
} else {
  throw new Error(`${processType} is an unsupported process type. Use one of: 'web', 'twitter-stream-worker', 'social-preprocessor-worker'!`)

}

В результате оказывается, что приложение у нас одно, но разбито оно на множество независимых процессов. Каждый из них можно запустить индивидуально, при необходимости — поднять несколько параллельных процессов одного вида, которые не повлияют на другие части приложения. При этом частями кода, вроде моделей, могут совместно пользоваться разные процессы, что способствует соблюдению в ходе разработки принципа DRY.

Организация файлов с тестами


Файлы с тестами стоит размещать рядом с тестируемыми модулями, использовав при этом некое соглашение об именовании, вроде .spec.js и .e2e.spec.js. Тесты должны развиваться вместе с модулями, которые они проверяют. Если файлы тестов отделены от файлов с логикой приложения, их сложнее будет искать и поддерживать в актуальном состоянии.

В отдельной папке /test имеет смыл хранить все дополнительные тесты и утилиты, которые не используются самим приложением.

Размещение build-файлов и файлов скриптов


Мы обычно создаём папку /scripts, в которую помещаем bash-скрипты, скрипты Node.js для синхронизации базы данных, сборки фронт-энда и так далее. Благодаря такому подходу, скрипты отделены от основного кода приложения, да и корневая директория проекта не окажется, через некоторое время, переполнена файлами скриптов. Для того, чтобы всем этим было удобнее пользоваться, можно зарегистрировать скрипты в разделе scripts файла package.json.

Выводы


Надеемся, наши идеи о структурировании и масштабировании сложных проектов для Node.js принесут вам пользу. Вот, кстати, ещё материал на эту тему.

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

Комментарии (1)

  • 22 февраля 2017 в 13:48

    0

    Почему бы для конфигурирования не использовать config?

© Habrahabr.ru