Django: Размещайте логику вне шаблонов (и представлений)
Когда я впервые начал увлекаться Django и веб-разработкой, хороший друг с немного большим опытом посоветовал мне не использовать логику в своих шаблонах «Шаблоны должны быть простыми».
Я действительно не понимал, что это значит, пока не начал страдать от последствий наличия логики в моих файлах .html. После трех лет работы с Django я теперь стараюсь держать бизнес-логику подальше не только от шаблонов, но и от представлений.
В этом посте я постепенно расскажу о наиболее рекомендуемых способах размещения бизнес логики и обрисую преимущества, которые предлагает каждый из них.
Приложение: простой блог
Давайте начнем с извлечения логики из шаблонов. Как и в случае с большинством реальных приложений, пусть наш выдуманный проект начнется с простого блога и позже постепенно начнет усложнятся.
Пусть у нас будет такая простая модель:
# models.py from django.db import models from django.utils import timezone class Post(models.Model): title = models.CharField(max_length=90, blank=False) content = models.TextField(blank=False) slug = models.SlugField(max_length=90) is_draft = models.BooleanField(default=True, null=False) is_highlighted = models.BooleanField(default=False) published_date = models.DateTimeField(default=timezone.now) likes = models.IntegerField(default=0) class Meta: ordering = ('-published_date',) def __str__(self): return self.title @property def is_in_past(self): return self.published_date < timezone.now()
Худший вариант: логика в шаблонах
Допустим на главной странице блога index.html мы хотим отобразить заголовки последних 10 постов и дату их публикации. Заголовок также должен быть ссылкой на представление post-detail, где будет отображаться контент поста.
Так же допустим нам нужно видеть наши черновики (не опубликованные статьи), чтобы просмотреть их до публикации и мы не хотим, чтобы они были видны другим посетителям.
# views.py def all_posts(request): context = {} posts = Post.objects.all()[:10] context['posts'] = posts return render(request, 'index.html', context)
{# index.html #} {% for post in posts %} {% if request.user.is_superuser %} <div class="post-section"> <h4> <a href="{% url 'post-detail' pk=post.id %}">{{ post.title }}</a> {% if post.is_draft %} <span class="alert alert-info small">Draft</span> {% endif %} {% if not post.is_in_past %} <span class="alert alert-info small">Future Post</span> {% endif %} <span class="text-muted"> Date: {{ post.published_date }}</span> </h4> </div> {% elif not request.user.is_superuser and not post.is_draft %} <div class="post-section"> <h4> <a href="{% url 'post-detail' pk=post.id %}">{{ post.title }}</a> </h4> <span class="text-muted"> Date: {{ post.published_date }}</span> </div> {% endif %} {% endfor %}
В index.html мы проверяем, является ли request.user администратором, и если так и есть, мы не фильтруем никаких сообщений. В блоке elif, который применяется ко всем остальным посетителям, мы проверяем свойство is_draft, равное False, перед отображением сообщения:
{% elif not request.user.is_superuser and not post.is_draft %}
Мы также добавляем некоторую разметку Bootstrap, чтобы администратор мог ясно видеть, является ли определенный пост черновиком или его публикация запланировано на будущее. Нам не нужна эта разметка для обычных посетителей, потому что они не должны видеть эти сообщения.
На самом деле подобный дизайн просто ужасен по нескольким причинам:
- Нет разделения интересов: почему шаблон решает, какие сообщения показывать?
- Нарушает принцип DRY (не повторяй себя): взгляни на тег span, содержащий дату. Мы повторяем его в обоих пунктах нашего оператора if.
- Многословие: наш index.html отображает только ссылки на наши сообщения, но он уже выглядит очень загроможденным.
- Читаемость и удобство обслуживания: шаблонизаторы Jinja/Django очень полезны и функциональны, но так же они известны загроможденным синтаксисом. Если вы вернетесь к этому шаблону через 6 месяцев, сможете ли вы быстро разобраться, что происходит? Вспомните ли вы, что если вы добавите div содержащий имя автора сообщения, вы должны будете сделать это в обоих пунктах if?
Лучший способ
Если вместо этого мы напишем как то так:
# views.py from django.utils import timezone def posts_index(request): context = {} limit = 10 posts = Post.objects.all()[:limit] if not request.user.is_superuser: # filter out drafts and future posts posts = Post.objects.filter(is_draft=False, published_date__lte=timezone.now())[:limit] context['posts'] = posts return render(request, 'index.html', context)
Тогда наш файл index.html будет выглядеть как то так:
{# index.html #} {% for post in posts %} <div class="post-section"> <h4> <a href="{% url 'post-detail' pk=post.id %}">{{ post.title }}</a> {% if post.is_draft %} <span class="alert alert-info small">Draft</span> {% endif %} {% if not post.is_in_past %} <span class="alert alert-info small">Future Post</span> {% endif %} </h4> <span class="text-muted"> Date: {{ post.published_date }}</span> </div> {% endfor %}
В этом случае мы разместили бизнес-логику вне шаблона. Основной шаблон должен отвечать только за отображение элементов.
Что мы получаем в этом случае:
- DRYness: мы больше не повторяем HTML для рендеринга поста.
- Возможность повторного использования: поскольку index.html больше не принимает решение о том, отображать ли сообщение, мы можем использовать его позже в других представлениях (например, в архиве).
- Удобочитаемость: теперь гораздо понятнее, что происходит в index.html, и его будет легче понять, когда мы вернемся к нему в будущем.
Так что это намного лучше и, вероятно, достаточно, если вы разрабатываете сверхпростое приложение. Но используя такой подход рано или поздно вы все равно начнете повторяться.
Пусть наш блог будет развиваться и допустим нам потребовалось создать новое представление featured_posts, в котором мы хотим отображать только сообщения, помеченные как выделенные, используя поле is_highlighted в модели.
def featured_posts(request): context = {} posts = Post.objects.filter(is_highlighted=True) if not request.user.is_superuser: posts = posts.filter(is_draft=False, published_date__lte=timezone.now()) context['posts'] = posts # we're free to use `index.html` here because our template is now re-usable return render(request, 'index.html', context)
Далее создадим еще одно представление, панель мониторинга, где мы отображаем последние 5 обычных сообщений и последние 5 выделенных сообщений (они могут перекрываться):
def dashboard(request): context = {} posts = Post.objects.all() limit = 10 posts_featured = Post.objects.filter(is_highlighted=True) if not request.user.is_superuser: posts = posts.filter(is_draft=False, published_date__lte=timezone.now()) posts_featured = posts_featured.filter(is_draft=False, published_date__lte=timezone.now()) context['last_posts'] = posts[:limit] context['last_posts_featured'] = posts_featured[:limit] return render(request, 'dashboard.html', context)
Мы уже видим тут две проблемы:
- Наш код становится все более детализированным, а в нем всего два поля для фильтрации. Представьте себе 3 или 4 (например, авторы и теги). В реальных приложениях у вас часто будет больше полей для фильтрации.
- Мы переносим детали реализации наших моделей в наши представления: теперь наше представление должно знать, что в наших моделях есть поле is_highlighted.
Что еще хуже, подумайте о том, что произойдет, если мы решим, что сообщения, размещаемые в разделе featured_posts, должны соответствовать еще двум критериям:
- is_published должно быть True
- счетчик likes должен быть не менее 3
Теперь нам нужно обновить код в двух наших представлениях, чтобы включить новые критерии:
Post.objects.filter(is_draft=False, is_highlighted=True, likes__gte=3)
Теперь представьте себе, какую работу вы должны выполнить, когда у вас есть 7 представлений и еще два критерия для фильтрации.
Еще более лучшие способы
Есть два способа сделать это лучше. Мы начнем с первого, который считается менее традиционным и менее естественным, но отлично справляется, если вам нужно что-то быстрое (хотя может быть и более грязное).
Методы класса
class Post(models.Model): # ... @classmethod def published(cls): """ :return: published posts only: no drafts and no future posts """ return cls.objects.filter(is_draft=False, published_date__lte=timezone.now()) @classmethod def featured(cls): """ :return: featured posts only """ return cls.objects.filter(is_highlighted=True)
Мы добавили в модель два метода, которые мы можем использовать в наших представлениях следующим образом:
# notice: no .objects because it's model/class method published_posts = Post.published() featured_posts = Post.featured() published_and_featured = Post.published() & Post.featured()
Посмотрите, насколько чище становится наша панель с этими изменениями:
def dashboard(request): context = {} posts = Post.objects.all() limit = 10 posts_featured = Post.featured() if not request.user.is_superuser: posts = posts & Post.published() posts_featured = posts_featured & Post.published() context['last_posts'] = posts[:limit] context['last_posts_featured'] = posts_featured[:limit] return render(request, 'dashboard.html', context)
Более того, изменить наши критерии для того, что считается «featured» публикацией, так же просто, как изменить одну строку в Post.featured():
class Post(model.Model): # ... @classmethod def featured(cls): """ :return: highlighted posts with at least 3 likes """ return cls.objects.filter(is_highlighted=True, likes__gte=3)
Теперь все представления, которые вызывают этот метод модели, будут обновлены соответствующим образом.
Так что это довольно просто, но, как я писал, считается менее общепринятым в сообществе Django. Главное ограничение модельных методов заключается в том, что они не имеют возможности выстраивать цепочку вызовов:
# пытаемся построить цепочку из двух методов >>> posts_featured_published = Post.featured().published() 'QuerySet' object has no attribute 'published'
Вот почему мы использовали логический оператор AND (&):
# использование '&' для фильтрации queryset posts_featured_published = Post.featured() & Post.published()
Таким образом, использование методов модели решает многие недостатки предыдущего метода, но есть и лучший способ.
Индивидуальные менеджеры моделей
Я не буду вдаваться в подробности о менеджерах (managers) и querysets, поскольку это выходит за рамки этого поста. Давайте избавимся от наших методов модели и определим наш файл models.py следующим образом:
class PostQuerySet(models.QuerySet): def published(self): return self.filter(is_draft=False, published_date__lte=timezone.now()) def featured(self): return self.filter(is_highlighted=True) # Create your models here. class Post(models.Model): title = models.CharField(max_length=90, blank=False) content = models.TextField(blank=False) slug = models.SlugField(max_length=90) is_draft = models.BooleanField(default=True, null=False) is_highlighted = models.BooleanField(default=False) published_date = models.DateTimeField(default=timezone.now) likes = models.IntegerField(default=0) # use PostQuerySet as the manager for this model objects = PostQuerySet.as_manager() class Meta: ordering = ('-published_date',) def __str__(self): return self.title @property def is_in_past(self): return self.published_date < timezone.now()
Следует отметить поле objects, которое мы добавили в Post, которое инструктирует эту модель использовать PostQuerySet в качестве менеджера.
Теперь рассмотрим наше представление панели инструментов:
def dashboard(request): context = {} posts = Post.objects.all() limit = 10 posts_featured = Post.objects.featured() if not request.user.is_superuser: posts = posts.published() posts_featured = posts_featured.published() context['last_posts'] = posts[:limit] context['last_posts_featured'] = posts_featured[:limit] return render(request, 'dashboard.html', context)
Обратите внимание на то, что эти два метода менеджера теперь связаны друг с другом:
>>> posts_featured_published = Post.objects.featured().published() <PostQuerySet [<Post: ...>, <Post: ...>]>
Используя PostQuerySet в файле models.py, мы расширяем имеющиеся у нас методы менеджера, поэтому наряду с get, filter, aggregate и т. д. теперь мы имеем published и featured.
Преимущества использования менеджеров моделей перед методами классов:
- Цепочность и ясность: Post.objects.featured().published() выглядит более питонным и естественным, чем Post.featured() & Post.published().
- Возможность повторного использования: во многих случаях вы можете повторно использовать один и тот же менеджер для более чем одной модели. Возможно, в будущем вы создадите модель ShortNote, и вы сможете управлять ей с помощью того же PostQuerySet. При использовании методов модели вам придется переопределить пользовательские фильтры внутри вашей модели ShortNote.
Есть еще несколько преимуществ, таких как возможность определения нескольких менеджеров в одной модели, но они выходят за рамки этого поста.
Заключение
Держите логику в стороне от шаблонов любой ценой, постарайтесь, чтобы в ваших представлениях было как можно меньше бизнес логики а была только логика построения отображения данных. Если вам нужно что-то быстрое и простое, используйте модельные методы, но старайтесь чаще использовать менеджеры моделей.
Оригинал: Django: Keeping logic out of templates (and views)
Замечательная статья!
Да, было бы классно если бы методы модели (их результаты)можно было использовать прямо в queryset
Ещё лучше сделать Rest API