Строим свой full-stack на JavaScript: Сервер
Вторая статья из серии о full-stack JS разработке.
JavaScript постоянно меняется, очень сложно угнаться за последними технологиями, ведь то, что было лучшей практикой полгода назад, сейчас уже устарело. Подобные утверждения во многом правда, но следует отметить, что это больше относится к клиентскому JavaScript. Для сервера все гораздо стабильнее и основательней.
- Статья 1: Основы
- Статья 2: Сервер
Статья базируется на коде проекта Contoso Express.
Список ресурсов на котором можно более детально ознакомиться с некоторыми темами статьи здесь.
Причуды JS
Язык JavaScript известен своими причудами (quirks). Например, знаменитое 0.1 + 0.2 не равняется 0.3 в связи с тем, что в JavaScript нет встроенного десятичного типа.
console.log(0.1 + 0.2 === 0.3); //false
Или для того, чтобы корректно проверить, что значение переменной является строкой, нужна такая конструкция:
if (typeof myVar === 'string' || myVar instanceof String) {
console.log('That is string!');
}
Некоторые из этих проблем устранены в ES6 версии языка. Например, областью видимости переменной объявленной через var является вся функция (в большинстве других языков переменная имеет блочную область видимости). В ES6 эта проблема решена объявлением переменных через let/const, а var не рекомендуется к использованию вообще. При этом она остается в JS (навсегда) для обратной совместимости.
Для устранения других недостатков языка JS используются сторонние библиотеки. Например, для точных десятичных вычислений можно воспользоваться «big.js». Есть множество мелких пакетов, каждый решающий одну подобную задачу, но помнить их названия и подключать по одному к проекту слишком сложно. Поэтому удобнее пользоваться такими универсальными решениями как «lodash», предоставляющими сразу целый набор дополнительных утилитных функций.
На сайте lodash отличная документация с примерами, при этом, не обязательно смотреть все сразу, вы можете бегло ознакомится с тем что есть и смотреть в дальнейшем только то, что вам конкретно необходимо.
Например, проверить что переменная имеет строковое значение, с lodash гораздо проще:
if (_.isString(myVar)) {
console.log('That is string!');
}
Обратите внимание, что в lodash есть поддержка вызова цепочек функций, например:
let arr = [2, 3, 1, 5, 88, 7, 13];
let oddSquares = _(arr)
.filter(x => x % 2 === 1) //filter odd numbers
.sort((a,b) => a > b) //sort by default sorts lexicographically: [1, 13, 3, 7]
.map(x => x*x);
console.log(oddSquares.join(',')); //1,9,25,49,169
TypeScript на сервере
Чем сложнее JS код, тем больше преимуществ дает использование TypeScript. Для сложного серверного кода польза от TS быстро становится очевидной.
В Contoso настройки компиляции TS для серверной части находятся в файле «tsconfig.json».
Вот некоторые, на которые стоит обратить внимание:
rootDir/outDir: весь код (все TS файлы) из rootDir, компилируются в JS файлы в outputDir, при этом структура папок сохраняется. К примеру файл '/server/helpers/emailHelper.ts' будет скомпилирован в '/build/server/helpers/emailHelper.js'
sourceMap: если этот флаг установлен, то кроме JS, создаются файлы source maps (myModule.ts → myModule.js + myModule.js.map). Это позволяет отлаживать файлы TS, точки останова (breakpoints) выставляются и срабатывают в ts файле, при этом реально выполняется JS код.
- target: TS позволяет компилировать код под разные версии JS. Но есть несколько фич, которые поддерживаются только при компиляции в последнюю версию ES6. Одна из них — async/await, которая описана ниже. Для того чтобы ее использовать для сервера, код компилируется в ES6 и используется 6x версия Node.
Если у вас установлен TypeScript глобально, вы можете скомпилировать проект консольной командой tsc (TypeScript Compiler) из корня проекта, опционально с watch параметром. В режиме watch после любого изменения в исходном коде будет выполнена повторная компиляция
tsc --watch
Для запуска приложения нужно выполнить (через Node) «build/server/startServer.js».
Если вы не хотите использовать TS, то вы легко можете преобразовать TS код в JS. Основное, что надо сделать:
- преобразовать ES6 imports в Node require
- убрать аннотации типов
- использовать промисы вместо asyn/await или подключить babel для транспиляции
Асинхронность в JS
Асинхронное (async) программирование в JS один из самых сложных моментов при переходе из других языков программирования. Я даю очень краткий обзор, прочитать больше можно в списке ресурсов.
Высокая производительность Node обусловлена тем, что все долго выполняющиеся операции не блокируют основной процесс. Примером таких операций, может быть запрос в базу данных или запись информации в файл. После вызова операции продолжает выполняться другой код. Когда операция завершается (в будущем), либо успешно либо с какой-то ошибкой, нужно указать что делать дальше.
Для работы с async кодом можно использовать несколько шаблонов. Эти шаблоны менялись (эволюционировали) вместе с развитием Node.
Колбэки (callbacks) — традиционный способ организации async в Node. Прост для понимания, но имеет ряд проблем: сложно делать несколько вложенных async вызовов. В этом случае код становится сложным в написании, поддержке и в понимании (callback hell); возвращение данных, обработка и генерация ошибок принципиально отличаются от того, как это происходит в синхронном JS коде.
Промисы (обещания/promises) — современный стандарт для async в JS. Сейчас становится повсеместно используемым. Промис это конструкция которая может находится в одном из 3 состояний ожидание/отказ/выполнено (pending/rejected/resolved). Изначально состояние ожидания, но в будущем промис обязуется (обещает) перейти в одно из финальных состояний ошибки или успешного выполнения (с результатом). Конструкции промисов позволяют организовать цепочки вызовов async операций более линейно, что делает поддержку легче и улучшает понимание кода. При этом данные возвращаются через return, ошибки генерятся через throw, и можно использовать один обработчик ошибок для нескольких async операций.
- async/await — это будущее async в JS. Сейчас находится в финальной стадии stage4 обсуждений после чего станет новым утвержденным дополнением JS. Async/awiat в дальнейшем стирает грань между синхронным и асинхронным кодом, так что они гармонично сочетаются вместе. Код с использованием async/await максимально просто писать и поддерживать. Схожий синтаксис существует в других языках, например в C# и Python. Недостаток async/await в том что нужны дополнительные инструменты для его использования, такие как TypeScript или Babel.
Есть еще несколько async шаблонов, модуль «async» и модуль «co» с использованием генераторов. Это промежуточные эволюционные этапы перед промисами и async/await. «co» можно использовать без дополнительных преобразований уже сейчас, но по-моему мнению лучше использовать промисы, если использование async/await не представляется возможным.
Для успешной работы с JS вам необходимо знать основные asynс шаблоны, т.к. время от времени придется иметь дело с каждым из них. Если к примеру вы работаете с async/await, то вам все-равно нужны промисы для некоторых операций, таких как параллельное выполнение через Promise.all, если вы работаете с промисами, то иногда приходится оборачивать промисами функции на колбэках.
Обработка ошибок
Это еще одна область вызывающая затруднение при переходе на Node с других платформ. Начнем с того, что то, как нужно обрабатывать ошибку, зависит от того, какой асинхронный паттерн вы используете. При использовании колбэков, ошибка передается как первый параметр колбэк функции, при использовании промисов, обрабатывать ошибки нужно через catch в цепочке промисов, при синхронном вызове кода и при использовании async/await следует использовать try/catch.
Дальше, надо знать что в JavaScript можно сгенерировать (через throw) ошибку передав любой объект, но при этом правильной практикой является использование встроенного объекта Error. В простейшем виде это выглядит так:
throw new Error('Param value is wrong');
При этом вы можете создавать свой кастомный объект ошибки унаследовав его от встроенного Error объекта.
function AppError(errorCode, data) {
this.code = errorCode;
Error.captureStackTrace(this, this.constructor);
//merge data props into error object
return _.merge(this, options);
}
...
throw new AppError('user_not_found', {userId: 5});
Обратите внимание на вызов Error.captureStackTrace, это нужно для того, чтобы корректно добавить stack trace в объект ошибки. Вы можете добавлять в ошибку свои данные и определять свои сигнатуры для конструктора ошибки.
В Contoso объект AppError по умолчанию принимает строчные параметры: тип ошибки и код ошибки, текст ошибки вычитывается по коду из внешнего файла.
Еще один момент: следует отлавливать и логировать необработанные ошибки, если этого не делать, приложение будет завершать работу без внятного указания причины, подключать обработчик для необработанных ошибок нужно как можно раньше.
process.on('uncaughtException', (err) => {
console.log(`Uncaught exception. ${err}`);
});
Выбираем бэк-энд веб фреймворк
Express — основной выбор, все альтернативы в десятки раз менее популярны. Express следует философии минимализма и гибкости. Минимализм в этом контексте означает, что без дополнительной настройки доступна очень небольшая функциональность. Например, чтобы добавить поддержку cookies, парсить body в HTTP запросах, иметь доступ к сессии на клиенте, нужно добавить соответствующий модуль. Каждую используемую возможность нужно непосредственно активировать. Гибкость означает, что у вас есть много возможностей изменения/дополнения существующего функционала.
Эти особенности Express, делают его отличным выбором как для веб приложений так и для отдельных API серверов.
Другие опции можно разделить на два вида: полностью отдельные фреймворки и те, которые базируются на Express.
Отдельные:
Koa — новый фреймворк от создателей Express. С одной стороны это улучшенная версия Express, в которой некоторые моменты сделаны лучше, с другой стороны ничем принципиально не отличается и нет обратной совместимости с Express.
- Hapi — полностью отдельный фреймворк, изначально был создан и финансово поддерживается Wallmart, крупнейшей сетью супермаркетов в США. В отличие от Express, требует меньше настройки и больше функций доступны сразу.
Базируются на Express:
Loopback — фреймворк заточеный для написания API серверов, особенно для мобильных устройств, есть DSK для iOS/Android, используется встроенная ORM для работы с различными БД.
Sails — позиционируется как хорошая основа для API сервера, особенно если есть взаимодействия в реальном времени через socket.io.
- FeathersJs — этот фреймворк имеет множество адаптеров для других пакетов / фреймворков, целью является добавить больше стандартизации и облегчить процесс разработки.
Что выбрать
Начните с Express, если есть время и желание посмотрите другие варианты. Лично у меня не было достаточно мотивации разбираться с другими фреймворками, потому что я быстро смог построить структуру на основе Express, которая меня полностью устраивает. Преимущество этого подхода — меньшая зависимость от специфики фреймворка, хоть это и требует изначально больше работы.
Структура проекта
Структура проекта Contoso базируется на MVC архитектуре.
Контроллеры (controllers) — модули в которых находятся обработчики маршрутов, функции, принимающие стандартные express параметры request и response. В контроллерах есть логика валидации данных и базовой обработки запроса, но они напрямую не обращаются к базе данных и не выполняют рутинных операций, для этого используются репозитории и хэлперы.
Репозитории (repositories) — здесь находится логика доступа данным (БД) и дополнительная бизнес логика, часть бизнес логики может быть в контроллере, но репозиторий предпочтительнее для этого.
Помощники (helpers) — выполняют специфические операции — отправку имейлов, обработку ошибок, логирование, и т.д.
Маршрутизаторы (routers) — проставляют соответствия между маршрутами (URLs) и соответствующими им обработчиками из контроллеров. При этом не делается особого различия между маршрутами для API методами и маршрутами для веб страниц (views).
Задачи (tasks) — утилитные скрипты для таких задач, как создание первоначальной базы или импорт данных из файла.
Представления (views) — серверное представление (HTML шаблон) поддерживают общие шаблоны (layout views) и частичные шаблоны (partial views). В Contoso серверные представления используются для страниц аутентификации, остальные HTML представления генерируются на клиенте.
В классической архитектуре MVC отдельно определяются модели, здесь модель это просто объект с данными, который создается в контроллере и используется при генерации представления.
Express — гибкий фреймворк и позволяет организовывать код, как вам удобно. При этом разработчик сам решает, какая структура ему подходит. Нет единственно правильной структуры, но однозначно хорошо, когда она есть в принципе.
Многие функции Express и сторонних пакетов используются через оберточные модули. Это удобно, так как легко позволяет заменить пакет в будущем или переопределить стандартный функционал.
Конфигурация
Конфигурация обычно хранится в физических файлах в поддиректории проекта. В Node, в отличии от других платформ, JSON используется чаще чем XML.
Есть несколько пакетов для работы с конфигурационными значениями, я предпочитаю «config» другая популярная опция «nconf».
В «config» данные вычитываются из нескольких конфигурационных файлов по определенным правилам. Значения по умолчанию хранятся в файле «default.json», они могут переопределяться значениями из файла «local.json». Помимо JSON формата для хранения конфигурации можно использовать многие другие форматы, такие как yaml/xml/hjson/js и т.д. Подробнее об этом здесь.
В репозиторий добавляется файл настроек по умолчанию «default.json», а файл «local.json» в котором их можно переопределять исключается из репозитория через .gitignore.
Для хранения конфигурации можно использовать несколько стратегий:
- настройки разработчика по умолчанию — в конфигурации (default файл) хранятся настройки которые подходят для локальной разработки (можно переопределить в local файле). В продакшене нужно переопределить настройки по умолчанию из default файла, настройками продакшена (local файл или файл с именем сервера).
- настройки продакшена по умолчанию — тут наоборот, в конфигурации по умолчанию хранятся настройки продакшена, а разработчик локально переопределяет настройки для разработки.
Первый вариант предпочтительнее — во-первых, добавлять настройки для продакшена в репозиторий не очень правильно в плане безопасности, во-вторых, часто бывает несколько окружений для деплоймента production/uat/test, для которых требуются разные конфигурационные значения. Для большей безопасности можно в продакшене не использовать default файл вообще, полностью задав конфигурационные значения в local файле (подробнее в статье по развертке).
В Contoso конфигурация используется через оберточный модуль 'server/config'.
Логирование
Для простого дебага, я по-прежнему пользуюсь console.log, но если нужно вывести в логи ошибку или какую-то вспомогательную информацию следует воспользоваться одной из библиотек логирования.
Есть несколько популярных пакетов логирования: «winston», «bunyan» и «log4js». Я пользуюсь самой популярной «winston» по принципу «от добра добра не ищут», у меня получилось без проблем итнегрировать winston и там есть все, что мне было нужно.
Вы можете сравнить и выбрать библиотеку, которая вам больше понравится. В дополнительных ресурсах есть статья со сравнением winston vs bunyan.
В Contoso логирование используется через обертку loggerHelper. Логи хранятся в '/data/logs' лог для ошибок и лог для диагностических сообщений записываются в разные файлы.
Валидация данных
Данные, которые попадают в приложение из вне, нужно проверять на корректность. В случае простого веб приложения основной источник входящих данных это веб запросы: данные HTML форм или клиентские AJAX запросы.
Валидировать данные можно на разных уровнях, в Contoso валидация выполняется на уровне контроллера и, предполагается, что в репозиторий поступают уже корректные данные. В более сложных приложениях, может иметь смысл дополнительно проверять данные в репозитории, непосредственно перед тем как будет измненено состояние БД.
Для более эффективной проверки входящих данных можно воспользоваться пакетом «joi». Это плагин для hapi (альтернатива express), который работает независимо.
Данные попадают в контроллер в теле запроса (req.body) или в параметрах query string (req.query). Это произвольный JS объект в котором может быть что угодно. Мы же ожидаем получить определенный набор параметров, каждый из которых имеет определенный тип, допустимый набор значений, может отсутствовать или должен быть обязательно, т.д.
Чтобы поверить данные на корректность нужно обявить Joi схему описывающую ожидаемые данные и выполнить проверку на соответствие:
let schema = {
id: Joi.number(),
number: Joi.number().required(),
title: Joi.string().required()
};
let obj = {
number: 8,
title: 'Gone with the Wind'
};
Joi.validate(obj, schema, {/*options*/}, (err, val) => {
//...
});
В Contoso Joi используется через вызов метода loadSchema в controllerHelper. Этот метод оборачивает Joi.validate в промис и генерирует понятную приложению ошибку валидации.
Отправка имейлов
Для отправки имейлов, я использую «nodemailer» он популярен и хорошо поддерживается.
Вы можете использовать различные режимы транспорта (то, каким способом имейл будет пересылаться). По умолчанию используется direct режим.
Этот режим хорош для работы над прототипами проектов, потому что не требует отдельного SMTP сервера или стороннего сервиса отправки имейлов такого как Amazon SES или SendGrid.
Недостатком является то, что в зависимости от вашего IP адреса, письма могут оказаться в спам директории.
Больше по различным видам транспорта здесь.
Для генерации самого содержимого имейлов в виде HTML используются пакет «email-templates» с «handlebars» шаблонами. В Contoso имейлы пересылаются через »/helpers/emailHelper», темплейты имейлов хранятся в »/data/emails».
Аутентификация
Passport — самый популярный пакет для аутентификации в Node. Он поддерживает множество различных механизмов аутентификации называемых стратегиями. Обычно в проекте есть локальная стратегия, которая использует традиционный механизм доступа к системе через логин/пароль и хранит данные пользователя в базе данных приложения. Дополнительно вы можете предоставить возможность доступа к системе через SSO (single sign-on) провайдеры, такие как google/facebook/twitter или использовать особые виды аутентификации, как например Windows Active Directory.
Для локальной стратегии, помимо формы, где пользователь вводит логин/пароль, нужно предоставить форму для регистрации нового профиля юзера, реализовать механизм активации нового профиля (отправить имейл с ссылкой для активации), поддерживать случаи когда пользователь забыл пароль или хочет его поменять.
Существует несколько сторонних авторизационных платформ (Auth0 или Stormpath), которые позволяют использовать их инфраструктуру для всего, что связанно с управлением профилями юзеров.
Это однозначно может сэкономить много времени разработчика, но имеет минусы, такие как дополнительная стоимость и зависимость от стороннего сервиса (насколько ему можно доверять).
Еще одна хорошая альтернатива Google Firebase. Недавно этот сервис был основательно обновлен. Это не просто база данных, а целая платформа для разработки, в которой аутентификация доступна бесплатно и может быть использована даже если это единственное, для чего вы будете использовать Firebase.
В Contoso реализована аутентификация с локальной стратегией и SSO с google/facebook. Для того чтобы работала SSO аутентификации, нужно добавить свои clientID/clientSecret в конфигурацию, там же аутентификацию можно опционально отключить.
Что дальше?
В следующей статье я расскажу про особенности работы с JS на клиенте. На клиенте JS бурно развивается, постоянно появляются новые фреймворки, библиотеки и инструменты разработки, которые частично вытесняют существующие. Не успели вы ознакомиться с очередной технологией, как она уже устарела. Кстати, говоря об этом, на днях наконец-то вышел Angular 2.0. Добро пожаловать в клуб, новый фреймворк!
Буду рад замечаниям комментариям.
Stay tuned!