У аббревиатуры BFF кроме Backend for Frontend есть и другая расшифровка — Best Friends Forever. И в контексте статьи это только отчасти шутка. Общение фронтенда и бэкенда не всегда происходит гладко (опустим тот факт, что существует множество мемов о противостоянии фронтендеров и бекендеров): клиент запрашивает данные, бэкенд отдаёт то, что запросили, но часто данных сильно больше, чем нужно, а это значит, что запрос будет возвращаться дольше, фронтенд будет отрисовываться тоже дольше и всё это отразится на опыте конечного пользователя.
А что если между фронтендом и бэкендом построить мостик, который распределит нагрузку и сделает всех дружелюбнее? Примерно в этом и состоит суть паттерна BBF, а в статье разберём подробнее: зачем его внедрять и какую роль он играет в масштабировании современных сервисов; как мы реализуем этот подход в рамках RUTUBE, какой профит он нам даёт; почему мы отказались от GraphQL; в чём отличия от API Gateway и как вообще проектировать такие сервисы.

Меня зовут Максим Ульянов, я руковожу отделом веб-разработки в RUTUBE, отвечаю за все браузерные интерфейсы: сайта rutube.ru, студии блогеров studio.rutube.ru, всех сателлитов и внутренних продуктов платформы. В свободное время я веду подкаст «Куда расти?» и Telegram-канал ULYANOV.LIFE. Этот материал написан по мотивам моего доклада на FrontendConf.
Предыстория, или Зачем нужен BFF
Изначально архитектура веб-сервисов (тогда они еще были сайтами) была простой и строилась по принципу монолита: один бэкенд напрямую общается с единой клиентской частью с одной стороны, и с базой данных — с другой.

В целом монолиты создаются и по сей день и такой подход может быть оправдан — но это не тема сегодняшнего обсуждения. Сегодня нас интересуют более сложные сценарии и сервисы с большим количеством разных клиентских приложений.
Backend for Frontend или BFF — архитектурный паттерн, который предполагает создание отдельного бэкенда под каждый фронтенд (клиентский интерфейс или приложение). Его задача — собирать и проксировать данные, необходимые для конкретного клиента.
Термин Backend for Frontend появился в SoundCloud в 2013 году. Тогда их сайт выглядел примерно так:

На примере SoundCloud посмотрим, как идет развитие сервисов от монолита к микросервисам — в данном случае к архитектуре BFF.
Основная проблема, которую решали в команде, заключалась в том, что существующая монолитная архитектура очень ограничивала скорость развития сервиса: мешала проводить A/B-тесты на разных платформах, реализовывать редизайн и быстро выкатывать обновления.
Самая простая архитектура схематично изображена на следующей картинке слева: есть клиенты, например, веб, iOS, Android и партнёрские приложения, которые завязаны на один бэкенд. В таком случае бэкенд должен готовить данные для всех клиентов, учитывать особенности каждого из них и реализовывать все специфичные сценарии.

Если же вы хотите быстрее развиваться и тестировать гипотезы на отдельных приложениях — на схеме справа это iOS и Android — а также проводить A/B-тесты и готовить разные наборы данных для разных клиентов, то для этого можно создать отдельный сервис, который будет называться Backend for Frontend. BFF с точки зрения данных будет завязан на всё тот же монолит, но при этом iOS- и Android-приложения будут получать данные через него, а не напрямую от бекенда. BFF будет передавать только те данные, которые нужны в данный конкретный момент времени и в той структуре, чтобы снизить количество операций при подготовке данных на клиенте и быстрее отрисовать интерфейс.
Можно пойти дальше и заменить в этой схеме монолитный бэкенд на Public API и постепенно реформировать то, что скрывается за фасадом: распиливать монолит, добавлять (микро)сервисы и т.д. Либо можно добавлять отдельные модули под конкретный BFF.

Такая архитектура позволяет постепенно эволюционировать и добавлять отдельные, независимые BFF под каждый клиент, постепенно отрезая куски от монолита, а за ними реализовывать контроллеры и shared-утилиты, которые могут переиспользоваться между разными BFF.

Эта архитектура в первую очередь направлена на то, чтобы обеспечивать хороший time to market клиентских приложений.
Так развивалась архитектура SoundCloud и это отчасти похоже на путь RUTUBE.
Контекст RUTUBE
Посмотрим на страницу с плеером RUTUBE. На первый взгляд она выглядит достаточно просто и кажется, что её легко заверстать и она может жить на монолите.

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

Также у нас есть серийный контент, на странице с которым показывается список сезонов и серий.

А ещё есть трансляции блогеров и телеканалов c чатом и переключением между эфирами.

Страницу отдельного видео можно поделить на смысловые блоки: само видео, которое мы запрашиваем чанками, чтобы в реалтайме подстраивать качество стриминга; информация о видео; настройки и пр. Чтобы отрисовать только блок с плеером, необходимо опросить разом девять API.

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

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

BFF как подход помогает нам перестраиваться, но 3,5 года назад ему тоже требовалось обновление. Когда я пришел в RUTUBE, BFF уже существовал, он был написан на Express.js, и там был in-memory cache, который кешировал данные на каждом конкретном инстансе приложения, запущенного в продакшене.

Архитектура нашего BFF тогда выглядела стандартно: несколько ручек, описанных в контроллерах.

Данное решение было реализовано «на коленке» в качестве эксперимента и не было эталоном гибкости или надёжности.
Например, мы собирались выкатить один A/B-тест и думали, что раз у нас есть BFF, сейчас всё будет быстро и здорово. Но в итоге та задача заняла порядка трех месяцев вместо пары недель! Тогда мы поняли, что надо что-то менять.
Мы стали исследовать варианты и выяснили, что лучшим решением для организации Node.js-приложений является фреймворк Nest. У Nest сильное комьюнити и хорошая документация.

Мы используем многие классные инструменты, которые есть в Nest и, кроме того:
Redis для распределенного кэширования;
RxJS для асинхронных операций;
Fastify в качестве роутера вместо Express.
Fastify мы взяли, потому что это самый быстрый на сегодняшний день Node.js роутер по количеству обрабатываемых запросов за секунду.

В обновлённом BFF мы смогли добиться такой стабильности, что его захотели использовать и другие клиенты: мобильные приложения, Студия RUTUBE (приложение для авторов), Smart TV. Поэтому сейчас наш BFF обслуживает 5 сервисов и выглядит следующим образом.

Здесь есть важный момент — насколько правильно с точки зрения архитектурного паттерна иметь один BFF для пяти клиентов? Чтобы BFF соответствовал своей изначальной идее — обеспечивать быстрый доступ к нужным данных для разных независимых клиентов — в Nest есть Workspaces. В результате сейчас у нас каждый сервис может разрабатываться и катиться полностью независимо, SLA всех клиентов, которые к нам обращаются, полностью соблюдается.

Таким образом BFF является центральным сервисом для агрегации и проксирования данных для всех наших клиентов. За ним стоят бэкенды, к которым мы обращаемся.
С точки зрения структуры модули нашего BFF организованы в концепции Nest: мы не стали «изобретать велосипед», действовали в соответствии с документацией фреймворка.

В модуле есть, разумеется, тесты, DTO-объекты, из которых впоследствии генерируются типы данных и которые мы можем переиспользовать, а также мапперы, которые нужны для обработки определённых запросов:

Мы используем Swagger-документацию, которая автогенерируется из DTO-объектов. Её очень удобно передавать бэкендерам, тестировщикам и всем, с кем надо согласовать контракт.

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

