Команда Python for Devs подготовила перевод статьи о том, как автор выбирает способ написания представлений в Django. Он считает, что обобщённые классовые представления (CBV) скрывают слишком много магии, усложняют чтение кода и отладку. Вместо них он использует базовый View, чтобы сохранять контроль, но при этом избегать громоздких if в функциях.


Когда изучаете Django, одна из первых серьезных развилок — как писать представления. Django предлагает два основных подхода: простые функции или мощные классы. Официальный туториал сначала аккуратно знакомит с представлениями на функциях.

def index(request):
    return HttpResponse("Hello, world. You're at the polls index.")

Затем становится немного сложнее, но все еще используются представления на функциях:

def index(request):
    latest_question_list = Question.objects.order_by("-pub_date")[:5]
    context = {"latest_question_list": latest_question_list}
    return render(request, "polls/index.html", context)

Но совсем скоро он переходит к обобщенным классовым представлениям (CBV):

class IndexView(generic.ListView):
    template_name = "polls/index.html"
    context_object_name = "latest_question_list"

    def get_queryset(self):
        """Вернуть последние пять опубликованных вопросов."""
        return Question.objects.order_by("-pub_date")[:5]

Я считаю, что это ошибка. В Django очень много обобщенных представлений: View, TemplateView, DetailView, ListView, FormView, CreateView, DeleteView, UpdateView, RedirectView, а еще целая россыпь представлений, завязанных на даты: ArchiveIndexView, YearArchiveView, MonthArchiveView, WeekArchiveView, DayArchiveView, TodayArchiveView, DateDetailView.

Самая большая проблема, которую я вижу в этих представлениях, — их скрытая сложность. Достаточно взглянуть на документацию DetailView. Чтобы понять работу одного этого класса, нужно разобраться в его дереве наследования:

django.views.generic.detail.SingleObjectTemplateResponseMixin
django.views.generic.base.TemplateResponseMixin
django.views.generic.detail.BaseDetailView
django.views.generic.detail.SingleObjectMixin
django.views.generic.base.View

А затем нужно знать порядок разрешения методов (MRO) и то, какие вызовы происходят внутри. «Диаграмма методов» включает:

setup()
dispatch()
http_method_not_allowed()
get_template_names()
get_slug_field()
get_queryset()
get_object()
get_context_object_name()
get_context_data()
get()
render_to_response()

Это 11 методов, разбросанных по 5 классам и примесям (mixins). Отладка такого представления или попытка понять, какой именно метод нужно переопределить, чтобы изменить его поведение, быстро превращается в бесконечное открывание файлов и прыжки между определениями методов. Это слишком стрёмно.

Предполагаемое преимущество заключается в упрощении кода и уменьшении его объема, но на практике для простых представлений это почти не экономит строчек, а для сложных вы чаще всего боретесь с поведением, которое задано по умолчанию в обобщённых представлениях.

Документации по всем этим классам и примесям (mixins) так много, что проще не становится. Именно поэтому мне так близка позиция, которую озвучивает Люк Плант в статье Django Views — The Right Way. Он призывает использовать представления на функциях во всех случаях. Вот как он объясняет свою точку зрения:

Одна из причин, по которой я рекомендую такой подход, — он даёт отличную отправную точку для любых задач. Тело представления — функция, которая принимает запрос и возвращает ответ — находится прямо перед глазами… Если разработчик понимает, что такое представление, он, скорее всего, сразу догадается, какой код нужно написать. Структура кода не станет препятствием. С CBV всё иначе: как только появляется какая-то логика, нужно знать, какие методы или атрибуты определять, а это значит разбираться в огромном API.

Это отличный гайд, который показывает, как привычные паттерны CBV можно реализовать более явно и зачастую лаконичнее с помощью функций. Очень рекомендую его прочитать.

Однако в своих проектах я иду немного другим путем: использую только базовый класс View. Я избегаю и представлений на функциях, и сложных обобщённых классовых представлений. Для меня это идеальная золотая середина. Такой подход даёт чистую организацию кода по методам запроса (getpostput и т.д.) и автоматически обрабатывает ответ 405 Method Not Allowed.

То есть вместо представления на функции с большим блоком if:

def comment_form_view(request, post_id):
    post = get_object_or_404(Post, pk=post_id)

    if request.method == "POST":
        form = CommentForm(data=request.POST)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.post = post
            comment.save()
            return redirect(post)  # assumes Post has get_absolute_url()
    else:
        form = CommentForm()

    return TemplateResponse(request, "form.html", {"form": form, "post": post})

я пишу так:

class CommentFormView(View):
    def get(self, request, post_id, *args, **kwargs):
        post = get_object_or_404(Post, pk=post_id)
        form = CommentForm()
        return TemplateResponse(request, "form.html", {"form": form, "post": post})

    def post(self, request, post_id, *args, **kwargs):
        post = get_object_or_404(Post, pk=post_id)
        form = CommentForm(data=request.POST)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.post = post
            comment.save()
            return redirect(post)
        
        return TemplateResponse(request, "form.html", {"form": form, "post": post})

Хотя версия на классе получается на несколько строк длиннее, разделение логики GET и POST выглядит куда чище, чем вложение основной обработки POST внутрь if request.method == "POST".

Вы могли заметить небольшое дублирование: get_object_or_404 вызывается и в get, и в post. «Книжный» способ решить это при использовании базового класса View — переопределить метод dispatch. Он выполняется до вызова get или post, поэтому логично поместить туда подготовительную логику:

class CommentFormView(View):
    def dispatch(self, request, post_id, *args, **kwargs):
        self.post_obj = get_object_or_404(Post, pk=post_id)
        return super().dispatch(request, *args, **kwargs)

    def get(self, request, *args, **kwargs):
        form = CommentForm()
        return TemplateResponse(request, "form.html", {"form": form, "post": self.post_obj})

    def post(self, request, *args, **kwargs):
        form = CommentForm(data=request.POST)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.post = self.post_obj
            comment.save()
            return redirect(self.post_obj)
        
        return TemplateResponse(request, "form.html", {"form": form, "post": self.post_obj})

Однако в собственном коде я почти не использую этот приём — он кажется мне немного «магическим». Вместо явного вызова метода мы полагаемся на ещё одну особенность реализации View, о которой нужно помнить.

В простых случаях вроде этого небольшое дублирование, как ни странно, чаще всего оказывается самым понятным вариантом. Всё явно, и не требуется никакого умственного усилия, чтобы понять, что происходит в getи post. Если подготовительная логика становится сложнее или появляется общий контекст с большим числом переменных, то вместо dispatch я выношу её в простой вспомогательный метод и вызываю его из обоих мест. Так сохраняется явный контроль потока выполнения.

class CommentFormView(View):
    def get_shared_context(self, request, post_id):
        # Представим, что здесь возвращается не только одна переменная post 😅
        post = get_object_or_404(Post, pk=post_id)
        return {"post": post}

    def get(self, request, post_id, *args, **kwargs):
        form = CommentForm()
        context = self.get_shared_context(request, post_id) | {"form": form}
        return TemplateResponse(request, "form.html", context)

    def post(self, request, post_id, *args, **kwargs):
        form = CommentForm(data=request.POST)
        context = self.get_shared_context(request, post_id) | {"form": form}
        post = context["post"]

        if form.is_valid():
            comment = form.save(commit=False)
            comment.post = post
            comment.save()
            return redirect(post)
        
        return TemplateResponse(request, "form.html", context)

Для меня это идеальный вариант. Мы устранили дублирование кода, но сделали это максимально явно. Методы get и post полностью контролируют процесс, нет никакого «магического» состояния, которое где-то устанавливается за кулисами. Получается простота и прозрачность функций, но с лучшей организацией кода, автоматической обработкой HTTP-методов и возможностью разделять общую логику так, как удобно нам.

И да, в самой базовой форме FormView в Django короче:

class CommentFormView(FormView):
    template_name = "form.html"
    form_class = CommentForm

    def form_valid(self, form):
        post = get_object_or_404(Post, pk=self.kwargs["post_id"])
        comment = form.save(commit=False)
        comment.post = post
        comment.save()
        return redirect(post)

Но стоит только захотеть добавить свою логику в обработку GET (например, расширить контекст), по-разному обрабатывать результаты POST или настроить обработку ошибок — и вы быстро приходите к переопределению множества методов. В этот момент снова приходится разбирать внутренности фреймворка, и первоначальная краткость оборачивается сложностью. Мой подход держит всю логику прямо перед глазами — каждый раз.

Русскоязычное сообщество про Python

Друзья! Эту статью перевела команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!