Опыт Rambler Group: как мы начали полностью контролировать формирование и поведение фронтовых React компонентов +31



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

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

Какой формат обмена данными мы можем выбрать, планируя разработку API бэкенда в активно разрабатываемом веб-продукте, содержащем динамические формы со связанными полями и сложную бизнес-логику?

  • SWAGGER — вариант неплохой, есть документация и удобные инструменты для отладки. Тем более для Symfony есть библиотеки которые позволяют автоматизировать процесс, но к сожалению JSON Schema оказалась предпочтительнее;
  • JSON Schema — данный вариант предложили фронтенд разработчики. У них уже были библиотеки, позволяющие на его основе выводить формы. Это и определило наш выбор. Формат позволяет описывать примитивные проверки, которые можно сделать в браузере. Так же есть документация, которая описывает все возможные варианты схемы;
  • GraphQL — довольно молод. Не такое большое количество server side и фронтенд библиотек. На момент создания системы не рассматривался, в перспективе — оптимальный способ создания API, об этом будет отдельная статья;
  • SOAP — имеет строгую типизацию данных, возможность построить документацию, но его не так-то просто подружить с React фронтом. Также SOAP имеет больший overhead на один и тот же полезный объем передаваемых данных;

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

  • высокая вероятность багов;
  • зачастую не 100% документация и покрытие тестами;
  • низкая «модульность» в силу закрытости программного API. Обычно такие решения пишутся под монолит и не подразумевают шаринг между проектами в виде компонентов, так как это требует особого архитектурного построения (читай удорожания разработки);
  • высокий уровень вхождения новых разработчиков. Чтобы понять всю крутость велосипеда может потребоваться много времени;

Поэтому хорошей практикой является использование распространенных и стабильных библиотек (вроде left-pad из npm) по правилу — лучший код это тот, который ты так и не написал, а бизнес-задачу решил. Разработка бэкенда веб-решений в рекламных технологиях Rambler Group ведется на Symfony. На всех используемых компонентах фреймворка останавливаться не будем, ниже поговорим о главной части, на базе которой реализована работа — Symfony form. На фронтенде используется React и соответствующая библиотека, расширяющая JSON Schema под WEB специфику — React JSON Schema Form.

Общая схема работы:



Подобный подход дает много плюсов:

  • документация генерируется из коробки, как и возможность построить автоматические тесты — опять же по схеме;
  • все передаваемые данные типизированы;
  • есть возможность передать информацию о базовых валидационных правилах;
    быстрая интеграция транспортного уровня в React — за счет библиотеки React JSON Schema от Mozilla;
  • возможность на фронтенде из коробки генерировать web компоненты за счет интеграции bootstrap;
  • логическая группировка, набор валидаций и возможных значений HTML элементов, а также вся бизнес логика контролируется в единой точке — на бэкенде, нет дублирования кода;
  • максимально просто портировать приложение на другие платформы — view часть отделена от управляющей (см. предыдущий пункт), вместо React и браузера рендерингом и обработкой запросов пользователя может выступать Android или iOS приложение;

Давайте рассмотрим компоненты и схему их взаимодействия подробнее.

Изначально JSON Schema позволяет описывать примитивные проверки, которые можно сделать на клиенте, вроде обязательности или типизации различных частей схемы:

const schema = {
  "title": "A registration form",
  "description": "A simple form example.",
  "type": "object",
  "required": [
    "firstName",
    "lastName"
  ],
  "properties": {
    "firstName": {
      "type": "string",
      "title": "First name"
    },
    "lastName": {
      "type": "string",
      "title": "Last name"
    },
    "password": {
      "type": "string",
      "title": "Password",
      "minLength": 3
    },
    "telephone": {
      "type": "string",
      "title": "Telephone",
      "minLength": 10
    }
  }
}

Для работы со схемой на фронтенде есть популярная библиотека React JSON Schema Form, дающая необходимые для веб-разработки надстройки над JSON Schema:

uiSchema — сама JSON Schema определяет тип передаваемых параметров, но для построения веб-приложения этого недостаточно. Например поле типа String может быть представлено в виде <input… /> или в виде <textarea… />, это важные нюансы, с учетом которых нужно правильно отрисовать схему для клиента. Для передачи этих нюансов и служит uiSchema, например для представленной выше JSON Schema можно уточнить визуальную веб-составляющую следующей uiSchema:

const uiSchema = {
  "firstName": {
    "ui:autofocus": true,
    "ui:emptyValue": ""
  },
  "age": {
    "ui:widget": "updown",
    "ui:title": "Age of person",
    "ui:description": "(earthian year)"
  },
  "bio": {
    "ui:widget": "textarea"
  },
  "password": {
    "ui:widget": "password",
    "ui:help": "Hint: Make it strong!"
  },
  "date": {
    "ui:widget": "alt-datetime"
  },
  "telephone": {
    "ui:options": {
      "inputType": "tel"
    }
  }
}

Live Playground пример можно посмотреть здесь.

При таком использовании схемы рендеринг на фронтенде будет реализован стандартными компонентами bootstrap в несколько строк:

render((
  <Form schema={schema}
        uiSchema={uiSchema} />
), document.getElementById("app"));

Если стандартные виджеты, поставляемые с bootstrap вас не устраивают и нужна кастомизация — для некоторых типов данных можно указать в uiSchema свои шаблоны, на момент написания статьи поддержаны string, number, integer, boolean.

FormData — содержит данные формы, например:

{
  "firstName": "Chuck",
  "lastName": "Norris",
  "age": 78,
  "bio": "Roundhouse kicking asses since 1940",
  "password": "noneed"
}

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

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

Из коробки библиотека позволяет работать только с этими тремя секциями, но для полноценного веб-приложения необходимо добавить еще ряд фич:

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

Action, Method — для отправки на бэкенд подготовленных пользователем данных были добавлены два атрибута, содержащих URL бэкенд контроллера осуществляющего обработку и HTTP метод доставки

В итоге для коммуникации между фронтом и бэком получился json со следующими секциями:

{
  "action": "https://...",
  "method": "POST",
  "errors":{},
  "schema":{},
  "formData":{},
  "uiSchema":{}
}

Но как генерировать эти данные на бэкенде? На момент создания системы не было готовых библиотек, позволяющих конвертировать Symfony Form в JSON Schema. Сейчас они уже появились, но имеют свои недостатки — например LiformBundle довольно свободно трактует JSON Schema и меняет стандарт по своему усмотрению, поэтому, к сожалению, пришлось писать свою реализацию.

В качестве основы для генерации используются стандартные Symfony form. Достаточно использовать builder и добавить необходимые поля:
Пример формы
$builder
   ->add('title', TextType::class, [
       'label' => 'label.title',
       'attr' => [
           'title' => 'title.title',
       ],
   ])
   ->add('description', TextareaType::class, [
       'label' => 'label.description',
       'attr' => [
           'title' => 'title.description',
       ],
   ])
   ->add('year', ChoiceType::class, [
       'choices' => range(1981, 1990),
       'choice_label' => function ($val) {
           return $val;
       },
       'label' => 'label.year',
       'attr' => [
           'title' => 'title.year',
       ],
   ])
   ->add('genre', ChoiceType::class, [
       'choices' => [
           'fantasy',
           'thriller',
           'comedy',
       ],
       'choice_label' => function ($val) {
           return 'genre.choice.'.$val;
       },
       'label' => 'label.genre',
       'attr' => [
           'title' => 'title.genre',
       ],
   ])
   ->add('available', CheckboxType::class, [
       'label' => 'label.available',
       'attr' => [
           'title' => 'title.available',
       ],
   ]);


На выходе эта форма преобразуется в схему вида:
Пример JsonSchema
{
 "action": "//localhost/create.json",
 "method": "POST",
 "schema": {
   "properties": {
     "title": {
       "maxLength": 255,
       "minLength": 1,
       "type": "string",
       "title": "label.title"
     },
     "description": {
       "type": "string",
       "title": "label.description"
     },
     "year": {
       "enum": [
         "1981",
         "1982",
         "1983",
         "1984",
         "1985",
         "1986",
         "1987",
         "1988",
         "1989",
         "1990"
       ],
       "enumNames": [
         "1981",
         "1982",
         "1983",
         "1984",
         "1985",
         "1986",
         "1987",
         "1988",
         "1989",
         "1990"
       ],
       "type": "string",
       "title": "label.year"
     },
     "genre": {
       "enum": [
         "fantasy",
         "thriller",
         "comedy"
       ],
       "enumNames": [
         "genre.choice.fantasy",
         "genre.choice.thriller",
         "genre.choice.comedy"
       ],
       "type": "string",
       "title": "label.genre"
     },
     "available": {
       "type": "object",
       "title": "label.available"
     }
   },
   "required": [
     "title",
     "description",
     "year",
     "genre",
     "available"
   ],
   "type": "object"
 },
 "formData": {
   "title": "",
   "description": "",
   "year": "",
   "genre": ""
 },
 "uiSchema": {
   "title": {
     "ui:help": "title.title",
     "ui:widget": "text"
   },
   "description": {
     "ui:help": "title.description",
     "ui:widget": "textarea"
   },
   "year": {
     "ui:widget": "select",
     "ui:help": "title.year"
   },
   "genre": {
     "ui:widget": "select",
     "ui:help": "title.genre"
   },
   "available": {
     "ui:help": "title.available",
     "ui:widget": "checkbox"
   },
   "ui:widget": "mainForm"
 }
}


Весь код, преобразовывающий формы в JSON закрытый и используется только в Rambler Group, если у сообщества будет интерес к этой теме — мы отрефакторим выложим ее в формате бандла в наш github репозиторий.

Давайте рассмотрим еще несколько аспектов без реализации которых сложно построить современное веб-приложение:

Валидация полей


Она задается с помощью symfony validator, описывающих правила валидации объекта, пример валидатора:

<property name="title">
    <constraint name="Length">
        <option name="min">1</option>
        <option name="max">255</option>
        <option name="minMessage">title.min</option>
        <option name="maxMessage">title.max</option>
    </constraint>
    <constraint name="NotBlank">
        <option name="message">title.not_blank</option>
    </constraint>
</property>


В данном примере constrain типа NotBlank модифицирует схему, добавляя поле в массив required полей схемы, а constrain типа Length добавляет атрибуты schema->properties->title->maxLength и schema->properties->title->minLength, которые уже должна учитывать валидация на фронтенде.

Группировка элементов


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

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

Как вы знаете, возможности Symfony Form из коробки довольно большие — например формы могут наследоваться от других форм, это удобно, но в нашем случае есть минусы. В текущей реализации порядок в JSON Schema определяет порядок отрисовки элемента формы в браузере, наследование может этот порядок нарушать. Одним из вариантов было группировать элементы, например:

Пример вложенной формы
$info = $builder
   ->create('info',FormType::class,['inherit_data'=>true])
   ->add('title', TextType::class, [
       'label' => 'label.title',
       'attr' => [
           'title' => 'title.title',
       ],
   ])
   ->add('description', TextareaType::class, [
       'label' => 'label.description',
       'attr' => [
           'title' => 'title.description',
       ],
   ]);

$builder
   ->add($info)
   ->add('year', ChoiceType::class, [
       'choices' => range(1981, 1990),
       'choice_label' => function ($val) {
           return $val;
       },
       'label' => 'label.year',
       'attr' => [
           'title' => 'title.year',
       ],
   ])
   ->add('genre', ChoiceType::class, [
       'choices' => [
           'fantasy',
           'thriller',
           'comedy',
       ],
       'choice_label' => function ($val) {
           return 'genre.choice.'.$val;
       },
       'label' => 'label.genre',
       'attr' => [
           'title' => 'title.genre',
       ],
   ])
   ->add('available', CheckboxType::class, [
       'label' => 'label.available',
       'attr' => [
           'title' => 'title.available',
       ],
   ]);


Такая форма будет преобразована в схему вида:

Пример вложенной JsonSchema
"schema": {
   "properties": {
     "info": {
       "properties": {
         "title": {
           "type": "string",
           "title": "label.title"
         },
         "description": {
           "type": "string",
           "title": "label.description"
         }
       },
       "required": [
         "title",
         "description"
       ],
       "type": "object"
     },
     "year": {
       "enum": [
         "1981",
         "1982",
         "1983",
         "1984",
         "1985",
         "1986",
         "1987",
         "1988",
         "1989",
         "1990"
       ],
       "enumNames": [
         "1981",
         "1982",
         "1983",
         "1984",
         "1985",
         "1986",
         "1987",
         "1988",
         "1989",
         "1990"
       ],
       "type": "string",
       "title": "label.year"
     },
     "genre": {
       "enum": [
         "fantasy",
         "thriller",
         "comedy"
       ],
       "enumNames": [
         "genre.choice.fantasy",
         "genre.choice.thriller",
         "genre.choice.comedy"
       ],
       "type": "string",
       "title": "label.genre"
     },
     "available": {
       "type": "object",
       "title": "label.available"
     }
   },
   "required": [
     "info",
     "year",
     "genre",
     "available"
   ],
   "type": "object"
 }


и соответствующую uiSchema
"uiSchema": {
   "info": {
     "title": {
       "ui:help": "title.title",
       "ui:widget": "text"
     },
     "description": {
       "ui:help": "title.description",
       "ui:widget": "textarea"
     },
     "ui:widget": "form"
   },
   "year": {
     "ui:widget": "select",
     "ui:help": "title.year"
   },
   "genre": {
     "ui:widget": "select",
     "ui:help": "title.genre"
   },
   "available": {
     "ui:help": "title.available",
     "ui:widget": "checkbox"
   },
   "ui:widget": "group"
 }


Данный способ группировки нам не подошел так как форма для данных начинает зависеть от представления и ее нельзя использовать, к примеру, в API или других формах. Было решено использовать дополнительные параметры в uiSchema не поломав текущий стандарт JSON Schema. В итоге в симфоневую форму добавили дополнительные опции примерно такого вида:

'fieldset' => [
   'groups' => [
       [
           'type' => 'base',
           'name' => 'info',
           'fields' => ['title', 'description'],
           'order' => ['title', 'description']
       ]
   ],
   'type' => 'base'
]

Это будет преобразовано в следующую схему:

"ui:group": {
    "type": "base",
    "groups": [
    {
        "type": "group",
        "name": "info",
        "title": "legend.info",
        "fields": [
        "title",
        "description"
        ],
        "order": [
        "title",
        "description"
        ]
    }
    ],
    "order": [
    "info"
    ]
},


Полная версия schema и uiSchema
"schema": {
   "properties": {
     "title": {
       "maxLength": 255,
       "minLength": 1,
       "type": "string",
       "title": "label.title"
     },
     "description": {
       "type": "string",
       "title": "label.description"
     },
     "year": {
       "enum": [
         "1989",
         "1990"
       ],
       "enumNames": [
         "1989",
         "1990"
       ],
       "type": "string",
       "title": "label.year"
     },
     "genre": {
       "enum": [
         "fantasy",
         "thriller",
         "comedy"
       ],
       "enumNames": [
         "genre.choice.fantasy",
         "genre.choice.thriller",
         "genre.choice.comedy"
       ],
       "type": "string",
       "title": "label.genre"
     },
     "available": {
       "type": "boolean",
       "title": "label.available"
     }
   },
   "required": [
     "title",
     "description",
     "year",
     "genre",
     "available"
   ],
   "type": "object"
 }

"uiSchema": {
   "title": {
     "ui:help": "title.title",
     "ui:widget": "text"
   },
   "description": {
     "ui:help": "title.description",
     "ui:widget": "textarea"
   },
   "year": {
     "ui:widget": "select",
     "ui:help": "title.year"
   },
   "genre": {
     "ui:widget": "select",
     "ui:help": "title.genre"
   },
   "available": {
     "ui:help": "title.available",
     "ui:widget": "checkbox"
   },
   "ui:group": {
     "type": "base",
     "groups": [
       {
         "type": "group",
         "name": "info",
         "title": "legend.info",
         "fields": [
           "title",
           "description"
         ],
         "order": [
           "title",
           "description"
         ]
       }
     ],
     "order": [
       "info"
     ]
   },
   "ui:widget": "fieldset"
 }


Так как на стороне фронтенда используемая нами React библиотека этого не поддерживает из коробки, пришлось добавить эту функциональность самим. С добавлением нового элемента «ui:group» мы получаем возможность полностью контролировать процесс группировки элементов и форм используя текущий API.

Динамические формы


Что, если, одно поле зависит от другого, например, выпадающий список подкатегорий зависит от выбранной категории?



Symfony FORM дает нам возможность с помощью Event’ов делать динамические формы, но, к сожалению, на момент реализации эту возможность не поддерживала JSON Schema, хотя в последних версиях эта возможность появилась. Первоначально была идея отдавать весь список в Enum и EnumNames объекте, на основе которого и фильтровать значения:

{
 "properties": {
   "genre": {
     "enum": [
       "fantasy",
       "thriller",
       "comedy"
     ],
     "enumNames": [
       "genre.choice.fantasy",
       "genre.choice.thriller",
       "genre.choice.comedy"
     ],
     "type": "string",
     "title": "label.genre"
   },
   "sgenre": {
     "enum": [
       "eccentric",
       "romantic",
       "grotesque"
     ],
     "enumNames": [
       {
         "title": "sgenre.choice.eccentric",
         "genre": "comedy"
       },
       {
         "title": "sgenre.choice.romantic",
         "genre": "comedy"
       },
       {
         "title": "sgenre.choice.grotesque",
         "genre": "comedy"
       }
     ],
     "type": "string",
     "title": "label.genre"
   }
 },
 "type": "object"
}

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

  • SchemaID — атрибут схемы, содержит адрес контроллера для обработки текущей введенной FormData и обновления схемы текущей формы, если этого требует бизнес логика;
  • Reload — атрибут, говорящий фронтенду что изменение этого поля инициирует обновление схемы, отправляя данные формы на бэкенд;

Наличие SchemaID может казаться дублированием — ведь есть атрибут action, но здесь мы говорим о разделении ответственности — контроллер SchemaID отвечает за промежуточное обновление schema и UISchema, а контроллер action выполняет необходимое бизнес действие — например создает или обновляет объект и не допускает отправки части формы так как производит валидационные проверки.С этими дополнениями схема начинает выглядеть следующим образом:

{
 "schemaId": "//localhost/schema.json",
 "properties": {
   "genre": {
     "enum": [
       "fantasy",
       "thriller",
       "comedy"
     ],
     "enumNames": [
       "genre.choice.fantasy",
       "genre.choice.thriller",
       "genre.choice.comedy"
     ],
     "type": "string",
     "title": "label.genre"
   },
   "sgenre": {
     "enum": [],
     "enumNames": [],
     "type": "string",
     "title": "label.sgenre"
   }
 },
 "uiSchema": {
   "genre": {
     "ui:options": {
       "reload": true
     },
     "ui:widget": "select",
     "ui:help": "title.genre"
   },
   "sgenre": {
     "ui:widget": "select",
     "ui:help": "title.sgenre"
   },
   "ui:widget": "mainForm"
 },
 "type": "object"
}

В случае изменения поля «genre» фронтенд отправляет всю форму с текущими введенными данными на бэкенд, получает в ответ набор секций необходимых для отрисовки формы:

{
  action: “https://...”,
  method: "POST",
  schema:{}
  formData:{}
  uiSchema:{}
}

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

Заключение


За счет небольшого расширения стандартного подхода мы получили ряд дополнительных возможностей, позволяющих полностью контролировать формирование и поведение фронтовых React компонентов, строить динамические схемы исходя из бизнес логики, иметь единую точку формирования валидационных правил и возможность быстро и гибко создавать новые VIEW части — например мобильные или desktop приложения. Пускаясь в подобные смелые эксперименты нужно помнить о стандарте, на базе которого вы работаете и сохранить обратную совместимость с ним. Вместо React на фронтенде может использоваться любая другая библиотека, главное написать транспортный адаптер к JSON Schema и подключить какую-либо библиотеку рендеринга форм. У нас хорошо сработал Bootstrap с React так как был опыт работы с этим технологическим стеком, но подход, о котором мы рассказали никак не ограничивает вас в выборе технологий. На месте Symfony также мог быть любой другой фреймворк позволяющий конвертировать формы в формат JSON Schema.

Upd: вы можете посмотреть наш доклад на Symfony Moscow Meetup#14 об этом с 1:15:00.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (6):

  1. Golem765
    /#18876239 / +1

    «… возможность быстро и гибко создавать новые VIEW части — например мобильные или desktop приложения»
    Как велосипед для создания форм на бутстрапе, реакте и пхп поможет создавать мобильные и десктоп приложения?
    Он в лучшем случае поможет создавать те же формы при использовании реакт нейтив для смартфонов и електрона для десктопа, но никак не все приложение

    • SerafimArts
      /#18876513

      Есть подозрения, что под «формами» можно подразумевать вообще любой компонент. Который в свою очередь ссылается на React компонент. Так что в результате получается результат: Общая переносимая АПИшка, общий набор компонентов, различия только в данных «UISchema», т.е. в метаинформации о том как отображать данные.

      • vintage
        /#18876917 / +2

        Только вот подобные конфиги тяготеют к тьюринг-полноте.

        • SerafimArts
          /#18876927

          А это плохо? Если документировано, читаемо и лаконично, конечно же.

          • vintage
            /#18876939 / +1

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

        • andrey1s
          /#18877013

          мы к этому точно не стремимся. по максимуму использовать jsonSchema. добавлять только если действительно проще будет использовать в работе.