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

Всем привет!

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

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

vg7yfjk789fjsjjp-xvk7gw4p0e.png

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

  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//reports//', user_report, name='user_report'),
path('teams//reports//', 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//reports//', user_report, name='user_report'),
path('teams//reports//', team_report, name='team_report'),

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

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

'users//reports/--/'

Действительно, часть проблем удалось устранить, ведь теперь гарантируется, что дата будет отображаться тремя числами через дефисы. Однако всё ещё придётся обрабатывать проблемные случаи в обработчике, если клиент передаст некорректную дату, например 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//reports//', user_report, name='user_report'),
path('teams//reports//', 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//reports//
превратится в 
^users/(?P[0-9]+)/reports/(?P
[0-9]{4}-[0-9]{2}-[0-9]{2})/$

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

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

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

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

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


Кажется, что всё отлично, конвертеры работают, в обработчики сразу приходят нужные типы… Или не сразу?
path('users//reports//', 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 Category.DoesNotExist:
           raise ValueError('not exists') # именно ValueError

   def to_url(self, value: User) -> str:
       return str(value.id)

После описания класса регистрирую его:
register_converter(UserConverter, 'user')

И наконец описываю маршрут:
path('users//reports//', 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 Category.DoesNotExist:
           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. Расскажите, пользуетесь ли вы конвертерами маршрутов и, если пользуетесь, то какими?

© Habrahabr.ru