company_banner

Конвертеры маршрутов в Django 2.0+ (path converters)

    Всем привет!

    Маршрутизация в Django со второй версии фреймворка получила замечательный инструмент — конвертеры. С добавлением этого инструмента появилась возможность не только гибко настраивать параметры в маршрутах, но и разделять зоны ответственности компонентов.

    Меня зовут Александр Иванов, я наставник в Яндекс.Практикуме на факультете бэкенд-разработки и ведущий разработчик в Лаборатории компьютерного моделирования. В этой статье я расскажу о конвертерах маршрутов в Django и покажу преимущества их использования.



    Первое, с чего начну, — границы применимости:

    1. версия Django 2.0+;
    2. регистрация маршрутов должна выполняться с помощью django.urls.path.

    Итак, когда к Django-серверу прилетает запрос, он сперва проходит через цепочку middleware, а затем в работу включается URLResolver (алгоритм). Задача последнего — найти в списке зарегистрированных маршрутов подходящий.

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

    users/21/reports/2021-01-31/
    teams/4/reports/2021-01-31/


    Как бы могли выглядеть маршруты в urls.py? Например, так:

    path('users/<id>/reports/<date>/', user_report, name='user_report'),
    path('teams/<id>/reports/<date>/', team_report, name='team_report'),

    Каждый элемент в < > является параметром запроса и будет передан в обработчик.
    Важно: название параметра при регистрации маршрута и название параметра в обработчике обязаны совпадать.

    Тогда в каждом обработчике был бы примерно такой код (обращайте внимание на аннотации типов):

    def user_report(request, id: str, date: str):
       try:
           id = int(id)
           date = datetime.strptime(date, '%Y-%m-%d')
       except ValueError:
           raise Http404()
      
       # ...

    Но не царское это дело — заниматься копипастом такого блока кода для каждого обработчика. Разумно этот код вынести во вспомогательную функцию:

    def validate_params(id: str, date: str) -> (int, datetime):
       try:
           id = int(id)
           date = datetime.strptime(date, '%Y-%m-%d')
       except ValueError:
           raise Http404('Not found')
       return id, date

    А в каждом обработчике тогда будет просто вызов этой вспомогательной функции:

    def user_report(request, id: str, date: str):
       id, date = validate_params(id, date)
      
       # ...

    В целом это уже удобоваримо. Вспомогательная функция либо вернёт корректные параметры нужных типов, либо прервёт выполнение обработчика. Кажется, что всё хорошо.

    Но на самом деле вот что я сделал: я переложил часть ответственности по решению, должен ли этот обработчик запускаться для этого маршрута или нет, с URLResolver на сам обработчик. Выходит, что URLResolver некачественно проделал свою работу, и моим обработчикам приходится не только заниматься полезной работой, но и решать, а должны ли они вообще этим заниматься. Это явное нарушение принципа единственной ответственности из SOLID. Так не пойдёт. Надо исправляться.

    Стандартные конвертеры


    Django предоставляет стандартные конвертеры маршрутов. Это механизм определения, подходит ли часть маршрута или нет самим URLResolver. Приятный бонус: конвертер может менять тип параметра, а значит, в обработчик может прийти сразу нужный нам тип, а не строка.

    Конвертеры указываются перед названием параметра в маршруте через двоеточие. На самом деле конвертер есть у всех параметров, если он не указан явно, то по умолчанию используется конвертер str.

    Осторожно: некоторые конвертеры выглядят как типы в Python, поэтому может показаться, что это обычные приведения типов, но это не так — например, нет стандартных конвертеров float или bool. Позднее я покажу, что из себя представляет конвертер.


    После просмотра стандартных конвертеров, становится очевидно, что для id стоит использовать конвертер int:

    path('users/<int:id>/reports/<date>/', user_report, name='user_report'),
    path('teams/<int:id>/reports/<date>/', team_report, name='team_report'),


    Но как быть с датой? Стандартного конвертера для неё нет.

    Можно, конечно, извернуться и сделать так:

    'users/<int:id>/reports/<int:year>-<int:month>-<int:day>/'
    

    Действительно, часть проблем удалось устранить, ведь теперь гарантируется, что дата будет отображаться тремя числами через дефисы. Однако всё ещё придётся обрабатывать проблемные случаи в обработчике, если клиент передаст некорректную дату, например 2021-02-29 или вообще 100-100-100. Значит, этот вариант не подходит.

    Создаём свой конвертер


    Django помимо стандартных конвертеров предоставляет возможность создать свой конвертер и описать правила конвертации так, как угодно.

    Для этого надо сделать два шага:

    1. Описать класс конвертера.
    2. Зарегистрировать конвертер.

    Класс конвертера — это класс с определённым набором атрибутов и методов, описанных в документации (на мой взгляд, несколько странно, что разработчики не сделали базовый абстрактный класс). Сами требования:

    1. Должен быть атрибут regex, описывающий регулярное выражение для быстрого поиска требуемой подпоследовательности. Чуть позже покажу, как он используется.
    2. Реализовать метод def to_python(self, value: str) для конвертации из строки (ведь передаваемый маршрут — это всегда строка) в объект python, который в итоге будет передаваться в обработчик.
    3. Реализовать метод def to_url(self, value) -> str для обратной конвертации из объекта python в строку (используется, когда вызываем django.urls.reverse или тег url).

    Класс для конвертации даты будет выглядеть так:

    class DateConverter:
       regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'
    
       def to_python(self, value: str) -> datetime:
           return datetime.strptime(value, '%Y-%m-%d')
    
       def to_url(self, value: datetime) -> str:
           return value.strftime('%Y-%m-%d')

    Я противник дублирования, поэтому формат даты вынесу в атрибут — так и поддерживать конвертер проще, если вдруг захочу (или потребуется) изменить формат даты:

    class DateConverter:
       regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'
       format = '%Y-%m-%d'
    
       def to_python(self, value: str) -> datetime:
           return datetime.strptime(value, self.format)
    
       def to_url(self, value: datetime) -> str:
           return value.strftime(self.format)

    Класс описан, значит, пора его зарегистрировать как конвертер. Делается это очень просто: в функции register_converter надо указать описанный класс и название конвертера, чтобы использовать его в маршрутах:

    from django.urls import register_converter
    register_converter(DateConverter, 'date')

    Вот теперь можно описать маршруты в urls.py (я специально сменил название параметра на dt, чтобы не сбивала с толку запись date:date):

    path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),
    path('teams/<int:id>/reports/<date:dt>/', team_report, name='team_report'),

    Теперь гарантируется, что обработчики вызовутся только в том случае, если конвертер отработает корректно, а это значит, что в обработчик придут параметры нужного типа:

    def user_report(request, id: int, dt: datetime):
       # больше никакой валидации в обработчиках
       # сразу правильные типы и никак иначе

    Выглядит потрясающе! И это так, можно проверять.

    Под капотом


    Если посмотреть внимательно, то возникает интересный вопрос: нигде нет проверки, что дата корректна. Да, есть регулярка, но под неё подходит и некорректная дата, например 2021-01-77, а значит, в to_python должна быть ошибка. Почему же это работает?

    Про такое я говорю: «Играй по правилам фреймворка, и он будет играть за тебя». Фреймворки берут на себя ряд стандартных задач. Если же фреймворк чего-то не может, то хороший фреймворк предоставляет возможность расширить свой функционал. Поэтому не стоит заниматься велосипедостроением, лучше посмотреть, как фреймворк предлагает улучшить собственные возможности.

    У Django есть подсистема маршрутизации с возможностью добавления конвертеров, которая берёт на себя обязанности по вызову метода to_python и отлавливания ошибок ValueError.

    Привожу код из подсистемы маршрутизации Django без изменений (версия 3.1, файл django/urls/resolvers.py, класс RoutePattern, метод match):

    match = self.regex.search(path)
    if match:
       # RoutePattern doesn't allow non-named groups so args are ignored.
       kwargs = match.groupdict()
       for key, value in kwargs.items():
           converter = self.converters[key]
           try:
               kwargs[key] = converter.to_python(value)
           except ValueError:
               return None
       return path[match.end():], (), kwargs
    return None
    

    Первым делом производится поиск совпадений в переданном от клиента маршруте с помощью регулярного выражения. Тот самый regex, что определен в классе конвертера, участвует в формировании self.regex, а именно подставляется вместо выражения в угловых скобках <> в маршруте.

    Например,
    users/<int:id>/reports/<date:dt>/
    превратится в
    ^users/(?P<id>[0-9]+)/reports/(?P<dt>[0-9]{4}-[0-9]{2}-[0-9]{2})/$

    В конце как раз та самая регулярка из DateConverter.

    Это и есть быстрый поиск, поверхностный. Если совпадение не найдено, то маршрут точно не подходит, но если найдено, то это потенциально подходящий маршрут. Значит, нужно запускать следующий этап проверки.

    Для каждого параметра имеется свой конвертер, который и используется для вызова метода to_python. И вот здесь самое интересное: вызов to_python обёрнут в try/except, и отлавливаются ошибки типа ValueError. Именно поэтому и работает конвертер даже в случае некорректной даты: валится ошибка ValueError, и это расценивается так, что маршрут не подходит.

    Так что в случае с DateConverter, можно сказать, повезло: в случае некорректной даты валится ошибка нужного типа. Если будет ошибка другого типа, то Django вернёт ответ с кодом 500.

    Не стоит останавливаться


    Кажется, что всё отлично, конвертеры работают, в обработчики сразу приходят нужные типы… Или не сразу?

    path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),

    В обработчике для формирования отчёта наверняка нужен именно User, а не его id (хотя и такое может быть). В моей гипотетической ситуации для создания отчёта нужен как раз именно объект User. Что же тогда получается, опять двадцать пять?

    def user_report(request, id: int, dt: datetime):
       user = get_object_or_404(User, id=id)
      
       # ...

    Снова перекладывание обязанностей на обработчик.

    Но теперь понятно, что с этим делать: писать свой конвертер! Он убедится в существовании объекта User и передаст его в обработчик.

    class UserConverter:
       regex = r'[0-9]+'
    
       def to_python(self, value: str) -> User:
           try:
               return User.objects.get(id=value)
           except User.DoesNotExist:
               raise ValueError('not exists') # именно ValueError
    
       def to_url(self, value: User) -> str:
           return str(value.id)

    После описания класса регистрирую его:

    register_converter(UserConverter, 'user')

    И наконец описываю маршрут:

    path('users/<user:u>/reports/<date:dt>/', user_report, name='user_report'),

    Так-то лучше:

    def user_report(request, u: User, dt: datetime):  
       # ...

    Конвертеры для моделей могут использоваться часто, поэтому удобно сделать базовый класс такого конвертера (заодно добавил проверку на существование всех атрибутов):

    class ModelConverter:
       regex: str = None
       queryset: QuerySet = None
       model_field: str = None
    
       def __init__(self):
           if None in (self.regex, self.queryset, self.model_field):
               raise AttributeError('ModelConverter attributes are not set')
    
       def to_python(self, value: str) -> models.Model:
           try:
               return self.queryset.get(**{self.model_field: value})
           except ObjectDoesNotExist:
               raise ValueError('not exists')
    
       def to_url(self, value) -> str:
           return str(getattr(value, self.model_field))
    

    Тогда описание нового конвертера в модель сведётся к декларативному описанию:

    class UserConverter(ModelConverter):
       regex = r'[0-9]+'
       queryset = User.objects.all()
       model_field = 'id'
    

    Итоги


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

    Но это не значит, что конвертеры не надо использовать вовсе. Это значит, что (пока) не везде получится их использовать. Но там, где это возможно, я призываю вас не пренебрегать ими.

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

    P.S. На практике я делал и использовал только конвертер для дат, как раз тот самый, который приведён в статье, поскольку почти всегда использую DRF или GraphQL. Расскажите, пользуетесь ли вы конвертерами маршрутов и, если пользуетесь, то какими?
    Яндекс.Практикум
    Помогаем людям расти

    Комментарии 14

      +2
      У вас там баг в паре мест. Делаете query в одну модель а эксепшн от другой:
      except Category.DoesNotExist:

      Если из контекста нельзя получить класс модели то можно делать:
      from django.core.exceptions import ObjectDoesNotExist
        +1
        Точно, спасибо!
        Исправил ;)
        +3
        по мелочи:
        В самом последнем примере
         queryset = User.objects.all()

        лучше поправить на
        manager = User.objects

        и по месту исправить queryset на manager
        Причина в том, что результат .all() — кешируется и может привести к неожиданным ошибкам.

        Не совсем понятно, почему не через видовые классы решается, кода еще меньше и почему не используется repath для предобработки.

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

        Тогда для получения Repor-Листа получать обьект User нам не надо — можно сразу фильтровать по числовому значению user_id. Нет записей — возвращаем «Записей по запрошенному пользователю не найдено».
        Конечно, когда надо делать что-то с самим пользователем, то можно получить связанный объект User, например, одновременно с запросом по UserReport.

        Если придерживаться идеологии джанго, то Resolver должен работать максимально быстро, и, желательно, без запросов в БД: в resolver нет текущего пользователя, который прикреплен в request, и не известно, имеет ли текущий пользователь права доступа к модели обработчика, в примере это модель Users.

        Обработка даты, idsn, EAN — да, это возможно. Как я понимаю в Djangoproject просто отдали на откуп создание частных обработчиков, оставив себе общие случаи: path, slug, string, int, UUID.
          +1
          Спасибо за комментарий!

          .all() кеширует результаты при исполнении, а как раз в приведенном конвертере исполняется не .all(), а .get(), поэтому проблем не возникнет.

          При этом получаем возможность в декларативном стиле добавлять фильтрации:
          queryset = User.objects.filter(is_active=True)

          Если же захочется в конвертере использовать .all(), то тогда в нем же и стоит позаботиться об актуальности данных :)
          def to_python(self, value: str):
              # ensure queryset is re-evaluated
              queryset = self.queryset.all()
              # process queryset

          Но пока не могу представить себе конвертер, в котором это потребовалось бы.

          Про видовые классы и re_path не понял. Финальная view-функция выглядит так:
          path('users/<user:u>/reports/<date:dt>/', user_report, name='user_report')
          
          def user_report(request, u: User, dt: datetime):  
              # logic


          CBV выглядел бы так:
          path('users/<user:u>/reports/<date:dt>/', UserReport.as_view(), name='user_report')
          
          class UserReport(generic.View):
              def get(self, request, u: User, dt: datetime): 
                  # logic

          Можно пояснение, что имелось ввиду?

          Что же касается логики получения самого отчета — действительно, возможно нужны будут связанные модели и сам User не нужен вовсе, а возможно улетит запрос в сторонний сервис и нужен будет некий токен из модели User. Тут уже зависит от задач — если обработчик использует user_id — значит его и получаем, если же использует User, то хотелось бы получить User на вход.

          А вот насчет отсутствия request согласен. Сделать запрос в БД, чтобы убедиться в валидности маршрута, а потом узнать что нет прав на исполнение этого обработчика и можно было бы не делать запрос в БД — грустный сценарий. Это будет бить по производительности.

          При этом разрешать вопрос прав нужно не в конвертере или URLResolver, это не их забота, а частично до (вот этого слоя не хватает, простые проверки, типа маршрут только для админа) и частично после (в обработчике).

          На практике же, действительно, пока из самописных конвертеров я использую только конвертер для даты :)
          +4

          Интересная штука, но мне кажется, дальше чем даты или какие-то простые скалярные конвертеры, использовать не стоит.


          Пример с юзером — хорош для демонстрации, но на практике скорее всего приведёт к куче лишних запросов к БД. И главное, как мне кажется, это размывание логики — вставка модельной логики в декларативные УРЛы и вынос важной части обработки собственно запроса в другое место. Как только в этой части, считающейся декларативной, возникает сайд эффект — возникают проблемы.


          То же, что и с сигналами — вещь в теории полезная, на практике приводящая к большому батхёрту, если не сдерживаться и не пичкать модели комментариями "смотри сигналы".

            +1
            Избыточные запросы действительно появляются, как раз комментарием выше это обсуждали. И это действительно серьезная проблема.

            А размывание логики не стоит допускать — задача конвертера только дать ответ: могу сконвертировать или нет = маршрут не подходит, сайд эффекты привносить в конвертер точно не стоит :)

            Я к конвертерам скорее отношусь как к сериализаторам из DRF, только которые принудительно вызываются до обработчика, а не внутри.
              +1

              Именно так. :)


              сериализаторам из DRF

              удачное сравнение.


              В общем спасибо за подкинутую идею, Today I learned как говорится.

            +1
            Использовал конвертеры маршрутов, когда нужно было размещать страницы разных типов на одном уровне вложенности URL. То есть, грубо говоря такая ситуация: test.com/car-slug (страница с каким-то автомобилем), test.com/dog-slug (страница про какую-то собаку), test.com/post-slug (какая-то статья) — и во всех 3-х случаях разные шаблоны и переменные для них. Конвертеры маршрутов решили эту проблему, но, увы, делаются лишние запросы к базе данных (чтобы по slug определить тип страницы) — и это не очень хорошо.

            С другой стороны, если сделать общую вьюху (для всех этих типов страниц) — и там по slug определять тип страницы, то получаем при этом точно такие же лишние запросы к БД. Так что в такой ситуации самый правильный вариант, на мой взгляд, это пытаться по возможности избежать такой структуры URL-адресов. То есть сделать test.com/cars/car-slug, test.com/dogs/dog-slug и test.com/posts/post-slug. Тогда не нужно лазать в БД ради роутинга.
              +1
              Ого, честно, не подумал бы даже делать такое на конвертерах :)
              Если приходится для роутинга лезть в БД, то тут как ни крути — придется лезть в БД. Кажется, что в обработчике будет меньше запросов (если сам объект знает все что нужно, чтобы выбрать нужный роут).
                +1
                А там вариантов не сильно много: либо конвертер, либо вьюха. Я это сунул в конвертер ещё и для того, чтобы reverse урлов работал.
                  0

                  А что не так с reverse в случае вьюхи? Делаю в своих проектах именно вьюхи, проблем вроде не испытываю

                    0
                    Я это написал в контексте того комментария, который написал в самом начале. Если сделать общую вьюху под несколько страниц, внутри которой по части path будет разруливаться, какой шаблон рендерить и с какими переменными, то как reverse будет работать? Хотя могу и ошибаться.
                      0
                      то как reverse будет работать?

                      Эм, а почему он должен не работать?


                      class Auto(models.Model):
                          def get_absolute_url(self):
                              return reverse("common_url", kwargs={"slug": self.slug})
                      
                      class Article(models.Model):
                          def get_absolute_url(self):
                              return reverse("common_url", kwargs={"slug": self.slug})

                      Пишу в своих проектах подобный код — всё отлично работает

                        0
                        А, ну ок — значит, я ошибся

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое