Как стать автором
Обновить

Использование паттерна BFF для создания общих типов в бэкенде и фронтенде

Время на прочтение10 мин
Количество просмотров46K
Автор оригинала: Fernando Doglio
image

Контракт между бэкендным сервисом и фронтендным потребителем (или клиентом) обычно является местом соединения двух миров. Такой контракт может принимать форму спецификации 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 лучше, чем отсутствие шаблонов? Преимущества:

  1. Клиентское приложение остаётся «глупым», и это предпочтительно с точки зрения безопасности и простоты использования. Чем глупее должны быть клиентские приложения, тем быстрее другие команды смогут создавать клиентские приложения. Кроме того, основная бизнес-логика и необходимая обработка данных остаются сокрытыми на стороне бэкенда.
  2. Увеличение задержки из-за дополнительного подключения в бэкенде теоретически должно быть меньше, чем влияние потребления дополнительных ресурсов во фронтенде.

Идеален ли такой подход? Недостатки:

  1. Из-за нового элемента (т.е. ещё одного сервиса) архитектура системы усложняется. В нашем примере рассматривается только один BFF, но потенциально вы можете создавать по одному BFF для каждого типа клиентских приложений.
  2. Из-за 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 пока нет для них шаблонов:

image

Просто скопируйте показанную выше структуру, поместив общие типы в папку 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

image

image

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. На случай, если вам захочется взглянуть, я поделился своим здесь.

image

Мой remote scope со всеми общими независимыми компонентами

Что же мы сделали?


Благодаря использованию Bit и шаблона общих компонентов мы смогли соединить бэкенд-сервис с фронтенд-приложением, обеспечив для них общее определение типов.

Но что ещё здесь произошло?

  1. Во-первых, мы разработали и опубликовали три модуля, при этом нам даже не пришлось думать о компиляторе TS, WebPack, NPM или любых других инструментах, за исключением Bit. Именно поэтому я выбрал Bit и поэтому считаю его таким полезным инструментом. Он абстрагировал все шаги, необходимые для создания трёх компонентов, даже когда они работают в трёх разных средах.
  2. Мы работали с локальной зависимостью, которая должна быть внешней зависимостью, и даже не заметили разницы. Каждое обновление, вносимое в локальную версию компонента с типами BFF, подхватывалось другими двумя компонентами, и нам не приходилось заново выполнять весь процесс экспорта.

В конечном итоге, шаблон BFF и шаблон общих типов — это просто способ сказать: в конкретном сценарии, когда приемлема определённая степень связывания, давайте согласуем контракт между клиентом и сервером, переписав любые другие общие контракты, имевшиеся в прошлом.

Эта связь образуется между сервером и одним клиентом — помните, BFF связывает одно клиентское приложение (или тип приложений) со специализированной версией сервера. Он не отменяет обобщённый контракт с другими клиентами, которые могут использоваться с исходной версией сервиса.

______________

Дата-центр ITSOFT – размещение и аренда серверов и стоек в двух ЦОДах в Москве; colocation GPU-ферм и ASIC-майнеров, аренда GPU-серверов. Лицензии связи, SSL-сертификаты. Администрирование серверов и поддержка сайтов. UPTIME за последние годы составляет 100%.
Теги:
Хабы:
Всего голосов 9: ↑7 и ↓2+5
Комментарии19

Публикации

Истории

Работа

Ближайшие события