Джэми Мэтьюз. Построение высокоуровневого API запросов: правильный способ использования ORM Django, 2012

Перевод статьи: Building a higher-level query API: the right way to use Django's ORM

Автор: Джэми Мэтьюз (Jamie Matthews)

Эта статья основана на обсуждении в группе пользователей Python города Брайтон (Brighton Python User Group) 10 апреля 2012 года.

1. Аннотация

В этой статье я хочу показать, что использование низкоуровневых методов запросов ORM Django (filter, order_by и т.п.) прямо в представлении обычно является плохой практикой. Вместо этого лучше построить собственный проблемно-ориентированный API запросов на уровне модели, которой принадлежит бизнес-логика. Сделать это в Django не особо просто, но глубоко погрузившись во внутренности ORM, всё же можно найти для этого несколько приемлемых способов.

2. Обзор

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

>>> from django.contrib.auth.models import User
>>> user = User.objects.get(pk=5)
>>> user.set_password('super-sekrit')
>>> user.save()

Здесь set_password - это метод, определённый в модели django.contrib.auth.models.User, который скрывает детали реализации хэширования пароля. В наглядном виде этот код выглядит примерно следующим образом:

from django.contrib.auth.hashers import make_password

class User(models.Model):

    # здесь находятся поля модели...

    def set_password(self, raw_password):
        self.password = make_password(raw_password)

Мы построили проблемно-ориентированный API поверх инструментов для обобщённого низкоуровневого объектно-реляционного отображения, которые предоставляются Django. Это основа проблемного-ориентированного подхода: мы увеличиваем количество уровней абстракции, делая менее явным код, взаимодействующий с API. В результате код получается более устойчивым, пригодным для повторного использования и (самое важное) более наглядным.

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

3. Учебное приложение: список дел

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

Вот файл models.py из нашего приложения:

from django.db import models

PRIORITY_CHOICES = [(1, 'High'), (2, 'Low')]

class Todo(models.Model):
    content = models.CharField(max_length=100)
    is_done = models.BooleanField(default=False)
    owner = models.ForeignKey('auth.User')
    priority = models.IntegerField(choices=PRIORITY_CHOICES,
                                   default=1)

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

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,
    })

Да, я знаю, что этом можно записать проще: request.user.todo_set.filter(is_done=False, priority=1). Но напоминаю: это просто пример!

4. Почему это плохо?

Давайте подведём итоги: использование низкоуровневых методов ORM прямо в представлении обычно является плохой практикой.

Ну хорошо, а как же это можно улучшить?

5. Менеджеры запросов и объекты запросов

Перед рассмотрением вариантов решений ненадолго отвлечёмся, чтобы пояснить некоторые понятия.

В Django есть две тесно связанные конструкции, относящиеся к операциям над таблицами: менеджеры запросов и объекты запросов.

Менеджер запросов (экземпляр django.db.models.manager.Manager) описывается как "интерфейс, через который осуществляются операции с моделями Django в базе данных". Менеджер модели - это шлюз к функциональности ORM для доступа к таблицам (экземпляры моделей обычно предоставляют функциональность для доступа к одной строке таблицы). Каждый класс модели предоставляет менеджер по умолчанию, который называется objects.

Объект запроса (django.db.models.query.QuerySet) представляет "коллекцию объектов из базы данных". Это абстракция с отложенным выполнением вычисленного запроса SELECT. Эта абстракция может быть отфильтрована, упорядочена и использована для ограничения или изменения набора строк, который она представляет. Она отвечает за создание и манипулирование экземплярами django.db.models.sql.query.Query, которые преобразуются в настоящие SQL-запросы к одному из поддерживаемых типов нижележащих баз данных.

Уф. Запутались? Хотя разницу между менеджерами запросов и объектами запросов легко объяснить тем, кто хорошо знаком со внутренностями ORM, она не кажется очевидной, особенно новичкам.

