Бэкенд для фронтенда, или Как в Яндекс.Маркете создают API без костылей

Почему некоторыми API удобнее пользоваться, чем другими? Что мы как фронтендеры можем сделать на своей стороне, чтобы работать с API приемлемого качества? Сегодня я расскажу читателям Хабра как о технических вариантах, так и об организационных мерах, которые помогут фронтендерам и бэкендерам найти общий язык и наладить эффективную работу.


usfqlosft_umexv3_kh5zyd_wdy.png

Этой осенью Яндекс.Маркету исполняется 18 лет. Все это время развивается партнерский интерфейс Маркета. Если кратко, то это админка, с помощью которой магазины могут загружать каталоги, работать с ассортиментом, следить за статистикой, отвечать на отзывы и т.д. Специфика проекта такова, что приходится очень много взаимодействовать с различными бэкендами. При этом данные не всегда можно получить в одном месте, из одного конкретного бэкенда.


Симптомы проблемы


Итак, представьте, появилась какая-то задача. Менеджер идет с задачей к дизайнерам — они рисуют макет. Потом он идет к бэкендерам — они делают какие-то ручки и на внутренней вики пишут список параметров и формат ответа.
Потом менеджер идет к фронтендеру со словами «я тебе API принес» и предлагает все по-быстрому заскриптовать, т. к., по его мнению, почти вся работа уже сделана.
Вы смотрите на документацию и видите это:

№   |  Имя параметра
----------------------
53  |  feed_shoffed_id
54  |  fesh
55  |  filter-currency
56  |  showVendors

Не замечаете ничего странного? Camel, Snake и Kebab Case в рамках одной ручки. Я уже не говорю про параметр fesh. Что вообще такое fesh? Такого слова даже не существует. Попробуйте догадаться до того, как раскроете спойлер.


Спойлер

Fesh — это фильтр по ID магазина. Можно передать несколько айдишников через запятую. Перед ID может идти знак минус, что означает, что надо исключить этот магазин из результатов.

При этом из JavaSctipt’а, понятное дело, я не могу получить доступ к свойствам такого объекта через точечную нотацию. Не говоря уже о том, что если у вас больше 50 параметров у одного плейса, то, очевидно, вы в своей жизни повернули куда-то не туда.

Вариантов неудобного API очень много. Классический пример — API осуществляет поиск и возвращает результаты:

result: [
   {id: 1, name: 'IPhone 8'},
   {id: 2, name: 'IPhone 8 Plus'},
   {id: 3, name: 'IPhone X'},
]

result: {id: 1, name: 'IPhone 8'}

result: null

Если товары найдены — получаем массив. Если найден один товар, то получаем объект с этим товаром. Если ничего не найдено, то в лучшем случае получаем null. В худшем случае бэкенд отвечает 404 или вообще 400 (Bad Request).

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

Итак, какие могут быть пути решения этой проблемы? Что мы как фронтендеры можем сделать на своей стороне, чтобы работать с API приемлемого качества?


Бэкенд для фронтенда

Мы в партнерском интерфейсе используем на клиенте React/Redux. Под клиентом лежит Node.js, который делает много вспомогательных вещей, например прокидывает на страницу InitialState для Редакса. Если у вас сервер-сайд рендеринг, не важно с каким клиентским фреймворком, скорее всего, он рендерится нодой. А что, если пойти на шаг дальше и не обращаться с клиента напрямую в бэкенд, а сделать свое проксирующее API на ноде, максимально заточенное под клиентские нужды?
Эту технику принято называть BFF (Backend For Frontend). Впервые этот термин ввел SoundCloud в 2015 году, и схематично идею можно изобразить в таком виде:


dujwreelvkjlvykh1ut-dphjhxe.png

