Главная / Статьи

Преобразование обработчиков маршрутов Next.js в целенаправленный слой BFF

Узнайте, что решает паттерн Backend for Frontend, почему он снова используется в приложениях Next.js и как избежать превращения обработчиков маршрутов в тяжелые «боговские» объекты.

6 мин чтения1435 слов

Один из разработчиков мобильной команды однажды спросил, почему для отображения экрана оформления покупки требуется шесть отдельных запросов к API, при этом три из них возвращают дублирующиеся поля, полученные из одного и того же базового сервиса. Честным объяснением стало то, что никто не определял точную структуру того, что на самом деле нужно фронтенду, поэтому каждая команда разрабатывала свою версию решения и оставляла клиента собирать всё вместе. Это на самом деле не техническая проблема, а организационная, и именно её предназначалось решить паттерном Backend for Frontend. Вероятно, именно поэтому этот паттерн снова появился в нескольких статьях в последнее время, после того как десять лет назад он оставался практически забытой идеей в технических блогах Netflix и SoundCloud.

BFF далеко не новая идея. Многие инженеры уже создавали подобные решения под другими названиями ещё до того, как «BFF» стал стандартным термином. Новым является то, что этот подход снова внедряется в контексте приложений Next.js, которые постепенно превращаются в слои агрегации API, часто без какого-либо преднамеренного решения со стороны разработчиков. Учитывая эту тенденцию, целесообразно сознательно принимать эту практику, а не натыкаться на неё случайно.

Характер проблемы

Эта ситуация постоянно повторяется в незначительно изменённых формах. У вас есть несколько сервисов на стороне сервера: некоторые разработаны собственными силами, другие — это API от поставщиков, добавленные позже. Также у вас есть несколько клиентов: веб-приложение, мобильное приложение и, возможно, внутренний инструмент администрирования. У каждого из этих клиентов свои требования. Мобильный клиент нуждается в лёгком объёме данных, поскольку часто работает в ненадёжной сети. Веб-панель управления требует полностью денормализованного ответа с уже объединёнными данными, чтобы избежать серии последовательных запросов. Инструмент администрирования нуждается в необработанных данных в исходном виде, поскольку люди, которые им пользуются, обычно устраняют какие-то проблемы.

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

Что на самом деле такое BFF

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

Эта идея постоянно возникает в обсуждениях Next.js, потому что App Router по сути предоставляет вам слой BFF бесплатно, при условии использования его именно в этом формате. Обработчики маршрутов и серверные компоненты уже выполняются на сервере, находятся между клиентом и бэкендом и уже способны отправлять несколько параллельных запросов и преобразовывать совокупный результат ещё до того, как он дойдет до браузера. Многие команды, перешедшие на App Router, уже выполняют задачи в стиле BFF, не называя их формально или не применяя их последовательно.

// app/api/checkout-summary/route.ts
// A web-specific BFF endpoint that aggregates three backend calls
// into the one shape the checkout screen actually needs
export async function GET(req: Request) {
  const userId = getUserIdFromSession(req);  const [cart, pricing, shippingOptions] = await Promise.all([
    fetch(`${CART_SERVICE}/users/${userId}/cart`).then(r => r.json()),
    fetch(`${PRICING_SERVICE}/quote?userId=${userId}`).then(r => r.json()),
    fetch(`${SHIPPING_SERVICE}/options?userId=${userId}`).then(r => r.json()),
  ]);  // Reshape into exactly what the checkout component renders,
  // nothing more. This is the whole point.
  return Response.json({
    items: cart.items.map((i: CartItem) => ({ id: i.id, name: i.name, qty: i.qty })),
    total: pricing.total,
    currency: pricing.currency,
    shipping: shippingOptions.filter((o: ShippingOption) => o.available),
  });
}

Теперь представьте, что мобильное приложение напрямую обращается к тем же трём сервисам и самостоятельно выполняет агрегацию данных на языках Swift или Kotlin, самостоятельно реализуя логику определения доступных вариантов доставки. Именно так постепенно возникает дрейф в работе системы. Два отдела, два языка программирования, два разных толкования одного и того же бизнес-правила — рано или поздно эти толкования противоречат друг другу, и никто этого не замечает, пока на стол кому-то не попадёт заявка на поддержку.

Здесь дело становится не только техническим, но и политическим

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

Именно поэтому такой подход так естественно вписывается в кодовую базу Next.js: разработчики, пишущие обработчики маршрутов, как правило, являются теми же людьми, которые пишут компоненты, вызывающие их. Не нужно создавать заявки, нет передачи задач между командами и не требуется отдельный процесс развертывания для координации. Если необходимо изменить формат ответа с подведением итогов оплаты, это делается в том же pull request, что и изменения в компоненте, отображающем этот ответ.

Ловушка: превращение каждого обработчика маршрутов в «бога-объекта»

Существует ещё один способ возникновения сбоев, о котором редко говорят. Как только команда понимает, что обработчики маршрутов могут объединять любые данные, которые им нужны, возникает сильное желание создать один огромный конечный пункт, возвращающий всё начальное состояние приложения за один вызов, поскольку меньшее количество итераций кажется очевидной победой. Иногда это действительно так. Но это также означает, что один обработчик маршрутов вынужден одновременно поддерживать шесть не связанных между собой функций, а любые изменения в модуле доставки заставляют добавлять их в тот же файл, что и виджет лояльностных баллов, поскольку оба они помещаются во что-то вроде getInitialAppState.

Эта модель справедлива, когда конечная точка BFF соответствует одному экрану или одной четко определенной функции, а не «всему, что приложение может понадобиться при загрузке». Если у вас возникают трудности с присвоением названия конечной точке в соответствии с конкретным элементом интерфейса, это обычно означает, что ее функционал стал слишком обширным.

Есть ещё один связанный сигнал тревоги, на который стоит обратить внимание: концовки BFF, которые переходят от преобразования данных к повторной реализации бизнес-логики. Обработчик маршрутов, который самостоятельно решает, подходит ли клиент для бесплатной доставки, вместо того чтобы просто обратиться к сервису ценообразования и передать полученный ответ, тихо превращается в второй источник истины для правила, которое уже существует где-то ещё в системе. Задача BFF — переводить и объединять данные, а не принимать решения. Как только он начинает принимать решения, мы возвращаемся к той же проблеме с отклонениями, о которой шла речь ранее, только на один уровень ближе к клиенту, где её гораздо сложнее обнаружить во время проверки кода.

Мой подход к этой проблеме

БФФ не обязательно должен рассматриваться как какое-то формальное архитектурное решение с диаграммами и страницей в wiki, хотя в этом нет ничего плохого, если такой подход соответствует способу работы вашей организации. Для большинства команд, уже использующих Next.js, принятие этого подхода — это скорее вопрос называния того, что уже существует: признание того, что обработчики маршрутов выполняют функции БФФ, отказ от иллюзии того, что они являются обычными API-маршрутами, и структурирование кода с учетом реальных потребностей клиента, а не границ бэкенд-сервисов. Сама схема никогда не была сложной частью. Сложно, и именно это откладывается большинством команд, — признать, что логика агрегации требует своего места, и передать это место команде фронтенда, которая на самом деле отвечает за нее, вместо того чтобы ждать, пока на экране оплаты потребуется шесть отдельных запросов для отображения контента, прежде чем кто-то задастся вопросом, почему так происходит.