П'ять URL-патернів на все: як HTMX та абстрактні представлення усунули весь мій шар API
Що станеться, якщо довести Django-класові подання (class-based views) до їхньої логічної межі? Ви отримаєте п'ять URL-патернів, які забезпечують повний CRUD для кожної моделі у вашому застосунку - без REST framework, без серіалізаторів, без клієнтського роутера. Лише HTMX, конвенція замість конфігурації та філософське відкидання випадкової складності.
Уявний експеримент
Я хотів відповісти на питання, яке не давало мені спокою: наскільки далеко можна проштовхнути абстракцію Django, перш ніж вона зламається?
Не академічно - практично. Я побудував повноцінний застосунок для управління резюме з більш ніж 20 окремими моделями (досвід роботи, освіта, навички, сертифікації, публікації, нагороди, волонтерська робота, мови тощо) і кинув собі виклик обслуговувати все це з одного набору з п'яти URL-патернів.
Ніякого REST-фреймворку. Ніякого JSON API. Ніякого кроку збірки JavaScript. Ніякого React. Ніякого управління станом на боці клієнта. Лише Django, HTMX, Alpine.js і переконання, що більшість веб-застосунків надмірно ускладнені.
П'ять патернів
Увесь застосунок маршрутизується через ці п'ять URL-записів:
# Every model. Every operation. Five lines.
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 — усі вони розв'язуються через одні й ті самі п'ять в'ю. Параметр model_name — єдине, що змінюється.
Це не іграшка. Двадцять моделей із повними операціями створення, читання, оновлення, видалення — ізольовані за користувачем, автентифіковані, з кастомними формами там, де потрібно, та автоматично згенерованими формами там, де ні.
Як це працює: конвенція як конфігурація
Трюк до незручності простий. Кожне view розв'язує модель під час диспетчеризації, використовуючи вбудований реєстр застосунків 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)
Усю роботу виконують три конвенції:
- Виявлення моделі -
apps.get_model("nest", model_name)перетворює сегмент URL на клас моделі Django. Ні реєстру, ні конфігураційного файлу, ні декоратора. Якщо модель існує в застосунку, вона доступна. - Виявлення форми - оператор walrus перевіряє
globals()на наявність класу з назвою{ModelName}Form. Якщо знаходить його, використовує кастомну форму. Якщо ні, повертається до автоматичної генерації форми з атрибутаfieldsмоделі. - Ізоляція користувачів - кожен
form_validштампуєrequest.userна екземпляр, а коженget_querysetфільтрує за ним. Не потрібен жоден фреймворк дозволів - користувачі просто не можуть бачити дані одне одного.
Контракт моделі: один атрибут, що керує всім
Щоб модель могла брати участь у універсальній системі CRUD, їй потрібна рівно одна річ: атрибут класу fields, що перелічує редаговані поля.
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"]
Це весь контракт. Визначте свою модель, перелічіть поля - і загальна система бере на себе решту. Додавання нової сутності до застосунку - скажімо, Patent чи MilitaryService - це процес із трьох кроків:
- Напишіть модель з атрибутом
fields - Створіть шаблон деталей (розширює базову картку)
- Додайте його до циклу секцій на дашборді
Жодного нового URL. Жодного нового представлення. Жодного нового серіалізатора. Жодного нового API-ендпоінту. Жодної міграції шару маршрутизації. Система виявляє модель і обслуговує її.
HTMX: де живе магія
Причина, чому це працює, не відчуваючись як серверно-рендерений застосунок 2005 року, - це HTMX. Кожна CRUD-операція асинхронна, кожна взаємодія плавна, а сторінка ніколи не перезавантажується повністю.
Ключова техніка - Out-of-Band Swaps (hx-swap-oob). Коли ви натискаєте "Add Experience", HTMX отримує форму створення й вставляє її у визначений контейнер - не замінюючи кнопку, а націлюючись на div будь-де на сторінці за його ID.
<!-- Кнопка створення -->
<button hx-get="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML">
Додати досвід
</button>
<!-- Форма надсилається через HTMX, а не перезавантаженням сторінки -->
<form method="post">
{{ form.as_p }}
<button hx-post="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "..."}'>Створити</button>
<button hx-get="/clean-div/experience_section"
hx-target="#experience_section"
hx-swap="innerHTML">Скасувати</button>
</form>
<!-- Видалення повністю прибирає картку з DOM -->
<button hx-delete="/experience/42/delete"
hx-target="#experience_42"
hx-swap="delete">Видалити</button>
Кнопка скасування особливо елегантна - вона звертається до кінцевої точки /clean-div/{id}, яка повертає порожній <div>, фактично закриваючи модальне вікно, замінюючи його вміст на ніщо. Жодної логіки show/hide на JavaScript. Жодного перемикання класів CSS. Сервер повертає порожнечу, а HTMX кориться.
Alpine.js: тонкий шар оркестрації
Alpine.js виконує рівно одне завдання: яка секція дашборду відображається. Усе управління станом зводиться до однієї змінної:
<body x-data="{ openDiv: null }">
<!-- Перемикачі бічної панелі -->
<a @click="openDiv = openDiv === 'experience'
? null : 'experience'">Робота</a>
<a @click="openDiv = openDiv === 'education'
? null : 'education'">Освіта</a>
<!-- Секції з'являються/зникають із переходами -->
<div x-show="openDiv === 'experience'"
x-transition:enter="transition ease-out"
x-transition:leave="transition ease-in">
{% include "list.html" %}
</div>
</body>
Ось і все. Одна змінна, кілька обробників @click та x-show з переходами. Alpine.js не отримує дані, не керує формами й не відстежує стан. Він лише вирішує, яка панель видима. Усе інше обробляє HTMX.
Саме такий розподіл праці робить архітектуру чистою. Alpine володіє видимістю. HTMX володіє даними. Django володіє істиною. Ніхто нікому не наступає на п'яти.
Дворівнева система форм
Не кожну модель можна обслуговувати пласкою автозгенерованою формою. Освіта потребує автозаповнення школи з фільтрацією за країною. Досвід роботи потребує мультивибору навичок. Система обробляє це за допомогою елегантного дворівневого запасного варіанту:
- Рівень 1: Кастомні форми - якщо у просторі імен модуля існує
EducationForm, подання використовує її. Ці форми можуть мати кастомні віджети, кастомну валідацію, поля автозаповнення - усе, що потрібно моделі. - Рівень 2: Автозгенеровані форми - якщо кастомної форми немає, Django будує її зі списку
fieldsмоделі. Прості моделі на кшталтHobby(лише поле title) ніколи не потребують кастомної форми.
Механізм виявлення - це один рядок з оператором "морж":
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
Це convention over configuration у найчистішому вигляді. Немає реєстру форм, немає декоратора, немає словника налаштувань. Назвіть свою форму {Model}Form, розмістіть її там, де view її бачить, і система її підхопить. Не робіть нічого - і вона все одно працюватиме.
Філософська дискусія: випадкова vs сутнісна складність
Фред Брукс розрізняв суттєву складність (притаманну задачі) та випадкову складність (привнесену нашими інструментами й вибором). Більшість веб-застосунків потопають у випадковій складності.
Подумайте, чого вимагає типовий сучасний стек для однієї CRUD-операції:
- Django-модель
- Серіалізатор (DRF)
- Viewset або APIView
- URL-маршрут, зареєстрований у роутері
- TypeScript-інтерфейс, що дзеркалить серіалізатор
- React-компонент з useState, useEffect, викликами fetch
- Клієнтський маршрут у React Router
- Обробка помилок з обох боків
- Стани завантаження, оптимістичні оновлення, інвалідація кешу
Це дев'ять артефактів для однієї сутності. Помножте на двадцять моделей - і ви супроводжуєте 180 файлів, які мають лишатися синхронізованими між двома мовами, двома середовищами виконання та двома конвеєрами розгортання.
У цьому проєкті додавання моделі вимагає:
- Моделі (з атрибутом
fields) - Шаблону детального перегляду (10 рядків, що розширює базовий)
- Одного рядка в циклі дашборду
Три артефакти. Одна мова. Одне середовище виконання. Одне розгортання. Усе інше обробляється за конвенцією.
Де це ламається (і де ні)
Я не стверджую, що цей патерн замінює кожен SPA. Він ламається, коли:
- Потрібен offline-first - SSR з HTMX потребує сервера. PWA з IndexedDB - ні.
- Складні взаємодії на боці клієнта - канбан-дошка з drag-and-drop, редактор спільних документів або гра реального часу потребують багатого стану на клієнті.
- API - це і є продукт - якщо ви будуєте платформу, яку споживають мобільні застосунки та треті сторони, вам у будь-якому разі потрібен повноцінний шар API.
Але ось у чому річ: більшість CRUD-застосунків не є жодною з цих речей. Більшість бізнес-застосунків - це форми, списки та детальні перегляди з автентифікацією. Їм не потрібне 50-кілобайтне середовище виконання JavaScript, щоб відрендерити таблицю записів, яку сервер уже вміє рендерити.
Цей експеримент довів мені, що class-based views у Django в поєднанні зі здатністю HTMX змушувати відрендерений на сервері HTML відчуватися як SPA можуть обробити двадцять моделей з повним CRUD приблизно в 1500 рядках коду. Еквівалентний стек React + DRF був би в п'ять-десять разів більшим.
Чого я навчився
Створення цього проєкту змінило те, як я думаю про вебархітектуру:
- Конвенція перемагає конфігурацію - пошук форми через
globals()нетрадиційний, але він усунув цілий шар реєстру. Іноді найпростіше рішення є найкращим. - HTMX - це не компроміс - позасмугові заміни (out-of-band swaps), стратегії заміни (
delete,innerHTML,none) та тригери з дебаунсом дають вам 90% інтерактивності SPA за 10% складності. - Узагальнені в'юхи Django недооцінені -
CreateView,UpdateView,ListView,DeleteViewстворені саме для такого роду композиції. Фреймворк завжди був на це здатний; ми просто перестали до нього звертатися, коли з'явився React. - Патерн атрибута
fieldsнапрочуд потужний. Він одночасно виступає як список полів форми та як документаційний контракт того, що можна редагувати. Один атрибут, дві мети. - Alpine.js + HTMX - ідеальна пара - Alpine опікується суто клієнтськими аспектами (видимість, переходи), HTMX опікується комунікацією із сервером. Жоден не намагається виконувати роботу іншого.
Найкращий код — це код, який ви не пишете. Найкращий ендпоінт — це той, що обробляє кожну модель. Найкращий фреймворк — це той, що відступає вбік і дозволяє конвенції робити основну роботу.