Таким образом, вы перестаете с клиентского кода ходить напрямую в API. Каждую ручку, каждый метод реального API вы дублируете на ноде и с клиента ходите исключительно на ноду. А нода уже проксирует запрос в реальное API и возвращает вам ответ.
Это касается не только примитивных get-запросов, а вообще всех запросов, в том числе с multipart/form-data. Например, магазин через форму на сайте загружает .xls-файл со своим каталогом. Так вот, в этой реализации каталог загружается не напрямую в API, а в вашу нодовскую ручку, которая проксирует stream в настоящий бэкенд.

Помните тот пример с result-ом, когда бэкенд возвращал null, массив или объект? Теперь мы можем привести его к нормальному виду — чему-нибудь вроде такого:

function getItems (response) {
   if (isNull(response)) return []
   if (isObject(response)) return [response]
   return response
}

Этот код выглядит ужасно. Потому что он ужасен. Но нам все равно нужно это сделать. У нас выбор: делать это на сервере или на клиенте. Я выбираю сервер.
Также мы можем мапить все эти кебаб- и снейк-кейсы в удобный нам стиль и тут же проставлять значение по умолчанию в случае необходимости.

query: {
   'feed_shoffer_id': 'feedShofferId',
   'pi-from': 'piFrom',
   'show-urls': ({showUrls = 'offercard'}) => showUrls,
}

Какие еще плюсы мы получаем?


  1. Фильтрация. Клиент получает только то, что ему нужно, ни больше ни меньше.
  2. Агрегация. Не нужно тратить клиентскую сеть и батарею, чтобы делать множественные аякс-запросы. Заметный выигрыш по скорости за счет того, что открытие соединения — это дорогая операция.
  3. Кэширование. Ваш повторный агрегированный вызов не будет лишний раз никого дергать, а просто вернет 304 Not Modified.
  4. Сокрытие данных. Например, у вас могут быть токены, которые нужны между бэкендами и не должны попадать на клиент. У клиента может не быть прав даже знать о существовании этих токенов, не говоря уже об их содержимом.
  5. Микросервисы. Если у вас на бэке монолит, то BFF — это первый шаг к микросервисам.

Теперь о минусах.


  1. Повышение сложности. Любая абстракция — это еще один слой, который необходимо кодить, деплоить, поддерживать. Еще одна движущаяся часть механизма, которая может сбоить.
  2. Дублирование ручек. Например, несколько ендпойнтов могут выполнять один и тот же тип агрегаций.
  3. BFF — это пограничный слой, который должен поддерживать общую маршрутизацию, ограничения прав пользователей, ведение журнала запросов и т. д.

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


GraphQL

Похожие проблемы решает GraphQL. С GraphQL вместо множества «глупых» endpoint у вас одна умная ручка, которая умеет работать со сложными запросами и формировать данные в том виде, в котором их запрашивает клиент.
При этом GraphQL может работать поверх REST, т. е. источником данных служит не база, а рестовое API. За счет декларативности GraphQL, за счет того, что все это дружит с Реактом и Редаксом, ваш клиент становится проще.
На самом деле, GraphQL мне видится реализацией BFF со своим протоколом и строгим языком запросов.

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


Best Friends Forever

Ни одно техническое решение не будет правильно работать без организационных изменений. Вам все равно нужна документация, гарантии того, что формат ответа внезапно не изменится и т. д.
При этом нужно понимать, что все мы в одной лодке. Абстрактному заказчику, будь то менеджер или ваш руководитель, по большому счету без разницы — GraphQL у вас там или BFF. Ему важнее, чтобы задача была решена и на проде не всплывали ошибки. Для него нет особой разницы, по чьей вине возникла ошибка в проде — по вине фронта или бэка. Поэтому нужно договариваться с бэкендерами.

К тому же изъяны бэка, о которых я говорил в начале доклада, не всегда возникают из-за чьих-то злонамеренных действий. Вполне возможно, что и у параметра fesh есть какой-то смысл.


ceu4v5bqpzc3gzkbyugkmogidlw.jpeg

Обратите внимание на дату коммита. Получается совсем недавно fesh отметил свое семнадцатилетие.
Видите какие-то странные идентификаторы слева? Это SVN, просто потому что в 2001 году гита не было. Не гитхаба как сервиса, а именно гита как системы управления версиями. Он появился только в 2005 году.


Документация

Итак, все, что нам нужно — не ссориться с бэкендерами, а договориться. Это можно сделать только если мы найдем один единственный источник правды. Таким источником должна быть документация.
Самое главное здесь — написать документацию до того, как мы начнем работать над функциональностью. Как с брачным договором, лучше обо всем договориться на берегу.
Как это работает? Условно говоря, собираются трое: менеджер, фронтендер и бэкендер. Фронтедер хорошо разбирается в предметной области, поэтому его участие критически важно. Они собираются и начинают думать над API: по каким путям, какие ответы должны возвращаться, вплоть до названия и формата полей.


Swagger

Хороший вариант для документации API — формат Swagger, сейчас он называется OpenAPI. Лучше использовать Swagger в YAML-формате, т. к., в отличие от JSON, он лучше читается человеком, а для машины разницы нет.

В итоге все договоренности фиксируются в Swagger-формате и публикуются в общий репозиторий. Документация на продовый бэкенд должна лежать в мастере.
Мастер защищен от коммитов, код в него попадает только через пул реквесты, пушить в него нельзя. Представитель фронт-команды обязан проводить ревью пул реквеста, без его апрува код в мастер не едет. Это защищает вас от неожиданных изменений API без предварительного уведомления.

Итак, вы собрались, написали Swagger, таким образом фактически подписали контракт. С этого момента вы как фронтендер можете начинать свою работу, не дожидаясь создания реального API. Ведь в чем был смысл разделения на клиент и сервер, если мы не можем работать параллельно и клиентским разработчикам приходится ждать серверных разработчиков? Если у нас есть «контракт», то мы можем спокойно параллелить это дело.


Faker.js

Для этих целей отлично подходит Faker. Это библиотека для генерации огромного количества фейковых данных. Она умеет генерировать разные типы данных: даты, имена, адреса и т. д., все это хорошо локализуется, есть поддержка русского языка.
При этом фейкер дружит со свагером, и вы можете спокойно поднять моковый сервер, который на основе Swagger-схемы будет генерировать вам фейковые ответы по нужным путям.


Валидация

Swagger можно сконвертировать в json-схему, и с помощью таких инструментов как ajv вы можете прямо в рантайме, в своем BFF, валидировать ответы бэкендов и в случае расхождений репортить тестировщикам, самим бэкендерам и т. д.

Допустим, тестировщик находит на сайте какой-то баг, например при клике на кнопку ничего не происходит. Что делает тестировщик? Ставит тикет на фронтендера: «это ведь ваша кнопка, вот она не нажимается, чините».
Если между вами и бэком стоит валидатор, то тестировщик будет знать, что кнопка на самом деле нажимается, просто бэкенд присылает неправильный ответ. Неправильный — это такой ответ, который фронт не ожидает, т. е. не соответствующий «контракту». И тут уже надо или чинить бэк или менять контракт.


Выводы


  1. Принимаем активное участие в проектировании API. Проектируем API так, чтобы им было удобно пользоваться через 17 лет.
  2. Требуем Swagger-документацию. Нет документации — работа бэка не выполнена.
  3. Есть документация — публикуем ее в git, при этом любые изменения в интерфейсе API должны апрувиться представителем фронт-команды.
  4. Поднимаем фейковый сервер и начинаем работать над фронтом не дожидаясь реального API.
  5. Кладем ноду под фронтенд и валидируем все ответы. Плюс получаем возможность агрегировать, нормализовать и кэшировать данные.


См. также

Как построить REST-like API в крупном проекте
Backend In the Frontend
Using GraphQL as BFF Pattern Implementation

© Habrahabr.ru