17.07.2021       Выпуск 395 (12.07.2021 - 18.07.2021)       Статьи

Как работают Django Class-based views

Для новичка, который осваивает Django, представления на основе классов больше похожи на магию чёрного ящика, по крайней мере, у меня при первом знакомстве сложилось именно такое впечатление. Обильные руководства зачастую показывают, какие атрибуты и методы следует определить в вашем классе, чтобы этот ящик работал на вас, но не дают понимания принципа работы.Я хочу залезть под капот фреймворка и строчка за строчкой разобрать, как же работают представления на основе классов. Надеюсь, что по прочтении, Class-based views уже не будут казаться такими пугающими и я подстегну вас к дальнейшему самостоятельному изучению исходников. Возможно, вы думали о фреймворке как о некой магии, которую невозможно понять, но на самом деле это обычный код, написанный опытными разработчиками.

Читать>>




Экспериментальная функция:

Ниже вы видите текст статьи по ссылке. По нему можно быстро понять ссылка достойна прочтения или нет

Просим обратить внимание, что текст по ссылке и здесь может не совпадать.

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

Я хочу залезть под капот фреймворка и строчка за строчкой разобрать, как же работают представления на основе классов. Надеюсь, что по прочтении, Class-based views уже не будут казаться такими пугающими и я подстегну вас к дальнейшему самостоятельному изучению исходников. Возможно, вы думали о фреймворке как о некой магии, которую невозможно понять, но на самом деле это обычный код, написанный опытными разработчиками.


Background

Я предполагаю, что у читателя есть базовое представление об ООП в Python и опыт создания проекта на Django. Для более комфортного чтения рекомендую также познакомиться со следующими темами:

В качестве представления на основе класса я возьму абстрактный AboutView, наследника TemplateView, и на этом примере рассмотрю жизнь класса: классы-предки, формирование функции конструктора, вызов из URLConf, примеси и внутреннюю логику работы.

 class AboutView(TemplateView):
    template_name = "about.html"

Несмотря на то, что я старался максимально подкрепить мыслительный процесс исходным кодом, я понимаю, что держать в голове нить повествования может быть сложно. Для облегчения задачи я создал блок-схему, на которой можно увидеть все атрибуты и методы класса, откуда они наследуются, и окинуть процесс взаимодействия методов между собой одним взглядом.

Блок-схема

0. URL dispatcher

Работу с представлениями в Django инициализирует диспетчер URL: он сопоставляет запрошенный URL c шаблонами и, находя совпадения, вызывает указанное представление, которому передаётся экземпляр HttpRequest и аргументы, если они имеются в шаблоне.

Давайте посмотрим, как бы выглядел urlpatterns на основе привычного Function-based View (FBV) и на Class-based View (CBV):

urlpatterns = [
    # Function-based View
    path('about/', about),

    # Class-based View
    path('about/', AboutView.as_view()),
]

Сразу бросается в глаза отличие: в FBV мы передаём объект функции about, а не about(), потому что не хотим вызвать её (это будет делать диспетчер). А в CBV — метод класса .as_view(), который вызывается нами. Но этот метод в нашем классе мы не определяли, и в классе непосредственного предка его тоже нет:

Исходный код класса TemplateView
class TemplateView(TemplateResponseMixin, ContextMixin, View):
    """
    Render a template. Pass keyword arguments from the URLconf to the context.
    """
    def get(self, request, *args, **kwargs):
        context = self.get_context_data(**kwargs)
        return self.render_to_response(context)

Откуда же он взялся? Для ответа на этот вопрос следует рассмотреть всю иерархию наследования AboutView.

1. Class View

CBV могут наследоваться от множества классов и миксинов, но все CBV берут начало от класса View. Наш класс не исключение:

Иерархия наследования класса AboutView
Иерархия наследования класса AboutView

Давайте посмотрим на исходный код View:

Исходный код класса View
class View:
    """
    Intentionally simple parent class for all views. Only implements
    dispatch-by-method and simple sanity checking.
    """

    http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']

    def __init__(self, **kwargs):
        """
        Constructor. Called in the URLconf; can contain helpful extra
        keyword arguments, and other things.
        """
        # Go through keyword arguments, and either save their values to our
        # instance, or raise an error.
        for key, value in kwargs.items():
            setattr(self, key, value)

    @classonlymethod
    def as_view(cls, **initkwargs):
        """Main entry point for a request-response process."""
        for key in initkwargs:
            if key in cls.http_method_names:
                raise TypeError(
                    'The method name %s is not accepted as a keyword argument '
                    'to %s().' % (key, cls.__name__)
                )
            if not hasattr(cls, key):
                raise TypeError("%s() received an invalid keyword %r. as_view "
                                "only accepts arguments that are already "
                                "attributes of the class." % (cls.__name__, key))

        def view(request, *args, **kwargs):
            self = cls(**initkwargs)
            self.setup(request, *args, **kwargs)
            if not hasattr(self, 'request'):
                raise AttributeError(
                    "%s instance has no 'request' attribute. Did you override "
                    "setup() and forget to call super()?" % cls.__name__
                )
            return self.dispatch(request, *args, **kwargs)
        view.view_class = cls
        view.view_initkwargs = initkwargs

        # take name and docstring from class
        update_wrapper(view, cls, updated=())

        # and possible attributes set by decorators
        # like csrf_exempt from dispatch
        update_wrapper(view, cls.dispatch, assigned=())
        return view

    def setup(self, request, *args, **kwargs):
        """Initialize attributes shared by all view methods."""
        if hasattr(self, 'get') and not hasattr(self, 'head'):
            self.head = self.get
        self.request = request
        self.args = args
        self.kwargs = kwargs

    def dispatch(self, request, *args, **kwargs):
        # Try to dispatch to the right method; if a method doesn't exist,
        # defer to the error handler. Also defer to the error handler if the
        # request method isn't on the approved list.
        if request.method.lower() in self.http_method_names:
            handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
        else:
            handler = self.http_method_not_allowed
        return handler(request, *args, **kwargs)

    def http_method_not_allowed(self, request, *args, **kwargs):
        logger.warning(
            'Method Not Allowed (%s): %s', request.method, request.path,
            extra={'status_code': 405, 'request': request}
        )
        return HttpResponseNotAllowed(self._allowed_methods())

    def options(self, request, *args, **kwargs):
        """Handle responding to requests for the OPTIONS HTTP verb."""
        response = HttpResponse()
        response.headers['Allow'] = ', '.join(self._allowed_methods())
        response.headers['Content-Length'] = '0'
        return response

    def _allowed_methods(self):
        return [m.upper() for m in self.http_method_names if hasattr(self, m)]

Для новичка выглядит устрашающе, но мы пойдем по пути, которым следует Django.

Поскольку в URLConf вызывается метод as_view(), примем его за отправную точку. Согласно правилам наследования, если метод не найден в текущем классе, поиск будет продолжен далее по списку MRO и в конце концов он будет обнаружен в классе View:

    @classonlymethod
    def as_view(cls, **initkwargs):
        """Main entry point for a request-response process."""
        for key in initkwargs:
            if key in cls.http_method_names:
                raise TypeError(
                    'The method name %s is not accepted as a keyword argument '
                    'to %s().' % (key, cls.__name__)
                )
            if not hasattr(cls, key):
                raise TypeError("%s() received an invalid keyword %r. as_view "
                                "only accepts arguments that are already "
                                "attributes of the class." % (cls.__name__, key))

        def view(request, *args, **kwargs):
            # здесь был код функции view, который нам пока не нужен
            
        view.view_class = cls
        view.view_initkwargs = initkwargs

        # take name and docstring from class
        update_wrapper(view, cls, updated=())

        # and possible attributes set by decorators
        # like csrf_exempt from dispatch
        update_wrapper(view, cls.dispatch, assigned=())
        return view

В сигнатуре метода есть интересные детали, которые заслуживают отдельного внимания: во‑первых, это декоратор @classonlymethod из django.utils.decorators — потомок builtins.classmethod. Из названия можно догадаться, что декоратор гарантирует вызов только из класса. При вызове метода из экземпляра возбуждается исключение AttributeError.

Во‑вторых, привычные **kwargs заменены на **initkwargs — это сделано неспроста. Если бросить взгляд ниже, то можно увидеть, что внутренняя функция view тоже принимает **kwargs параметр. Если бы имя параметра не было изменено, внутренний **kwargs затенил бы внешний, полученный за счёт замыкания. В конечном счёте имя не играет никакой роли, главное здесь оператор распаковки ** — именно он преобразует все именованные параметры в словарь .

Что может получить функция в **initkwargs? По задумке создателей Django, в as_view можно сразу передавать необходимые атрибуты и тем самым избежать создания класса. Для примера, наш класс AboutView можно было заменить следующим выражением:

path('about/', TemplateView.as_view(template_name="about.html"))

В этом случае initkwargs = {'template_name': 'about.html'}.

Смотрим код дальше. В цикле for перебираются ключи initkwargs, которые проходят две проверки:

for key in initkwargs:
    if key in cls.http_method_names:
        raise TypeError(
            'The method name %s is not accepted as a keyword argument '
            'to %s().' % (key, cls.__name__)
        )

Первая позволяет убедиться в том, что ни один из переданных аргументов не использует HTTP‑глагол в качестве имени (они перечислены в атрибуте http_method_names класса View)

http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']

Дело в том, что методы класса, которые названы в честь HTTP‑глаголов, как раз и будут отвечать за их обработку (т.е. метод get() будет отвечать за запрос c методом GET), мы это ещё увидим позднее.

Вторая проверка разрешает нам переопределять только известные атрибуты и методы класса:

    if not hasattr(cls, key):
        raise TypeError("%s() received an invalid keyword %r. as_view "
                        "only accepts arguments that are already "
                        "attributes of the class." % (cls.__name__, key))

То есть Python по списку MRO собирает все методы и атрибуты, определённые в нашем классе, и, если ключ не совпадает ни с одним из них, возбуждает исключение TypeError.

Далее по коду мы встречаемся с функцией view(), подробности реализации которой нам сейчас не важны, мы вернёмся к ней позже. На данном этапе достаточно понимания того, что в локальном пространстве имён появляется view с типом function.

В следующих двух строках мы добавляем к view два атрибута: .view_class, который содержит ссылку на наш класс AboutView и .view_initkwargs, который содержит словарь initkwargs:

view.view_class = cls
view.view_initkwargs = initkwargs

и под конец вызываются два wrapper-метода:

# take name and docstring from class
update_wrapper(view, cls, updated=())

# and possible attributes set by decorators
# like csrf_exempt from dispatch
update_wrapper(view, cls.dispatch, assigned=())

Те, кто знакомы с декораторами уже заметили, что функция as_view как раз им и является, оборачивая и возвращая функцию view. Врапперы — обычное дело для декораторов, они позволяют заменить метаданные оборачиваемой функции. Если вы посмотрите код враппера, то увидите, что атрибуты __module__, __name__, __qualname__, __doc__, __annotations__ и __dict__ копируются из класса AboutViewи метода .dispatch в функцию View.

WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
                       '__annotations__')
WRAPPER_UPDATES = ('__dict__',)

def update_wrapper(wrapper,
                   wrapped,
                   assigned = WRAPPER_ASSIGNMENTS,
                   updated = WRAPPER_UPDATES):
    for attr in assigned:
        try:
            value = getattr(wrapped, attr)
        except AttributeError:
            pass
        else:
            setattr(wrapper, attr, value)
    for attr in updated:
        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
    wrapper.__wrapped__ = wrapped
    return wrapper

Наконец, мы заканчиванием нашу работу в as_view и на выходе получаем нашпигованный и подготовленный объект функции view, который и возвращаем в URLConf. То есть сейчас URLConf можно было бы представить следующим образом:

# Class-based View
path('about/', <function AboutView>)

, что делает его очень похожим на обычный FBV.

Теперь предположим, что пользователь запросил у нашего сервера web-страницу, диспетчер URL сопоставил паттерн и вызывает нашу функцию view...

2. View function

Если описать коротко, то именно функция view является главным дирижёром: она создаёт экземпляр класса нашего представления, запускает внутреннюю логику и в итоге возвращает response. Взглянем на код:

def view(request, *args, **kwargs):
    self = cls(**initkwargs)
    self.setup(request, *args, **kwargs)
    if not hasattr(self, 'request'):
        raise AttributeError(
            "%s instance has no 'request' attribute. Did you override "
            "setup() and forget to call super()?" % cls.__name__
        )
    return self.dispatch(request, *args, **kwargs)

Функция принимает три аргумента. Первым передаётся request в виде экземпляра класса WSGIRequest, далее идут аргументы *args и **kwargs, которые содержат именованные аргументы, захваченные из URL адреса . Вот пример того, что может получить функция:

path('articles/<slug:title>/<int:section>/', views.section, )
# Запрос URL www.smth.ru/articles/foobar/3
# вернёт kwargs = {'title': 'foobar', 'section': 3}

Здесь важно не перепутать **kwargs с**initkwargs, которые доступны функции через замыкание: в **initkwargs мы получаем переопределённые атрибуты для экземпляра класса, а в **kwargs — параметры из URL адреса.