Эта путаница усугубляется тем, что знакомый API менеджеров на самом деле немного не такой, каким кажется...

6. API менеджера - это обман

Методы объектов запросов можно объединять в цепочки. Каждый вызов метода объекта запроса (например filter) возвращает клонированную версию исходного объекта запроса, готового к вызову другого метода. Этот естественный интерфейс - часть прекрасного ORM Django.

Но на самом деле Model.objects - это менеджер запросов (а не объект запроса), что создаёт проблемы: нам нужно начать нашу цепочку методов вызовов с objects, но продолжение цепочки даст в результате объект запроса.

И как же эта проблема решается в коде самого Django? Итак, обман API объясняется: все методы объекта запроса повторно реализуются в менеджере запросов. Версии этих методов из менеджера запросов просто транслируются в только что созданный объект запроса через self.get_query_set():

class Manager(object):

    # Пропускаем служебные вещи...

    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)

    # и так далее сто с лишним строк...

Чтобы увидеть весь этот ужас, загляните в исходный код класса Manager.

Мы скоро вернёмся к этому хитромудрому API...

7. Возвращаемся к списку дел

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

Можно даже добавить в модель несколько дополнительных менеджеров или можно переопределить objects, оставив один менеджер, но добавив к нему собственные методы.

Давайте попробуем каждый из этих подходов в приложении со списком дел.

8.1. Подход 1: несколько собственных менеджеров

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)
    # здесь следуют прочие поля...

    objects = models.Manager() # менеджер по умолчанию

    # присоединяем собственные менеджеры:
    incomplete = models.IncompleteTodoManager()
    high_priority = models.HighPriorityTodoManager()

Реализованное здесь API выглядит следующим образом:

>>> Todo.incomplete.all()
>>> Todo.high_priority.all()

К несчастью, этот подход порождает несколько больших проблем.

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

8.2. Подход 2: методы менеджера

Что ж, давайте попробуем другой подход, разрешённый в Django: собственный менеджер с несколькими методами.

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)
    # здесь следуют прочие поля...

    objects = TodoManager()

Теперь наш API выглядит следующим образом:

>>> Todo.objects.incomplete()
>>> Todo.objects.high_priority()

Гораздо лучше. Здесь меньше кода (есть только одно определение класса) и методы запроса размещаются в пространстве имён внутри менеджера objects.

Однако, такие запросы по-прежнему нельзя объединять в цепочки. Todo.objects.incomplete() вернёт обычный объект запроса, поэтому нельзя написать Todo.objects.incomplete().high_priority(). Нам по-прежнему придётся писать Todo.objects.incomplete().filter(is_done=False). Не годится.

8.3. Подход 3: собственный объект запроса

Теперь мы на неизведанной территории. Этого нельзя найти в документации 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)
    # здесь следуют прочие поля...

    objects = TodoManager()

Вот как это выглядит при использовании:

>>> Todo.objects.get_query_set().incomplete()
>>> Todo.objects.get_query_set().high_priority()
>>> # или
>>> Todo.objects.all().incomplete()
>>> Todo.objects.all().high_priority()

Мы почти на месте! Здесь не больше кода, чем в подходе 2, имеются те же достоинства, а кроме того (барабанная дробь) - можно использовать цепочки!

>>> Todo.objects.all().incomplete().high_priority()

Однако, совершенство ещё не достигнуто. Собственный менеджер - ничего более, чем шаблонная заготовка. И этот all() выглядит как нарост, который надоедает набирать. Но важнее то, что он всё запутывает - из-за него код выглядит странно.

8.4. Подход 3a: копируем Django, транслируем всё

Теперь пригодится наше обсуждение "API менеджера - это обман": мы знаем, как исправить эту проблему. Мы просто переопределим все методы из объекта запросов в нашем менеджере, транслируя их обратно в наш объект запросов:

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()

Мы получаем в точности тот API, какой нам и нужен:

>>> Todo.objects.incomplete().high_priority() # Ура!

