Магический метод работы с формами

от автора

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

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

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

Меня зовут Владимир Кирилкин, я техлид в Цифровом СИБУРе, в команде Индустрии 4.0. Мы разрабатываем продукт «Видеоаналитика на производстве», и о наших задачах уже писали на Хабре.

Мы подошли к задаче нестандартно: вместо заранее заданных форм на фронте реализовали их автоматическую генерацию с использованием JSON-схем и немного ✨магии✨.

Наши сервисы построены на Python и React, но предложенный подход можно адаптировать и для других технологий — правда, с чуть меньшим количеством ✨магии✨.

Итак, решение — использовать Pydantic, который генерирует JSON-схему для конфигов → фронт рендерит формы по json схеме → пользователь заполняет форму и видит ошибки валидации → применение конфига на беке.

Этот подход имеет дополнительное преимущество: при изменении бэкенда нет необходимости тревожить фронтенд-разработчика.

С чем работаем

Проектов в работе много, приведу пару примеров.

Контроль использования страховочной привязи

  • Детекция людей, поиск привязи на людях.

  • Интеграция с системами технологического видеонаблюдения (СТВН).

  • Оповещения по email в ОТиПБ.

Отслеживание работ на станках

  • Детекция движений в зонах.

  • Отправка данных в BI систему для дашборда план/факта работ.

Сливо-наливные эстакады

  • Классификация этапа слива-налива по видеокамере.

  • Получение данных из MES (Система управления производственными процессами).

  • Отправка данных в ЭКОНС (внутренняя система визуализации, о ней тоже писали тут).

Куча конфигов для разных сервисов

Куча конфигов для разных сервисов

Поиск решения

У Pydantic есть интересная функция: мы можем получить JSON-схему для описанной модели EmailConfig1.model_json_schema()

{     "properties": {         "host": {             "title": "Host",             "type": "string"         },         "port": {             "title": "Port",             "type": "integer"         },         "username": {             "title": "Username",             "type": "string"         },         "sender": {             "title": "Sender",             "type": "string"         },         "timeout": {             "default": 60,             "title": "Timeout",             "type": "integer"         }     },     "required": [         "host",         "port",         "username",         "sender"     ],     "title": "EmailConfig1",     "type": "object" }

Но что мы можем с ней сделать?

Для фронта (здесь и далее под фронтом подразумевается web приложение на React) существует библиотека react-jsonschema-form, которая умеет рендерить формы по JSON-схеме.

Что такое JSON схема

JSON Schema — это спецификация для описания структуры JSON-документов. Она позволяет указать формат данных (числа, строки, объекты, массивы), допустимые значения (минимумы, максимумы), обязательные и необязательные поля.

Учитывая, что JSON легко конвертируется и в другие структурированные форматы (yaml, toml, …), то json схемы применимы и к ним.

Можно заодно упомянуть сайт schemastore.org, на котором есть схемы для множества файлов. Сам я постоянно пользуюсь этими схемами для gitlab-ci.yml и pyproject.toml.

Так же json схемы можно встретить в openAPI. Именно с помощью него описываются параметры и результаты запросов

Вернемся к спецификации. У неё есть разные версии.

На текущий момент

  • Pydantic поддерживает 2020-12

  • RJSF — draft-07, на имплементацию новых версий открыт issue

  • AJV (библиотека, которую использует RJSF для валидации форм) — все версии

Краткий список изменений новых версий
  •  2019-09

    • Версионирование

    • Зависимости схем

    • Новые форматы даты/времени

    • Дополнительная валидация

  • 2020-12

    • Динамические ссылки

    • Формализация ключевых слов

    • Дополнительная кастомизация

В своей работе мы не встречали ситуации, в которой pydantic генерировал схему, вызывающую проблемы на фронте.

Реализация

Схема
{     "properties": {         "host": {             "title": "Host",             "type": "string"         },         "port": {             "title": "Port",             "type": "integer"         },         "username": {             "title": "Username",             "type": "string"         },         "sender": {             "title": "Sender",             "type": "string"         },         "timeout": {             "default": 60,             "title": "Timeout",             "type": "integer"         }     },     "required": [         "host",         "port",         "username",         "sender"     ],     "title": "EmailConfig1",     "type": "object" }

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

class EmailConfig2(BaseModel):     """Конфигурация почтового сервера для отправки оповещений"""      host: IPvAnyAddress = Field(..., title="Хост почтового сервера")     port: int = Field(         default=25,         title="Порт почтового сервера",         description="Порт сервера отправки почты",         le=65535,         ge=1,     )     username: str | None = Field(None, title="Имя пользователя", min_length=3)     sender: str = Field(         default="Оповещения bscreen",         title="Имя отправителя",         min_length=3,         description="Имя отправителя, которое будет отображаться в письме",     )     timeout: int = Field(60, title="Таймаут", description="Таймаут соединения", ge=1)     model_config = ConfigDict(title="Конфигурация почтового сервера")

Все эти дополнения отражаются в json схеме

Схема
{     "description": "Конфигурация почтового сервера для отправки оповещений",     "properties": {         "host": {             "format": "ipvanyaddress",             "title": "Хост почтового сервера",             "type": "string"         },         "port": {             "default": 25,             "description": "Порт сервера отправки почты",             "maximum": 65535,             "minimum": 1,             "title": "Порт почтового сервера",             "type": "integer"         },         "username": {             "anyOf": [                 {                     "minLength": 3,                     "type": "string"                 },                 {                     "type": "null"                 }             ],             "default": null,             "title": "Имя пользователя"         },         "sender": {             "default": "Оповещения bscreen",             "description": "Имя отправителя, которое будет отображаться в письме",             "minLength": 3,             "title": "Имя отправителя",             "type": "string"         },         "timeout": {             "default": 60,             "description": "Таймаут соединения",             "minimum": 1,             "title": "Таймаут",             "type": "integer"         }     },     "required": [         "host"     ],     "title": "Конфигурация почтового сервера",     "type": "object" }

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

В итоге мы можем рассчитывать на три уровня валидации:

  1. HTML5

  2. AJV

  3. Pydantic — валидация на стороне бекэнда

Усложняем схему

Добавляем вложенные схемы и свои валидаторы.

Конечно, написанные нами валидаторы model_validator и field_validator никак не попадут на фронт. Задействовать их мы сможем только при попытке применить к схеме новые данные:

class TLSConfig(BaseModel):     key_file: Path | None = None     cert_file: Path | None = None      @model_validator(mode="after")     @classmethod     def check_tls(cls, v):         if bool(v.cert_file) ^ bool(v.key_file):             raise ValueError("Оба параметра key_file и cert_file должны быть установлены или не установлены")         return v      @field_validator("key_file", "cert_file")     @classmethod     def check_paths(cls, v: Path | None):         if v and not v.exists():             raise ValueError(f"Файл {v} не существует")         return v   class EmailConfig3(BaseModel):     """Конфигурация почтового сервера для отправки оповещений"""      tls: TLSConfig = Field(default_factory=TLSConfig, title="Настройки безопасности")
Схема
{     "$defs": {         "TLSConfig": {             "properties": {                 "key_file": {                     "anyOf": [                         {                             "format": "path",                             "type": "string"                         },                         {                             "type": "null"                         }                     ],                     "default": null,                     "title": "Key File"                 },                 "cert_file": {                     "anyOf": [                         {                             "format": "path",                             "type": "string"                         },                         {                             "type": "null"                         }                     ],                     "default": null,                     "title": "Cert File"                 }             },             "title": "TLSConfig",             "type": "object"         }     },     "description": "Конфигурация почтового сервера для отправки оповещений",     "properties": {         "tls": {             "$ref": "#/$defs/TLSConfig",             "title": "Настройки безопасности"         }     },     "required": [         "host"     ],     "title": "Конфигурация почтового сервера",     "type": "object" }

Как мы можем видеть, на фронте tls отображается в отдельном блоке.

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

Проброс ошибок с бека

Pydantic умеет подробно рассказывать о возникших ошибках:

[     {         "type": "ip_any_address",         "loc": [             "host"         ],         "msg": "value is not a valid IPv4 or IPv6 address",         "input": "34234",         "url": "https://errors.pydantic.dev/2.9/v/ip_any_address"     } ]

RJSF умеет рисовать на форме ошибки извне, однако ожидает он совсем другой формат:

{     "host": {         "__errors": [             "value is not a valid IPv4 or IPv6 address"         ]     } }

Мы решили эту проблему через указатели в JSON (RFC 6901):

  1. Преобразуем путь к ошибке из списка полей в строку "/host/__errors/-"

  2. Формируем новый объект и добавляем к нему все полученные ошибки jsonpointer.set(res_errors, path, error.msg)

Кастомизация фронта

Тема

RJSF предоставляет несколько официальных тем, но нам этого не хватило. Мы решили доработать тему antd под свои требования и расширить собственными компонентами.

Это выглядит следующим образом:

const customTheme = {   ...AntdFormTheme,   templates: {     ...AntdFormTheme.templates,     FieldTemplate,     ArrayFieldTemplate,     ArrayFieldItemTemplate,     ObjectFieldTemplate,     TitleFieldTemplate,     ErrorListTemplate: ErrorList,     WrapIfAdditionalTemplate,     BaseInputTemplate,   },   fields: { ...AntdFormTheme.fields, StringField },   widgets: {     ...AntdFormTheme.widgets,     RegionWidget,     RegionWidgetV2,     SelectBoxCustom,     TextareaWidget,   }, };

При наличии свободных рук можно сделать полностью свою тему, используя UI Kit компании, однако у нас свободных рук не нашлось)

Свои виджеты

RJSF позволяет настраивать выбор виджетов через объект ui_schema, но из-за динамичности формы мы отказались от этого варианта.

Мы пошли другим путём — немного дополнили компонент ObjectFieldTemplate, добавив возможность определять виджет в JSON схеме:

function loadWidget() {   if (!schema.hasOwnProperty("web_widget")) return false;   let Widget = getWidget(schema, schema.web_widget, widgets);   return (     <Widget       addText={addText}       disabled={disabled}       formContext={formContext}       formData={formData}       idSchema={idSchema}       properties={properties}       onAddClick={onAddClick(schema)}       readonly={readonly}       registry={registry}       required={required}       schema={schema}       title={title}       uiSchema={uiSchema}     />   ); }

Использование в беке:

regions: Dict[str, Region] = Field(     default_factory=dict,     title="Регионы камеры",     json_schema_extra={"web_widget": "RegionWidgetV2"}, )

Пример своего виджета

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

Особенности библиотеки RJSF: необходимо получать данные из formContext.formData, обновлять через колбеки onAddClick, item.onKeyChange, item.onChange и item.onKeyChange. То есть, нельзя сразу добавить полигон с координатами, необходимо сначала вызвать onAddClick, а затем отловить созданный элемент и уже на нём вызвать onChange.

Ссылки на данные внутри формы

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

Для этого мы используем JSONPath — язык запросов для json.

class NotifierMessageConfig(BaseModel):     name: str = Field(         title="Плагин способа отправки",         json_schema_extra={             "web_widget": "SelectBoxCustom",             "web_query": "$.notifiers[*].web_dict_key",         },         description="Выбрать способ отправки уведомления"     )

На фронте для этого создали контекст, который задаём в ObjectFieldTemplate

<PathContext.Provider value={path + "." + element.name} key={element.name}> <Col span={spanWidth}>{element.content}</Col> </PathContext.Provider>

И затем используем в компоненте SelectBoxCustom:

function updateVariants() {   let variants: string[] = jsonpath.query(     props.formContext.formData,     props.schema.web_query,   );   setVariants(variants.map((s) => ({ value: s, label: s }))); } return (   <SelectWidget     {...props}     options={{ enumOptions: variants }}     onFocus={updateVariants}   /> );

