Architecture 2026-05-16·Ievgenii Svyryd

Zabij swoje zakodowane na sztywno formularze: jak generowanie formularzy sterowane JSON-em zmieniło sposób, w jaki buduję aplikacje Django

Co gdyby każdy formularz w twojej aplikacji Django - tworzenie, edycja, masowa aktualizacja, modal, inline - był zdefiniowany jako słownik w Pythonie zamiast jako klasa? Dwa systemy produkcyjne nauczyły mnie, że formularze to dane, a nie kod, i że ta różnica jest transformująca.

Zabij swoje zakodowane na sztywno formularze: jak generowanie formularzy sterowane JSON-em zmieniło sposób, w jaki buduję aplikacje Django

Ból formularzy zaszytych na sztywno

Każdy deweloper Django zna ten rytuał. Definiujesz model. Potem piszesz ModelForm. Potem poprawiasz widżety. Potem dodajesz crispy layout. Potem klient mówi "dodaj pole" i dotykasz czterech plików. Potem mówi "uczyń to pole warunkowym" i dodajesz JavaScript. Potem mówi "ten sam formularz, ale w modalu" i duplikujesz szablon.

Pomnóż to przez 20 modeli, a utrzymujesz małą armię klas formularzy, każdą nieco inną, każdą zakodowaną na sztywno, każdą po cichu rozjeżdżającą się z modelem, który reprezentuje.

Uderzyłem w tę ścianę na dwóch bardzo różnych projektach - przemysłowym systemie kontroli jakości w produkcji oraz back office handlu finansowego - i za każdym razem doszedłem do tego samego rozwiązania: przestań pisać klasy formularzy i zacznij pisać konfiguracje formularzy.

Główna idea: formularze to dane

Pole formularza Django to tak naprawdę tylko worek właściwości: nazwa, typ, widget, etykieta, informacja o tym, czy jest wymagane, jakie ma opcje. W tym opisie nie ma niczego, co wymagałoby definicji klasy w Pythonie. To są dane, a dane należą do struktury danych.

Spostrzeżenie jest proste: zdefiniuj pola jako słowniki, przechowuj je w schemacie i pozwól jednej generycznej klasie formularza zbudować się z tego schematu w czasie działania.

# Zamiast tego:
class InspectionForm(forms.Form):
    diameter = forms.FloatField(
        label="Inner diameter",
        widget=forms.NumberInput(attrs={"class": "form-control"}),
    )
    status = forms.ChoiceField(
        choices=[("pass", "Pass"), ("fail", "Fail")],
        widget=forms.Select(attrs={"class": "form-select"}),
    )

# Napisz to:
INSPECTION_SCHEMA = {
    "fields": [
        {
            "name": "diameter",
            "type": "FloatField",
            "widget": "NumberInput",
            "label": "Inner diameter",
            "widget_attrs": {"class": "form-control"},
        },
        {
            "name": "status",
            "type": "ChoiceField",
            "widget": "Select",
            "label": "Status",
            "choices": [("pass", "Pass"), ("fail", "Fail")],
            "widget_attrs": {"class": "form-select"},
        },
    ]
}

Silnik DynamicForm

Cały system opiera się na jednej klasie. Mapuje ona identyfikatory tekstowe na klasy pól i widżetów Django, a następnie iteruje po schemacie, aby zbudować samą siebie:

class DynamicForm(forms.Form):
    FIELD_MAP = {
        "CharField": forms.CharField,
        "ChoiceField": forms.ChoiceField,
        "FloatField": forms.FloatField,
        "IntegerField": forms.IntegerField,
        "BooleanField": forms.BooleanField,
        "DateField": forms.DateField,
        "DecimalField": forms.DecimalField,
        "EmailField": forms.EmailField,
        # ... every Django field type
    }

    WIDGET_MAP = {
        "TextInput": forms.TextInput,
        "Select": forms.Select,
        "NumberInput": forms.NumberInput,
        "Textarea": forms.Textarea,
        "CheckboxInput": forms.CheckboxInput,
        "DateInput": forms.DateInput,
        # ... every Django widget type
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field_cfg in self.scheme["fields"]:
            field_class = self.FIELD_MAP[field_cfg["type"]]
            widget_class = self.WIDGET_MAP[field_cfg["widget"]]
            self.fields[field_cfg["name"]] = field_class(
                label=field_cfg["label"],
                widget=widget_class(
                    attrs=field_cfg.get("widget_attrs", {})
                ),
                required=field_cfg.get("required", True),
                choices=field_cfg.get("choices", []),
            )

To cały silnik. Każdy typ pola Django, każdy typ widgetu, zmapowany przez wyszukiwanie po stringu. Nowy formularz to nowy słownik, a nie nowa klasa.

Studium przypadku 1: kontrola jakości w produkcji

Pierwszym projektem był produkcyjny system danych dla producenta przemysłowego. Inspektorzy jakości postępują zgodnie ze Standardowymi Procedurami Operacyjnymi (SOP), które wymagają pomiaru określonych wymiarów, sprawdzania obróbki powierzchni oraz rejestrowania wyników pozytywny/negatywny dla każdego etapu produkcji.

Wyzwanie: ponad 20 różnych formularzy inspekcyjnych, każdy z unikalnymi polami specyficznymi dla typu obrabianego przedmiotu i etapu SOP. Inspekcja płyty czołowej na tokarce karuzelowej mierzy inne rzeczy niż inspekcja frezowania płyty bipolarnej.

Przy formularzach zakodowanych na sztywno to ponad 20 klas formularzy, każda z własnymi definicjami pól, własną walidacją, własnymi dziwactwami szablonów. Przy podejściu blueprintowym to ponad 20 słowników i jedna klasa formularza.

# Każda inspekcja QC to po prostu definicja schematu
QC_ENDPLATE_LATHE_SCHEMA = {
    "fields": [
        {
            "name": "inner_diameter",
            "widget": "TextInput",
            "label": _("Średnica wewnętrzna: 1846mm"),
        },
        {
            "name": "outer_diameter",
            "widget": "TextInput",
            "label": _("Średnica zewnętrzna: 1876-1876.1mm"),
        },
        {
            "name": "surface_qualified",
            "type": "ChoiceField",
            "widget": "Select",
            "label": _("Kwalifikacja powierzchni"),
            "choices": [
                ("qualified", _("Zakwalifikowana")),
                ("unqualified", _("Niezakwalifikowana")),
            ],
        },
    ]
}

# Klasa formularza to jedna linia
class EndPlateLathQCForm(DynamicForm, QCValidationMixin):
    scheme = QC_ENDPLATE_LATHE_SCHEMA

Zebrane dane są przechowywane jako JSON w polu JSONField, zachowując zarówno etykiety pól, jak i wartości. Oznacza to, że rekord inspekcji jest samoopisujący się - można go odczytać bez znajomości tego, który schemat go wygenerował.

# Dane przechowywane jako samoopisujący się JSON
{
    "inner_diameter": {
        "label": "Średnica wewnętrzna: 1846mm",
        "value": "1846.02"
    },
    "surface_qualified": {
        "label": "Kwalifikacja powierzchni",
        "value": "qualified"
    }
}

Studium przypadku 2: back office w handlu finansowym

Drugi projekt pchnął ten wzorzec dalej. Back office do tradingu kryptowalut potrzebował formularzy dla kont, prowizji, profili ryzyka, statusu compliance, planów taryfowych - każdy z innymi polami, innymi uprawnieniami, innymi metodami przesyłania (POST przy tworzeniu, PATCH przy aktualizacjach masowych) oraz innymi kontekstami renderowania (pełna strona, modal, inline).

Tu blueprint wyrósł ponad definicje pól, stając się kompletną specyfikacją formularza:

ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
    "name": "account_create",
    "title": "Create Account",
    "column_count": 2,
    "modal_size": "xl",
    "method": "POST",
    "api_config": "account:accounts_v1",
    "success_message": "Account created successfully",
    "fields": [
        {
            "name": "currency",
            "type": "ChoiceField",
            "label": "Currency",
            "permission": "ACCOUNT_CURRENCY_UPDATE",
            "api_choices": "common:unified_search",
            "api_choices_args": ["assets-v1"],
            "api_choices_autocomplete": True,
            "row_class": "col-md-6",
        },
        {
            "name": "commission_rate",
            "type": "FloatField",
            "label": "Commission Rate (%)",
            "permission": "ACCOUNT_COMMISSION_UPDATE",
            "widget_attrs": {"step": "0.01", "min": "0"},
            "row_class": "col-md-6",
        },
    ],
}

Zauważ, co jest zakodowane w tym jednym słowniku: metoda HTTP, endpoint API, rozmiar okna modalnego, które pola wymagają których uprawnień, skąd załadować opcje listy rozwijanej, układ siatki Bootstrap oraz komunikat o powodzeniu. To całe doświadczenie formularza w czystych danych.

Widoczność pól zależna od uprawnień

Platforma finansowa dodała potężną funkcję: kontrolę uprawnień na poziomie pojedynczego pola. Każde pole w blueprincie może zadeklarować ciąg uprawnienia. Renderer formularza sprawdza uprawnienia bieżącego użytkownika i degraduje się z gracją:

class FormRenderer:
    def _apply_field_permissions(self, fields, user):
        user_perms = get_user_section_permissions(user)
        for field in fields:
            required_perm = field.get("permission")
            if required_perm and required_perm not in user_perms:
                field.setdefault("widget_attrs", {})
                field["widget_attrs"]["disabled"] = True
                field["widget_attrs"]["readonly"] = True

Użytkownicy bez uprawnienia ACCOUNT_COMMISSION_UPDATE widzą pole stawki prowizji - ale jest ono tylko do odczytu i wyszarzone. Żadnego osobnego szablonu, żadnych bloków {% if perms.x %}, żadnej logiki warunkowego renderowania. Blueprint deklaruje regułę, a renderer ją egzekwuje.

Wybory ładowane z API i autouzupełnianie

Statyczne listy wyboru sprawdzają się w rozwijanych menu typu "Qualified/Unqualified", ale selektor waluty musi pobierać dane z żywego API. Blueprint obsługuje to za pomocą dwóch właściwości:

  • api_choices - nazwa URL Django, która zwraca opcje
  • api_choices_autocomplete - włącza Select2 z wyszukiwaniem po stronie serwera

Silnik formularzy rozwiązuje nazwę URL, pobiera opcje w czasie renderowania i konfiguruje autouzupełnianie Select2, jeśli zostało skonfigurowane. Autor blueprintu nigdy nie pisze JavaScriptu - po prostu deklaruje, gdzie znajdują się dane.

Dlaczego to lepsze niż zakodowane na sztywno formularze

Po zbudowaniu dwóch dużych aplikacji z tym wzorcem potrafię dokładnie wyartykułować, co on daje:

  1. Dodanie formularza to dodanie danych, a nie kodu. Nowa inspekcja QC lub nowy typ konta to nowy słownik. Żadnej nowej klasy, żadnego nowego szablonu, żadnego nowego URL-a. System wykrywa go i renderuje.
  2. Zmiana formularza to zmiana w jednym miejscu. Błędna etykieta pola? Zmień słownik. Potrzebna nowa opcja listy rozwijanej? Zaktualizuj listę opcji. Chcesz uczynić pole opcjonalnym? Ustaw "required": False. Jeden plik, jedna zmiana, gotowe.
  3. Formularze stają się przenośne. Blueprint to po prostu słownik. Możesz go zserializować do JSON, zapisać w bazie danych, wczytać z pliku konfiguracyjnego lub przesłać przez API. Silnik formularzy nie dba o to, skąd wziął się schemat.
  4. Spójność jest automatyczna. Każdy formularz przechodzi przez ten sam pipeline renderowania. Atrybuty widgetów, klasy CSS, zachowanie układu — wszystkie są ustandaryzowane przez silnik, a nie kopiowane między klasami formularzy.
  5. Samoopisujące się przechowywanie danych. Gdy dane formularza są zapisywane jako JSON z etykietami i wartościami, zapisany rekord jest czytelny dla człowieka bez odwoływania się do schematu. Raporty i eksporty stają się trywialne.
  6. Miksiny walidacji komponują się czysto. Dynamiczny formularz to wciąż formularz Django. Możesz dodawać miksiny do walidacji specyficznej dla domeny (sprawdzanie istnienia obrabianego elementu, walidacja uprawnień) bez dotykania schematu.

Kiedy sięgnąć po ten wzorzec

Dynamiczne formularze nie zawsze są właściwym wyborem. Dla formularza logowania z dwoma polami ModelForm jest prostszy i czytelniejszy. Ale wzorzec błyszczy, gdy:

  • Masz wiele podobnych formularzy z różnymi zestawami pól (inspekcje QC, kroki onboardingu, strony ankiet)
  • Formularze muszą być konfigurowalne bez zmian w kodzie - przez administratorów, przez klientów lub przez systemy zewnętrzne
  • Potrzebujesz kontroli pól opartej na uprawnieniach w wielu formularzach bez powielania logiki szablonów
  • Dane formularza muszą być przechowywane jako samoopisujący się JSON, a nie w stałych kolumnach bazy danych
  • Budujesz platformę z wieloma najemcami, z których każdy potrzebuje nieco innych formularzy

Koszt początkowy to jedna klasa DynamicForm (około 50 linii) oraz dyscyplina definiowania schematów zamiast klas formularzy. Zyskiem jest system, w którym dodanie 21. formularza wymaga tyle samo wysiłku, co dodanie 2.

Zakodowany na sztywno formularz to migawka dzisiejszych wymagań. Schemat formularza to kontrakt, który ewoluuje. Napisz kontrakt.

Kontakt →