Архитектура JS Back-end: подводные камни, принципы работы, лайфхаки
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Я занимаюсь построением высоконагруженных сервисов и разработкой архитектурных решений. Когда выдается свободное время, иногда пишу о JS на Medium: medium.com/@alexanderbidiuk — делиться опытом не только интересно, но и полезно. Эта моя статья пригодится тем, кто не знает, какие технологии использовать в своем проекте. Она послужит поверхностным руководством при выбора стека и подходов к разработке продукта на JS.
Node JS был создан как легкийая для вхождения язык платформа, дополняющийая фронтендовый стек. Он был призван имплементировать событийную неблокирующую ввод-вывод модель. И до сих пор «нода» хороша для асинхронных операций.
Независимо от подхода к написанию кода, Node JS нельзя использовать для тяжелых вычислительных процессов. Лучше это отдать на исполнение микросервису на другом языке или написать модуль на C/C++. Как опциональное решение подойдут worker threads.
«Нода» отлично зарекомендовала себя при работе над легкими операциями input и output, когда нет большой нагрузки на процессор. Но если нам нужно обрабатывать не такие уж большие данные, и вы не хотите блокировать свою «ноду», потому что она должна ждать запроса от пользователей и продолжать работу, уже на неэкспериментальном этапе можно использовать worker threads.
О возможностях Node JS и архитектуре JS Backend, подводных камнях и лайфхаках при работе с ними — обо все, что мы рассмотрели на митапе Fullstack JS Beetup от Beetroot Ukraine, — читайте дальше.
Node JS и разные виды архитектуры
Давайте подробнее остановимся на архитектуре. В базе у нас есть две основные структуры: монолит и микросервисы.
В случае с монолитом мы всю бизнес-логику реализуем в рамках одного приложения. Все модули, все сервисы — все в одной аппликации. Это выгодно при работе с небольшими проектами, простым сервером или MVP, потому что монолит дешевле деплоить и проще разрабатывать.
В случае с монолитом разработчику не нужно беспокоиться, как будут взаимодействовать разные сервисы, потому что их просто нет: сервис один. Разрабатывать монолит проще, потому что он маленький, все внутри.
Использование BFF на монолите
Идеальный вариант для монолита — это разделение на бекенд с бизнес-логикой и BFF. Что такое BFF, в чем его суть? Это сервер, который занимается только логикой, необходимой для фронтенда. В нем нет крупной бизнес-логики, которая должна быть на бекенде.
Например, BFF может хранить секьюрные данные, например, ключи. И при запросах на third party сервисы использовать их. Это хорошее решение для платежных систем: фронтенд ничего не «знает» о секьюрности. Так же BFF может держать SSE-сессии. То есть у нас куча пользователей, они все подсоединились — зачем на основном бекенде хранить все эти сессии и заниматься их менеджментом? Также этот сервис возьмет на себя проксирование и фильтрацию потока данных, который потом пойдет в сессии.
Сервер BFF может также работать как API gateway. Например, можно сделать один универсальный эндпоинт для фронтенда, а уже внутри BFF он раскидывается на несколько запросов: на основной бекенд или сторонние сервисы, такие как Amazon S3, AWS Lambda и так далее.
Микросервисы
Когда нам нужны микросервисы? Идеальный вариант использования микросервисов — это когда поток данных может быть асинхронным. В таком случае можно будет использовать системы очередей для коммуникации, которые дают крутые возможности по управлению трафиком.
Бекенды микросервисов, по мере своих возможностей, когда у них освобождается thread, подхватывают эти данные. Такой подход дает независимость при разработке сервисов (микросервисы могут быть написаны на разных языках). К тому же каждый микросервис работает независимо от других частей системы.
Это удобно скейлить, но стоит учитывать минусы: это тяжело разрабатывать, дороже деплоить. К тому же, чтобы грамотно задеплоить микросервисы, нужно иметь больше знаний, поскольку придется заниматься вопросами доступа сервиса к сервису, их расположением в сети и прочим. Разработчику надо быть более подкованным в деплоинге.
Пример микросервисной архитектуры
На рисунке: сверху вниз — шкала по времени, слева направо — как идет флоу действий.
Пользователь делает запрос. Проходит некоторое время и UI делает запрос на первый микросервис, где эти данные валидируются, и уходят на второй микросервис. Там данные обрабатываются, а затем отправляются назад. Все это время пользователь ждет. То есть это хороший кейс для микросервисов, если во время обработки данных пользователь может делать что-то еще на UI, пока не получит извещение о выполнении запроса. Но обычно пользователь ничего не делает на страничке в ожидании отклика, поэтому при такой схеме он все время обработки данных микросервисами будет просто ждать.
Какие недостатки у микросервисов? Микросервисную архитектуру тяжелее поддерживать. Нужно тщательно продумать, как вы ее будете логировать. Допустим, у вас была цепочка из трех микросервисов. Вы в один из них отправили объект, а он не пришел. Вы должны прологировать каждый микросервис, чтобы потом можно было найти, на каком этапе «потерялся» объект, где именно произошла ошибка. В этом и сложность: всегда нужно думать о логировании, а при возникновении ошибок придется проверять все. Еще один минус микросервисов: когда деплоите, это дороже, сложнее, требует больше ресурсов.
Организация кода
Для приложений среднего размера и более монолит — не лучшее решение: наличие одного большого программного обеспечения с множеством зависимостей просто трудно осмыслить и это часто приводит к слишком сложной кодовой базе.
Поэтому наиболее рациональное решение — это разделить весь проект на автономные модули, которые обмениваются файлами (сервисами) с другими. Каждый модуль должен содержать мало файлов, например: сервис, доступ к данным, контроллер и так далее. В таком случае будет очень легко вынести логический модуль в другой микросервис или в shared-компоненты.
Обратите внимание, что проект разбивается именно по логическим модулям: продуктов, отзывов и так далее. Это напоминает «утиную типизацию» или ducks-подход, но мы пляшем от доменной области. Например, есть доменная область «пользователи» — все, что связано с ними, мы пишем в одной папке. Между собой компоненты внутри папки могут быть сильно связаны, но наружу мы предоставляем минимальное API, чтобы между модулями сильной связи не было.
Хороший пример
Допустим, у нас есть юзер и продукт. Внутри продукта есть сервис и контроллер. А между собой юзер и продукт общаются через Public API. Это не классический http-запрос. Он подразумевает, что один модуль экспортирует определенный набор функций, а другой модуль знает, что у него в зависимостях должен быть такой модуль, который имплементирует заданные методы. При использовании Dependency Injection зависимость внедряется автоматически, и разработчику не нужно прописывать import и export. Вы просто регистрируете модуль и конфигурируете. Например, что модуль users должен получить модуль products. Это и есть публичное API, когда вы вообще не импортируете сторонние модули между собой.
Что это дает? Мы можем взять, например, модуль Users и использовать его в другом микросервисе. То есть мы можем этот модуль вообще «запихнуть» в private registery и установить как в одном микросервисе, так и в другом. Плюс, если мы используем TypeScript, мы четко знаем, какой интерфейс он имплементирует, какие методы он нам дает, и можем использовать модуль, где хотим.
Плохой пример
Здесь проект разбит не по модулям, а по контроллерам. Поэтому сервис идет в другой модуль, импортирует в себя хелперы и прочее, получается путаница. Модули получаются жестко связанными между собой. При этом сохраняется связанность между внутренними сервисами. И мы уже не можем какой-то модуль вынести в отдельный репозиторий.
Классы vs. объявление функций как констант функциональный стиль
Когда вы описываете какой-то сервис, вы можете объявлять методы. Например, как константы со стрелочными функциям, и тогда вы просто в конце берете их и экспортируете. Но также вы можете сделать эти же методы, но только внутри класса, и экспортировать класс. И то, и другое будет работать.
Допустим, вам нужно протестировать user-сервис. В данном случае это будет файл с двумя методами. Если два метода используют третий, его надо будет как-то замокать. Но как мы его можем замокать, если мы этот метод не экспортируем? В случае с классом, когда вы импортируете его полностью, вы можете сразу указать, что хотите у этого класса замокать определенный метод. И тогда, когда вы будете писать тесты на методы, вы можете через this указать, чтобы они юзали не изначальный метод, а замоканный.
Когда вы выбираете стиль написания внутри файла, задумайтесь, стоит ли сделать именно класс, или будет достаточно просто констант с функциями, потому что они никак не повлияют на написание тестов. Когда организуете структуру, никогда не лишне продумать, как ее лучше оформить.
DDD — Domain Driven Design
Возвращаясь к организации кода, давайте поговорим про Domain Driven Design. Его суть состоит в том, чтобы разделять логику, связанную с любым модулем системы, на слои. Основная задача DDD — снизить сложность внедрения нового функционала и модификации, существующего с ростом приложения.
В этом случае мы, как и было сказано выше, отталкиваемся от модулей, а они уже импортируют какие-то сервисы и контроллеры, то есть слой, который работает с базой данных, ничего не знает о базе данных. Слой сервиса, который вызывает работающий с базой слой, ничего не знает про контроллер, который его дергает. То есть мы делаем все части (слои) независимыми и разбиваем на модули.
Схожая с DDD практика — это гексагональная архитектура (Hexagonal Architecture). Принцип и подход схож с DDD, просто некоторые вещи имеют свое название. Мы разбиваем все на модули. Хороший пример: модуль статей работает с сервисом для статей через классы и методы, которые работают с базой данных. То есть легко вклеивать части системы в эту архитектуру.
Фреймворки
Чтобы не рассматривать все фреймворки, давайте остановимся на наиболее популярных и интересных:
- Express.
- Nest JS.
- Koa JS.
- Fastify.
Express
Начнем, наверное, с самого популярного фреймворка на планете. Это не диктующий правила минималистичный фреймворк. Он позволяет разработчикам выбирать любые инструменты и технологии, мидлвары и хуки, которые они хотят использовать. Разработчики структурируют свой код без какой-либо конкретной системы, навязанной фреймворком. Express не имеет конкретного механизма шаблонизации, парсера тела запроса, обработчика ошибок и так далее. Для начинающего девелопера это идеальный вариант.
Что за собой влекут особенности Express? Каждый разработчик начинает тянуть одеяло на себя и ставить свои любимые инструменты. Кому-то нравится шаблонизатор pug, а кому-то — ejs. Если в рамках одного проекта это все решается с помощью тимлида или заданных правил разработки (и демократии), то на проектах с той же микросервисной архитектурой — это колоссальная проблема. Разработчикам будет тяжело свитчнуться с проекта на проект, а также вам будет невозможно сделать какие-то shared-утилиты.
Плюсы:
- Легковесность.
- Свобода выбора инструментов.
- Легкость вхождения.
- Популярность.
Минусы:
- Свобода выбора инструментов.
- Скорость.
- Легкость вхождения.
- Не дает четкой структуры кода.
Nest JS
Nest следует парадигме дизайна «convention over configuration». Он настоятельно рекомендует разработчикам использовать определенные инструменты и кодить определенным образом. Под капотом он уже обертывает контроллер блоком try-catch, анализирует тело запроса, добавляет обработчик ошибок, мидлвары и т.д.
Таким образом, не нужно делать эти обычные вещи каждый раз при создании нового проекта. Nest с коробки дает нужную структуру папок, говорит, как и где писать нужные части кода. А еще Nest использует TypeScript, что очень хорошо для больших команд, а также при разработке микросервисов.
Плюсы:
- Четкие паттерны разработки.
- Легкость вхождения.
- Легкая первоначальная настройка проекта.
- Все типовые решения идут с коробки.
- Возможность использования разного транспорта под капотом.
Минусы:
- Тяжеловесность.
- Не всегда уместное использование. (Например, если у вас есть сервер, на котором мало круда, но много обработки на микросервисы, Nest не подходит).
- TypeScript. (Если проект простой, TypeScript будет только замедлять работу над ним).
- Скорость. (Когда вы пишите свой код, он еще оборачивается функциями самого Nest, поэтому, каким бы движок под капотом ни был быстрым, за счет вот этих лишних вызовов функций он все равно будет чуть-чуть медленнее).
В Nest под капотом можно использовать разные фреймворки: тот же Express или Fastify. Нет готового решения. Когда вы получаете задачу, лучше выписать плюсы и минусы разных фреймворков и сопоставить их с вашим проектом.
Koa JS
Коа был сделан теми же разработчиками, которые создали Express. Он очень легкий: всего около 500 строк кода. Там нет ни одного встроенного мидлвара, также в нем нету встроенного роутера, как в Express. В последних версиях он очень быстрый.
Плюсы:
- Быстрый (второй по скорости из всех фреймворков).
- Удобно кастомизировать.
- Легковесный и простой.
Минусы:
- Свобода выбора инструментов.
- Не дает четкой структуры кода.
Fastify
Самый быстрый фреймворк. По структуре и принципу написания кода он не похож на предыдущие фреймворки, у него интересный подход к организации модулей и скоупов. Предоставляет свой dependency injection.
Он обрабатывает почти 77 тысяч запросов в секунду. Но нужно учитывать, что когда вы работаете над проектом, и используете хотя бы одну мидлвару, он будет «тянуть» уже 20 тысяч запросов. Также у Fastify есть определенная часть статических проверок, когда он парсит запросы. Плюс он очень быстро валидирует JSON.
Плюсы:
- Супербыстрый.
- Удобно кастомизировать.
- Легковесный и простой.
Минусы:
- Не дает четкой структуры кода.
- Не так много инструментов, как кажется, некоторые вещи придется реализовывать самому.
Например, у нас общение с сервером происходит по SSE-connection. Есть единственный модуль, единственная библиотека по SSE для Fastify,. Но она очень старая и не работает с текущий версией Fastify. Вы ее импортируете, подключаете, а она отказывается функционировать. В итоге может потребоваться самостоятельно писать плагин для SSE. Или, как вариант решения: можно заюзать Nest с Fastify в качестве движка. Но если круда на проекте почти нет, зачем нужен Nest с его большим оверхедом?
Хранилища и базы данных
Все слышали SQL и NoSQL. Давайте поговорим, что и когда нужно использовать. Напомню особенности обоих типов.
SQL | NoSQL |
Гарантирует консистентность | Имеет eventual consistency |
Имеет жесткую структуру, которую нельзя быстро изменить | Структура может легко меняться |
Каждая сущность может быть беспроблемно размазана по БД | Предполагает, что сущность «локальная» (когда вся информация о сущности — внутри) |
Требует хорошие знания для скейлинга | Легко скейлится (та же Mongo дает шардирование с коробки) |
Дает не такой крутой перфоманс | Хороша для интенсивных записей и чтений |
NoSQL крут для проектов без четких требований, поскольку структура может легко меняться. Также он подойдет для проектов, которые имеют кучу «мусорных» данных. Я имею в виду сбор больших массивов статистики, кликов, данными по пользователю и тому подобного. Не менее хорош он для проектов с очень активным чтением/записью.
SQL крут для данных с жесткой структурой и гарантирования консистентности. Можно спокойно использовать SQL, если вы знаете, что структура не меняется. Как ни крути, SQl мощнее, в нем сложнее серьезно напортачить, наделать багов.
Рассмотрим самые хайповые базы данных:
- MongoDB — NoSQL.
- PostgreSQL — SQL/NoSQL.
- MySQL — SQL.
- REDIS, Aerospike, Memcached — NoSQL.
Здесь не упомянуты графовые базы данных, но из них чаще всего встречается Neo4j. Для чего можно использовать разные базы данных?
| MongoDB, REDIS, Aerospike, Memcached, PostgreSQL |
| PostgreSQL, MySQL |
Почему PostgreSQL указан в обоих строках таблицы? Это очень интересная база данных, она поддерживает новые SQL-решения: можно сохранять новые SQL-данные. Мало того, что мы можем сохранять, записывать JSON, мы также можем делать запросы, индексировать. В некоторых случаях она работает даже быстрее, чем MongoDB.
Docker
Для чего мы используем Docker в приложениях?
- Используется для гарантирования исполняемости кода на любой машине. Почти все, кто писал код на JS или Node, сталкивался с тем, что на «Маке» код собирается, а на Windows уже нет. Что делает Docker? Он гарантирует, что код исполняется на любой ОС.
- Основа для CI/CD. Собирая проект, мы с помощью Docker создаем образ, и с этим образом уже могут работать различные CI/CD системы.
- Помощь при разработке микросервисов (вместе с Docker Compose File). Вы сразу можете запустить одной командой весь environment, и он будет нормально функционировать: точно так же, как если бы его задеплоили на реальный продакшн.
Рассмотрим общие практики:
- Используйте многоступенчатые сборки для более компактных и безопасных образов Docker. Сам Docker-образу будет супербольшим, поэтому мы билдим в одном образе, а потом запускает Node на другом, более легком образе, копируя все, что мы сбилдили, из первого образа.
- Делайте запуск внутри Docker командой Node, а не npm. Когда вы раните ваш образ, npm не пробрасывает системные
командысигналы. Что это значит? Вы, например, подняли у себя Docker, образ Node, но что-то пошло не так, и вы решили остановить процесс. Но с npm вот этот сигнал остановки процесса не пробросится внутрь Node. Перед тем, как «убить» Node, вы должны дать команду отсоединиться от базы данных, от системы очередей, чтобы конекшн не висел. Когда вы выполняете команду Node внутри Docker-образа, вы гарантируете, что этот сигнал проскочит, и вы поймаете эту команду, сможете выполнить необходимые функции. - Всегда добавляйте .dockerignore, чтобы тесты и другие лишние данные не попали в ваш Docker-образ.
- Всегда очищайте зависимости. Используйте в конце команду npm prune, которая почистит лишний dependence. И это сделает образ меньшим.
- Используйте фичу кеширования. То есть копируйте уже все приложение после того, как скопировали package.json и сделали ему npm-инстал. Когда выполняется команда «copy», и она видит, что скопированные до этого файлы не изменились, она пропустит команду «npm ci», и ваш образ будет запускаться быстрее.
Выбирая стек приложения, нужно опираться на множество факторов: начиная от требований, заканчивая знание тех или иных технологий командой, которая будет заниматься разработкой. Мы не ставили себе целью дать однозначный ответ, какой фреймворк или БД выбрать, все очень ситуативно и зависит от многих нюансов. При проектировании продукта всегда стоит подумать на пару шагов вперед: «А вдруг захотят такой функционал?». Или «А вдруг захотят хранить в БД иные данные?». Тогда Вы сможете продумать и применить более универсальный стек, который покроет не только текущие требования и «хотелки», но и будущие.
68 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів