Привет, Хабр! Мы крупная производственная компания с 50К+ сотрудников, и в 2019 году поняли, что нам нужно мобильное приложение. Срок реализации 5 месяцев. Какой стек вы бы выбрали при такой скорости? Мы выбрали нативные Kotlin и Swift. Поначалу запилили всего 6 сервисов (новости, зарплатный лист, отпуска, блоги, регистрацию опасностей, выдачу СИЗ), и даже при том, что нанесли минимальную пользу, приложение очень зашло, количество пользователей начало расти лавинообразно. И тут мы поняли, что серверная часть на node.js + PostgreSQL создана без всякой мысли о развитии и масштабировании, решала исключительно локальные задачи. Все было на неоптимальной монолитной архитектуре, развивать и поддерживать которую просто нельзя.

Расскажу, как мы решили проблему.

___________

Сейчас количество нативных сервисов в нашем приложении более 20. Помимо перечисленных выше, из самых интересных у нас имеются:

  • мобильный пропуск, который позволяет проходить через турникеты на проходных по смартфонам;

  • бронирование рабочих мест и парковок – для гибридных режимов работы офисных сотрудников;

  • карта комбината, которая позволяет сотрудникам на липецкой площадке (26 кв. км) в деталях увидеть границы цехов, пешеходные дорожки, столовые и другие точки притяжения, такие как автобусные остановки или парк на территории комбината, медицинские пункты, проходные;

  • Сервис заказа справок, чьей особенностью является полностью динамическое формирование полей на фронтенде на основании структуры, которая передается с бэка.

  • И многое другое…

 

  • 5 месяцев — время разработки мобильного приложения.

  • 30 000 MAU — количество уникальных пользователей.

  • 50 000 сотрудников в Группе НЛМК.

Сервисов много и все они на бэкенде сидят, как уже было сказано выше, на монолитной и трудно поддерживаемой архитектуре. Это произошло исторически, в связи с фокусом на быстрые результаты в ущерб качественно проработанному решению. Что, я считаю, вполне нормально в концепции любых стартапов или создании MVP. Однако, когда мы говорим о развитии, то такие застарелые монолиты нуждаются в реформировании. В противном случае команда продукта будет похоронена под тяжестью legacy вкладывая все силы в его поддержку и стабилизацию вместо развития по все возрастающим потребностям внутри компании.

В общем, решили рядом с чистого листа создать новое решение на гибкой архитектуре. Дело за малым, выбрать готовое решение или сделать свое.

Из имеющихся наиболее полных и комплексных решений был только LoopBack 3, но он жестко завязан на собственную модель данных, а нам хотелось иметь свободу выбора.

К тому же останавливаться на Node.js мы не планировали, было желание создать реализацию на Go. По этому автоматика фреймворков только бы помешала.

Решили делать свой велосипед. Благо, начало проекта с небольших сервисов позволяло немного экспериментировать.

 Архитектура послойная, напоминающая структуру слоев модели OSI. Слои снизу-вверх:

  1. Сервер TCP соединений.

  2. Сервер HTTP.

  3. Обработка конкретных запросов HTTP (GET, HEAD, POST, PUT, DELETE).

  4. Контроллер обрабатывает тело запроса и формирует тело ответа.

  5. Сервис принимает данные, излеченные из тела запроса контроллером и возвращает данные, которые контролер помещает в тело ответа. Именно тут содержится бизнес логика.

  6. Источники данных, модели, которые используются сервисами для реализации бизнес логики.

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

Задачу первых двух уровней взял на себя Express, который имеет так же удобный механизм создания скелета проекта.

И так, расскажу, как и что делали.

Установку Node.js пропустим, предполагая, что он уже стоит.

Устанавливаем express проект генератор:

$ npm install ‑g express‑generator

Затем с его помощью создаем болванку проекта. Так как мы реализует API, то движок шаблонов на не нужен, указываем флаг –-no-view:

$ express my_rest_api –-no-view

Переходим в целевую папку my_rest_api и устанавливаем зависимости:

$ cd my_rest_api
$ npm install

Удаляем парку public, она нам не нужна. В итоге получаем следующую структуру:

.
├── app.js
├── bin
│   └── www
├── node_modules
├── package.json
├── package-lock.json
└── routes
   ├── index.js
   └── users.js

Сервер уже работоспособен и если ввести команду:

$ npm start

то сервер запуститься и будет обрабатывать запросы на порту по умолчанию 3000.

Удаляем все содержимое папки routes, маршруты у нас будут выглядеть по-другому.

