Pięć wzorców URL na wszystko: jak HTMX i widoki abstrakcyjne wyeliminowały całą moją warstwę API
Co się dzieje, gdy doprowadzisz widoki klasowe Django do logicznego ekstremum? Dostajesz pięć wzorców URL obsługujących pełny CRUD dla każdego modelu w twojej aplikacji - bez REST frameworka, bez serializerów, bez routera po stronie klienta. Tylko HTMX, konwencja ponad konfiguracją i filozoficzne odrzucenie przypadkowej złożoności.
Eksperyment myślowy
Chciałem odpowiedzieć na pytanie, które nie dawało mi spokoju: jak daleko można napchać abstrakcję Django, zanim się załamie?
Nie akademicko - praktycznie. Zbudowałem pełną aplikację do zarządzania CV z ponad 20 odrębnymi modelami (doświadczenie, wykształcenie, umiejętności, certyfikaty, publikacje, nagrody, wolontariat, języki i więcej) i rzuciłem sobie wyzwanie, aby obsłużyć całość z jednego zestawu pięciu wzorców URL.
Bez REST frameworka. Bez API JSON. Bez kroku budowania JavaScriptu. Bez React. Bez zarządzania stanem po stronie klienta. Tylko Django, HTMX, Alpine.js i przekonanie, że większość aplikacji webowych jest dramatycznie przeinżynierowana.
Pięć wzorców
Cała aplikacja jest routowana przez te pięć wpisów URL:
# Każdy model. Każda operacja. Pięć linii.
urlpatterns = [
path("<str:model_name>/list",
ModelListView.as_view(), name="model_list"),
path("<str:model_name>/create",
ModelCreateView.as_view(), name="model_create"),
path("<str:model_name>/<int:pk>",
ModelDetailView.as_view(), name="model_detail"),
path("<str:model_name>/<int:pk>/update",
ModelUpdateView.as_view(), name="model_update"),
path("<str:model_name>/<int:pk>/delete",
ModelDeleteView.as_view(), name="model_delete"),
]
/experience/list, /education/create, /award/42/update, /hobby/7/delete — wszystkie rozwiązują się przez te same pięć widoków. Parametr model_name to jedyne, co się zmienia.
To nie zabawka. Dwadzieścia modeli z pełnym tworzeniem, odczytem, aktualizacją i usuwaniem — izolowane per użytkownik, uwierzytelnione, z niestandardowymi formularzami tam, gdzie są potrzebne, i automatycznie generowanymi tam, gdzie nie są.
Jak to działa: konwencja jako konfiguracja
Sztuczka jest wstydliwie prosta. Każdy widok rozwiązuje model w momencie dyspozycji, korzystając z wbudowanego rejestru aplikacji Django:
class ModelCreateView(CreateView, LoginRequiredMixin):
template_name = "create.html"
@staticmethod
def get_model(model_name):
try:
return apps.get_model("nest", model_name)
except LookupError:
return None
def dispatch(self, request, *args, **kwargs):
self.model_name = self.kwargs.get("model_name")
self.model = self.get_model(self.model_name)
# Convention: look for ModelNameForm in globals
if form_class := globals().get(
f"{self.model.__name__}Form"
):
self.form_class = form_class
else:
self.fields = self.model.fields
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
Całą robotę wykonują trzy konwencje:
- Odkrywanie modeli -
apps.get_model("nest", model_name)zamienia segment adresu URL w klasę modelu Django. Bez rejestru, bez pliku konfiguracyjnego, bez dekoratora. Jeśli model istnieje w aplikacji, jest dostępny. - Odkrywanie formularzy - operator morsa sprawdza
globals()w poszukiwaniu klasy o nazwie{ModelName}Form. Jeśli ją znajdzie, używa niestandardowego formularza. Jeśli nie, wraca do automatycznego generowania formularza na podstawie atrybutufieldsmodelu. - Izolacja użytkowników - każde
form_validodciskarequest.userna instancji, a każdeget_querysetfiltruje po nim. Nie jest potrzebny żaden framework uprawnień - użytkownicy po prostu nie mogą zobaczyć nawzajem swoich danych.
Kontrakt modelu: jeden atrybut, by wszystkimi rządzić
Aby model mógł uczestniczyć w generycznym systemie CRUD, potrzebuje dokładnie jednej rzeczy: atrybutu klasy fields wymieniającego edytowalne pola.
class Award(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
title = models.CharField(max_length=255)
awarded_by = models.CharField(max_length=255)
date_awarded = models.DateField()
fields = ["title", "awarded_by", "date_awarded"]
class VolunteerExperience(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
organization = models.CharField(max_length=255)
role = models.CharField(max_length=255)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
description = models.TextField()
fields = ["organization", "role", "start_date",
"end_date", "description"]
To cały kontrakt. Zdefiniuj swój model, wylistuj pola, a system generyczny zajmie się resztą. Dodanie nowej encji do aplikacji - powiedzmy Patent lub MilitaryService - to proces trzyetapowy:
- Napisz model z atrybutem
fields - Utwórz szablon szczegółów (rozszerza bazową kartę)
- Dodaj go do pętli sekcji dashboardu
Bez nowego URL. Bez nowego widoku. Bez nowego serializera. Bez nowego endpointu API. Bez migracji warstwy routingu. System odkrywa model i go serwuje.
HTMX: tam, gdzie dzieje się magia
Powodem, dla którego to działa, nie sprawiając wrażenia serwerowo renderowanej aplikacji z 2005 roku, jest HTMX. Każda operacja CRUD jest asynchroniczna, każda interakcja jest płynna, a strona nigdy nie przeładowuje się w całości.
Kluczową techniką są Out-of-Band Swaps (hx-swap-oob). Kiedy klikniesz "Add Experience", HTMX pobiera formularz tworzenia i podmienia go do wyznaczonego kontenera - nie przez zastąpienie przycisku, lecz przez wskazanie diva w dowolnym miejscu na stronie na podstawie jego ID.
<!-- The create button -->
<button hx-get="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML">
Add Experience
</button>
<!-- The form submits via HTMX, not a page reload -->
<form method="post">
{{ form.as_p }}
<button hx-post="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "..."}'>Create</button>
<button hx-get="/clean-div/experience_section"
hx-target="#experience_section"
hx-swap="innerHTML">Cancel</button>
</form>
<!-- Delete removes the card from DOM entirely -->
<button hx-delete="/experience/42/delete"
hx-target="#experience_42"
hx-swap="delete">Delete</button>
Przycisk anulowania jest szczególnie elegancki - odwołuje się do punktu końcowego /clean-div/{id}, który zwraca pusty <div>, skutecznie zamykając modal poprzez zastąpienie jego zawartości niczym. Bez logiki pokazywania/ukrywania w JavaScript. Bez przełączania klas CSS. Serwer zwraca pustkę, a HTMX się jej podporządkowuje.
Alpine.js: cienka warstwa orkiestracji
Alpine.js zajmuje się dokładnie jednym zadaniem: tym, która sekcja pulpitu jest widoczna. Całe zarządzanie stanem to pojedyncza zmienna:
<body x-data="{ openDiv: null }">
<!-- Sidebar toggles -->
<a @click="openDiv = openDiv === 'experience'
? null : 'experience'">Work</a>
<a @click="openDiv = openDiv === 'education'
? null : 'education'">Education</a>
<!-- Sections appear/disappear with transitions -->
<div x-show="openDiv === 'experience'"
x-transition:enter="transition ease-out"
x-transition:leave="transition ease-in">
{% include "list.html" %}
</div>
</body>
I to wszystko. Jedna zmienna, kilka handlerów @click oraz x-show z przejściami. Alpine.js nie pobiera danych, nie zarządza formularzami ani nie śledzi stanu. Decyduje jedynie, który panel jest widoczny. HTMX obsługuje całą resztę.
Ten podział pracy sprawia, że architektura jest czysta. Alpine odpowiada za widoczność. HTMX odpowiada za dane. Django odpowiada za prawdę. Nikt nikomu nie wchodzi w drogę.
Dwuwarstwowy system formularzy
Nie każdy model da się obsłużyć płaskim, automatycznie wygenerowanym formularzem. Wykształcenie potrzebuje autouzupełniania szkoły z filtrowaniem po kraju. Doświadczenie potrzebuje wielokrotnego wyboru umiejętności. System obsługuje to za pomocą eleganckiego dwupoziomowego mechanizmu awaryjnego:
- Poziom 1: Niestandardowe formularze - jeśli w przestrzeni nazw modułu istnieje
EducationForm, widok go używa. Te formularze mogą mieć niestandardowe widgety, niestandardową walidację, pola autouzupełniania - cokolwiek model potrzebuje. - Poziom 2: Automatycznie generowane formularze - jeśli nie istnieje żaden niestandardowy formularz, Django buduje go z listy
fieldsmodelu. Proste modele, takie jakHobby(tylko pole tytułu), nigdy nie potrzebują niestandardowego formularza.
Mechanizm wykrywania to pojedyncza linia z operatorem morsa:
if form_class := globals().get(f"{self.model.__name__}Form"):
self.form_class = form_class # custom form
else:
self.fields = self.model.fields # auto-generate
To konwencja ponad konfiguracją w najczystszej postaci. Nie ma rejestru formularzy, nie ma dekoratora, nie ma słownika ustawień. Nazwij swój formularz {Model}Form, umieść go tam, gdzie widok może go zobaczyć, a system go wychwyci. Nie rób nic, a mimo to zadziała.
Argument filozoficzny: złożoność przypadkowa kontra istotna
Fred Brooks rozróżniał złożoność istotną (nieodłączną od problemu) oraz złożoność przypadkową (wprowadzoną przez nasze narzędzia i wybory). Większość aplikacji webowych tonie w złożoności przypadkowej.
Rozważ, czego typowy nowoczesny stos wymaga dla jednej operacji CRUD:
- Model Django
- Serializator (DRF)
- Viewset lub APIView
- Trasa URL zarejestrowana w routerze
- Interfejs TypeScript odzwierciedlający serializator
- Komponent React z useState, useEffect, wywołaniami fetch
- Trasa po stronie klienta w React Routerze
- Obsługa błędów po obu stronach
- Stany ładowania, optymistyczne aktualizacje, unieważnianie pamięci podręcznej
To dziewięć artefaktów dla jednej encji. Pomnóż przez dwadzieścia modeli, a utrzymujesz 180 plików, które muszą pozostawać zsynchronizowane w dwóch językach, dwóch środowiskach uruchomieniowych i dwóch potokach wdrożeniowych.
W tym projekcie dodanie modelu wymaga:
- Modelu (z atrybutem
fields) - Szablonu widoku szczegółów (10 linii, rozszerzającego bazowy)
- Jednej linii w pętli dashboardu
Trzy artefakty. Jeden język. Jedno środowisko uruchomieniowe. Jedno wdrożenie. Cała reszta jest obsługiwana przez konwencję.
Gdzie to się załamuje (a gdzie nie)
Nie twierdzę, że ten wzorzec zastępuje każde SPA. Załamuje się, gdy:
- Wymagane jest podejście offline-first - SSR z HTMX potrzebuje serwera. PWA z IndexedDB nie.
- Złożone interakcje po stronie klienta - tablica Kanban z przeciąganiem i upuszczaniem, edytor dokumentów do współpracy lub gra w czasie rzeczywistym potrzebują bogatego stanu po stronie klienta.
- API jest produktem - jeśli budujesz platformę konsumowaną przez aplikacje mobilne i podmioty trzecie, i tak potrzebujesz porządnej warstwy API.
Ale rzecz w tym: większość aplikacji CRUD nie jest żadną z tych rzeczy. Większość aplikacji biznesowych to formularze, listy i widoki szczegółów z uwierzytelnianiem. Nie potrzebują 50-kilobajtowego środowiska uruchomieniowego JavaScriptu, by wyrenderować tabelę rekordów, którą serwer już umie wyrenderować.
Ten eksperyment udowodnił mi, że widoki oparte na klasach w Django, w połączeniu ze zdolnością HTMX-a do sprawienia, że renderowany na serwerze HTML sprawia wrażenie SPA, potrafią obsłużyć dwadzieścia modeli z pełnym CRUD-em w mniej więcej 1500 liniach kodu. Równoważny stos React + DRF byłby od pięciu do dziesięciu razy większy.
Czego się nauczyłem
Budowa tego projektu zmieniła moje myślenie o architekturze webowej:
- Konwencja bije konfigurację - wyszukiwanie formularza przez
globals()jest niekonwencjonalne, ale wyeliminowało całą warstwę rejestru. Czasem najprostsze rozwiązanie jest najlepsze. - HTMX to nie kompromis - podmiany out-of-band, strategie podmiany (
delete,innerHTML,none) oraz wyzwalacze z debounce dają ci 90% interaktywności SPA przy 10% złożoności. - Widoki generyczne Django są niedoceniane -
CreateView,UpdateView,ListView,DeleteViewzostały zaprojektowane dokładnie pod tego rodzaju kompozycję. Framework zawsze był do tego zdolny; po prostu przestaliśmy po to sięgać, gdy pojawił się React. - Wzorzec atrybutu
fieldsjest zaskakująco potężny. Pełni funkcję zarówno listy pól formularza, jak i kontraktu dokumentacyjnego określającego, co podlega edycji. Jeden atrybut, dwa cele. - Alpine.js + HTMX to idealne połączenie - Alpine obsługuje kwestie wyłącznie po stronie klienta (widoczność, przejścia), HTMX zajmuje się komunikacją z serwerem. Żadne z nich nie próbuje wykonywać pracy drugiego.
Najlepszy kod to kod, którego nie piszesz. Najlepszy endpoint to taki, który obsługuje każdy model. Najlepszy framework to taki, który schodzi z drogi i pozwala, by konwencja wykonała najcięższą pracę.