Создаем высокоуровневое API запросов или как правильно пользоваться Django ORM

Представляю вам перевод статьи «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. Данный подход дает следующие преимущества:

  • Вы пишите меньше кода.
  • Код становиться более читаемым.
  • Появляется абстракция в коде.

Спасибо!

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *