Node.JS / Разработка WEB-проекта на Node.JS: Часть 2

в 9:24, , рубрики: node.js, web программирование, web-разработка, метки: , ,

В прошлой статье я начал рассказывать о своём опыте разработки экспериментального WEB-проекта «Что делать?» на Node.JS. Первая часть была обзорной, в ней я постарался раскрыть плюсы и минусы технологии, а также предупредить о проблемах, с которыми, возможно, придётся столкнуться в ходе разработки. В этой статье я подробнее остановлюсь на технических деталях.

Несколько слов о «хабраэффекте»

Честно говоря, после периодических наблюдений за падениями сайтов, ссылки на которые попадают на главную хабра, я ожидал увидеть гораздо более серьёзные цифры. Обе предыдущие статьи побывали на главной странице. Хотя первая статья находилась в закрытом блоге «Я пиарюсь» и была видна только его подписчикам, а вторая в профильном блоге «Node.JS» и вызвала довольно продолжительную дискуссию в комментариях — количество людей пришедших на сайт с обоих статей было примерно одинаковым. Одинаково маленьким.
Node.JS / Разработка WEB проекта на Node.JS: Часть 2

Node.JS / Разработка WEB проекта на Node.JS: Часть 2

Эти цифры слишком малы, чтобы говорить о какой-либо серьёзной нагрузке. На самом пике заходов htop показывал приблизительно следующую картину:
Node.JS / Разработка WEB проекта на Node.JS: Часть 2

Load average иногда доходил до 1, но потом опять спускался до 0.3-0.5. Страницы отдавались быстро. Среднее время генерации страницы, данные для формирования которой есть в memcached — 15-20мс. Если данные в memcached отсутствуют, время генерации увеличивается до 40-100мс, но такое бывает крайне редко. Некоторые посетители тестировали сайт при помощи утилит siege и ab, а также при помощи сервиса LoadImpact. На тот момент я был уверен, что все страницы хорошо кешируется Nginx'ом и эти запросы не доходят до Node.JS. Оказалось, что это было не так. Позже я обнаружил некорректное поведение одного из модулей, которое препятствовало кешированию страниц (подробнее я расскажу об этом далее). Фактически все запросы обслуживал Node.JS и при этом сайт работал стабильно.

К сожалению я не знаю, насколько сильно различается «хабраэффект» в зависимости от тематики статьи и тематики сайта, на который установлена ссылка. Но если сайт падает от такого же (ну или даже x2) количества людей, то тут проблема далеко не в выборе технологии.

На основании тестов и данных о посещаемости я сделал вывод, что проект довольно устойчив и не упадёт при резком наплыве посетителей.

Архитектура

Железо и ПО

Сайт живёт на VPS со скромными характеристиками:

  • 1 CPU с гарантированной частотой 1200Mhz;
  • 1024Mb RAM;
  • 25Gb HDD (для данного проекта этот показатель не играет особой роли).

На сервере установлена ОС Ubuntu Server. Я к ней привык, мне удобно с ней работать, поэтому я выбрал именно её.

Дополнительное программное обеспечение установлено по минимуму. В данном случае это:

  • Node.JS — Среда исполнения JavaScript приложений на сервере;
  • MongoDB — NoSQL СУБД;
  • Memcached — Кеширующий демон;
  • Nginx — Frontend-сервер.

Версии ПО я стараюсь поддерживать в актуальном состоянии.

Конфигурация

По умолчанию Node.JS работает в одном потоке, что не совсем удобно и не оптимально, особенно для многоядерных процессоров. Практически сразу появились модули, для удобного запуска нескольких процессов (различные реализации Web Workers). Не сложно было сделать это и с помощью стандартного API Node.JS. С выходом версии 0.6.0 в Node.JS появился новый модуль — Cluster. Он значительно упрощает задачу запуска нескольких процессов Node.JS. API этого модуля позволяет форкать процессы node, net/http-серверы которых будут использовать общий TCP порт. Родительский процесс может управлять дочерними процессами: останавливать, запускать новые, реагировать на неожиданные завершения. Дочерние процессы могут обмениваться сообщениями со своим родителем.

