Как устроена серверная UI-система Airbnb

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Как Airbnb обеспечивает быстрое развертывание функций в браузерах, на iOS и Android с помощью серверной UI-системы Ghost Platform

Введение. Серверный UI

Прежде чем разбираться с реализацией серверного UI (SDUI) от Airbnb, важно понять, что это вообще такое и какие преимущества оно дает относительно традиционного клиентского UI.

Обычно данные обрабатываются серверной частью, а за работу интерфейса отвечает конкретный клиент (веб-браузер, приложения для iOS, Android). В качестве примера возьмем страницу Airbnb со списком предложений. Чтобы отобразить этот список пользователю, клиент может запросить данные у бэкенда, а затем преобразовать их в UI.

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

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

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

Применение SDUI

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

Реализация SDUI в Airbnb позволяет бэкенду контролировать одновременно и отображаемые данные, и способ их представления — причем для всех клиентов сразу. В браузере, приложениях для iOS и Android всё контролируется единственным ответом бэкенда: и макет экрана, и расположение разделов, и отображаемых в них данные, и даже действия, выполняемые при взаимодействии пользователей с разделами.

SDUI в Airbnb: Ghost Platform ​

Ghost Platform (GP) — это унифицированная гибкая серверная UI-система, которая позволяет быстро проводить итерации разработки и надежным образом запускать новые функции в браузерных клиентах, приложениях iOS и Android. Она называется Ghost, потому что наша основная цель — реализация функций «гость» (guest) и «хозяин» (host), представляющих две стороны приложений Airbnb.

GP предоставляет фреймворки для браузеров, iOS и Android на нативных языках каждого клиента (Typescript, Swift и Kotlin соответственно), которые позволяют разрабатывать управляемые сервером функции с минимальными усилиями.

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

Стандартизированная схема

Основа Ghost Platform — это стандартизированная модель данных, на базе которой клиенты отрисовывают UI. Чтобы это было осуществимо, GP использует общий уровень данных между бэкенд-сервисами и унифицированную сетку сервисов данных Viaduct.

Ключевым решением, которое помогло сделать серверную систему UI масштабируемой, стало использование единой совместно используемой схемы GraphQL для браузерного клиента, приложений iOS и Android — иными словами, для обработки ответов и создания строго типизированных моделей данных на всех платформах мы используем одну схему.

Мы продумали, как обобщить совместно используемые аспекты различных функций и как можно единообразно учитывать особенности каждой страницы. В результате получилась универсальная схема, позволяющая отображать все функции Airbnb. В ее рамках можно применять концепцию повторного использования разделов, динамические макеты, подстраницы, действия и многое другое. Соответствующие GP-фреймворки в клиентских приложениях используют эту универсальную схему для стандартизации отрисовки UI.

Ответ GP

Первый фундаментальный аспект GP — это структура ответа в целом. Для описания UI в ответе GP используются две основные концепции: разделы и экраны.

Рисунок 1. Как пользователи видят функции Airbnb на GP — и как GP видит те же функции как экраны и разделы
Рисунок 1. Как пользователи видят функции Airbnb на GP — и как GP видит те же функции как экраны и разделы
  • Разделы. Самый примитивный стандартный блок в GP — это раздел. Раздел описывает данные связанной группы компонентов UI, содержащие конкретную информацию для отображения — переведенную, локализованную и отформатированную. Клиенты берут данные раздела и преобразуют их непосредственно в интерфейс.

  • Экраны. В ответе GP может содержаться произвольное количество экранов. Экран описывает собственный макет, а также расположение разделов из массива разделов — «места размещения». Также экран определяет иные метаданные, например, способ отрисовки разделов (всплывающие, модальные, полноэкранные) и данные журнала.

interface GPResponse {
  sections: [SectionContainer]
  screens: [ScreenContainer]
  
  # ... Other metadata, logging data or feature-specific logic
}
{"mode":"full","isActive":false}

Рисунок 2. Пример схемы GraphQL для ответа GP

Бэкенд функции на базе GP реализует этот GPResponse (рисунок 2) и заполняет экраны и разделы в зависимости от варианта использования. Клиентские GP-фреймворки в браузере, на iOS и Android предоставляют разработчику стандартный подход, позволяющий получить реализацию GPResponse и преобразовать ее в UI с минимальными усилиями.

Разделы

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

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

Схема разделов

