Структура программных проектов – это важно. От решений, принятых в самом начале работы, зависит то, какой будет эта работа в течение всего жизненного цикла продукта.
В основу данного материала легли ответы на часто задаваемые здесь вопросы, касающиеся структурирования сложных приложений для Node.js. Он предназначен для всех, кто чувствует потребность в улучшении структуры собственных разработок.
Вот основные темы, которые мы здесь раскроем:
Здесь мы, иллюстрируя различные концепции, будем пользоваться приложением-примером, полный код которого можно найти на GitHub.
Наше приложение получает данные из Твиттера, подписавшись на обновления по определённым ключевым словам. Подходящие твиты передаются в очередь RabbitMQ. Содержимое очереди обрабатывается и сохраняется в базе данных Redis. Кроме того, в приложении имеется REST API, предоставляющее доступ к сохранённым твитам.
Структура файлов проекта выглядит так:
В проекте имеется 3 процесса:
На том, чем различаются процессы
Конфигурационные данные для конкретного экземпляра приложения следует загружать из переменных окружения. Их не надо добавлять в код как константы.
Речь идёт о параметрах, которые могут не совпадать в различных вариантах развёртывания приложения и в разных средах выполнения. Например, это может быть запуск в среде разработки, на билд-сервере, в среде, максимально приближенной к рабочей, и, наконец, в продакшн-окружении. Такой подход позволяет иметь единую кодовую базу приложения, способную работать в любых условиях.
Хороший способ проверки корректности разделения конфигурационных данных и внутренних механизмов приложения заключается в следующем. Если код проекта можно, в любой момент работы над ним, выложить в открытый доступ, значит логика и настройки разделены как следует. Это автоматически означает защиту от попадания секретных данных или параметров учётных записей в систему контроля версий.
Доступ к переменным окружения можно получить с помощью объекта
Не включать настройки в код – решение правильное, но весьма полезно ещё и проверять переменные окружения перед их использованием. Это поможет обнаружить ошибки конфигурации в самом начале работы и избежать ситуаций, в которых приложение попытается работать с неверными или отсутствующими настройками. О плюсах раннего обнаружении ошибок в конфигурационных данных можно почитать здесь.
Вот как мы улучшили файл
Все конфигурационные данные можно держать в одном файле, но, в ходе роста и развития проекта, такой файл будет увеличиваться в размерах, работать с ним будет неудобно. Для того, чтобы этих проблем избежать, настройки имеет смысл разделить, основываясь, например, на компонентах приложения. В нашем примере это выглядит так:
После этого в основном файле
Обратите внимание на то, что не следует группировать конфигурационные данные по признаку рабочего окружения, то есть, скажем, держать в файле
Процесс – это основной строительный блок современных приложений. Программный продукт может состоять из множества процессов, которые не отслеживают собственное состояние. В нашем примере используются именно такие процессы. Так, HTTP-запросы может обработать процесс
Выше мы говорили о разделении конфигурационных данных по компонентам. Этот подход оказывается очень кстати при наличии в проекте процессов различных типов. Процесс каждого типа может получить собственные настройки, запрашивая лишь необходимые ему компоненты, не ожидая наличия не использованных ранее переменных среды.
В файле
В корневом файле
В результате оказывается, что приложение у нас одно, но разбито оно на множество независимых процессов. Каждый из них можно запустить индивидуально, при необходимости – поднять несколько параллельных процессов одного вида, которые не повлияют на другие части приложения. При этом частями кода, вроде моделей, могут совместно пользоваться разные процессы, что способствует соблюдению в ходе разработки принципа DRY.
Файлы с тестами стоит размещать рядом с тестируемыми модулями, использовав при этом некое соглашение об именовании, вроде
В отдельной папке
Мы обычно создаём папку
Надеемся, наши идеи о структурировании и масштабировании сложных проектов для Node.js принесут вам пользу. Вот, кстати, ещё материал на эту тему.
Node.js – очень гибкая среда, поэтому нельзя говорить о том, что одни решения в областях структуры и масштабирования приложений – истина в последней инстанции, а другие – совершенно недопустимы. Не исключено, что у вас есть собственные наработки, которые, возможно, в корне отличаются от изложенных выше рекомендаций, а может быть – идут в том же русле. Если такие наработки у вас имеются – будет замечательно, если вы ими поделитесь.
В основу данного материала легли ответы на часто задаваемые здесь вопросы, касающиеся структурирования сложных приложений для 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.
Организация файлов с тестами
Файлы с тестами стоит размещать рядом с тестируемыми модулями, использовав при этом некое соглашение об именовании, вроде
<module_name>.spec.js
и <module_name>.e2e.spec.js
. Тесты должны развиваться вместе с модулями, которые они проверяют. Если файлы тестов отделены от файлов с логикой приложения, их сложнее будет искать и поддерживать в актуальном состоянии.В отдельной папке
/test
имеет смыл хранить все дополнительные тесты и утилиты, которые не используются самим приложением.Размещение build-файлов и файлов скриптов
Мы обычно создаём папку
/scripts
, в которую помещаем bash-скрипты, скрипты Node.js для синхронизации базы данных, сборки фронт-энда и так далее. Благодаря такому подходу, скрипты отделены от основного кода приложения, да и корневая директория проекта не окажется, через некоторое время, переполнена файлами скриптов. Для того, чтобы всем этим было удобнее пользоваться, можно зарегистрировать скрипты в разделе scripts файла package.json
.Выводы
Надеемся, наши идеи о структурировании и масштабировании сложных проектов для Node.js принесут вам пользу. Вот, кстати, ещё материал на эту тему.
Node.js – очень гибкая среда, поэтому нельзя говорить о том, что одни решения в областях структуры и масштабирования приложений – истина в последней инстанции, а другие – совершенно недопустимы. Не исключено, что у вас есть собственные наработки, которые, возможно, в корне отличаются от изложенных выше рекомендаций, а может быть – идут в том же русле. Если такие наработки у вас имеются – будет замечательно, если вы ими поделитесь.