Существует масса способов создать современное веб-приложение, однако перед каждой командой неизбежно встает примерно один и тот же набор вопросов: как распределить обязанности фронта и бэка, как минимизировать появление дублирующейся логики — например при валидации данных, какие библиотеки для работы использовать, как обеспечить надежный и прозрачный транспорт между фронтом и бэком и задокументировать код.
На наш взгляд нам удалось реализовать неплохой пример сбалансированного по сложности и профиту решения, который мы успешно используем в 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',
],
]);
На выходе эта форма преобразуется в схему вида:
{
"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',
],
]);
Такая форма будет преобразована в схему вида:
"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": {
"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": {
"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.
Автор: andrey