Представляю вам перевод статьи «Building a higher-level query API: the right way to use Django’s ORM«. Буду признателен за указание найденных неточностей при переводе.
В данной статье я попытаюсь аргументировать, почему использование низкоуровневых методов Django ORM (filter
, order_by
и т.д.) в представлении, является «анти-паттерном». Правильный путь — создавать «query APIs» на уровне моделей, где и описывается вся бизнес-логика приложения. Django не позволяет это сделать очень легко, но окунувшись во внутренности ORM, я покажу вам как достигнуть поставленной цели.
Введение
Когда мы создаем Django приложение, мы обычно добавляем методы в модель, чтобы спрятать бизнес логику и детали ее реализации. Данный подход естественен и очевиден, и часто используется в Django приложениях:
Посмотрим на метод set_password
из модели пользователей django.contrib.auth.models.User
в котором скрыта реализация создания «хэша» пароля (код упрощен для наглядности):
Мы используем узко-специализированное API общего уровня, низкоуровневые объектно-реляционные инструменты, которые дает нам Django. Основной идеей данного подхода является: увеличение уровня абстракции и создание меньшего количества кода, который взаимодействует с API. В результате код получается более надежным, повторно используемым и самое главное более читаемым.
Почему бы нам не применить данную идею в нашем API для выбора коллекций экземпляров модели из базы данных?
Детская проблема: «Todo List»
Чтобы проиллюстрировать подход, возьмем простое приложение списка дел. Трудно привести реальный, полезный пример без «кучи» кода. Не будем концентрироваться на реализации «Todo» списка, вместо этого, представим как данный подход будет работать для одного из наших масштабируемых приложений.
Взглянем на нашу модель models.py
:
Теперь давайте рассмотрим запрос, который будет получать данные с модели. Например, создадим «view» панели задач нашего «Todo» списка. На ней мы покажем все не законченные задачи, задачи с высоким приоритетом для авторизованных пользователей. Первый вариант кода будет следующим:
(Да, я знаю что данный пример можно было написать как request.user.todo_set.filter(is_done=False, priority=1)
.)
Почему данный подход плох?
- Во первых, код слишком подробный. Семь строк (в зависимости от того, как вы предпочитаете располагать новые строки в цепочке вызовов методов) о форматировании которых необходимо заботиться. Конечно это простейший пример. Реальное использование ORM может быть намного сложнее.
- Во вторых, видна детальная реализация выбора данных. Коду, который взаимодействует с моделью, необходимо знать что существует свойств
is_done
, и что оно должно бытьBooleanField
. Если мы поправим реализацию модели (допустим мы изменим типis_done
с «boolean» на тип, имеющий множественный выбор из списка значений), то код станет не работающим. - В третьих, не прозрачность кода — предназначение или цель, скрывающееся в коде, не понятна на первый взгляд (можно сказать «трудно читаемый код»).
- В четвертых, данный подход может привести к копипасту. Представим что у нас появились новые требования: написать команду, вызываемую в
cron
каждую неделю, для отправки напоминаний пользователям о не завершенных и приоритетных задачах. Следуя текущему подходу, нам придется копировать эти семь строк в новый скрипт, что не очень хорошо.
Итак, давайте подведем итог: использование низко-уровневого кода ORM непосредственно во «view» является (обычно) анти-паттерном.
Так как же мы можем исправить ситуацию?
«Managers» и «QuerySets»
Перед тем как продолжить, сделаем небольшое отступление, чтобы рассказать о некоторых основных концепциях.
Django имеет две тесно связанных конструкции завязанных на уровне операций с таблицами («table-level»): managers and querysets.
Manager (экземпляр класса django.db.models.manager.Manager
) можно описать как — «интерфейс, через который запросы к базе данных транслируется в Django модели.» Manager
модели служит воротами к функциональности «table-level» в ORM (экземпляр модели предоставляет «row-level» функциональность). Каждые класс модели имеет менеджера по умолчанию, который называется objects
.
Queryset (django.db.models.query.QuerySet
) представляет собой «коллекцию объектов из базы данных.» Это — по существу абстракция результата выполнения SELECT
запроса, которую можно фильтровать, упорядочивать и вообще управлять им по разному. Он ответственен за создание и работу экземпляров класса django.db.models.sql.query.Query
, которые компилируются в обычные SQL запросы к базе данных.
Уф-ф. Запутались? Различие между Manager
и QuerySet
можно понять, если вы глубоко знакомы с внутренностями ORM, что далеко от интуитивного понимания, особенно для новичков.
Данное обстоятельство усугубляется еще тем, что Manager
API не вполне, чем это кажется…
Manager
API — обманчивость
QuerySet
методы последовательны. Каждый вызов метода QuerySet
(например, filter
) возвращает копию оригинального queryset, готового для вызова следующих методов. Так называемый fluent interface — это часть прекрасного Django ORM.
Но только факт что Model.objects
является Manager
(не QuerySet
) представляет собой проблему: нам необходимо начинать нашу цепочку методов с objects
, но продолжать используя QuerySet
.
Так как эта проблема решается в Django? Таким образом, API обманчивость раскрывается: все методы QuerySet
переопределяются на Manager
. Версии этих методов Manager
являются прокси для вновь созданных QuerySet
через self.get_query_set()
:
class Manager(object): # SNIP some housekeeping stuff.. def get_query_set(self): return QuerySet(self.model, using=self._db) def all(self): return self.get_query_set() def count(self): return self.get_query_set().count() def filter(self, *args, **kwargs): return self.get_query_set().filter(*args, **kwargs) # and so on for 100+ lines...
Взгляните на весь этот ужас в код Manager
исходники.
Мы вернемся к данному примеру API чуть позже…
Назад к списку дел (Todo List)
Так, давайте вернемся к решению проблемы чистоты кода при построении запросов. Подход, рекомендованный документацией Django, состоит в том, чтобы создавать собственные Manager
классы и использовать их в моделях.
Вы так же можете добавить «multiple extra managers« к модели, или вы можете перегрузить objects
, сохраняя один менеджер, но добавляя свои методы.
Давайте попробуем каждое из утверждений на примере списка дел (Todo List).
Подход 1: несколько своих Managers
class IncompleteTodoManager(models.Manager): def get_query_set(self): return super(TodoManager, self).get_query_set().filter(is_done=False) class HighPriorityTodoManager(models.Manager): def get_query_set(self): return super(TodoManager, self).get_query_set().filter(priority=1) class Todo(models.Model): content = models.CharField(max_length=100) # other fields go here.. objects = models.Manager() # the default manager # attach our custom managers: incomplete = models.IncompleteTodoManager() high_priority = models.HighPriorityTodoManager()
Данный подход дает нам следующий вариант обращения в API:
Todo.incomplete.all() Todo.high_priority.all()
К сожалению, есть несколько больших проблем с этим подходом.
- Реализация требует много кода. Необходимо реализовывать целый класс, для того чтобы сделать кастомный запрос.
- Нет никакого способа объединения менеджеров: получить Todos, которые являются «incomplete» и «high_priority».
Я думаю, что эти недостатки полностью перевешивают любые преимущества этого подхода.
Подход 2: методы Manager
Следующий вариант — определить методы менеджера модели.
class TodoManager(models.Manager): def incomplete(self): return self.filter(is_done=False) def high_priority(self): return self.filter(priority=1) class Todo(models.Model): content = models.CharField(max_length=100) # other fields go here.. objects = TodoManager()
Данный подход дает нам следующий вариант обращения в API:
Todo.objects.incomplete() Todo.objects.high_priority()
Лучше. Гораздо меньше кода (только одно определение класса) и методы запросов остаются в пространстве имен класса объекта.
Но до сих пол мы не можем использовать цепочку запросов, например:
Todo.objects.incomplete().high_priority()
Подход 3: свой QuerySet
Следующий вариант реализации мы не найдем в документации Django
class TodoQuerySet(models.query.QuerySet): def incomplete(self): return self.filter(is_done=False) def high_priority(self): return self.filter(priority=1) class TodoManager(models.Manager): def get_query_set(self): return TodoQuerySet(self.model, using=self._db) class Todo(models.Model): content = models.CharField(max_length=100) # other fields go here.. objects = TodoManager()
Вот как это выглядит с точки зрения код, который вызывает его:
Todo.objects.get_query_set().incomplete() Todo.objects.get_query_set().high_priority() # (or) Todo.objects.all().incomplete() Todo.objects.all().high_priority()
Мы почти у цели! Данный подход дает те же преимущества, что и подход 2, и, кроме того (барабанная дробь…) Можно использовать цепочки запросов!!
Todo.objects.all().incomplete().high_priority()
Однако, нужно еще поработать.
Подход 3a: копируем Django, проксируем все
Мы переопределим все наши QuerySet
медоты в нашем Manager
, и проксируем их в обратно в наш кастомный QuerySet
:
class TodoQuerySet(models.query.QuerySet): def incomplete(self): return self.filter(is_done=False) def high_priority(self): return self.filter(priority=1) class TodoManager(models.Manager): def get_query_set(self): return TodoQuerySet(self.model, using=self._db) def incomplete(self): return self.get_query_set().incomplete() def high_priority(self): return self.get_query_set().high_priority()
Мы получаем нужный нам результат:
Todo.objects.incomplete().high_priority() # yay!
В этом варианте есть свой минус. Каждый раз, когда мы добавляем новый методв в QuerySet
, или изменяем название текущих методов, мы должны помнить о том, что нужно поменять на нашем Manager
.
Подход 3b: django-model-utils
Python является динамическим языком. На помощь нам приходит django-model-utils
. Просто устанавливаем pip install django-model-utils
, и получаем:
from model_utils.managers import PassThroughManager class TodoQuerySet(models.query.QuerySet): def incomplete(self): return self.filter(is_done=False) def high_priority(self): return self.filter(priority=1) class Todo(models.Model): content = models.CharField(max_length=100) # other fields go here.. objects = PassThroughManager.for_queryset_class(TodoQuerySet)()
Выглядит намного лучше. Мы определяем наш QuerySet
, и подключаем его в нашей модели через PassThroughManager
из django-model-utils
.
PassThroughManager
работаем путем имплементации метода __getattr__
, который перехватывает вызовы на несуществующие методы и автоматически проксирует их в QuerySet
. При данном подходе нужно быть аккуратным в названиях методов т.к. это может вызвать рекурсию..
Как это помогает?
Помните мы писали код ранее?
def dashboard(request): todos = Todo.objects.filter( owner=request.user ).filter( is_done=False ).filter( priority=1 ) return render(request, 'todos/list.html', { 'todos': todos, })
С новым подходом код будет выглядеть следующим образом:
def dashboard(request): todos = Todo.objects.for_user( request.user ).incomplete().high_priority() return render(request, 'todos/list.html', { 'todos': todos, })
Надеюсь, вы согласитесь, что вторая версия гораздо проще, яснее и более читабельна, чем первая.
Может ли помочь Django?
Данная проблема обсуждалась в рассылке django-dev сообщества, в результате чего был открыт ticket. Zachary Voase предложил следующее:
class TodoManager(models.Manager): @models.querymethod def incomplete(query): return query.filter(is_done=False)
Используя только один декоратор можно будет использовать методы в Manager
и QuerySet
.
С данным подходом можно поспорить. Возможно в Django 2.0 или выше будет переработана API взаимодействия с ORM и данная проблема исчезнет.
Подведем итоги:
Использование чистого ORM во view — плохая идея. Нужно создавать свои QuerySet
и использовать их в моделях через PassThroughManager
из django-model-utils
. Данный подход дает следующие преимущества:
- Вы пишите меньше кода.
- Код становиться более читаемым.
- Появляется абстракция в коде.
Спасибо!