В схеме GraphQL разделы GP представляют собой совокупность всех возможных типов разделов. Каждый тип раздела определяет поля, предоставляемые для отрисовки. Разделы передаются через реализацию GPResponse с определенными метаданными и предоставляются посредством оболочки SectionContainer, которая содержит сведения о состоянии раздела, данных журнала и фактической модели данных раздела.

# Example sections
type HeroSection {
  # Image urls
  images: [String]!
}

type TitleSection {
  title: String!,
  titleStyle: TextStyle!
  
  # Optional subtitle
  subtitle: String
  subtitleStyle: TextStyle
  
  # Action to be taken when tapping the optional subtitle
  onSubtitleClickAction: IAction
}

enum SectionComponentType {
  HERO,
  TITLE,
  PLUS_TITLE,
  
  # ... There's alot of these :)
}

union Section = HeroSection
  | TitleSection
  | # ... More section data models
  
# The wrapper that wraps each section. Responsible for metadata, logging data and SectionComponentType
type SectionContainer {
  id: String!
  
  # The key that determines how to render the section data model
  sectionComponentType: SectionComponentType
  
  # The data for this specific section
  section: Section
  
  # ... Metadata, logging data & more
}
{"mode":"full","isActive":false}

Рисунок 3. Фрагмент схемы GraphQL для раздела

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

Например, два типа SectionComponentType — TITLE и PLUS_TITLE — могут использовать одну модель данных TitleSection, но в реализации PLUS_TITLE для отображения раздела TitleSection будет использоваться логотип и стиль заголовка для Airbnb Plus. Такой подход обеспечивает универсальность функций на базе GP и возможность множественного использования схемы и данных.

Рисунок 4. Пример альтернативной отрисовки модели данных TitleSection с использованием SectionComponentType
Рисунок 4. Пример альтернативной отрисовки модели данных TitleSection с использованием SectionComponentType

Компоненты раздела

Данные раздела преобразуются в UI посредством так называемых «компонентов раздела». Каждый компонент отвечает за преобразование модели данных и SectionComponentType в элементы UI. GP для каждой платформы предоставляет абстрактные компоненты разделов на нативных языках (Typescript, Swift, Kotlin), поэтому разработчики могут расширять их и создавать новые разделы.

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

Возьмем пример для платформы Android (потому что я на ней разрабатываю — а еще потому, что Kotlin классный ​). Создадим раздел заголовка, используем фрагмент кода, приведенный ниже (рисунок 5). В реализациях для браузера и iOS построение компонентов раздела делается похожим образом (на Typescript и Swift соответственно).

// This annotation builds a Map<SectionComponentType, SectionComponent> that GP uses to render sections
@SectionComponentType(SectionComponentType.TITLE)
class TitleSectionComponent : SectionComponent<TitleSection>() {
 