Запрос за видео состоит из нескольких параллельных запросов и раскладывается в такую схему:
клиент отправляет запрос по одному конкретному URL;
URL через балансер попадает на наш BFF-сервис;
если ответ на этот запрос закеширован, то он автоматически отдаётся из кеша на клиент, не создавая дополнительной нагрузки на API;
если в кеше ответа нет, то идём в API;
полученный по API ответ кешируем в Redis с указанием временного ключа кеширования (ключи можно посмотреть на иллюстрации выше);
далее прогоняем некоторые мапперы — это позволяет убрать часть бизнес-логики с клиента на сторону BFF и при этом предоставлять клиенту конститентные данные;
отдаём данные через сервис на клиент;
пользователь смотрит видео :)
Итого, суммируем, когда BFF нужен, а когда — это погоня за хайпом.
Когда BFF полезен:
У продукта несколько клиентов. У нас это web, iOS, Android, Smart TV и ещё партнёры — нам необходимо поддерживать для них различную структуру данных.
Частые изменения UI: A/B-тесты, быстрые MVP, редизайны.
Нужно собрать много данных в один ответ. Как в примере выше, когда чтобы отрисовать страницу, нужно опросить 9-10 API ручек, то через BFF это будет сильно дешевле с точки зрения сетевых задержек.
Требуется изоляция контрактов — то самое распиливание монолита и постепенная замена его на микросервисы. При этом есть возможность поддерживать на уровне BFF старые версии ручек, что очень важно для клиентов типа Smart TV и мобильных приложений, которые медленно обновляются и имеют очень длинный хвост поддержки.
Когда BFF — хайп:
Если у вас всего один клиент, например, одно, пусть даже большое, SPA-приложение, то BFF не нужен.
Если все клиенты могут работать с универсальным API, то проще иметь public API вокруг монолита или микросервисов — неважно.
Если дополнительный слой не ускоряет работу и не упрощает контракты — этот слой, т.е. BFF не нужен.
Если нет ресурсов на поддержку ещё одного сервиса, то он вместо пользы может превратиться в узкое горлышко.
Best practices при проектировании BFF-сервиса
Теперь, разобравшись в концепции BFF, его преимуществах и случаях, когда он не нужен, перечислю важные элементы для проектирования действительно полезного BFF.
Один сервис — один ответственный
Это правило справедливо для большинства инфраструктурных сервисов и их поддержки. В нашем случае оно означает, что если при большом количестве разных клиентов, каждый из которых имеет свой бэклог, свои сроки и хочет работать стабильно, сделать один большой монолитный BFF, то это не даст никаких преимуществ. Разным клиентам нужны отдельные BFF — то есть свои ответственные.
У нас это устроено следующим образом: у нас в веб-отделе есть платформенная команда, это фронтендеры, которые умеют писать на Node.js, и именно они поддерживают BFF. А чтобы каждый конкретный сервис мог катиться отдельно, как уже говорилось выше, у нас есть Workspaces и конкретные договоренности по SLA со всеми клиентскими командами.
Прозрачное логирование
BFF — так или иначе бэкенд-сервис и ему необходимо прозрачное логирование, чтобы мы понимали, что происходит.
У нас стандартный стэк: Kibana (интерфейс к данным в Elasticsearch) + Senry + Prometheus + Grafana.

Выше скрин наших дашбордов, туда можно вывести все метрики жизнеспособности сервиса и быстро реагировать, если что-то идёт не так.
Заголовки trace-id
Как мы помним, на одну страницу у нас опрашивается несколько ручек — если не подписать изначальный запрос, то будет трудно проследить дальнейшую цепочку.
С trace-id через тот же самый интерфейс Kibana можно проследить весь поток запросов и то, какой сервис отдавал или принимал данные, какие были заголовки и т.д. Это позволит воспроизвести всю цепочку запросов для конкретной ручки конкретного клиента и понять, на каком из звеньев и в каком сервисе произошла ошибка.