Несмотря на то, что c помощью Cluster удобно запускать необходимое количество процессов, я запускаю 2 экземпляра node на разных TCP портах. Делаю я это для того, чтобы при обновлении избежать простоя приложения, иначе во время перезагрузки (которая, к слову, занимает всего несколько секунд), сайт будет недоступен пользователям. Между экземплярами node, нагрузка распределяется при помощи HttpUpstreamModule Nginx'а. Когда, во время перезагрузки, один из экземпляров становится недоступен, все запросы берёт на себя второй.

Nginx настроен так, что для не авторизованных пользователей все страницы сайта кешируются на небольшой промежуток времени — 1 минута. Это позволяет значительно снять нагрузку с Node.JS и при этом отображать актуальный контент довольно быстро. Для авторизованных пользователей время кеширования установлено в 3 секунды. Это совершенно незаметно для обычных пользователей, но спасёт от злоумышленников, пытающихся нагрузить сайт большим количеством запросов содержащих cookie авторизации.

Модули

При разработке приложений на Node.JS очень часто встаёт вопрос выбора модуля для выполнения той или иной задачи. Для некоторых задач есть уже проверенные, популярные модули, для других — выбор сделать сложнее. Выбирая модуль стоит ориентироваться на количество наблюдателей, количество форков и даты последних коммитов (сейчас речь идёт о GitHub). По этим показателям можно определить жив ли проект. Сильно облегчает данную задачу недавно появившийся сервис The Node Toolbox.

Теперь настало время рассказать о модулях, которые я выбрал для разработки проекта.

connect

github.com/senchalabs/connect
Этот модуль является надстройкой над http-сервером Node.JS и существенно расширяет его возможности. Он добавляет такой функционал, как роутинг, поддержку cookies, поддержку сессий, разбор тела запроса и многое другое, без чего разработка web-приложения на Node.JS, скорее всего, превратится в кошмар. Большинство возможностей connect реализовано в виде plugin'ов. Существуют также множество не менее полезных plugin'ов для connect, которые не входят в его стандартную поставку. Добавить недостающий функционал, разработав свой plugin, также довольно просто.

Несмотря на популярность данного модуля и на его быстрое развитие, проблема, которая не давала Nginx’у кешировать ответ от Node.JS, находилась именно в нём. По умолчанию директива proxy_cache в Nginx не кеширует ответы бекэнда если в них присутствует хотябы один из следующих заголовков:

  • Set-Cookie;
  • Cache-Control содержащий значения «no-cache», «no-store», «private», или «max-age» с не числовым или нулевым значением;
  • Expires с датой в прошлом;
  • X-Accel-Expires: 0.

В connect сессии были реализованы так, что заголовок Set-Cookie отправлялся при каждом ответе. Это было сделано для поддержки сессий со временем жизни больше чем время жизни сессии браузера. В PHP, если установить время сессии в конкретное значение — она завершится по истечении этого времени даже если пользователь активен на сайте. Connect использует другую политику — cookie обновляется при каждом запросе и время её жизни начинает отсчитываться от текущего, т.е. пока пользователь активен — сессия не завершится. Подход PHP мне кажется более верным, т.к. сессия всё же не предназначена для длительного хранения данных. Я внёс соответствующие изменения в код и отправил pull request. Далее после небольшого обсуждения (не пинайте за мой английский) было найдено компромиссное решение — для сессий expires которых не установлен, отправка cookie теперь происходит только один раз. Для сессий с жёстко заданным временем жизни эта проблема пока осталась не решённой.

connect-memcached

github.com/balor/connect-memcached
Данный модуль является plugin'ом для connect. Он добавляет возможность хранения сессий в memcached. Без дополнительных plugin'ов connect умеет хранить сессии только в памяти одного процесса. Этого явно недостаточно для применения в боевых условиях, поэтому для всех популярных хранилищ уже разработаны соответствующие plugin'ы.

async