Начинаем с исправления файла app.js, содержащего код сервера:

const express = require('express')
const ExpressPinoLogger = require('express-pino-logger')
const cookieParser = require('cookie-parser')
const pino = ExpressPinoLogger({
    serializers: {
        req: (req) => ({
            method: req.method,
            url: req.url,
            user: req.raw.user,
        }),
    },
})
const app = express()
app.use(pino)
app.use(express.json())
app.use(express.urlencoded({extended: false}))
app.use(cookieParser())
app.use('/', require('./routes'))
app.use((req, res, next) => {
    res.status(404)
        .json({
            code: 404,
            title: `That resource  "${req.url}"  was not found`,
            description: `Ресурс "${req.url}" не найден`
        })
})
module.exports = app

Меняем все var на const.

Добавляем модуль express‑pino‑logger который позволит писать логи в формате json в заданным набором полей.

В самой последней директиве use добавляем метод обработчик ошибок 404.

Создадим в корне проекта парку base, в ней будут лежать вспомогательные классы и методы, которые помогут нам создать полноценное REST API.

Первый метод getRoutes формирует маршруты на основе структуры папок, он вызывается в модулях в директории routes. Листинг метода выглядит так:

get_routes.js

const fs = require('fs')
const path = require('path')
/**
 *
 * @param {string}dirName
 * @param {object}router
 */
module.exports = (dirName, router) => {
    const basename = 'index.js'
    fs.readdirSync(dirName)
        .filter(item => {
            return (item.indexOf('.') !== 0) && (item !== basename)
        })
        .forEach(item => {
            const itemBody = require(path.join(dirName, item))
            let pathName = fs.lstatSync(path.join(dirName, item)).isDirectory() ? item : item.replace('.js', '')
            router.use('/' + pathName, itemBody)
        })
}

Метод сканирует текущую директорию и подгружает модули в директиву use.

При следующей структуре парки routes:

routes
├── api
│  ├── v1
│  │  ├── users
│  │  │  └── index.js
│  │  ├── groups
│  │  │  └── index.js
│  │  └── index.js
│  └── index.js
└── index.js

Мы получаем следующее REST API:

  • GET /api/v1/users – лист пользователей;

  • GET /api/v1/users/{userId} – один пользователей;

  • POST /api/v1/users – создать одного пользователя;

  • PUT /api/v1/users/{userId} – обновить одного пользователя;

  • DELETE /api/v1/users/{userId} – удалить одного пользователя;

  • GET /api/v1/groups – лист групп;

  • GET /api/v1/ groups /{groupId} – одна группа;

  • POST /api/v1/ groups – создать одну группу;

  • PUT /api/v1/ groups /{groupId} – обновить одну группу;

  • DELETE /api/v1/ groups /{groupId} – удалить одну группу.

 Индексные файлы index.js тут двух видов:

index.js – без обработки запросов, транзитные, которые сканируют директорию и формируют маршруты

const router = require('express').Router()
const getModules = require('../base/get_routes')
getModules(__dirname, router)
module.exports = router

index.js – индексные файлы с обработкой запросов, которые делают то же самое что и первые, а также обрабатывают запросы методов http (GET, POST etc.)

const router = require('express').Router()
const getRoutes = require('../../base/get_routes')
const baseCrud = require('../../base/base_crud')
const exampleController = require('../../controllers/example')

getRoutes(__dirname, router)
baseCrud(router, exampleController)

module.exports = router

Для реализации обработчиков мы интегрируем с текущим экземпляром router экземпляр контроллера exampleController.

Код метода baseCrud, добавляющий обработчики, выглядит так:

/**
 *
 * @param {Router}router
 * @param {BaseController}controller
 */
function baseCrud(router, controller) {
    router.get('/', (req, res, next) => controller.index(req, res, next))
        .post('/', (req, res, next) => controller.store(req, res, next))
        .get('/:id', (req, res, next) => controller.show(req, res, next))
        .put('/:id', (req, res, next) => controller.update(req, res, next))
        .delete('/:id', (req, res, next) => controller.destroy(req, res, next))
}

module.exports = baseCrud

Первыми аргументом передается экземпляр маршрута, вторым контроллер, отвечающий за обработку запросов.

Базовый класс контроллер выглядит так:

class BaseController {
    /**
     * {BaseService}
     */
    service

    /**
     *
     * @param {BaseService}service
     */
    constructor(service) {
        this.service = service
    }