    // Developers override this method and build UI from TitleSection corresponding to TITLE
    override fun buildSectionUI(section: TitleSection) {

        // Text() Turns our title into a styled TextView
        Text(
            text = section.title,
            style = section.titleStyle
        )

        // Optionally build a subtitle if present in the TitleSection data model
        if (!section.subtitle.isNullOrEmpty() {

            Text(
                text = section.subtitle,
                style = section.subtitleStyle
            )
        }
    }
}
{"mode":"full","isActive":false}

Рисунок 5. Пример компонента раздела (Kotlin)

GP предоставляет множество «основных» компонентов раздела (например, TitleSectionComponent в примере на рисунке 5 выше), для которых можно изменять конфигурацию, стиль и которые обратно совместимы на бэкенде, что позволяет адаптировать их к любому варианту использования функции. При этом для разработки новых функций на GP можно при необходимости добавлять новые компоненты раздела.

Рисунок 6. GP берет данные раздела, с помощью компонента раздела преобразует их в UI (TitleSectionComponent на рисунке 5) и показывает пользователю собранный интерфейс раздела
Рисунок 6. GP берет данные раздела, с помощью компонента раздела преобразует их в UI (TitleSectionComponent на рисунке 5) и показывает пользователю собранный интерфейс раздела

Экраны

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

Схема экранов

Экраны передаются как тип ScreenContainer и могут запускаться в модальном режиме (всплывающее окно), на нижнем слое или в полноэкранном режиме — в зависимости от значений, указанных в поле screenProperties.

Экраны позволяют динамически настраивать макет экрана и, соответственно, размещать разделы с помощью типа LayoutsPerFormFactor, который определяет макет для компактных и широких контрольных точек с помощью интерфейса ILayout (об этом — ниже). Затем GP-фреймворк на клиентах с помощью данных о пиксельной плотности, ориентации и других данных определяет, какие ILayout из LayoutsPerFormFactor отрисовывать.

type ScreenContainer {
  id: String
  
  # Properties such as how to launch this screen (popup, sheet, etc.)
  screenProperties: ScreenProperties
  
  layout: LayoutsPerFormFactor
}

# Specifies the ILayout type depending on rotation, client screen density, etc.
type LayoutsPerFormFactor {
  
  # Compact is usually used for portrait breakpoints (i.e. mobile phones)
  compact: ILayout
  
  # Wide is usually used for landscape breakpoints (i.e. web browsers, tablets)
  wide: ILayout
}
{"mode":"full","isActive":false}

Рисунок 7. Пример схемы экранов GP

Макеты ILayout

Рисунок 8. Несколько примеров реализации ILayout, которые используются для указания различных мест размещения
Рисунок 8. Несколько примеров реализации ILayout, которые используются для указания различных мест размещения

ILayout позволяет менять макет экрана в зависимости от ответа. В схеме ILayout это интерфейс, в котором каждый ILayout представляет собой реализацию с указанием различных мест размещения. Места размещения включают в себя один или несколько типов SectionDetail, которые указывают на разделы во внешнем массиве sections ответа. Здесь мы указываем на модели данных раздела, а не включаем их в код, что сокращает размер ответа за счет повторного использования разделов в различных конфигурациях макета (LayoutsPerFormFactor на рисунке 7).

interface ILayout {}

type SectionDetail {
  # References a SectionContainer in the GPResponse.sections array
  sectionId: String
  
  # Styling data
  topPadding: Int
  bottomPadding: Int
  
  # ... Other styling data (margins, borders, etc)
}

# A placement meat to display a single GP section
type SingleSectionPlacement {
  sectionDetail: SectionDetail!
}

# A placement meat to display multiple GP sections in the order they appear in the sectionDetails array
type MultipleSectionsPlacement {
  sectionDetails: [SectionDetail]!
}

# A layout implementation defines the placements that sections are inserted into.
type SingleColumnLayout implements ILayout {
  
  nav: SingleSectionPlacement
  
  main: MultipleSectionsPlacement
  
  floatingFooter: SingleSectionPlacement
}
{"mode":"full","isActive":false}

Рисунок 9. Пример схемы GP для ILayout

Клиентские GP-фреймворки наполняют макеты ILayout, поскольку типы ILayout более гибкие, чем разделы. В GP-фреймворке каждого клиента есть уникальный отрисовщик для каждого ILayout. Отрисовщик берет данные SectionDetail из каждого места размещения, находит соответствующий компонент раздела для отрисовки этого раздела, формирует с его помощью UI раздела, а затем помещает собранный UI в макет.

Действия

И еще одна концепция GP — это инфраструктура обработки действий и событий. Одним из наиболее важных аспектов GP является то, что из сетевого ответа мы можем определять не только разделы и макет экрана, но и действия, выполняемые, когда пользователи взаимодействуют с UI на экране, например, нажимают кнопку или пролистывают карточку. В схеме это делается с помощью интерфейса IAction.

interface IAction {}

# A simple action that will navigate the user to the screen matching the screenId when invoked
type NavigateToScreen implements IAction {
  screenId: String
}

# A sample TitleSection using an IAction type to handle the click of the subtitle
type TitleSection {
  ...
  
  subtitle: String
  
  # Action to be taken when tapping the subtitle
  onSubtitleClickAction: IAction
}
{"mode":"full","isActive":false}

Рисунок 10. Пример схемы IAction в GP

Ранее (см. рисунок 6) мы видели, что компонент раздела на каждом клиенте преобразует TitleSection в UI. Взглянем на тот же пример TitleSectionComponent для Android с динамичным IAction, которое запускается по нажатию на текст подзаголовка.

@SectionComponentType(SectionComponentType.TITLE)
class TitleSectionComponent : SectionComponent<TitleSection>() {
 
    override fun buildSectionUI(section: TitleSection) {

        // Build title UI elements

        if (!section.subtitle.isNullOrEmpty() {

            Text(
                ...
                onClick = {
                  GPActionHandler.handleIAction(section.onSubtitleClickAction)
                }
            )
        }
    }
}
{"mode":"full","isActive":false}

Рисунок 11. Пример компонента раздела с действием IAction, которое запускается при нажатии на подзаголовок

Когда пользователь нажимает на подзаголовок в этом разделе, запускается действие IAction, переданное для поля onSubtitleClickAction в TitleSection. GP отвечает за направление этого действия к определенному для этой функции обработчику событий, который исполнит вызванное IAction.

Есть стандартный набор обычных действий, которые GP выполняет универсально (например, переход к экрану или прокрутка к разделу). Функции могут добавлять собственные типы IAction и использовать их для обработки своих уникальных действий. Обработчик событий для конкретной функции ограничен этой функцией, поэтому он может содержать всю необходимую для нее бизнес-логику, что дает свободу использовать настраиваемые действия и бизнес-логику при возникновении конкретных вариантов использования.

Собираем всё вместе

Мы рассмотрели несколько концепций — теперь, чтобы связать всё воедино, взглянем на ответ GP и разберемся, как он отрисовывается.

{
  "screens": [
    {
      "id": "ROOT",
      "screenProperties": {},
      "layout": {
        "wide": {},
        "compact": {
          "type": "SingleColumnLayout",
          "main": {
            "type": "MultipleSectionsPlacement",
            "sectionDetails": [
              {
                "sectionId": "hero_section"
              },
              {
                "sectionId": "title_section"
              }
            ]
          },
          "nav": {
            "type": "SingleSectionPlacement",
            "sectionDetail": {
              "sectionId": "toolbar_section"
            }
          },
          "footer": {
            "type": "SingleSectionPlacement",
            "sectionDetail": {
              "sectionId": "book_bar_footer"
            }
          }
        }
      }
    },
  ],
  "sections": [
    {
      "id": "toolbar_section",
      "sectionComponentType": "TOOLBAR",
      "section": {
        "type": "ToolbarSection",
        "nav_button": {
          "onClickAction": {
            "type": "NavigateBack",
            "screenId": "previous_screen_id"
          }
        }
      }
    },
    {
      "id": "hero_section",
      "sectionComponentType": "HERO",
      "section": { 
        "type": "HeroSection",
        "images": [
          "api.airbnb.com/...",
        ],
      }
    },
    {
      "id": "title_section",
      "sectionComponentType": "TITLE",
      "section": { 
        "type": "TitleSection",
        "title": "Seamist Beach Cottage, Private Beach & Ocean Views",
        "titleStyle": {}
      }
    },
    {
      "id": "book_bar_footer",
      "sectionComponentType": "BOOK_BAR_FOOTER",
      "section": {
        "type": "ButtonSection", 
        "title": "$450/night",
        "button": {
          "text": "Check Availability",
          "onClickAction": {
            "type": "NavigateToScreen",
            "screenId": "next_screen_id"
          }
        },
      }
    },
  ]
}
{"mode":"full","isActive":false}

Рисунок 12. Пример действительного ответа GP в формате JSON

Создание компонентов раздела

Функции, использующие GP, должны получить ответ, реализующий GPResponse (см. рисунок 2 выше). Получив GPResponse, инфраструктура GP анализирует этот ответ и формирует разделы.

Напомню, что каждый раздел в массиве sections включает в себя тип SectionComponentType и модель данных раздела. Разработчик добавляет компоненты section, используя SectionComponentType как ключ к отрисовке модели данных раздела.

GP находит все компоненты раздела и передает им соответствующую модель данных. Каждый компонент раздела создает компоненты UI для раздела, после чего GP вставляет их в нужное место размещения в макете (см. ниже).

Рисунок 13. Преобразование данных раздела в UI
Рисунок 13. Преобразование данных раздела в UI

Обработка действий

Элементы пользовательского интерфейса для каждого компонента раздела настроены — теперь нужно обработать взаимодействие пользователей с этими разделами. Например, если пользователь нажимает кнопку, нужно обрабатывать действие, выполняемое при нажатии.

Напомню, что GP направляет действия к соответствующим обработчикам. В примере ответа выше (рисунок 12) есть два раздела, которые могут запускать действия: toolbar_section и book_bar_footer. Компонент раздела для создания этих разделов должен всего лишь принять действие IAction указать, когда его запускать (в обоих случаях — при нажатии кнопки).

Сделать это можно с помощью обработчиков нажатий на каждом клиенте, которые будут использовать инфраструктуру GP для направления события по нажатию.

button(
  onClickListener = {
    GPActionHandler.handleIAction(section.button.onClickAction)
  }
)

Подготовка экрана и макета

Чтобы сформировать для пользователя интерактивный экран, GP ищет в массиве экранов элемент с идентификатором ROOT (экран по умолчанию для GP). Затем GP ищет подходящий тип ILayout в зависимости от контрольной точки и других данных, относящихся к конкретному устройству пользователя. Для простоты используем макет из поля compact — SingleColumnLayout.

Затем GP ищет отрисовщик макета для SingleColumnLayout, где он наполняет макет верхним контейнером (место размещения nav), прокручиваемым списком (main) и плавающей нижней панелью (footer).

Этот отрисовщик макета берет модели для мест размещения, содержащие объекты SectionDetail. В каждом SectionDetail есть сведения о стиле, а также sectionId наполняемого раздела. GP проходит по объектам SectionDetail и с помощью созданных ранее компонентов раздела наполняет разделы, формируя соответствующие места размещения.

Рисунок 14. Инфраструктура GP берет созданные разделы с добавленными обработчиками действий и добавляет разделы в места размещения ILayout
Рисунок 14. Инфраструктура GP берет созданные разделы с добавленными обработчиками действий и добавляет разделы в места размещения ILayout

Развитие GP

GP существует всего около года, но уже сейчас большинство самых часто используемых функций Airbnb (поиск, страницы со списками, бронирование) построены на базе этой системы. Несмотря на такое широкое применение, GP всё еще на начальных этапах развития — впереди многое предстоит сделать.

Мы планируем расширить возможность компоновки UI за счет «вложенных разделов», повысить удобство поиска имеющихся элементов в инструментах для дизайна, таких как Figma, а также реализовать WYSIWYG-редактирования разделов и мест размещения, что позволит изменять функции без написания кода.

Если вам интересен серверный UI или создание масштабируемых UI-систем — работа для вас найдется. Смело подавайте резюме на вакансии в нашей команде разработчиков.

Конференция Re-engineering Travel

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

Если вам интересен более высокоуровневый обзор SDUI и GP — можете послушать мое недавнее выступление в рамках проводимого компанией Airbnb мероприятия Re-engineering Travel, где я представлял систему GP. Там я даю общий обзор серверного пользовательского интерфейса и GP (если хотите покороче — слушайте с 31-й минуты).

Благодарности

Хочу поблагодарить за неутомимую работу над созданием и поддержкой GP следующих людей: Abhi Vohra, Wensheng Mao, Jean-Nicolas Vollmer, Pranay Airan, Stephen Herring, Jasper Liu, Kevin Weber, Rodolphe Courtier, Daniel Garcia-Carrillo, Fidel Sosa, Roshan Goli, Cal Stephens, Chen Wu, Nick Miller, Yanlin Chen, Susan Dang, Amity Wang, а также многих, кто остался за кулисами.


О переводчике

Перевод статьи выполнен в Alconost.

Alconost занимается локализацией игр, приложений и сайтов на 70 языков. Переводчики-носители языка, лингвистическое тестирование, облачная платформа с API, непрерывная локализация, менеджеры проектов 24/7, любые форматы строковых ресурсов.

Мы также делаем рекламные и обучающие видеоролики — для сайтов, продающие, имиджевые, рекламные, обучающие, тизеры, эксплейнеры, трейлеры для Google Play и App Store.

Источник: https://habr.com/ru/company/alconost/blog/568444/


Интересные статьи

Интересные статьи

Пока компьютерный прогресс бежит сломя голову, в стане серверов остаются доступными совершенно различные конфигурации, как современные, так и 5-10 летние железки. И в момент подбора компл...
Памперсы на столе, походы на обед вместе с друзьями, отобранные детьми рабочие кабинеты — зарисовки из жизни директора по качеству СберМаркета, Software Engi...
Выпущенная в 1983 году домашняя консоль Nintendo Entertainment System (NES) была дешёвой, но мощной машиной, достигшей феноменального успеха. При помощи блока обработки изображений (Picture Pro...
Каждый лишний элемент на сайте — это кнопка «Не купить», каждая непонятность или трудность, с которой сталкивается клиент — это крестик, закрывающий в браузере вкладку с вашим интернет-магазином.
Если вы последние лет десять следите за обновлениями «коробочной версии» Битрикса (не 24), то давно уже заметили, что обновляется только модуль магазина и его окружение. Все остальные модули как ...