# A little bit about Node.js API Architecture (Архитектура/паттерны организации кода Node.js приложений)

## 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)