Передать сервис в службу мониторинга
Очевидная штука: это бэкенд-сервис, если за ним не следить, то можно слишком поздно узнать о проблемах. Для продукта с миллионами пользователей это недопустимо.
Распределённый кеш
Выше я уже говорил, что в первой, экспериментальной версии нашего BFF был in-memory cache Проблема заключалась в том, что у RUTUBE несколько ЦОДов и много инстансов сервисов, чтобы выдерживать нагрузку и горизонтально масштабироваться. Кеширование данных в памяти на конкретном инстансе приводит к тому, что они становятся неконсистентными. Чтобы обеспечить валидные данные для наших пользователей при перезаходах и обновлениях страниц, а так же, чтобы не плодить лишнюю нагрузку на бэкенды, необходим распределенный кеш. В данном случае мы выбрали Redis.
Договориться об SLA с клиентскими командами
Это поможет всем точнее планировать ресурсы разработки, определить формат поддержки и время реагирования на критичные инциденты.
BFF и GraphQL
Возможно, вам сейчас кажется, что описанная в статье схема работы с данными похожа на ту, что есть в GraphQL. Действительно, GraphQL и BFF — пересекающиеся подходы. Но есть существенные различия, из-за которых мы отказались от GraphQL.
Почему мы отказались от GraphQL:
Ценность GraphQL реализуется с помощью BFF. Конечно, это разные вещи, но с помощью BFF на REST можно сделать то же, что и с GraphQL.
В GraphQL есть сложности с кешированием данных (на GraphQL запросы идут, по факту, через метод POST и ответы просто так не закешировать), а с BFF мы избегаем этих проблем.
В GraphQL запросы произвольные, а в REST легко строить метрики/алерты по явным маршрутам (/video/:id), что нам необходимо для качественной аналитики и мониторинга.
В GraphQL нужны защиты от N+1 сложности запросов, а в REST вы контролируете форму и «вес» ответа на уровне проектирования контаракта.
Time-to-market в нашем случае с GraphQL просел бы: нам понадобилось бы перестраивать архитектуру внутри бэкенда, ребятам пришлось бы учиться работать с новой технологией и писать все эти запросы и guards.
BFF vs API Gateway
Также может показаться, что подход BFF похож на API Gateway, но это не так.
API Gateway — центральная точка входа в систему, которая принимает запросы клиентов и передаёт ответы обратно клиенту. Обычно управляется платформенной или DevOps-командой и меняется сравнительно редко.
BFF — как мы ранее говорили, это архитектурный паттерн, который подразумевает создание отдельного бэкенда под каждый фронтенд, чтобы в моменте отрисовать тот или иной UI.
Архитектурная схема взаимодействия API Gateway и микросервисов выглядит примерно так:

API Gateway отвечает за то, чтобы балансировать вообще все запросы и перенаправлять их к сервисам. А также за безопасность, логирование и мониторинг. Кроме того, бывает, что на стороне API Gateway присутствует такой аспект, как трансформации, но я считаю, что все трансформации должны происходить на уровне микросервисов. Можно сказать, что BFF как раз и является тем самым микросервисом, который за это должен отвечать.
Итого: BFF не серебряная пуля
BFF полезен далеко не в каждой архитектуре. Если после прочтения статьи у вас остались вопросы, зачем вам BFF, то скорее всего он вам и не нужен.
Относитесь к BFF, как к полноценному бэкенд-сервису.
Избегайте харкода и размазывания бизнес-логики. Если занести в BFF большую часть обработки данных, место которой на уровне клиента или бэкенда, то весь смысл BFF пропадет и потеряется гибкость решения.
Не превращайте BFF в API Gateway на стероидах. Пускать все запросы через BFF не нужно, это имеет смысл только для запросов, которые требуют сложной агрегации или предварительных подготовки данных для конкретного клиента.
Не переусложняйте! Когда вы внедряете новый сервис, ваша система сразу становится сложнее. Соответственно это решение должно решать какую-то вашу задачу и хорошо бы убедиться, что нет более простого способа это сделать.
Подписывайтесь на этот блог и канал Смотри за IT, если хотите знать больше о создании медиасервисов. Там рассказываем об инженерных тонкостях, продуктовых находках и полезных мероприятиях, делимся видео выступлений и кадрами из жизни команд Цифровых активов «Газпром-Медиа Холдинга» таких, как RUTUBE, PREMIER, Yappy.
А если вы хотите знать больше о трендах в IT-отрасли, следите за обновлениями и анонсами новых конференций на сайте «Онтико». Например, в Москве 3 декабря состоится бесплатная конференция про разработку системного ПО, ядра Linux и open source OS DevConf 25 powered by GigaChat.