# A little bit about Node.js API Architecture (Архитектура/паттерны организации кода Node.js приложений) ![node.js](https://i.imgur.com/EGp4qfC.jpg) ## TL;DR code: https://github.com/zmts/supra-api-nodejs ### Предисловие Одной из болезней **Node.js** комьюнити это отсутствие каких либо крупных фреймворков, действительно крупных уровня Symphony/Django/RoR/Spring. Что является причиной все ещё достаточно юного возраста данной технологии. И каждый кузнец кует как умеет ну или как в интернетах посоветовали. Собственно это моя попытка выковать некий свой подход к построению **Node.js** приложений. #### Несколько слов что такое архитектура (IMHO): ``` Архитектура - набор подходов для организации программно-аппаратного комплекса. Описание компонентов системы и взаимосвязей между ними. ``` #### Несколько слов про разделение приложения на слои: Обычно в слое контроллеров данные из запроса валидируются и приводятся к виду необходимому для последующего сервисного слоя. В свою очередь сервисный слой полностью изолирует бизнес логику и возвращает результат вычислений в контроллер. Но _иногда_, а может и немного _чаще_ в контроллер просачивается бизнес логика, а в сервисный слой валидация, а то и вообще контекст запроса. Дабы так не происходило данный подход предлагает использовать единый слой для всей логики касаемо конкретного `use case`. Назовем этот слой `Action layer`. В итоге имеем: 1. Минималистичный контроллер - маппинг экшенов на роуты 2. Экшен - определяет правила валидации входящих данных, проверки прав доступа(владелец/не владелец/админ/не админ/аноним...) и бизнес логику 3. Data layer - прослойка к БД ``` Controller(роутинг, проверка прав по роли) >> Action(проверка прав по id, схема валидации запроса, логика юзкейса) >> Data Layer(биндинги к БД) ``` ## Содержание - [Assert](#assert) - [Config](#config) - [Server](#serverapp-initialization) - [Middlewares](#middlewares) - [Controllers](#controllers) - [Actions](#actions) - [Model](#model) - [Agents](#agents) - [Providers](#providers) - [Helpers](#authhelpers) - [DAO](#dao) - [Errors](#errors) - [Logging](#logging) - [Policy/Roles/Permissions](#policyrolespermissions) ## Assert > Assert — это специальная конструкция, позволяющая проверять предположения о значениях произвольных данных в произвольном месте программы. Эта конструкция может автоматически сигнализировать при обнаружении некорректных данных, что обычно приводит к выбросу исключения с указанием места обнаружения некорректных данных. https://habr.com/ru/post/141080/ В этом мне помогает небольшой [Assertion class](https://github.com/zmts/supra-api-nodejs/tree/master/core/lib/assert). Из существующих библиотек есть [node-assert-plus](https://github.com/joyent/node-assert-plus). ```js async function foo (id, options = {}) { assert.integer(id, { required: true }) assert.object(options, { required: true, notEmpty: true }) const data = await db.getById(id, options) return data } ``` `Assertion` это набор статических методов для базовой проверки типов аргуметров и возможно некоторых дополнительных опций (аля `{ required: true, notEmpty: true, positive: true }`). ## Config Конфиг это святая-святых настроек приложения посему должен инстансироватся и инициализироватся(через асинхронные методы) перед HTTP сервером. Все параметры неободимые для работы приложения должны хранится в едином конфиге. Параметры конфига делятся на два типа: констаннты и переменные. Первые храним как обычные поля, вторые извлекаем из переменных окружения (для примера это: логины/пароли доступа в БД, ключи для шифрования сессий или JWT токенов, ключи доступа к другим внешним ресурсам итд...). ```js class AppConfig extends BaseConfig { constructor () { super() this.nodeEnv = this.set('NODE_ENV', v => ['development', 'production'].includes(v), 'development') this.port = this.set('APP_PORT', this.joi.number().port().required(), 5555) this.host = this.set('APP_HOST', this.joi.string().required(), 'localhost') this.name = this.set('APP_NAME', this.joi.string().required(), 'SupraAPI') this.foo = 'bar' this.dbUser = '' this.dbPassword = '' } async getSomeAsyncCredentials () { const { data } = await getDBCredentials() this.dbUser = data.user this.dbPassword = data.password } async init () { await getSomeAsyncCredentials() logger.debug(`${this.constructor.name}: Initialization finish...`) } } ``` `set` первым аргументом принимает название переменной окружения, вторым ф-цию валидатор (будь то `joi` правило или обычная ф-ция возвращающая `boolean`) и третий опциональный: аргумент по умолчанию. Таким образом система следит что бы все значения конфига были валидны. При условии невалидности приложение не стартует и выбросит исключение. ## Server(App initialization) Жизнь приложения начинается с класса [Server](https://github.com/zmts/supra-api-nodejs/blob/master/core/Server.js). Класс отвечает за: - Создание веб сервера(в данном случае `express.js`) - Инициализию дефолтных мидлварей(`bodyParser`, `helmet`, etc.) - Инициализию контроллеров(роутеров) - Инициализию дефолтного обработчика ошибок (`errorMiddleware`) - Отслеживание глобальных ошибок/исключений(`uncaughtException`, `unhandledRejection`, etc.) Компоненты(мидлвари, роутеры, провайдеры) приложения обязаны инициализироватся асинхронно друг после друга. Последовательная инициализация позволяет подготовить зависимые/асинхронные данные для использования другими компонентами. ``` Server start initialization... InitMiddleware initialized ... CorsMiddleware initialized ... SanitizeMiddleware initialized ... RootRouter initialized... RootProvider initialized.. UsersRouter initialized... UsersProvider initialized... DevErrorMiddleware initialized ... Server initialized... {"port":"5555","host":"localhost"} ``` ## Middlewares Прослойка промежуточных обработчиков запросов. Основаня задача дополниение контекста запроса/ответа. Например: - Получение мета данных пользователя из JWT и добавление их в `req.currentUser` - Санитизация запросов - Установка CORS в ответ(`res`) Плохая практика навешивать в мидлвари какую либо бизнес логику или тяжолые блокирующие операции. ## Controllers Основные задачи контроллера: - Роутинг - Взаимовоздействие с пользователем(принимает запрос, оправляет ответ) - Вызывает Action - Устанавливает хедеры
За автоматизацию вызова экшена в контроллере и отправку ответа пользователю отвечает ф-ция actionRunner ```js actionRunner (action) { assert.func(action, { required: true }) return async (req, res, next) => { assert.object(req, { required: true }) assert.object(res, { required: true }) assert.func(next, { required: true }) const ctx = { currentUser: req.currentUser, body: req.body, query: req.query, params: req.params, ip: req.ip, method: req.method, url: req.url, headers: { 'Content-Type': req.get('Content-Type'), Referer: req.get('referer'), 'User-Agent': req.get('User-Agent') } } try { await actionTagPolicy(action.accessTag, ctx.currentUser) if (action.validationRules && action.validationRules.notEmptyBody && !Object.keys(ctx.body).length) { return next(new ErrorWrapper({ ...errorCodes.EMPTY_BODY })) } if (action.validationRules) { await this.validate(ctx, action.validationRules) } const response = await action.run(ctx) if (response.headers) res.set(response.headers) return res.status(response.status).json({ success: response.success, message: response.message, data: response.data }) } catch (error) { error.req = ctx next(error) } } } ```
Таким образом контроллер представляет собой совсем небольшую прослойку мапинга экшенов на роуты ```js const router = require('express').Router() const actions = require('../actions/posts') const BaseController = require('../core/BaseController') const ErrorWrapper = require('../core/ErrorWrapper') const { errorCodes } = require('../config') const logger = require('../logger') class PostsController extends BaseController { get router () { router.param('id', preparePostId) router.get('/', this.actionRunner(actions.ListPostsAction)) router.get('/:id', this.actionRunner(actions.GetPostByIdAction)) router.post('/', this.actionRunner(actions.CreatePostAction)) router.patch('/:id', this.actionRunner(actions.UpdatePostAction)) router.delete('/:id', this.actionRunner(actions.DeletePostAction)) return router } async init () { logger.info(`${this.constructor.name} initialized...`) } } function preparePostId (req, res, next) { const id = Number(req.params.id) if (id) req.params.id = id next() } module.exports = new PostsController() ```
Рассмотрим более подробно что из себя представляет метод `actionRunner`. Если посмотреть на стандартное определение роута в Express.js. ```js router.get('/api/users', function(req, res){ res.send('hello world') }) ``` Мы увидим что для работы нам понадобится передать в ф-цию маппинга роута несколько параметров это сам путь(`'/api/users'`) и один или несколько обработчиков. У каждого обработчика есть доступ к двум(на самом деле их больше но в данный момент нас интересуют только первые два) аргументам: `req` - объект запроса и `res` - объект ответа. За что и отвечает `actionRunner`: - Передает данные из запроса в бизнес логику (обработчик экшена: метод `run`) и вызывает ее. - Забирает результат вычисления и отправляет(`req.json(data)`) пользователю - В случе ошибки: собираются метаданные и передаются дальше для логирования __Выходит такой Lifecycle >> Запрос проходит дефолтные мидлвари >> Попадает в контроллер >> Попадает в `actionRunner` тот проверяет права доступа текущего юзера к экшену, валидирует запрос и стартует процесс обработки (статическая ф-ция `run`) >> В экшене выполняется бизнес логика и результат возращается в контроллер >> `actionRunner` получает результат и возвращает его клиенту.__ Что не стоит делать в контроллере: - Валидировать параметры(`req.params`). Как показала практика это плохая идея. Проверку параметров лучше делать непосредственно в экшене. Таким образом в дальнейшем будет более наглядно видно какие парметры в запросе доступны экшену. ## Actions Основным ключевым моментом является использование отдельного класса для каждого эндпоинта. Я определяю понятие `Action` как класс инкапсулирующий всю логику работы эндпоинта. То есть для реализации круда у нас будет 5 файлов (`CreatePostAction`, `GetPostByIdAction`, `UpdatePostAction`, `DeletePostAction`, `ListPostsAction`) по экшену на каждый эндпоинт. Каждый экшен обязан имплементировать такой контракт: - Статический метод `run` в задачи которого входит выполнение бизнес логики. Его и вызывает `actionRunner`. - Геттер `validationRules` - объект выполняющий роль схемы валидации входящих параметров экшена. - Геттер `accessTag` - тег по которому специальный сервис проверяет права доступа. Правила валидации (`validationRules`) импортируются из модели или в случае необходимости используются кастомные. ***Роль модели в экшене:*** Дабы не дублировать кучу подобных моделей(одна модель для создания сущности со всеми `required` полями, другая для обновления, третья для обновления указанного набора полей итд...) было принято решение не указывать в ф-ции валидации модели `required` требование. Вместо этого `required` флаг имеет место быть в момент валидации как опция класса `RequestRule`. ```js { validationRules: { body: { id: new RequestRule(PostModel.schema.id, { required: true }) } } } ``` Повторюсь таким образом мы избегаем черезмерного колличества однотипных моделей используя в экшене непосрественно те правила которые необходимы для конкретного юзкейса.
В итоге имеем один класс(один файл) в котором сосредоточена все логика эндпоинта ```js const joi = require('joi') const BaseAction = require('../BaseAction') const PostDAO = require('../../dao/PostDAO') const PostModel = require('../../models/PostModel') class CreateAction extends BaseAction { static get accessTag () { return 'posts:create' } static get validationRules () { return { params: { id: [PostModel.schema.id, true] }, body: { title: [PostModel.schema.title, true], content: [PostModel.schema.content, true], someCustomField: [new Rule({ validator: v => (typeof v === 'string') && v.length >= 10, description: 'string; min length 10 chars;' }), true], } } } static async run (ctx) { const { currentUser } = ctx const data = await PostDAO.BaseCreate({ ...ctx.body, userId: currentUser.id }) return this.result({ data }) } } module.exports = CreateAction ```
Все эшнены являются __framework agnostic__ это значит что в экшенах отсуствует код относящийся к веб-фреймворку. Что позволеят нам переиспользовать экшены как в других проектах так и с другими фреймворками. __p.s.__ А еще разделение логики на отдельные экшены, облегчает командную работу над проектом. Меньше конфликтов при слиянии веток :) ## Model В данном подходе модель представляет собой исключительно набор полей и правил валидации без какой либо бизнес логики и доп. ф-ционала.
model example: ```js const joi = require('@hapi/joi') const { BaseModel, Rule } = require('supra-core') const schema = { id: new Rule({ validator: v => joi.validate(v, joi.number().integer().positive(), e => e ? e.message : true), description: 'number integer positive' }), userId: new Rule({ validator: v => joi.validate(v, joi.number().integer().positive(), e => e ? e.message : true), description: 'number; integer; positive;' }), title: new Rule({ validator: v => joi.validate(v, joi.string().min(3).max(20), e => e ? e.message : true), description: 'string; min 3; max 20;' }), content: new Rule({ validator: v => joi.validate(v, joi.string().min(3).max(5000), e => e ? e.message : true), description: 'string; min 3; max 5000;' }) } class PostModel extends BaseModel { static get schema () { return schema } } module.exports = PostModel ```
Каждое поле модели это инстанс класса `Rule` состоящий из валидатора и описания. Выполнение ф-ции валидации обязано вернуть либо булево значение либо строку(`error.message`) Все последующие проверки полей в других компонентах приложения обязаны импортироваться из схемы модели. Например в cлое `DAO` в неком `getById(id)` вместо того что-бы делать: ```js async function getById (id, options = {}) { assert.integer(id, { required: true }) assert.object(options, { required: true, notEmpty: true }) const data = await db.getById(id, options) return data } ``` Стоит лучше взять правило из схемы модели: ```js async function getById (id, options = {}) { assert.validate(Post.schema.id, { required: true }) assert.object(options, { required: true, notEmpty: true }) const data = await db.getById(id, options) return data } ``` ## Agents Агенты - это врапперы над внешними API/клиентами. Предназначение данной абстракции обеспечение единого контракта работы с внешними ресурсами. Допустим нам необходимо использовать в качестве хранилища файлов сервис от Амазон AWS S3. Мы создаем `S3Agent` добавляем в него свои методы-обертки. В случае критического изменения в клиенте амазона мы соотвественно меняем методы обертки без ущерба и переписывания остальной логики в остальных частях приложения. Перехваченные ошибки внешних API выбрасываем предватительно обернув их в свой кастомный класс ошибки (дабы иметь единый интервейс работы с ошибками).
agent example: ```js const { assert, AppError, errorCodes } = require('supra-core') const AWS = require('aws-sdk') const $ = Symbol('private scope') class S3Agent { constructor (options) { assert.object(options, { required: true, notEmpty: true }) assert.string(options.access, { required: true, notEmpty: true }) assert.string(options.secret, { required: true, notEmpty: true }) assert.string(options.bucket, { required: true, notEmpty: true }) assert.instanceOf(options.logger, AbstractLogger) AWS.config.update({ accessKeyId: options.access, secretAccessKey: options.secret }) this[$] = { client: new AWS.S3(), bucket: options.bucket, logger: options.logger } this[$].logger.debug(`${this.constructor.name} constructed...`) } async uploadImage (buffer, fileName) { if (!Buffer.isBuffer(buffer)) { throw new Error(`${this.constructor.name}: buffer param is not a Buffer type`) } assert.string(fileName, { required: true, notEmpty: true }) return new Promise((resolve, reject) => { const params = { Bucket: this[$].bucket, Key: fileName, Body: buffer, ContentType: 'image/jpeg' } this[$].client.upload(params, (error, data) => { if (error) { return reject(new AppError({ ...errorCodes.EXTERNAL, message: `${this.constructor.name}: unable to upload object. ${error.message}`, origin: error })) } resolve(data.Location) }) }) } } module.exports = S3Client ```
## Providers Как быть в ситуации когда необходимо использовать один и тот же агент в нескольких экшенах ? Для этого случая предназанчен слой `Providers`. Дабы не плодить для каждого экшена свой агент создаем необходимое единожды в провайдере, а дальше импортируем его в нужных местах.
code: ```js const S3Agent = require('../core/clients/S3Agent') const config = require('../config') class RootProvider { constructor () { this.s3Agent = new S3Agent({ access: config.s3.access, secret: config.s3.secret, bucket: config.s3.bucket }) } async init () { __logger.info(`${this.constructor.name} initialized...`) } } module.exports = new RootProvider() ```
## Auth(helpers) Хелперы отвечают за всевозможный процессинговые и утилитарные ф-ции(шифрование, JWT) В своем большинстве реализованные через промис. ## DAO Как не трудно догадаться это слой работы с БД. Исключительно экшены имеют доступ к DAO. Ни в каких сервисах или миддлварях не должно быть методов из DAO. Для работы с БД я юзаю https://github.com/Vincit/objection.js ## Errors Работа с ошибками организована через единственнный кастомный класс ошибки и список эррор кодов (хранящийся в виде конфига), это позволяет не создавать на каждый тип ошибки свой класс. ```js class AppError extends Error { constructor (options) { if (!options || !options.description) throw new Error('description param required') super() this.description = options.description || undefined // default error description from errorCodes this.message = options.message || this.description // message thrown by error this.status = options.status || 500 this.code = options.code || 'UNEXPECTED_ERROR' this.layer = options.layer || undefined this.meta = options.meta || undefined this.req = options.req || undefined this.origin = options.origin || undefined // origin error data } } ``` ```js module.exports = { ACCESS: { description: 'Access denied', status: 403, code: 'ACCESS_ERROR' }, BAD_ROLE: { description: 'Bad role', status: 403, code: 'BAD_ROLE_ERROR' }, DB: { description: 'Database error occurred', status: 500, code: 'DB_ERROR' } } ``` ```js new AppError({ ...errorCodes.ACCESS, message: 'Access denied dude !!!' }) ``` В последствии на фронте выводим ошибку из поля `message` или то что фронтенд посчтитает нужным в зависимости от поля `code`. ### Обработка ошибок
Опишу частую ошибку встречающуюся во многих проектах на просторах интернета ```js // IS BAD testControllerHandler (req, res, next) { try { // my logic with throwed error await testService.getTest({ id: req.params.id }) } catch (error) { res.send(error) // это и есть локальная обработка ошибки } } ```
Все ошибки обязаны быть перехваченны в единственном месте, в глобальном обработчике ошибок(глобальная `error middleware`). Остальные `unexpected` ошибки аля `unhandledRejection` в соотвествующих им хендлерам. __Это означает никаких try/catch и локальной обработки ошибок в контроллерах__ https://github.com/zmts/supra-api-nodejs/blob/master/core/lib/Server.js#L71
Только в случае если нам как-то необходимо обработать/дополнить ошибку делаем так: ```js // IS GOOD testControllerHandler (req, res, next) { try { // my logic with throwed error await testService.getTest({ id: req.params.id }) } catch (error) { if (error.code === 'SPECIFIC_ERROR_CODE') { error.message = 'Lets describe our specific error' } next(error) } } ```
В остальных случаях хватит такого ```js // IS GOOD testControllerHandler (ctx) { // my logic with throwed error await testService.getTest({ id: ctx.params.id }) } ```
## Logging Вместо `console.log` используем логгеры под каждый тип сообщения (`trace`, `warn`, `error`, `fatal`). Логи пишем в `Sentry` или что-то подобное. Ошибки и security issues логируем в первую очередь, дальше все предупреждения и [трассирующие логи](https://en.wikipedia.org/wiki/Tracing_(software)) ## Policy/Roles/Permissions Данный подход рассматривает использование статических прав(__hardcode__), тоесть необходимости менять их динамически (создавать/редактировать роли со своим уникальным списком прав через БД) отсуцтвует. При формировании JWT, каждый токен получает в `payload` роль пользователя по которой в дальнейшем просиходит проверка прав через сопоставление роли списку доступных ей эксес-тегов. Любой запрос с невалидным токеном или без него система идентифицирует как `ROLE_ANONYMOUS`. Как было сказано выше у каждого экшена есть свой `accessTag `. Это обычный стринговый ключ состоящий из двух частей название ресурса и название действия(например `posts:create`).
Определяем список прав каким ролям доступны какие эксес-теги. ```js const roles = require('./roles') const shared = [ 'users:list', 'users:update', 'users:get-by-id', 'users:remove', 'users:change-password' ] module.exports = { [roles.admin]: [ ...shared, 'posts:all' ], [roles.user]: [ ...shared, 'posts:all' ], [roles.anonymous]: [ 'users:list', 'users:get-by-id', 'users:create', 'users:send-reset-email', 'users:reset-password', 'user:get-posts-by-user-id', 'posts:list', 'posts:get-by-id' ] } ```
Обязательная дефотлная проверка (`actionTagPolicy`): это проверка права на обращение к экшену, она происходит в контроллере (в методе `actionRunner`). Все дальнейшие проверки происходят непосредственно в экшене: в зависимости от требований, будь то необходимость проверить является ли пользователь владельцем ресурса ли что-то еще дополнительное.
policy/actionTagPolicy.js ```js return new Promise((resolve, reject) => { if (currentUser.role === roles.superadmin) return resolve() if (permissions[currentUser.role].includes(accessTagAll)) return resolve() if (permissions[currentUser.role].includes(accessTag)) return resolve() return reject(new AppError({ ...errorCodes.ACCESS, message: 'Access denied, don\'t have permissions.' })) }) ```
При обращении к айтему(чтение/get by id) необходимо проверить айтем на приватность. В позитивном случае отдаем его только владелецу.
policy/privateItemPolicy.js ```js return new Promise((resolve, reject) => { if (user.role === roles.superadmin) return resolve(model) if (user.id === model.userId) return resolve(model) if (!model.private) return resolve(model) if (model.private) { return reject(new AppError({ ...errorCodes.ACCESS, message: `User ${user.id} don't have access to model ${model.id}` })) } return reject(new AppError({ ...errorCodes.ACCESS })) }) ```
Проверка оборачивается в промис дабы избежать лишних `if-else` конструкций в экшене при использовании.
Пример использования: ```js class GetPostByIdAction extends BaseAction { static get accessTag () { return 'posts:get-by-id' } static async run (req) { const { currentUser } = req const model = await PostDAO.baseGetById(+req.params.id) // получили модель await privateItemPolicy(model, currentUser) // проверили права return this.result({ data: model }) // отдали результат } } module.exports = GetPostByIdAction ```
При удалении или изменении проверяем является ли текущий юзер владельцем айтема.
policy/ownerPolicy.js ```js return new Promise((resolve, reject) => { if (user.role === roles.superadmin) return resolve() if (user.id === model.userId) return resolve() return reject(new AppError({ ...errorCodes.ACCESS })) }) ```
### Function declaration convention ```js function foo (firstRequired, secondRequired, { someOptionalParam = false } = {}) { // code } ``` ### Bad practice Во времена когда я впервые столкнулся с необходимостью проектирования API на **Node.js**, а точнее мне стало любопытно сделать это самому, делал я это не всегда правильнльным путем. - Для начала основной моей ошибкой было чрезмерное использование миддлварей. Мидллвари использовались для сервисных целей(валидация запороса, проверка прав доступа) что загромождало роутер и вносило еще большую путаницу в код. Миддлвари должны использоватся в качестве неких глобальных обработчиков и не должны навешиватся на каждый эндпоинт гроздью (https://github.com/zmts/lovefy-api-nodejs/blob/master/api/controllers/postCtrl.js#L107). - Контроллеры представляли из себя полотно кода из обработчиков плюс роутинг (которому в контроллере не место). На первый взгляд удобно все в одном файле - не нужно прыгать из файла в файл, но когда кода стало значительно больше ситуация поменялась (https://github.com/zmts/lovefy-api-nodejs/blob/master/api/controllers/postCtrl.js). - Компоненты приложения инициализировались при первичном старте как попало, не в контролируемой последуемости. - Отсуствие проверки типов. - Отсуствие логирования. ## P.S. Видео Виктора Турского про архитектуру - https://www.youtube.com/watch?v=Z08xL-oXMh0 (2017) - https://www.youtube.com/watch?v=TjvIEgBCxZo (2019)