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

В основу данного материала легли ответы на часто задаваемые здесь вопросы, касающиеся структурирования сложных приложений для 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 – очень гибкая среда, поэтому нельзя говорить о том, что одни решения в областях структуры и масштабирования приложений – истина в последней инстанции, а другие – совершенно недопустимы. Не исключено, что у вас есть собственные наработки, которые, возможно, в корне отличаются от изложенных выше рекомендаций, а может быть – идут в том же русле. Если такие наработки у вас имеются – будет замечательно, если вы ими поделитесь.