Однако код получился многословным и он не соответствует принципу DRY. Каждый раз, когда нужно добавить новый метод в объект запросов или поменять сигнатуру существующего метода, нужно не забыть сделать такие же изменения в менеджере. В противном случае этот метод не будет работать правильно. Похоже на источник будущих проблем.

8.5. Подход 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)
        # здесь следуют прочие поля...

        objects = PassThroughManager.for_queryset_class(TodoQuerySet)()

Вот так намного приятнее. Мы просто определили наш собственный подкласс объекта запросов, как и в прошлый раз, и добавили его в нашу модель через класс PassThroughManager, который имеется в django-model-utils.

PassThroughManager работает благодаря реализации метода __getattr__, который перехватывает вызовы несуществующих методов и автоматически передаёт их в объект запроса. Он выполняет несколько проверок для предотвращения бесконечной рекурсии при обращении к некоторым свойствам. Именно поэтому я рекомендую использовать испытанную реализацию, предоставляемую django-model-utils, а не пытаться накропать её собственноручно.

8. Чем это поможет?

Помните код представления, который был приведён выше?

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,
    })

Я думаю вы согласитесь, что эта вторая версия намного проще и нагляднее, чем первая?

9. Может ли помочь Django?

В списке рассылки django-dev обсуждались способы упростить решение рассмотренной проблемы. По итогам обсуждения была заведена заявка. Захари Воз (Zachary Voase) предложил следующее:

class TodoManager(models.Manager):

    @models.querymethod
    def incomplete(query):
        return query.filter(is_done=False)

Это определение задекорированного метода incomplete может волшебным образом сделать его доступным сразу в менеджере и объекте запросов.

Лично я не совсем согласен с идеей использования декоратора. Он слегка затуманивает детали и не выглядит изящным. Я нутром чую, что добавление методов в подкласс объектов запросов (а не в подкласс менеджера) - это лучший, более простой подход.

Можно было бы пойти и дальше. Вернувшись назад и заново продумав проектные решения API Django с нуля, может быть удалось бы найти настоящие, более глубокие улучшения. Можно ли стереть различия между менеджерами и объектами запросов? Или по крайней мере прояснить эти различия?

Я убеждён, что если бы была предпринята такая переработка, то она должна была бы появиться в Django 2.0 или в последующих версиях.

10. Итак, напомним:

Использование низкоуровневого кода запросов ORM в представлениях и других высокоуровневых частях приложения обычно является плохой практикой. Вместо этого, создав собственное API объектов запросов и присоединив его к модели при помощи PassThroughManager из django-model-utils, мы получаем следующие выгоды:

Благодарю за чтение!

Если вы хотите вонзить свои зубы в большие проекты на Django (а также и во множество других интересных вещей), мы можем предложить вам работу.

11. Примечания переводчика

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

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

Имеется модель, которая содержит настройки менеджера SNMP. Напоминаю, что менеджер - это программа, которая занимается опросом оборудования по SNMP. Настройки на оборудовании - это настройки агента SNMP. Настройки агента сложнее, потому что содержат список сообществ и пользователей, имеющих доступ к оборудованию по SNMP, тип доступа - только чтение или чтение-запись, и ветку дерева OID'ов, к которой относится описываемый доступ - так называемые SNMP View. Так вот, сейчас мы рассматриваем только настройки менеджера SNMP.

Модель так и называется - SNMP и первоначально описывается следующим образом:

class SNMP(models.Model):
    VERSION_1 = 1
    VERSION_2C = 2
    VERSION_3 = 3
    VERSION = (
	(VERSION_1, 'SNMPv1'),
	(VERSION_2C, 'SNMPv2c'),
	(VERSION_3, 'SNMPv3'),
    )

    V3_SECURITY_LEVEL_NO_AUTH_NO_PRIV = 0
    V3_SECURITY_LEVEL_AUTH_NO_PRIV = 1
    V3_SECURITY_LEVEL_AUTH_PRIV = 2
    V3_SECURITY_LEVEL = (
	(V3_SECURITY_LEVEL_NO_AUTH_NO_PRIV, 'noAuthNoPriv'),
	(V3_SECURITY_LEVEL_AUTH_NO_PRIV, 'authNoPriv'),
	(V3_SECURITY_LEVEL_AUTH_PRIV, 'authPriv'),
    )

    V3_AUTH_PROTOCOL_MD5 = 0
    V3_AUTH_PROTOCOL_SHA = 1
    V3_AUTH_PROTOCOL = (
	(V3_AUTH_PROTOCOL_MD5, 'MD5'),
	(V3_AUTH_PROTOCOL_SHA, 'SHA'),
    )

    V3_PRIV_PROTOCOL_DES = 0
    V3_PRIV_PROTOCOL_AES = 1
    V3_PRIV_PROTOCOL = (
	(V3_PRIV_PROTOCOL_DES, 'DES'),
	(V3_PRIV_PROTOCOL_AES, 'AES'),
    )

    DEFAULT_PORT = 161

    snmp_port = models.IntegerField(u'SNMP-порт', blank=True, default=DEFAULT_PORT)
    snmp_version = models.PositiveIntegerField(u'Версия SNMP', choices=VERSION, default=VERSION_1)
    snmp_community = models.CharField(u'SNMP-сообщество', max_length=255, blank=True, default='')
    snmpv3_contextname = models.CharField(u'SNMP-контекст безопасности', max_length=255, blank=True, default='')
    snmpv3_securityname = models.CharField(u'SNMP-имя безопасности', max_length=255, blank=True, default='')
    snmpv3_securitylevel = models.PositiveIntegerField(u'SNMP-уровень безопасности', choices=V3_SECURITY_LEVEL, default=V3_SECURITY_LEVEL_NO_AUTH_NO_PRIV)
    snmpv3_authprotocol = models.PositiveIntegerField(u'SNMP-протокол аутентификации', choices=V3_AUTH_PROTOCOL, default=V3_AUTH_PROTOCOL_MD5)
    snmpv3_authpassphrase = models.CharField(u'SNMP-пароль аутентификации', max_length=255, blank=True, default='')
    snmpv3_privprotocol = models.PositiveIntegerField(u'SNMP-протокол безопасности', choices=V3_PRIV_PROTOCOL, default=V3_PRIV_PROTOCOL_DES) 
    snmpv3_privpassphrase = models.CharField(u'SNMP-пароль безопасности', max_length=255, blank=True, default='')

    class Meta:
	unique_together = (('snmp_port', 'snmp_version', 'snmp_community', 'snmpv3_contextname', 'snmpv3_securityname', 'snmpv3_securitylevel', 'snmpv3_authprotocol', 'snmpv3_authpassphrase', 'snmpv3_privprotocol', 'snmpv3_privpassphrase'),)
	verbose_name = u'Настройки SNMP'
	verbose_name_plural = u'Настройки SNMP'

Все остальные поля, не имеющие отношения к рассматриваемому примеру, были пропущены.

Суть в том, что в зависимости от версии SNMP используются настройки, хранящиеся в разных полях. В случае SNMP версий 1 и 2c используется только поле snmp_community. В случае третьей версии SNMP как минимум используются ещё и поля snmpv3_contextname, snmpv3_securityname и snmpv3_securitylevel.

В зависимости от значения поля snmpv3_securitylevel могут использоваться ещё 4 поля. Если snmpv3_securitylevel соответствует noAuthNoPriv, то дополнительные поля не используются. Если snmpv3_securitylevel соответствует authNoPriv, то дополнительно используются поля snmpv3_authprotocol и snmpv3_authpassphrase. Если snmpv3_securitylevel соответствует authPriv, то используются поля snmpv3_authprotocol, snmpv3_authpassphrase, а так же snmpv3_privprotocol и snmpv3_privpassphrase.

