Многим знаком подход Backend Driven UI. С его помощью можно создавать новые страницы, запускать А/B-тесты, легко менять флоу в любое время и сразу на всех платформах. И при этом не надо долго и мучительно перевыкатывать приложение.

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

В этой статье я постараюсь рассказать, что такое BDUI с точки зрения бэкенд-разработчика, как мы прошли путь от стандартного API с данными для фронтенда до UI JSON-файлов, по которым строятся экраны приложения. Этот пост будет интересен тем, кто планирует перейти на этот подход, но пока не знает, как это сделать максимально плавно и сразу обойти проблемы, с которыми повстречались мы. 

Коротко о BDUI

Мои коллеги уже выпускали статьи об этом подходе — например, вот отличный рассказ Ольги Ким про DivKit. Повторим пару тезисов оттуда и вспомним, зачем нужен BDUI:

  • вам нужно часто тестировать разнообразные эксперименты, которые связаны с UI;

  • новая функциональность должна быть доступна даже тем пользователям, которые ещё не обновили приложение.

Как же поставленные задачи решает подход BDUI и на чём основана данная технология? Идея очень проста: бэкенд присылает фронтенду JSON со схемой UI-элементов, которые после парсинга отрисовываются в приложении.

Приведу небольшой пример:

Добавление элемента
Добавление элемента

Предположим, мы захотели показывать курьеру, что заказ будет состоять из двух доставок, и для этого нам надо добавить модификатор «Мультизаказ». Чтобы реализовать такое без BDUI, нужно добавить количество заказов и элемент в вёрстку фронта, выкатить приложение и дождаться, когда курьеры обновят его. Только после этого можно начинать собирать обратную связь о нововведении.

С помощью BDUI можно просто изменить JSON и перевыкатить сервис на бэкенде — дело одного часа. Плюсы очевидны.

Сложности, с которыми мы столкнулись

Сервис Еда, как и Лавка, тесно связан с сервисом Яндекс Доставка, который занимается назначением курьера, а также хранит у себя основную информацию о заказе. До переезда на BDUI все сервисы ходили в один API получения информации о заказе и по этим данным строили свой UI. 

При таком подходе сразу понятны проблемы: если кто-то хочет добавить что-то для себя, он должен это согласовать со всеми остальными сервисами. Иначе сильно повышается риск что-то сломать у соседа, что, соответственно, приведёт к проблемам доступности сервисов и потере заказов. Также весомым аргументом в пользу BDUI стал тот факт, что фронтенд-разработчики не хотели поддерживать одинаковые кодовые базы для Android и iOS и перешли на Flutter. Тут и сошлись две идеи по переработке фронтенда и бэкенда.

В итоге ребята из Лавки решили разделить UI всех сервисов. Это, безусловно, очень хороший шаг. Теперь изменения UI одного из приложений не будут задевать другие, а значит, меньше вероятность ошибки. Однако нашей команде пришлось потрудиться, чтобы новый подход стал работать правильно. Никто до этого не работал с BDUI, поэтому для нас это было в новинку. В разных сервисах данный подход частично применялся, но целый сервис ещё никто не строил. Перед нами стояло несколько сложностей: где-то у нас получилось найти решение сразу, а где-то пришлось покопаться.

Источник данных

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

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

Да, все мы знаем SOLID, но не всегда его применяем, особенно когда требуется, чтобы было сделано «ещё вчера». Мы нарушили принцип инверсии зависимостей, из-за чего, когда потребовалось изменить источник данных (а если точнее, начать поддерживать два), пришлось вносить правки в существующую схему. Благодаря тому, что все данные содержались в одном месте, а не были разбросаны по коду, замена явного на абстракцию не оказалась сложной. 

Отсюда можно вынести урок: не стоит забывать о принципах. Их придумали не просто так. Однако нужно понимать, что именно и почему ты используешь.

Гибкость в получении данных

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

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

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

Тут мы и подходим к динамическим данным. Основной смысл структуры остался таким же: есть заказ и точки. Но данные теперь присылаются логическими кусочками и в том объёме, в котором они нужны. Есть отдельные фича-сервисы, которые умеют аккумулировать и определять, требуются ли данные для данного типа заказов и наполнять ли своими данными итоговый ответ. Благодаря этому мы экономим много трафика, и нам стало легче добавлять новую функциональность. А самое главное, что мы можем запросто переиспользовать любые новые фичи от коллег, так как все данные для отображения и действия в мобильном приложении добавляет фича-сервис. 

Примерная схема взаимодействия с динамическими данными 
Примерная схема взаимодействия с динамическими данными 

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

Как собирать JSON на бекенде

Всё вышеописанное относится к важным аспектам разработки с использованием BDUI, но основная задача для бэкенда всё же сборка JSON. Наш сервис написан на С++ (причины можно прочитать в статье Антона Полухина про userver, а для тех, кому не хочется переходить, добавлю несколько пунктов ниже). К сожалению, язык не предоставляет удобных абстракций для оперирования объектами JSON, в отличие от того же Python. Поэтому далее я предлагаю рассмотреть возможные способы сборки JSON. 

Все примеры ниже — с использованием инструментов userver. Они представляют базовую абстракцию над объектами, и повторить реализацию у себя в проекте не должно быть проблемой. А ещё есть возможность подсмотреть решение в исходном коде фреймворка. Но всё же основной посыл примеров — понять способы взаимодействия с абстракцией и создания JSON, а не фокусироваться на использовании конкретной технологии.  

Вариант 1: Базовые механики работы с JSON в userver

Во многих сервисах Яндекса используется фреймворк userver, и Еда не стала исключением. Во-первых, он очень удобен, быстр из-за использования фулстек-корутин и прост.

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

Во-третьих, у фреймворка есть специальные расширения для работы с JSON-объектами, что очень помогает в разработке BDUI-сервиса.

В-четвертых, наш BDUI-сервис является высоконагруженным, так как все исполнители ходят в него для получения своего текущего экрана. C++ в данном месте весьма уместен. 

Итак, начнём: будем сериализовывать строку в JSON, а после наполнять данными.

std::string template_string = "R(
{
	"type": "text",
	"text": "{0}",
	"color": "#FFFFFF"
}
)";
 
auto json_object = formats::json::FromString(fmt::format(template_string), "order");

Или же будем собирать объекты руками.

formats::json::ValueBuilder text{formats::json::Type::kObject};
 
text["type"] = "text";
text["title"] = "order";
text["color"] = "#FFFFFF";

Если объекты представляют собой более сложные структуры, то получается что-то вроде такого:

formats::json::ValueBuilder button{formats::json::Type::kObject};
 
 
button["type"] = "button";
button["title"] = "make action";
button["color"] = "#FFFFFF";
 
formats::json::valueBuilder button_action{formats::json::Type::kObject};
button_action["type"] = "exchange order";
button_action["uri"] = "some uri";
 
button["paylaod"] = button_action.ExtractValue();

Чем сложнее объекты, тем больше вложенности. Одна из самых основных проблем в таком подходе — у нас нет никакой типизации и мы можем по ошибке засунуть обработчик, который не поддерживается фронтендом, или перепутать типы данных. И это обнаружится только во время тестирования. А также человек, который не знает всех тонкостей конкретного экрана, тратит много времени на осознание места изменения для решения его задачи. 

Вариант 2. Обёртка над отдельными элементами

Заведём для объектов из конструктора классы:

struct ButtonItem : public ItemBase { 
  ButtonItem(); 
 
  std::optional<std::string> title; 
  std::optional<std::string> subtitle; 
  std::optional<bool> enabled; 
  std::optional<ButtonStyleType> style; 
  std::optional<ButtonCooldown> cool_down_info; 
 
  formats::json::ValueBuilder ToJson() const override; 
};

Внутри опишем нашу логику заполнения JSON:

ButtonItem::ButtonItem() : ItemBase(ItemType::Button) {} 
 
formats::json::ValueBuilder ButtonItem::ToJson() const { 
  formats::json::ValueBuilder json = ItemBase::ToJson(); 
 
  if (enabled) json["enabled"] = *enabled; 
  if (title) json["title"] = *title; 
  if (subtitle) json["subtitle"] = *subtitle; 
  if (style) json["style"] = ToString(*style); 
  if (cool_down_info) json["cool_down_info"] = cool_down_info->ToJSon(); 
 
  return json; 
}

Уже выглядит намного лучше: у нас появляется возможность работать с привычными объектами языка, а не с JSON-значениями.

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

Вот как это выглядит в коде:

ButtonItem button;
 
 
button.title = "make action";
button.color = "#FFFFFF";
 
ExchangeOrderPayload payload;
payload.uri = "some uri";
button.payload = std::move(payload);

У такого подхода остаются некоторые минусы: всё равно при заполнении объектов приходится писать много кода. Особенно если он сильно расширяет базовую функциональность или внутри него появляются циклические зависимости между отдельными элементами конструктора.

Вариант 3. Кодогенерация

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

Мы используем формат OpenAPI 3.0 при описании сущностей для кодогенерации. Выглядит это так:

ItemButton: 
    allOf: 
    	- $ref: '#/definitions/Item' 
    	- type: object 
          additionalProperties: true 
          properties: 
            title: 
              type: string 
            subtitle: 
              type: string 
            enabled: 
              type: boolean 
            cool_down_info: 
              $ref: '#/definitions/CooldownInfo'

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

Вариант 4. js-pipeline

Коллеги из Такси реализовали удобный фреймворк — js-pipeline, который позволяет исполнять JS-код внутри фреймворка userver. Тем, кому лениво читать всю статью про фреймворк, расскажу в двух словах, чем он может нам помочь.

Всю логику по получению данных, матчингу экспериментов, подстановке локализации и действий, которые не относятся к построению UI, проводит сам сервис на С++, а после передаёт это всё внутрь фреймворка на JS. Он в свою очередь занимается только расчётом алгоритмов относительно этапов и ресурсов, описанных для данного консьюмера. В нашем случае алгоритмом является отрисовка экранов.
В таком случае мы теряем строгую схему, из-за которой реализовывали варианты 2 и 3, но возможность редактировать алгоритмы без перевыкатки сервиса, чего нельзя было делать до этого, перевешивает недостаток отсутствия схемы.

Как описывать данные на вход и выход:

consumers: 
	- name: bdu 
		input-schema: 
			type: object 
			required: 
    			- orders 
				- points 
			additionalProperties: false 
			properties: 
			orders: 
    			type: array 
    				items: 
                		$ref: 'OrderInfo' 
			points: 
				type: array 
					items: 
    					$ref: 'Point' 
	 
	output-schema:
		type: object 
		additionalProperties: false 
		properties: 
			ui: 
				type: object 
        		additionalProperties: true

Мы добавляем обязательные поля orders и points и заполняем их объектами типа OrderInfo и Point (тоже yaml-объекты) по аналогии из третьего примера. И готовим выходные данные — в данном случае это сырой JSON. 

Как это выглядит в плюсовом коде:

formats::json::ValueBulder builder{formats::json::Type::kObject};
builder["orders"] = ...;
builder["points"] = ...;
auto result = js_pipelines.ExecuteAsync<
js_pipeline::generated::consumers::BduTag>( 
"test_ui_build_pipeline", builder.ExtractValue(), 200ms); 
auto result = result.Get().output["ui"];

Внутри запускается скомпилированный JS-код, который наполняет итоговый ответ данными. Мы проводили тесты на текущих экранах курьерского приложения еды, чтобы узнать, насколько замедлится ответ. Замедление было в районе погрешности, а значит, мы выигрываем в скорости разработки без потери скорости работы самого сервиса. 

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


Вот так мы и переходили на BDUI и параллельно учились удобно и эффективно собирать экраны в курьерском приложении Еды. Надеюсь, после моего рассказа вам удастся обойти те грабли, на которые наткнулись мы за это время.

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

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

Комментарии (1)


  1. Dmitriys88
    02.11.2023 09:29

    Кажется, этот подход больше подходит под термин SSR (Server Side Rendering), нежели SDUI или BDUI (Server Driven User Interface или Backend Driven User Interface). В моем понимании SDUI позволяет определять верстку экранов через конфиг на бэкенде, а не через код на фронте и на этом все. В вашем же случае, если я верно понял, конфигурация верстки не может быть возвращена на фронт если нет данных, то есть имеет место зависимость верстки от данных. Кажется, более правильно было бы в конфигурацию вшивать ссылку на источник данных, а фронт, получая эту конфигурацию уже обращался бы к сервисам за самими данными, которые бы возвращали их в привычном виде. Ну и, конечно, в кофигурации нужны маппинги, позволяющие разложить данные в нужные поля, что можно сделать через переменные, значения которых заполняются из ответа сервиса с данными, а потом эти значения подставляются уже в поля элементов UI