Первое что делает функция view — вызывает класс, передавая ему **initkwargs, тем самым создавая его экземпляр.

self = cls(**initkwargs)

Не стоит смущаться названия переменной self, обычно мы привыкли видеть её в параметрах методов. Здесь это просто переменная, что появляется в локальной области видимости, и которой мы присваиваем ссылку на экземпляр класса.

Создавая экземпляр, мы неявно вызываем инициализатор класса __init__,

def __init__(self, **kwargs):
    """
    Constructor. Called in the URLconf; can contain helpful extra
    keyword arguments, and other things.
    """
    # Go through keyword arguments, and either save their values to our
    # instance, or raise an error.
    for key, value in kwargs.items():
        setattr(self, key, value)

который, проходя по словарю **kwargs (но мы-то помним, что на самом деле он **initkwargs:), создаёт атрибуты экземпляра.

Теперь у нас в переменной self сохранён экземпляр класса AboutView.

После вызывается метод setup, который принимает аргументы, переданные view (те, что захватили из URL), и сохраняет их в экземпляре класса, что делает их доступными для всех других методов экземпляра:

def setup(self, request, *args, **kwargs):
    """Initialize attributes shared by all view methods."""
    if hasattr(self, 'get') and not hasattr(self, 'head'):
        self.head = self.get
    self.request = request
    self.args = args
    self.kwargs = kwargs

Помимо этого он проверяет, определены ли у экземпляра обработчики методов GET и HEAD: если self.get определён, а self.head нет, то метод создаёт атрибут self.head и перенаправляет на self.get.

После вызова setup() в функции view() выполняется еще одна проверка, чтобы убедиться, что у self есть атрибут запроса:

if not hasattr(self, 'request'):
    raise AttributeError(
        "%s instance has no 'request' attribute. Did you override "
        "setup() and forget to call super()?" % cls.__name__
    )

Сообщение об ошибке даёт нам объяснение, зачем эта проверка нужна: метод setup() может быть переопределён, создавая вероятность того, что атрибут request может быть забыт.

По итогу функция возвращает нам результат выполнения следующего метода - dispatch().

return self.dispatch(request, *args, **kwargs)

3. dispatch()

Работа метода dispatch() отражает его название:

def dispatch(self, request, *args, **kwargs):
    # Try to dispatch to the right method; if a method doesn't exist,
    # defer to the error handler. Also defer to the error handler if the
    # request method isn't on the approved list.
    if request.method.lower() in self.http_method_names:
        handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
    else:
        handler = self.http_method_not_allowed
    return handler(request, *args, **kwargs)

Вначале мы берём HTTP-метод из запроса (request.method), приводим его к нижнему регистру и проверяем, а метод ли он, сравнивая с уже знакомым списком разрешённых методов из http_method_names.

Если метод не найден в атрибуте http_method_names (условие else) или в экземпляре и его родителях (третий аргумент функции getattr), вызывается http_method_not_allowed , определённый в классе View:

def http_method_not_allowed(self, request, *args, **kwargs):
    logger.warning(
        'Method Not Allowed (%s): %s', request.method, request.path,
        extra={'status_code': 405, 'request': request}
    )
    return HttpResponseNotAllowed(self._allowed_methods())

http_method_not_allowed возвращает HttpResponseNotAllowed со списком методов, которые мы определили для нашего класса.

Если же такой глагол нашёлся, диспетчер назначает соответствующий метод переменной handler (для OPTIONS → options,для GET → get, для POST → post).

В конце работы dispatch() возвращает нам результат выполнения handler(), что, исходя из вышесказанного, вернёт нам либо HttpResponse с кодом 405, либо результат нашего определённого метода.

Если мы вернёмся и посмотрим полный код класса View, то увидим, что в нём предопределён только один метод options().

    def options(self, request, *args, **kwargs):
        """Handle responding to requests for the OPTIONS HTTP verb."""
        response = HttpResponse()
        response.headers['Allow'] = ', '.join(self._allowed_methods())
        response.headers['Content-Length'] = '0'
        return response

Он вернёт нам ответ на запрос с заголовком Allow с поддерживаемыми методами.

Но что делать, если нам нужно обработать запрос GET?

4. get()

На этом моменте полномочия базового класса View всё :) и в игру вступает его наследник TemplateView, в котором нужный метод определён:

class TemplateView(TemplateResponseMixin, ContextMixin, View):
    """
    Render a template. Pass keyword arguments from the URLconf to the context.
    """
    def get(self, request, *args, **kwargs):
        context = self.get_context_data(**kwargs)
        return self.render_to_response(context)

Получая знакомые уже нам атрибуты, класс формирует контекст для шаблона, вызывая метод get_context_data из примеси ContextMixin.

class ContextMixin:
    """
    A default context mixin that passes the keyword arguments received by
    get_context_data() as the template context.
    """
    extra_context = None

    def get_context_data(self, **kwargs):
        kwargs.setdefault('view', self)
        if self.extra_context is not None:
            kwargs.update(self.extra_context)
        return kwargs

Миксин не занимается rocket science, а всего лишь берёт словарь ключевых аргументов, полученных из URL адреса, добавляет туда ключ 'view', который ссылается на наш экземпляр класса (таким образом вы сможете обратиться к экземпляру класса прямо из шаблона), и под конец примешивает словарь extra_context, если вы вдруг его переопределяли.

Получив контекст, TemplateView может его отрендерить при помощи примеси TemplateResponseMixin и её метода render_to_response.

5. TemplateResponseMixin

class TemplateResponseMixin:
    """A mixin that can be used to render a template."""
    template_name = None
    template_engine = None
    response_class = TemplateResponse
    content_type = None

    def render_to_response(self, context, **response_kwargs):
        """
        Return a response, using the `response_class` for this view, with a
        template rendered with the given context.

        Pass response_kwargs to the constructor of the response class.
        """
        response_kwargs.setdefault('content_type', self.content_type)
        return self.response_class(
            request=self.request,
            template=self.get_template_names(),
            context=context,
            using=self.template_engine,
            **response_kwargs
        )

Имена атрибутов информативны сами по себе (но если что вы всегда можете заглянуть в документацию).

Если вдруг вы забыли, то, наследуясь от класса TemplateView, мы переопределили имя шаблона на 'about.html', именно этот шаблон и будет использоваться для рендера.

Точно так же вы можете переопределять остальные параметры нашего класса, чтобы получить необходимое поведение CBV. Конечно, собирать все атрибуты при множественном наследовании выматывающая работа, но здесь нам на помощь приходит сайт Classy Class-Based Views, в котором вы сразу видите все атрибуты и методы класса и его родителей. Теперь не требуется бежать на StackOverflow, чтобы нагуглить ответ, можно использовать перегрузку аттрибутов с пониманием.

В этом миксине берётся дефолтный TemplateResponse (если вы его не переопределяли), ему передаётся контекст и список шаблонов, полученный с помощью несложного метода get_template_names :

def get_template_names(self):
    """
    Return a list of template names to be used for the request. Must return
    a list. May not be called if render_to_response() is overridden.
    """
    if self.template_name is None:
        raise ImproperlyConfigured(
            "TemplateResponseMixin requires either a definition of "
            "'template_name' or an implementation of 'get_template_names()'")
    else:
        return [self.template_name]

который возвращает имя шаблона внутри списка или возбуждает ошибку, если оно не определено.

И, как финальный результат, экземпляр TemplateResponse возвращается в метод get(), который далее передаёт по цепочке в dispatch() и view(), и на этом работа представления завершается.

Заключение

Конечно, разобрав один из наиболее простых CBV сложно сказать, что уверенно ими владеете, впереди вас ждут более интересные Generic display views, такие как DetailView и ListView с более сложными миксинами и разветвлённым наследованием, но хочется верить, что, прочитав этот материал, вы сможете разобраться в них самостоятельно.

А пока давайте подведём итог полученным знаниям:

  1. CBV базируется на множестве классов и миксинов, но во главе стоит класс View с конструктором экземпляра и методом as_view().

  2. Вызов метода as_view() в URLConf возвращает нам функцию view(), которую Django будет вызывать при совпадении адреса с паттерном

  3. Вызов функции view() создаёт экземпляр представления и запускает цепочку обработки запроса

  4. Метод dispach() определяет HTTP-глагол и направляет к соответствующему методу, если его нет, возвращает HttpResponse с кодом 405

  5. В конце цепочки методов-обработчиков нам возвращается экземпляр TemplateResponse, как результат выполнения представления

Полезные ссылки

P.S.: На написание этой статьи меня вдохновил пост в блоге Django Deconstructed.






Разместим вашу рекламу

Пиши: mail@pythondigest.ru

Нашли опечатку?

Выделите фрагмент и отправьте нажатием Ctrl+Enter.

Система Orphus