Представим теперь, что у нас имеется объект с настройками SNMP, которые были введены пользователем через веб-интерфейс, или поступили через API в составе структуры, описывающей устройство. Прежде чем добавлять эти настройки SNMP в таблицу, нужно проверить - есть ли уже эти настройки в таблице. Надеяться на индекс в этом случае бесполезно - значения неиспользуемых полей могут отличаться друг от друга, так что индекс не обнаружит по сути уже существующие в таблице настройки, у которых в неиспользуемых полях значения отличаются от значений в добавляемых настройках.

Верным решением была бы обязательная валидация данных при создании или изменении объекта SNMP. Но если проконтролировать изменение одного объекта при помощи методов геттеров и сеттеров ещё можно, то проконтролировать запросы, обновляющие массово несколько записей сразу, уже сложнее. Полноценную валидацию на всех возможных этапах использования объектов провести довольно сложно.

Можно немного упростить задачу и воспользоваться решением, описанным в рассматриваемой статье - сделать у объекта SNMP ещё один метод, который будет оставлять в выборке из таблицы только те объекты, которые соответствуют эталонному объекту SNMP. В моём случае это решение располагается перед описанием модели SNMP и выглядит следующим образом:

class SNMPQuerySet(models.query.QuerySet):
    def like(self, snmp):
	qs = self.filter(snmp_port=snmp.snmp_port,
			 snmp_version=snmp.snmp_version)

	if snmp.snmp_version in (self.model.VERSION_1, self.model.VERSION_2C):
	    qs = qs.filter(snmp_community=snmp.snmp_community)

	elif snmp.snmp_version == self.model.VERSION_3:
	    qs = qs.filter(snmpv3_contextname=snmp.snmpv3_contextname,
			   snmpv3_securityname=snmp.snmpv3_securityname,
			   snmpv3_securitylevel=snmp.snmpv3_securitylevel)

	    if snmp.snmpv3_securitylevel == self.model.V3_SECURITY_LEVEL_AUTH_NO_PRIV:
		qs = qs.filter(snmpv3_authprotocol=snmp.snmpv3_authprotocol,
			       snmpv3_authpassphrase=snmp.snmpv3_authpassphrase)
	    elif snmp.snmpv3_securitylevel == self.model.V3_SECURITY_LEVEL_AUTH_PRIV:
		qs = qs.filter(snmpv3_authprotocol=snmp.snmpv3_authprotocol,
			       snmpv3_authpassphrase=snmp.snmpv3_authpassphrase,
			       snmpv3_privprotocol=snmp.snmpv3_privprotocol,
			       snmpv3_privpassphrase=snmp.snmpv3_privpassphrase)
	return qs

class SNMPManager(models.Manager):
    def get_queryset(self):
	return SNMPQuerySet(self.model, using=self._db)

    def like(self, snmp):
	return self.get_queryset().like(snmp)

Теперь остаётся только добавить менеджер запросов SNMPManager в качестве менеджера по умолчанию в модель SNMP. Добавим перед строчкой "class Meta:" всего одну строку:

objects = SNMPManager()

После описанной доработки модели, можно действовать следующим образом:

# Получаем настройки SNMP от пользователя
user_snmp_settings = ...

# Ищем подобные настройки в таблице
found_snmp_settings = SNMP.objects.like(user_snmp_settings).first()

# Если настройки уже есть, используем их в последующих операциях
if found_snmp_settings:
    user_snmp_settings = found_snmp_settings
# В противном случае добавляем в таблицу настройки, полученные от пользователя
else:
    user_snmp_settings.save()

# Дальше используем user_snmp_settings
...

Стоит ли пользоваться этим методом или можно решить эту задачу изящнее каким-то другим способом - решать вам. Я хотел лишь сообщить о существовании подобного приёма и описать, как им можно воспользоваться.

Написать автору перевода