github.com/caolan/async
Без этого модуля писать асинхронный код для Node.JS было бы намного сложнее. В этой библиотеке собраны методы, которые позволяют «жонглировать» асинхронными вызовами и не раздувать код множеством вложенных друг в друга функций. Например, запустить несколько асинхронных вызовов и выполнить некоторое действие по их завершению становится намного проще. Я очень рекомендую ознакомиться с полным набором возможностей этой библиотеки, чтобы избежать в дальнейшем изобретения велосипедов.

node-oauth

github.com/ciaranj/node-oauth
Этот модуль реализует протоколы OAuth и OAuth2, что позволяет довольно просто обеспечить авторизацию пользователя на сайте через социальные сети, поддерживающие эти протоколы.

node-cron

github.com/ncb000gt/node-cron
Название этого модуля говорит само за себя. Он позволяет выполнять задачи по расписанию. Синтаксис расписаний очень похож на cron к которому все привыкли в linux, но, в отличие от него, node-cron поддерживает секундные интервалы запуска. Т.е можно настроить запуск метода раз в 10 секунд или даже каждую секунду. Такие задачи, как вывод популярных вопросов на главную и публикацию их в Twitter, запускаются при помощи этого модуля.

node-twitter

github.com/jdub/node-twitter
Данный модуль реализует взаимодействие приложения с Twitter API. Для работы он использует вышеописанный модуль node-oauth.

node-mongodb-native

github.com/christkv/node-mongodb-native
Этот модуль является интерфейсом к NoSQL СУБД MongoDB. Среди своих конкурентов он выделяется лучшей проработанностью и быстрым развитием. Открытие нескольких соединение с БД (pool) поддерживается из коробки, что избавляет от написания собственных костылей. На основе этого модуля разработана довольно удобная ORM Mongoose.

node-memcached

github.com/3rd-Eden/node-memcached
Это лучший, на мой взгляд, интерфейс доступа к memcached из Node.JS. Он поддерживает несколько серверов memcached и распределение ключей между ними, а также пул соединений.

http-get

github.com/SaltwaterC/http-get
Этот модуль предназначен для доступа к удалённым ресурсам по протоколам HTTP/HTTPS. С его помощью скачиваются фотографии пользователей, авторизующихся на сайте через социальные сети.

sprintf

github.com/maritz/node-sprintf
Небольшой, но очень полезный модуль, который, как видно из его названия, реализует функции sprintf и vsprintf на JavaScript.

daemon.node

github.com/indexzero/daemon.node
Данный модуль позволяет очень просто сделать из Node.JS приложения демон. С его помощью удобно отвязывать приложение от консоли и перенаправлять вывод в файлы логов.

Мой вклад

Следующие модули, разработаны мной во время работы над проектом, т.к. на момент их написания мне не удалось найти подходящих на их место готовых решений. Эти модули опубликованы на GitHub и в каталоге модулей npm.

aop

github.com/baryshev/aop
Этот модуль пока не претендует на полную реализацию паттерна AOP. Сейчас он содержит один единственный метод, позволяющий обернуть функцию в аспект, который, при необходимости, может изменять её поведение. Такую технику очень удобно применять для кеширования результатов работы функций.

Например у нас есть некая асинхронная функция:

	var someAsyncFunction = function(num, callback) { 		var result = num * 2; 		callback(undefined, result); 	};

Эта функция часто вызывается и результат её нужно закешировать. Обычно это выглядит прмерно так:

var someAsyncFunction = function(num, callback) { 	var key = 'someModule' + '_' + 'someAsyncFunction' + '_' + num; 	cache.get(key, function(error, cachedResult) { 		if (error || !cachedResult) { 			var result = num * 2; 			callback(undefined, result); 			cache.set(key, result); 		} else { 			callback(undefined, cachedResult); 		} 	}); };

Таких функций в проекте может быть очень много. Код будет сильно раздуваться и становится менее читаемым, появляется большое количество копипаста. А вот так можно сделать тоже самое при помощи aop.wrap:

var someAsyncFunction = function(num, callback) { 	var result = num * 2; 	callback(undefined, result); };  /**  * Первый параметр - ссылка на объект this для обёртки  * Второй параметр - функция, которую мы заворачиваем в аспект  * Третий параметр - аспект, который будет выполнятся при вызове заворачиваемой функции  * Последующие параметры - произвольные параметры, которые получает аспект (используются для настройки его поведения)  */ someAsyncFunction = aop.wrap(someAsyncFunction, someAsyncFunction, aspects.cache, 'someModule', 'someAsyncFunction');

Отдельно мы создаём библиотеку aspects и определяем там функцию cache, которая будет отвечать за кеширование всего и вся.

module.exports.cache = function(method, params, moduleName, functionName) { 	var that = this; 	// Такое формирование ключа кеширования приведено для простоты примера 	var key = moduleName + '_' + functionName + '_' + params[0]; 	cache.get(key, function(error, cachedResult) { 		// Получаем ссылку на callback-функцию (всегда передаётся последним параметром) 		var callback = params[params.length - 1]; 		if (error || !cachedResult) { 			// Результата в кеше не нашли, передаём управление в метод, подменяя callback-функцию 			params[params.length - 1] = function(error, result) { 				callback(error, result); 				if (!error) cache.set(key, result); 			}; 			method.apply(that, params); 		} else { 			callback(undefined, cachedResult); 		} 	}); };

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

В будущем я планирую расширить эту библиотеку реализацией остальных возможностей паттерна АОП.

form

github.com/baryshev/form
Задача данного модуля — проверка и фильтрация входных данных. Чаще всего это формы, но также это могут быть данные полученные при запросе от внешних API и т.п. Этот модуль включает в себя библиотеку node-validator и позволяет в полной мере использовать её возможности.

Принцип работы этого модуля такой: каждая форма описывается набором полей на которые навешиваются фильтры (функции влияющие на значение поля) и валидаторы (функции проверяющие значение поля на соответствие условию). При получении данных они передаются в метод формы process. В callback мы получим либо описание ошибки (если какие-либо данные не соответствовали критериям формы) либо объект, содержащий отфильтрованный набор полей и готовый к дальнейшему использованию. Небольшой пример использования:

var fields = { 	text: [ 		form.filter(form.Filter.trim), 		form.validator(form.Validator.notEmpty, 'Empty text'), 		form.validator(form.Validator.len, 'Bad text length', 30, 1000) 	], 	name: [ 		form.filter(form.Filter.trim), 		form.validator(form.Validator.notEmpty, 'Empty name') 	] };  var textForm = form.create(fields);  textForm.process({'text' : 'some short text', 'name': 'tester'}, function(error, data) { 	console.log(error); 	console.log(data); });

В данном случае мы получим ошибку ‘Bad text length’ для поля text, т.к. длина переданного текста меньше 30 символов.

Фильтры и валидаторы выполняются последовательно, поэтому даже если добавить в конец строки множество пробелов, мы всё равно получим ошибку, т.к. перед проверкой пробелы будут удалены фильтром trim.

Как создавать собственные фильтры и валидаторы можно прочитать на странице node-validator или посмотреть исходный код.В планах на будущее сделать порт этого модуля для использования в браузере и хорошо задокументировать его возможности.

configjs

github.com/baryshev/configjs
Данный модуль предназначен для удобного конфигурирования приложения. Конфигурации хранятся в обычных JS-файлах, это даёт возможность использовать JavaScript при конфигурировании и делает не нужным дополнительный разбор файлов. Можно создавать несколько дополнительных конфигураций для различных окружений (разработка, продакшн, тестирование и т.п.), которые будут расширять и/или изменять основную конфигурацию.

localejs

github.com/baryshev/localejs
Этот модуль очень похож на configjs, но предназначен для хранения строк разных локалей для поддержки мультиязычности. Модуль вряд ли подойдёт для приложений с большим количеством текста. В таком случае будет удобнее использовать решения на подобии GetText. Кроме средств загрузки необходимой локали, модуль содержит функцию вывода числительных, поддерживающую русский и английский язык.

hub

github.com/baryshev/hub/blob/master/lib/index.js
Наверное этот модуль может претендовать на звание самого маленького модуля для Node.JS. Он состоит всего из одной строки: module.exports = {}; При этом без него разработка была бы намного сложнее. Этот модуль является контейнером, для хранения объектов во время работы приложения. Он использует особенность Node.JS — при подключении модуль инициализируется только один раз. Все вызовы require(‘moduleName’), сколько бы их ни было в приложении, возвращают ссылку на один и тот же экземпляр модуля, инициализированный при первом упоминании. Фактически он заменяет использование глобального пространства для расшаривания ресурсов между частями приложения. Такая необходимость возникает довольно часто. Примеры: пул соединений с СУБД, пул соединений с кешем, ссылка на загруженную конфигурацию и локаль. Эти ресурсы нужны во многих частях приложения и доступ к ним должен быть простым. При инициализации ресурса он присваивается свойству объекта hub и в дальнейшем к нему можно обратиться из любого другого модуля предварительно подключив к нему hub.

connect-response

Этот plugin для connect добавляет возможность простой работы с cookies, а также включает в себя шаблонизатор, для формирования ответа пользователю. Я разработал свой шаблонизатор. Получилось довольно неплохо. За основу был взят шаблонизатор EJS, но в конечном итоге получился совершенно другой продукт, со своим функционалом, хотя и с похожим синтаксисом. Но это большая тема для отдельной статьи.

К сожалению этот модуль ещё не опубликован, т.к. не оформлен должным образом и не все ошибки ещё исправлены. Я собираюсь доделать и опубликовать его в ближайшем будущем, как только появится немного свободного времени.

Структура приложения

Поскольку приложение не использует фреймворки, его структура не завязана на какие-либо правила, кроме общего стиля написания приложений на Node.JS и здравого смысла. Приложение использует модель MVC.

Запускаемый файл server.js содержит в себе инициализацию основных ресурсов приложения: запуск http-сервера, настройку connect, загрузку конфигурации и языка, установку соединений с MongoDB и Memcached, подключение контроллеров, установку ссылок на ресурсы, которые необходимо расшарить между модулями — в hub. Здесь же форкается необходимое количество процессов. В master-процессе запускается node-cron, для выполнения задач по расписанию, а в дочерних процессах — http-серверы.

В каждом контроллере средствами connect устанавливается привязка url к методу-обработчику. Каждый запрос проходит через цепочку методов, которая создаётся при инициализации connect. Пример:

var server = connect(); server.listen(port, hub.config.app.host); if (hub.config.app.profiler) server.use(connect.profiler()); server.use(connect.cookieParser()); server.use(connect.bodyParser()); server.use(connect.session({          store: new connectMemcached(hub.config.app.session.memcached),          secret: hub.config.app.session.secret,         key: hub.config.app.session.cookie_name,         cookie: hub.config.app.session.cookie })); server.use(connect.query()); server.use(connect.router(function(router) { 	hub.router = router; }));

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

Контроллер, при необходимости, вызывает методы модели, которые получают данные из MongoDb или Memcached. Когда все данные для ответа готовы, контроллер даёт команду шаблонизатору на формирование страницы и отправляет сгенерированный html пользователю.

Заключение

Тема разработки WEB-приложений на Node.JS довольно большая и интересная. Изложить её полностью в двух статьях невозможно. Да это, наверное, и не нужно. Я постарался описать основные принципы разработки и указать на возможные проблемы. Этого должно быть достаточно для «вхождения» в тему, а дальше Google и GitHub придут на помощь. Все ссылки на страницы модулей на GitHub'е, которые я привёл в статье, содержат подробное описание установки модулей и примеры их использования.

Спасибо всем, кто дочитал. Мне будет очень интересно услышать отзывы и вопросы в комментариях.

Автор: BVadim


  1. Иван:

    Интересная статья. Особенно перечень модулей.

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js