Pull to refresh
176.1
Цифровой СИБУР
Цифровизируем промышленность

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

Level of difficultyMedium
Reading time10 min
Views1.3K

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

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

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

Меня зовут Владимир Кирилкин, я техлид в Цифровом СИБУРе, в команде Индустрии 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 в видеоаналитике на множестве площадок.

Tags:
Hubs:
Total votes 6: ↑6 and ↓0+7
Comments2

Articles

Information

Website
sibur.digital
Registered
Employees
1,001–5,000 employees
Location
Россия