    /**
     * Обработка результата работы сервиса и возврат его клиенту
     * @param {Promise<any>}serviceMethod
     * @param req
     * @param res
     */
    sendReplay(serviceMethod, req, res) {
        serviceMethod
            .then(result => res.json(result))
            .catch(error => {
                req.log.child({module: 'controller'}).error(error)
                res.status(error.status || 500)
                    .json({status: error.status, message: error.message, description: error.description})
            })
    }

    /**
     * Обзаботка запроса на получение одной сущности по уникальному идентификатору id
     * @param req
     * @param res
     */
    show(req, res) {
        this.sendReplay(this.service.getById(req.params.id, req.user), req, res)
    }

    /**
     * Обзаботка запроса на получение листа сущностей по пареметрам фильтра, указанным в query
     * @param req
     * @param res
     */
    index(req, res) {
        this.sendReplay(this.service.getList(req.query, req.user), req, res)
    }

    /**
     * Обзаботка запроса на создание сущности из данных переданных в body
     * @param req
     * @param res
     */
    store(req, res) {
        this.sendReplay(this.service.createItem(req.body, req.user), req, res)
    }

    /**
     * Обзаботка запроса на обновление сущности из данных переданных в body с идентификацией ее по id
     * @param req
     * @param res
     */
    update(req, res) {
        this.sendReplay(this.service.updateItem(req.params.id, req.body, req.user), req, res)
    }

    /**
     * Обзаботка запроса на удаление сущности по ее id
     * @param req
     * @param res
     */
    destroy(req, res) {
        this.sendReplay(this.service.destroyItem(req.params.id, req.user), req, res)
    }
}

module.exports = BaseController

Реализация экземпляра класса контроллера:

const BaseController = require('../base/base_controller')
const ExampleService = require('../services/example')
module.exports = new BaseController(new ExampleService())

В конструкторе ему передается экземпляр целевого сервиса. И методы контроллера взывают методы сервиса, передавая им в параметрах нужные значения из req и отдают результат работы в res в формате json.

Класс базовый сервис выглядит так:

const MethodNotImplementedError = require('./errors/method_not_implemented_error')

class BaseService {
    /**
     *
     * @param {{object}}data
     * @param {{object}}user
     * @returns {Promise<never>}
     */
    async createItem(data, user) {
        throw new MethodNotImplementedError()
    }

    /**
     *
     * @param id
     * @param {{object}}data
     * @param {{object}}user
     * @returns {Promise<never>}
     */
    async updateItem(id, data, user) {
        throw new MethodNotImplementedError()
    }

    /**
     *
     * @param {string}id
     * @param {{object}}user
     * @returns {Promise<never>}
     */
    async destroyItem(id, user) {
        throw new MethodNotImplementedError()
    }

    /**
     *
     * @param {string}id
     * @param {{object}}user
     * @returns {Promise<never>}
     */
    async getById(id, user) {
        throw new MethodNotImplementedError()
    }

    /**
     *
     * @param {{object}}data
     * @param {{object}}user
     * @returns {Promise<any>}
     */
    async getList(data, user) {
        throw new MethodNotImplementedError()
    }
}

module.exports = BaseService

Методы базовый класса сервиса имеют заглушки и при их вызове выбрасывается исключение MethodNotImplementedError.

Рабочий сервис выглядит так:

const BaseService = require('../base/base_service')

class Example extends BaseService {

    async getList(data, user) {
        return [{itemName: 'SomeItem'}]
    }

    async getById(id, user) {
        return {itemName: 'SomeItem'}
    }

    async createItem(data, user) {
        return {itemName: 'SomeItem'}
    }

    async destroyItem(id, user) {
        return {}
    }

    async updateItem(id, data, user) {
        return {itemName: 'SomeItem'}
    }

}

module.exports = Example

В нем переопределённые методы сервиса содержат бизнес логику. Именно тут разработчик и реализует механизм работы с данными.

Для обработки ошибочных ситуаций мы используем исключения. Классы ошибок, которые выбрасываются, содержат нужные поля для отображения описания и коды ошибки http для клиента API.

Классы ошибок лежат в папке /base/errors

http_error.js — базовый класс ошибки, с кодом 500

class HttpError extends Error {
    status = 500
    message = 'Internal server error'
    description = 'Внутренняя ошибка сервера'

    constructor(description = null) {
        super();
        this.description = description || this.description
    }
}

module.exports = HttpError

Если он выбрасывается в исключении, то клиент получает:

{
 "status": 500,
 "message": "Internal server error",
 "description": "Внутренняя ошибка сервера"
}

Таким образом, мы полностью абстрагируем сервисный уровень от формирования ошибок с заданными кодами http.

auth_error.js – класс ошибки авторизации

const HttpError = require('./http_error')

class AuthError extends HttpError {
    status = 401
    message = 'Not authorized'
    description = 'Вы неавторизованы'
}

module.exports = AuthError

 forbidden_error.js – класс ошибки прав доступа

const HttpError = require('./http_error')

class ForbiddenError extends HttpError {
    status = 403
    message = 'Forbidden'
    description = 'Данный ресурс вам недоступен'
}

module.exports = ForbiddenError

method_not_implemented_error.js — класс ошибки не реализованного метода.

const HttpError = require('./http_error')

class MethodNotImplementedError extends HttpError {
    status = 405
    message = 'Not implemented'
    description = 'Метод не рализован'
}

module.exports = MethodNotImplementedError

При желании можно реализовать любой вид ошибки, расширяя класс HttpError.

Что же мы получили в итоге

А вот, что: теперь мы имеем готовую болванку для реализации REST API. Программисту не нужно думать, как организовывать код, в какие папки его помещать. Все структурированно и разделено по слоям.

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

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

Такой подход позволяет нам без затруднений строить приложения в рамках мобильной платформы. Используя отлаженные механизмы. Это можно сравнить с типовым жилищным строительством. Когда имеется типовой проверенный проект здания, типовые составные части, типовой технологический процесс создания составных частей и типовой процесс сборки частей в готовое здание. Основная масса решений, как правило, типовые, что это ускоряет и удешевляет процесс. Для индивидуальных проектных решений используются так же типовые элементы, но они более мелкие, на уровне базовых технологий.

А теперь спрашивайте, отвечу!

Комментарии (11)


  1. zizop
    00.00.0000 00:00

    А вы не смотрели в сторону Nest.js? Если да, то интересно, почему отказались.


    1. kirill_ussr Автор
      00.00.0000 00:00
      +1

      Мы сначала запустили решение на node.js и Express, потренировались на кошках. Потом реализовали ту же послойную систему на Go и Gin, с учетом уже приобретенного опыта.

      А Nest.js все делает хорошо и из коробки. А мы не планировали долго сидеть на node.js.


  1. adruzh
    00.00.0000 00:00
    +1

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

    Но почему не использовать готовые решения, например NestJS ?

    Можно создавать новые сервисы из командной строки, есть сразу гибкость в транспорте между сервисами и ещё много чего вкусного.


    1. kirill_ussr Автор
      00.00.0000 00:00

      Почему все же не  коробочное Nest.js.

      Мы переносим реализацию серверных частей REST API на язык Go и Фреймворк Gin.

      Эта реализация на базе Express и node.js некий концепт, на базе которого реализуется послойное разделение в Go.


      1. adruzh
        00.00.0000 00:00
        +2

        Очень интересное продолжение проекта!

        Поделитесь результатами? Как оно на Go получилось, как быстро идет разработка и что с быстродействием?


        1. kirill_ussr Автор
          00.00.0000 00:00

          Уже реализованы решения на Go с послойной технологией. Они уже в продуктиве. Еще пока не в сильно нагруженных сервисах. Смотрим как работают в связках, вносим правки, если нужно. Скоро запускается в работу проект, где они будут под больной нагрузкой. По скорости обработки запросов, при обращении к БД или смежным источникам данных, сильного выигрыша в скорости не увидели. Так как основная задержка на источниках данных. Но что сразу бросается в глаза, более быстрый старт приложения и до 10 раз меньшее потребления оперативной памяти


  1. utya
    00.00.0000 00:00
    -1

    Не в плане рекламы, просто нахожусь в похожей ситуации, почему не битрикс24? Мне показалось там все есть, есть маркет, можно свои приложения втыкать в их приложение


    1. kirill_ussr Автор
      00.00.0000 00:00
      -1

      Мы не используем Битрикс именно как мобильное приложение. Мобильные приложения, над которыми мы работает нативные и работают с чистым REST API. Для этого нам не нужен функционал конструктора, который дает Битрикс


      1. utya
        00.00.0000 00:00

        Понятно, пилите все своё


  1. AlexeySi
    00.00.0000 00:00

    Дааааа.При вашем масштабе и количестве нужно было быстро запускаться, бить шишки и потиху уже неспешно расширять функционал и архитектуру. У нас в компании 500, поэтому таких проблем не было, поэтому сосредоточились больше на функционале.


    1. kirill_ussr Автор
      00.00.0000 00:00

      В нашем случае решения должны быть простые и легкие. И чем проще и легче, тем надежнее и выносливее. Но такие решения, как правило, даются сложно и тяжело...