Контракт между бэкендным сервисом и фронтендным потребителем (или клиентом) обычно является местом соединения двух миров. Такой контракт может принимать форму спецификации REST API, конечной точки GraphQL, или чего-то другого. Главное, чтобы он сообщал обеим сторонам, чего ожидать друг от друга.
Такова любовная история между бэкендом Node.js и фронтендом React. Живя в разных мирах, они нашли общий язык для общения, но этого было недостаточно — всё равно случались недопонимания: иногда один ждал, что другой скажет что-то такое, чего второй не может выразить. Такой была ситуация до недавнего времени, когда произошла генерализация TypeScript (и типов TypeScript), благодаря которой они начали говорить на одном языке.
Давайте узнаем, что такое шаблон BFF (нет, это не шаблон Best Friends Forever, как бы здорово это ни звучало), и разберёмся, как типы TS могут помочь нам создать надёжный контракт между бэком и фронтом.
Паттерн BFF
Как и другие забавные акронимы в нашей отрасли, шаблон BFF основан на концепции «Best Friends Forever», однако инженеры решили назвать его «Backend For Front-end».
Смысл этого шаблона — превращение бэкенда и фронтенда в лучших друзей, несмотря на то, что напрямую они могут и не общаться. При разработке микросервиса в бэкенде, который используется множеством клиентских приложений, его API должен быть стандартным; по сути, у всех клиентов он должен быть одинаковым. Теоретически это упрощает использование и освоение вашего сервиса другими разработчиками.
Тем не менее, иногда случается, что клиентские приложения по каким-то причинам не всегда хотят/могут исполнять контракт вашего API. Из-за этого во фронтенд нужно добавлять новую логику для парсинга и преобразования ответа в то, что он может использовать.
И здесь в игру вступает BFF: вместо того, чтобы заставлять клиентские приложения общаться напрямую с микросервисом, мы приказываем им общаться с прокси-сервисом, который занимается отправкой запроса нужному сервису и преобразует ответ в вид, который может понять фронтенд.
На самом ли деле шаблон BFF лучше, чем отсутствие шаблонов? Преимущества:
- Клиентское приложение остаётся «глупым», и это предпочтительно с точки зрения безопасности и простоты использования. Чем глупее должны быть клиентские приложения, тем быстрее другие команды смогут создавать клиентские приложения. Кроме того, основная бизнес-логика и необходимая обработка данных остаются сокрытыми на стороне бэкенда.
- Увеличение задержки из-за дополнительного подключения в бэкенде теоретически должно быть меньше, чем влияние потребления дополнительных ресурсов во фронтенде.
Идеален ли такой подход? Недостатки:
- Из-за нового элемента (т.е. ещё одного сервиса) архитектура системы усложняется. В нашем примере рассматривается только один BFF, но потенциально вы можете создавать по одному BFF для каждого типа клиентских приложений.
- Из-за BFF-сервиса существует тесная косвенная связь между бэкендом и фронтендом. Да, смысл BFF на самом деле в том, чтобы контракт между бэком и фронтом был именно таким, какой нужен клиенту, что до минимума снижает вероятность внесения изменений в будущем. Однако эта вероятность никогда не равна нулю, и модификации в бэкенде или фронтенде подразумевают непосредственное изменение на другой стороне. То есть они связаны.
Можете ли вы смириться с этими недостатками? Если да, то BFF вам подходит. Они вызывают слишком много проблем или вы используете монолитную архитектуру? Тогда забудьте о них и реализуйте BFF, только если начнёте работать с микросервисами.
Что насчёт общих типов?
Общие типы (Shared types) — это специализация шаблона BFF, рассчитанная на TypeScript; она позволяет использовать общее определение типов в коде и фронтенда, и бэкенда.
И это потрясающе, ведь вы в буквально смысле можете избежать проблем определений между двумя командами разработчиков, предоставив им общий источник истины.
Чтобы это сработало, нужно найти способ превратить определение типов в отдельный модуль, который смогут импортировать обе команды. Это можно сделать множеством способов, например:
- Если у вас есть монорепозиторий, то можно просто импортировать определение из generic path в оба проекта. Однако такой подход привязывает жизненный цикл ваших типов к обоим проектам одновременно, потому что внося изменение в файл (например, в определение типа), вы влияете на весь монорепозиторий. Да, хорошо владея git, вы можете обойти эти проблемы, но это немного трудоёмко.
- Можно превратить определение типов в модуль NPM, после чего установить в качестве зависимости в код фронтенда и бэкенда. Затем всё будет работать отлично, но на этапе начальной разработки вам необходимо будет опубликовать модуль, иначе у вас будут локальные пути импорта, которые придётся рефакторить в абсолютные. И даже после завершения начального этапа разработки любые изменения в локальной версии модуля типов нужно будет публиковать, иначе их не увидят другие проекты. Это может оказаться довольно хлопотным.
Но есть и третий, более интересный вариант, в котором используется Bit.
Что такое Bit?
Если вы о нём ещё не слышали, Bit — это open-source-инструмент (имеющий нативную интеграцию с платформой удалённого хостинга Bit.dev), помогающий создавать и делать общими независимые компоненты. Это компоненты (или модули), которые независимо разрабатываются, имеют собственные версии и над которыми можно совместно независимо работать.
Можно или создавать новые независимые компоненты с нуля, или постепенно извлекать компоненты из уже имеющейся кодовой базы.
Хотя кажется, что это ужасно похоже на NPM, есть и важные отличия:
- Не нужно физически извлекать код, чтобы создавать независимые новые версии, делать его общим и совместно над ним работать. Можно «экспортировать» компонент прямо из своего репозитория. Bit позволяет задать часть кода в качестве компонента и с этого момента работать с ним независимо. В свою очередь, это позволяет упростить процесс совместного использования, потому что нет необходимости в настройке отдельного репозитория и переработке процесса импорта этих файлов в свой проект.
- Люди, «импортирующие» ваши компоненты (а не просто устанавливающие их), также могут участвовать в совместной работе над ними, изменять их и экспортировать обратно в их «remote scope» (удалённый хостинг компонентов). Это невероятно мощный подход, если вы работаете группой команд в одной организации, потому что вы можете совместно работать над одним и тем же инструментом без необходимости работы над отдельным проектом. При импорте компонента Bit код скачивается и копируется в вашу рабочую папку. Также при этом генерируется соответствующий пакет в папке
node_modules
. При изменении исходного кода (в рабочей папке) пакет генерируется заново. Благодаря этому вы можете использовать его, указывая абсолютный путь, подходящий для всех контекстов (это сработает, даже если вы решите установить пакет компонента без его импорта).
Именно этим мы сегодня и воспользуемся — мы можем превратить локальную настройку в сценарий с несколькими репозиториями, особо ни о чём не беспокоясь.
Создание общего модуля типов
Чтобы показать красоту практического применения этого паттерна общих типов, я создам три компонента:
- Разумеется, определение типов. Его будут использовать на своей стороне два других компонента. Также этот компонент будет экспортировать мок с фейковыми данными, используемый компонентом фронтенда, когда у него нет связи с бэкендом. Последняя часть нужна только в качестве примера, вероятно, вы найдёте более удобные способы решения проблемы на своей стороне.
- Бэкенд-сервис, экспортирующий функцию, готовую к созданию HTTP-веб-сервера и передаче фиксированного ответа на каждый запрос. Так как это пример, сервер будет очень простым, однако его будет достаточно, чтобы показать полезность подхода. В своём коде он будет использовать общее определение типов для проверки того, что ответ имеет нужный формат.
- React-компонент, который будет запрашивать данные у сервиса (или использовать данные мока) и рендерить их на странице. Разумеется, в его коде тоже будет использоваться общее определение типов.
Давайте приступим.
Исходная структура
Будем считать, что вы уже установили Bit и залогинились в платформе. Теперь потратьте две минуты на создание коллекции (также называемой «scope») и назовите её «bff» (можете выбрать любое другое имя).
Затем инициализируем рабочую среду:
$ bit init --harmony
После этого отредактируем файл
workspace.jsonc
и зададим defaultScope
значение [username].bff
, в моём случае это выглядит как "deleteman.bff"
.Теперь можно перейти в терминал и создать первый компонент:
$ bit create react-component ui/client-app
Создастся новый компонент на основе React-шаблона, в котором будет присутствовать бойлерплейт. Также будет создана структура папок, необходимая для новых компонентов (должна создаться папка
bff
и папка ui/client-app
внутри неё).Следующие два компонента нужно создать вручную, потому что у Bit пока нет для них шаблонов:
Просто скопируйте показанную выше структуру, поместив общие типы в папку
common/bff-types
, а сервис в папку backend/service
.Нужно создать в обеих папках файлы
index.ts
, потому что они будут использоваться в качестве точек входа, когда мы превратим эти папки в компоненты.Определение типов в нашем примере будет очень простым:
//index.ts file
import {ServiceResponseType, PersonType} from './types'
const ServiceResponseMocks: ServiceResponseType = [
{
name: "Fernando Doglio",
age: 37,
address: "Madrid, Spain"
}
]
export {ServiceResponseMocks, ServiceResponseType, PersonType}
//types.d.ts file
type PersonType = {
name: string,
age: number,
address: string
}
type ServiceResponseType = PersonType[]
export {ServiceResponseType, PersonType}
Обратите внимание, что в показанный выше фрагмент кода я включил оба файла.
Код сервиса не сложнее:
//service.ts file
import * as http from 'http';
import {ServiceResponseType} from '@deleteman/bff.bff-types';
let data: ServiceResponseType = [
{
name: "Fernando Doglio",
age: 23,
address: "Madrid, Spain"
},
{
name: "Second Person",
age: 99,
address: "Somewhere else"
}
]
export function newServer() {
http.createServer(function (req, res) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET');
res.setHeader('Access-Control-Max-Age', 2592000); // 30 days
res.end(JSON.stringify(data));
}).listen(8000);
}
//index.ts file inside backend/service
export * as service from './service'
Обратите внимание, что я всегда возвращаю JSON с двумя элементами внутри и использую определение типов, чтобы создаваемая структура возвращалась без потери атрибутов. Экспортированная функция
newServer
создаёт простой HTTP-сервер (никакого Express или чего-то подобного) и настраивает необходимые CORS-заголовки, чтобы вы могли протестировать пример сервера со своего локального хоста.React-компонент выглядит так:
import React, { ReactNode, useState } from 'react';
import {useEffect} from 'react';
import {ServiceResponseType, PersonType, ServiceResponseMocks} from '@deleteman/bff.bff-types';
export function ClientApp(props: {defaultMessage?: string, mock?: boolean}) {
let [items, setItems] = useState([])
let [error, setError] = useState(null)
useEffect(() => {
if(props.mock) {
return setItems(ServiceResponseMocks)
}
fetch("http://localhost:8000")
.then(res => res.json())
.then(
(result: ServiceResponseType) => {
setItems(result as ServiceResponseType);
},
(error) => {
setError(error);
}
)
}, [])
if(error) {
return (<div >
<p>Error found: {error.message}</p>
<p>{props.defaultMessage}</p>
</div>)
}
return (
<div>
{renderPeople(items)}
</div>
);
}
function renderPeople(ppl: ServiceResponseType): ReactNode[] {
return ppl.map( (person: PersonType) => {
return <div key={person.name + person.age}>
<p>Name: {person.name} </p>
<p>Age: {person.age} </p>
<p>Address: {person.address} </p>
</div>
})
}
Много кода, но на самом деле здесь не происходит ничего особо сложного.
Вы уже наверно заметили две строки
import
и в компоненте сервера, и в React-компоненте:import {ServiceResponseType, PersonType, ServiceResponseMocks} from '@deleteman/bff.bff-types';
Обычно это должно обозначать, что я уже опубликовал модуль общих типов и теперь импортирую его. Однако благодаря Bit это не требуется. Мне достаточно было превратить модуль в компонент, после чего Bit создаёт внутри папки
node_modules
символическую ссылку, указывающую на мою локальную копию. Это, в свою очередь, позволяет мне работать над другими компонентами, как будто всё уже было опубликовано.Превращаем код в компоненты
Помните, что пока мы создали только один «официальный» компонент — React-компонент. Другие два пока представляют собой только папки с кодом. Нам нужно, чтобы Bit узнал о них, и это можно сделать так:
$ bit add bff/backend/service
$ bit add bff/common/bff-types
https://bit.dev/deleteman/bff
Итак, мы сообщили Bit, что эти две папки тоже содержат компоненты и что их тоже нужно отслеживать. Также это должно изменить содержимое файла
.bitmap
(трогать его не нужно, просто убедитесь, что он существует для каждого из модулей).Последнее, что нужно сделать, прежде чем всё будет готово — вернуться в файл
workspace.jsonc
и изменить ключ variants
, чтобы для каждого компонента использовалась нужная среда. Помните, что мы работаем с React-компонентом на одной стороне, с Node.js-компонентом с другой и с обобщённым компонентом, поэтому Bit должен знать об этом. Просто откройте файл и убедитесь, что раздел teambit.workspace/variants
выглядит следующим образом:"teambit.workspace/variants": {
"bff/ui/client-app": {
"teambit.react/react": {}
},
"bff/common/*": {
"teambit.react/react": {}
},
"bff/backend/service": {
"teambit.harmony/node": {}
}
}
Теперь можно выполнить
bit start
, запустив таким образом локальный сервер разработки. Этот сервер разработки предоставит вам всю необходимую информацию о компонентах и покажет, как будут выглядеть их документы после публикации.Интересной для нас информацией здесь является URL импорта. Можно получить его, нажав на компонент после запуска сервера, первым делом вы увидите автоматически сгенерированную строку.
Как и говорилось выше, благодаря использованию этого URL вам не нужно выполнять импорт напрямую и беспокоиться о том, на что он указывает, Bit разрешит эту зависимость за вас и вы можете разрабатывать компоненты, будучи уверенным, что этот общий код уже доступен.
Если вы хотите поделиться этой работой с другими, это тоже можно сделать через Bit:
$ bit tag --all #1
$ bit export #2
Шаг 1 помечает все компоненты, то есть мы создаём для них версию, под которой они будут экспортироваться. Шаг 2 экспортирует компоненты в глобальный репозиторий Bit.dev. На случай, если вам захочется взглянуть, я поделился своим здесь.
Мой remote scope со всеми общими независимыми компонентами
Что же мы сделали?
Благодаря использованию Bit и шаблона общих компонентов мы смогли соединить бэкенд-сервис с фронтенд-приложением, обеспечив для них общее определение типов.
Но что ещё здесь произошло?
- Во-первых, мы разработали и опубликовали три модуля, при этом нам даже не пришлось думать о компиляторе TS, WebPack, NPM или любых других инструментах, за исключением Bit. Именно поэтому я выбрал Bit и поэтому считаю его таким полезным инструментом. Он абстрагировал все шаги, необходимые для создания трёх компонентов, даже когда они работают в трёх разных средах.
- Мы работали с локальной зависимостью, которая должна быть внешней зависимостью, и даже не заметили разницы. Каждое обновление, вносимое в локальную версию компонента с типами BFF, подхватывалось другими двумя компонентами, и нам не приходилось заново выполнять весь процесс экспорта.
В конечном итоге, шаблон BFF и шаблон общих типов — это просто способ сказать: в конкретном сценарии, когда приемлема определённая степень связывания, давайте согласуем контракт между клиентом и сервером, переписав любые другие общие контракты, имевшиеся в прошлом.
Эта связь образуется между сервером и одним клиентом — помните, BFF связывает одно клиентское приложение (или тип приложений) со специализированной версией сервера. Он не отменяет обобщённый контракт с другими клиентами, которые могут использоваться с исходной версией сервиса.
______________
Дата-центр ITSOFT – размещение и аренда серверов и стоек в двух ЦОДах в Москве; colocation GPU-ферм и ASIC-майнеров, аренда GPU-серверов. Лицензии связи, SSL-сертификаты. Администрирование серверов и поддержка сайтов. UPTIME за последние годы составляет 100%.
Комментарии (19)
VanKrock
04.08.2021 22:04Почему просто не использовать OpenAPI (Swagger)? Из него можно автоматически генерировать клиентов как фронта так и для общения между сервисами на бэке (refit/retrofit) так же получаем хорошую документацию API
AlexSpaizNet
Вот убейте меня, но я все никак не пойму. Бэкендисты столько страдали, чтобы прийти к таким паттернам архитектуры как микросервисы, разделения всего и вся - от базы до моделей и обратно. И все равно придет кто-то да и посоветует делать shared dependencies.
И как бы сексуально это не выглядело, по моему личному опыту shared stuff это всегда проблемы. Это всегда зависимость от чего-то или кого-то еще.
Переубедите меня Ж)
aavezel
шарить не код, а интерфейсы (типы) в общую библиотеку вроде всегда было нормой.
AlexSpaizNet
Да нет. Как минимум в микро-сервисной архитектуре. В идеале не должно быть общих зависимостей. Ни моделей данных базы, ни dto, ни чего либо еще. Все должно быть по максимуму независимым.
Создание зависимостей, как вы их не назовите - останутся зависимостями и может выйти боком. Версионирование, разный цикл разработки и релизов различных сервисов в конце упрется в эти зависимости.
Хотя возможно для каких-то проектов это и будет работать.
essome
К примеру у вас микросервис users должен возвращать UserInterface
Если не шарить его - у вас в микросервисе users будет UserInterface, на фронте будет такой же дублированный UserInterface, на сервисе orders будет такой же дублированный UserInterface, потому-что каждый дергает /users/1, не лучше этот интерфейс поставить как зависимость?
AlexSpaizNet
Именно. Как показывает практика - не лучше. На эту тему много в интернете написано. Кажется контр-интуитивно. Как это!? А как же DRY?
По факту, это то что дает независимость. Естественно не бесплатно. Поэтому у ваш должно быть достаточно тестов чтобы предотвращать всякие инциденты.
У вас в сервисе ордер будут свои DTOошки, свои модели данных базы и т.д. Даже если сегодня они выглядят одинаково (так же как и в сервисе users).
essome
Они всегда будут одинаковые, так как это результат респонса микросервиса users, он не может быть другим
noodles
Поддержу @AlexSpaizNet против дополнительной зависимости.
Как по мне, какая разница межу шаред-типами\сгенерированными клиентами и копипастой типов на клиенте-сервере, если итоговое количество телодвижений при изменении контракта будет одинаковое?
Например, при изменении контракта:
1. если у нас шаред типы или автоматически сгенерированный клиент, нужно:
- скачать / обновить типы или клиент;
- подправить логику и/или UI;
2. если у нас дублирование типов, нужно
- подправить типы на клиенте;
- подправить логику и/или UI;
Поправьте, пожалуйста.
essome
Да, но одно дело запустить короткую команду в консоли, другое обновить 10 интерфейсов, что быстрее? А еще как минимум можно что-то упустить при обновлении и поймать баг, а если автоматом обновить то линтер подсветит несоответствие.
noodles
Всё-равно как-то не могу понять профиты.
Часто ли приходится обновлять сразу 10ть интерфейсов? А если и приходится, то после "выполнения короткой команды в консоли", как понять что именно обновилось, всматриваться в какие-то диффы?
При ручной правке, как-то более осознанно всё происходит.. постепенно что-ли, сразу прикидываешь что и где поламается, по очереди правишь себе спокойно.
Но обычно ведь одно-два поля меняется, не так-ли? И при этом предварительно это действие обсуждается и согласовывается. А не так что по пять раз на дню бекенд в одни ворота меняет контракты без предупреждений.
И получается, что как-бы и не тяжело у себя (фронт) подправить тип руками раз в пятилетку, вместо того чтобы тянуть автоматически сгенерированные клиенты или шаред-типы.
тут не совсем понял. Подправил руками тип, оно ж даже не скомпилируется, вперёд править и логику..)
essome
Дальше можно не читать.
Обычно в ide можно посмотреть какие файлы изменились и что в них поменялось. Еще чейнджлог может быть.
DarthVictor
Вы путаете зависимости и API.
У вас любой что микро что макросервис должен отвечать в каком-то формате. На основе этого формата клиенты вашего сервиса могут генерировать свои интерфейсы. Пока последний из собранных с таким интерфейсом клиентов не будет обновлен, ваш сервис всё равно будет обязан его реализовывать. Те же мобильные клиенты например могут месяцами не обновляться. Интерфейс сервера за это время, безусловно может расширится. Но старый интерфейс ему всё равно придется поддерживать (в реальности поддерживают штуки три последних у мобил, например). Иначе ваш сервис просто пятисотнит на старого клиента.
Для решения таких проблем были придуманы всякие Swagger и GraphQL. Но можно и просто типы TypeScript использовать. Да хоть клинопись, если вашим разработчикам удобно её поддерживать в генераторах. Как-только сервис какому-то разработчику отдали 200-й ответ на запрос, и разработчик на основании этого ответа написал что-то у себя у вас появилось API, хоть и недокументированное.
А микросервис не экспортирующий никакого интерфейса может быть только на выключенном сервере.
flancer
"Проблемы
индейцевбэкэндистовшерифаархитектора не волнуют" (с) Если смотреть на web-приложение с "высоты птичьего полёта", то бэк и фронт де-факто связаны по данным. И если бэк чего-то поменял в своём API, то на фронте, чтобы он продолжал работать правильно, это должно отразиться. Мы не можем убрать зависимость фронта от бэка, мы можем лишь переместить взаимозависимые структуры в отдельный пакет (модуль, файл) и замкнуть на них фронт и бэк, либо параллельно вносить изменения в обе части. И да, shared stuff - это всегда проблемы, даже если они не выделены в отдельный пакет, а "размазаны" по фронту и бэку. Но разрабам как раз и платят деньги за решение подобных проблем. А кто и как их решает - то на собственное усмотрение.AlexSpaizNet
Для начала нужно не создавать эти проблемы. Идея иметь общий код/модели/схемы вотевер чтобы сэкономить что-то там в конце концов обернется выстрелом в ногу или в голову.
И да, я не против BFF. Бизнес логика на клиенте это почти всегда катастрофа. Да и тут вроде понятно в чем минусы-плюсы.
Я против Shared types... нахавались... но опять же, это немного холиварная тема. Мы страдали потому что много разработчиков, продуктов, апишек, клиентов, требований, версийб багов...
flancer
Я не знаю, что вы подразумеваете под Shared Types, но в любом API, в котором передаются данные (например, таком), есть структуры, используемые как клиентом, так и (микро)сервисом. И вынесение подобных структур в отдельный пакет и замыкание на них клиента и сервиса - в этом нет ничего плохого. Особенно, если пользоваться версиями.
Полагаю, что тут проблема не в Shared Types, а в том, что "много разработчиков, продуктов, апишек, клиентов, требований, версий, багов...". А в итоге досталось Shared Types ;)
Кстати, заголовочные файлы C/C++ - это тоже в какой-то мере shared types (интерфейсы). Так что эта практика в IT проверена временем.