Тут стоит отметить потенциальные проблемы этого решения. Из-за того, что в web_query используется абсолютный путь к полям, в случае переименования/изменения структуры, необходимо проконтролировать обновление и самого поля

Плагины на стороне бека

В python у библиотек можно описывать “точки входа” — entrypoint (документация).

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

В файле pyproject.toml описывается список entrypoint пакета:

[project.entry-points."bscreen_notificator.notifiers"] storage = "bscreen_notificator.notifiers.storage:StorageNotifier" email = "bscreen_notificator.notifiers.email:EmailNotifier" logger = "bscreen_notificator.notifiers.logger:LoggerNotifier" mtoir = "bscreen_notificator.notifiers.mtoir:MtoirNotifier" intellect_video = "bscreen_notificator.notifiers.intellect:IntellectVideoNotifier" siiot = "bscreen_notificator.notifiers.siiot:SiiotNotifier"

А в коде мы можем получить все entrypoint по группе:

>>> from importlib.metadata import entry_points >>> entry_points(group="bscreen_notificator.notifiers")  [     EntryPoint( name="email", value="bscreen_notificator.notifiers.email:EmailNotifier", group="bscreen_notificator.notifiers", ),     EntryPoint(         name="intellect_video",         value="bscreen_notificator.notifiers.intellect_video:IntellectVideoNotifier",         group="bscreen_notificator.notifiers"     ),    ... ]

Погрузившись в дебри Pydantic и написав множество строк кода, мы получили возможность динамически подключать внешние модули и конфигурировать их.

Выглядит примерно так:

class NotifierPluginField(     PluginClassField,     entrypoint="bscreen_notificator.notifiers",     bases=[AbstractNotifier], ):     pas   class NotifierConfig(BuildablePluginModel, args_field_name="args"):     cls: NotifierPluginField = Field(         title="Плагин способа отправки",         description="Выбрать способ отправки уведомления (email, storage, logger..)",     )     args: NotifierPluginField.get_config_type_hint() = Field(         default_factory=dict,         title="Параметры способа отправки",     )

Получаем схему (приведена только часть):

{ ...     "allOf": [         {             "if": {                 "properties": {                     "cls": {                         "enum": [                             "email"                         ]                     }                 }             },             "then": {                 "properties": {                     "args": {                         "$ref": "#/$defs/bscreen_EmailNotifier__Config",                         "title": "EmailNotifier:Config-Input"                     }                 }             }         },         {             "if": {                 "properties": {                     "cls": {                         "enum": [                             "intellect_video"                         ]                     }                 }             },             "then": {                 "properties": {                     "args": {                         "$ref": "#/$defs/bscreen_IntellectVideoNotifier__Config",                         "title": "IntellectVideoNotifier:Config-Input"                     }                 }             }         }     ] }

Как мы можем видеть, объект в args выбирается динамически, в зависимости от cls

Что мы получили

Плюсы

📦 Всё работает «из коробки» — требуется только соединить фронт с беком.

✅ Проброс ошибок с бека требует одну функцию.

💅 Можно приделать любую тему.

✨ При должном подходе можно творить магию.

🫥  Для изменений фронта не нужен фронтенд разработчик — все изменения форм идут только с бека. В нашей команде вообще нет фронтенд разработчика, только я — фулстек.

Минусы

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

🌡️ На свои изменения фронта надо писать тесты, чтоб ничего не сломалось при переходе между версиями Pydantic или RJSF. У нас ломалось при переходе с первой версии Pydantic на вторую.

*Ссылки

Pydantic

RJSF playground — можно посмотреть примеры схем, попробовать свою схему

Рассказ от моих коллег о том, как устроен CD в видеоаналитике на множестве площадок.


ссылка на оригинал статьи https://habr.com/ru/articles/875372/


Комментарии

Добавить комментарий

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