Как мы быстро запустили мобильное приложение и споткнулись о свой успех
Привет, Хабр! Мы крупная производственная компания с 50К+ сотрудников, и в 2019 году поняли, что нам нужно мобильное приложение. Срок реализации 5 месяцев. Какой стек вы бы выбрали при такой скорости? Мы выбрали нативные Kotlin и Swift. Поначалу запилили всего 6 сервисов (новости, зарплатный лист, отпуска, блоги, регистрацию опасностей, выдачу СИЗ), и даже при том, что нанесли минимальную пользу, приложение очень зашло, количество пользователей начало расти лавинообразно. И тут мы поняли, что серверная часть на node.js + PostgreSQL создана без всякой мысли о развитии и масштабировании, решала исключительно локальные задачи. Все было на неоптимальной монолитной архитектуре развивать и поддерживать которую просто нельзя.
Расскажу, как мы решили проблему.
___________
Сейчас количество нативных сервисов в нашем приложении более 20. Помимо перечисленных выше, из самых интересных у нас имеются:
· мобильный пропуск, который позволяет проходить через турникеты на проходных по смартфонам;
· бронирование рабочих мест и парковок — для гибридных режимов работы офисных сотрудников;
· карта комбината, которая позволяет сотрудникам на липецкой площадке (26 кв. км) в деталях увидеть границы цехов, пешеходные дорожки, столовые и другие точки притяжения, такие как автобусные остановки или парк на территории комбината, медицинские пункты, проходные;
· Сервис заказа справок, чьей особенностью является полностью динамическое формирование полей на фронтенде на основании структуры, которая передается с бэка.
· И многое другое…
· 5 месяцев — время разработки мобильного приложения
· 30 000 MAU — количество уникальных пользователей
· 50 000 сотрудников в Группе НЛМК
Сервисов много и все они на бэкенде сидят, как уже было сказано выше, на монолитной и трудно поддерживаемой архитектуре. Это произошло исторически, в связи с фокусом на быстрые результаты в ущерб качественно проработанному решению. Что, я считаю, вполне нормально в концепции любых стартапов или создании MVP. Однако, когда мы говорим о развитии, то такие застарелые монолиты нуждаются в реформировании. В противном случае команда продукта будет похоронена под тяжестью legacy вкладывая все силы в его поддержку и стабилизацию вместо развития по все возрастающим потребностям внутри компании.
В общем, решили рядом с чистого листа создать новое решение на гибкой архитектуре. Дело за малым, выбрать брать готовое решение делать свое.
Из имеющихся наиболее полных и комплексных решений был только LoopBack 3, но он жестко завязан на собственную модель данных, а нам хотелось иметь свободу выбора.
Решили делать свой велосипед. Благо, начало проекта с небольших сервисов позволяло немного экспериментировать.
Архитектура послойная, напоминающая структуру слоев модели 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}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}
*/
async createItem(data, user) {
throw new MethodNotImplementedError()
}
/**
*
* @param id
* @param {{object}}data
* @param {{object}}user
* @returns {Promise}
*/
async updateItem(id, data, user) {
throw new MethodNotImplementedError()
}
/**
*
* @param {string}id
* @param {{object}}user
* @returns {Promise}
*/
async destroyItem(id, user) {
throw new MethodNotImplementedError()
}
/**
*
* @param {string}id
* @param {{object}}user
* @returns {Promise}
*/
async getById(id, user) {
throw new MethodNotImplementedError()
}
/**
*
* @param {{object}}data
* @param {{object}}user
* @returns {Promise}
*/
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. Программисту не нужно думать, как организовывать код, в какие папки его помещать. Все структурированно и разделено по слоям.
Мы просто клонируем в заданную папку на машине разработчика репозиторий болванку и допиливаем ее до нужной кондиции, добавляя идентичные модули маршрутов, контроллеров, и лишь в модулях сервиса реализуем ту специфику, которую требует конкретное приложение.
Все это позволяет поставить на поток создание типового микросеривса, которых зачастую надо очень много и быстро.
Такой подход позволяет нам без затруднений строить приложения в рамках мобильной платформы. Используя отлаженные механизмы. Это можно сравнить с типовым жилищным строительством. Когда имеется типовой проверенный проект здания, типовые составные части, типовой технологический процесс создания составных частей и типовой процесс сборки частей в готовое здание. Основная масса решений, как правило, типовые, что это ускоряет и удешевляет процесс. Для индивидуальных проектных решений используются так же типовые элементы, но они более мелкие, на уровне базовых технологий.
А теперь спрашивайте, отвечу!