Главная » Хабрахабр » Опыт Rambler Group: как мы начали полностью контролировать формирование и поведение фронтовых React компонентов

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

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

За подробностями На наш взгляд нам удалось реализовать неплохой пример сбалансированного по сложности и профиту решения, который мы успешно используем в 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 = , "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:

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

Достаточно использовать builder и добавить необходимые поля:
В качестве основы для генерации используются стандартные Symfony form.

Пример формы

$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.

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

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

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

{ "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.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

«Империя на глубине»: зачем крупные ИТ-компании прокладывают свои подводные кабели

В середине июля подразделение Google, занимающееся облачными технологиями, объявило о начале работы над первым частным трансатлантическим подводным кабелем — Dunant. «Частный» в этом случае значит, что весь проект реализуется на средства Google. К 2020 году он соединит Вирджинию-Бич, штат Вирджиния, ...

[Перевод] Дети на заказ в ближайшее время? Совет по этике в Великобритании разрешил генную инженерию человеческих эмбрионов

Улучшенные дети могут стать реальными после того, как влиятельная группа учёных пришла к выводу, что «морально допустимо» генетически изменять человеческие эмбрионы. В новом докладе, который открывает дверь к изменению закона, Nuffield Council on Bioethics сказали, что редактирование ДНК может стать ...