Django ORM и эффективная работа с базой данных

Архитектура Django позволяет значительно ускорить процесс разработки благодаря простой схеме использования баз данных в приложениях. Django ORM предоставляет простой механизм работы с базой без изучения синтаксиса SQL запросов. Однако подобное абстрагирование может привести к неэффективному использованию БД, что может сказаться на медленной работе сайтов даже при небольших объемах данных.

Давайте посмотрим, как можно создавать модели и работать с ними эффективно.

Тестирование производительности приложения Django

Иногда обнаружить проблемы производительности удается довольно быстро. Часто они появляются при первой попытке запустить приложение с реальными данными. Это может стать очевидным, когда выполнение набора из нескольких тестов занимает больше 5 минут. В других случаях медленная работа приложения становится заметной визуально. К счастью, существуют некоторые шаблоны проблем производительности, которые легко идентифицировать и исправить. В листинге 1 (файл models.py приложения) и листинге 2 показан пример типичной ошибки.
Листинг 1. Базовые модели для приложения examples: файл models.py

from django.db import models

# Некоторый документ, такой как запись в блоге или wiki-страничка

class Document(models.Model):
    name = models.CharField(max_length=255)

# Генерируемый пользователем комментарий, похожий на комментарии на сайте
# Digg или Reddit

class Comment(models.Model):
    document = models.ForeignKey(Document, related_name='comments')
    content = models.TextField()

Во всех приведенных примерах предполагается, что 

  • Проект Django называется better_models.
  • В проекте better_models имеется приложение с именем examples.

Приложение examples моделирует простую, похожую на блог систему документов, каждый из которых может иметь комментарии.

В листинге 2 показано, как можно осуществлять доступ к модели данных, показанной в листинге 1 неэффективным способом.
Листинг 2. Медленный доступ к моделям данных

from examples.model import *
import uuid

# Сначала создадим много документов и назначим им случайные имена

for i in range(0, 10000):
    Document.objects.create(name=str(uuid.uuid4()))

# Запрашиваем имена документов для последующего просмотра

names = Document.objects.values_list('name', flat=True)[0:5000]

# Медленный способ получения списка документов с 
# заданными именами

documents = []

for name in names:
    documents.append(Document.objects.get(name=name))

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

Приведенный выше наивный код выполняется около 65 секунд при использовании размещаемой в оперативной памяти базе данных sqlite3. С базой данных, зависящей от файловой системы, он бы работал еще дольше. В листинге 3 показано, как исправить этот медленный код. Вместо выполнения запроса к базе данных для каждого имени, следует использовать оператор fieldname__in, который сгенерирует SQL-запрос, выглядящий примерно так:

SELECT * FROM model WHERE fieldname IN ('1', '2', ...)

(Точный синтаксис запроса будет различаться в зависимости от используемой базы данных.)

Листинг 3. Быстрый запрос для получения списка элементов

from examples import models
import uuid

for i in range(0, 10000):
    Document.objects.create(name=str(uuid.uuid4()))

names = Document.objects.values_list('name', flat=True)[0:5000]

documents = list(Document.objects.filter(name__in=names))

Этот код выполняется всего 3 секунды. Обратите внимание, что для того, чтобы запрос был действительно выполнен, в этом коде результат запроса преобразуется в список. Без этого сравнение было бы некорректным, так как в Django реализован отложенный механизм выполнения запросов, в котором простого присваивания результата запроса переменной недостаточно для фактического обращения к базе данных.

Гуру баз данных, для которых написание SQL-кода обычное дело, сочтут этот пример очевидным, но многие программисты Python не имеют существенного опыта работы с базами данных. Иногда самые хорошие привычки разработчика могут сыграть против эффективности. В листинге 4 показан один из способов рефакторинга кода из листинга 2, который можно было бы применить, не понимая его ошибочности.
Листинг 4. Типичный шаблон кода, приводящего к медленной работе с базой данных 

	
for name in names:
    documents.append(get_document_by_name(name))

def get_document_by_name(name):
    return Document.objects.get(name=name))

 

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

В начало

Инкапсуляция типичных запросов с помощью управляющих классов моделей

Встроенный управляющий класс модели, называемый Manager, используют все разработчики Django: именно он вызывается для всех методов формы Model.objects.*. Базовый класс Manager доступен автоматически и предоставляет часто используемые методы, возвращающие объекты QuerySet (например, all()), простые значения (например, count()) и объекты класса Model (например, get_or_create()).

Платформа Django поощряет разработчиков переопределять базовый класс Manager. Чтобы проиллюстрировать, почему это может быть полезным, добавим в приложение examples новую модель Format, которая описывает формат хранимых в системе файлов документов, например, как это показано в листинге 5.
Листинг 5. Добавление модели в приложение examples

from django.db import models

class Document(models.Model):
    name = models.CharField(max_length=255)
    format = models.ForeignKey('Format')

class Comment(models.Model):
    document = models.ForeignKey(Document, related_name='comments')
    content = models.TextField()

class Format(models.Model):
    type = models.CharField(choices=( ('Text file', 'text'),
                                      ('ePub ebook', 'epub'),
                                      ('HTML file', 'html')),
                            max_length=10)

 

Передовые техники обновления базы данных

Каждый раз при добавлении в models.py новой таблицы или новых столбцов в существующую таблицу необходимо повторно синхронизировать соответствующую базу данных. Воспользовавшись следующими советами, вы сможете делать это более эффективно.

  • На ранних стадиях разработки используйте только размещаемую в оперативной памяти базу данных, такую как sqlite3. Пользуйтесь возможностями по автоматической загрузке данных в базу из файлов с тестовым содержимым. Базы данных, размещаемые в оперативной памяти, работают достаточно быстро для одного пользователя и позволяют существенно сократить время ожидания при удалении и повторном создании таблиц в традиционных СУБД, таких как MySQL.
  • Придерживайтесь стиля разработки через тестирование. Инфраструктура тестирования Django каждый раз пересоздает базу данных с нуля, поэтому таблицы всегда будут актуальными. Применение этой функциональности в сочетании с размещаемой в оперативной памяти базой данных sqlite3 делает тестирование еще быстрее.
  • Попробуйте одну из многочисленных надстроек Django, управляющих синхронизацией базы данных. У меня был положительный опыт использования пакетаdjango-evolution, кроме него имеются и другие пакеты. Больше информации оdjango-evolution можно найти в разделеРесурсы.

Если вы решили использовать для разработки или тестирования sqlite3, обязательно проведите финальное интеграционное тестирование на рабочей базе данных. Для большинства случаев механизм ORM Django помогает сгладить различия между разными СУБД, но нет гарантии, что поведение будет во всем идентичным.

Далее воспользуемся измененной моделью и создадим несколько документов различных форматов(листинг 6).
Листинг 6. Создаем несколько документов различных форматов

# Сначала создадим набор объектов класса Format
# и сохраним их в базе данных

format_text = Format.objects.create(type='text')
format_epub = Format.objects.create(type='epub')
format_html = Format.objects.create(type='html')

# Создадим несколько документов в различных форматах
for i in range(0, 10):
    Document.objects.create(name='My text document',
                                   format=format_text)
    Document.objects.create(name='My epub document',
                                   format=format_epub)
    Document.objects.create(name='My HTML document', 
                                   format=format_html)

 

Допустим, нужно, чтобы приложение предоставляло возможность сначала фильтровать документы по формату, а затем фильтровать этот объект QuerySet по другим полям, например по названию. Следующий простой запрос возвращает только текстовые документы: Document.objects.filter(format=format_text).

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

В таких ситуациях может помочь создание собственного управляющего класса модели. Собственные управляющие классы позволяют задавать неограниченное количество «шаблонных» запросов подобно методам встроенного управляющего класса модели, таким как latest() (который возвращает только последний экземпляр модели) или distinct() (который добавляет к сгенерированному запросу инструкцию SELECT DISTINCT). Управляющие классы не только сокращают дублирование кода в приложении, но также улучшают читаемость кода. Согласитесь, что спустя некоторое время по сравнению с кодом:

Documents.objects.filter(format=format_text,publish_on__week_day=todays_week_day, 
  is_public=True).distinct().order_by(date_added).reverse()

 

вам или новому разработчику будет гораздо проще понять следующий код:

Documents.home_page.all()

 

Создать собственный управляющий класс модели чрезвычайно просто. В листинге 7 показан пример создания для модели документа управляющего класса get_by_format.
Листинг 7. Собственный управляющий класс модели, предоставляющий методы для каждого типа формата документа 

	
from django.db import models

class DocumentManager(models.Manager):

    # Класс модели всегда доступен управляющему классу через
    # self.model, но в этом примере мы используем только метод
    # filter(), унаследованный от models.Manager.

    def text_format(self):
        return self.filter(format__type='text')

    def epub_format(self):
        return self.filter(format__type='epub')

    def html_format(self):
        return self.filter(format__type='html')

class Document(models.Model):
    name = models.CharField(max_length=255)
    format = models.ForeignKey('Format')

    # Новый управляющий класс модели
    get_by_format = DocumentManager()

    # Управляющий класс по умолчанию теперь нужно определять явно
    objects = models.Manager()


class Comment(models.Model):
    document = models.ForeignKey(Document, related_name='comments')
    content = models.TextField()

class Format(models.Model):
    type = models.CharField(choices=( ('Text file', 'text'),
                                      ('ePub ebook', 'epub'),
                                      ('HTML file', 'html')),
                            max_length=10)
    def __unicode__(self):
        return self.type

 

Несколько замечаний относительно этого кода.

  • Если вы создаете собственный управляющий класс модели, Django автоматически исключает управляющий класс по умолчанию. Я предпочитаю оставлять как управляющий класс модели по умолчанию, так и собственный управляющий класс, чтобы другие разработчики (или я сама, если забуду) могли все так же использовать objects, которые будут вести себя в точности так, как ожидается. Однако так как управляющий класс, доступный по имениget_by_format, является просто подклассом встроенного класса models.Manager, в нем доступны все методы по умолчанию, такие как all(). Делать или не делать одновременно доступными управляющий класс по умолчанию и собственный управляющий класс, зависит от личных предпочтений.
  • Также есть возможность напрямую назначать для objects новый управляющий класс. Единственный недостаток проявится, если вы захотите вручную переопределить изначальный класс QuerySet. В таком случае ваши новыеobjects могут вести себя неожиданным для других разработчиков образом.
  • Вам необходимо определить управляющий класс в models.py перед определением вашего класса модели, иначе он не будет доступным для Django. Это похоже на ограничения, имеющиеся у класса ForeignKey.
  • Можно было бы просто реализовать класс DocumentManager с единственным методом, принимающим аргумент, например with_format(format_name). Однако в общем случае я предпочитаю создавать методы управляющего класса с подробными именами, но не принимающие никаких аргументов.
  • Не существует технического ограничения на количество собственных управляющих классов, которые можно назначать модели, но маловероятно, что вам понадобится больше чем один или два.

Использовать методы нового управляющего класса модели достаточно просто.

In [1]: [d.format for d in Document.get_by_format.text_format()][0]
Out[1]: <Format: text>

In [2]: [d.format for d in Document.get_by_format.epub_format()][0]
Out[2]: <Format: epub>

In [3]: [d.format for d in Document.get_by_format.html_format()][0]
Out[3]: <Format: html>

 

Теперь появилось удобное место, в котором можно размещать любую функциональность, относящуюся к этим запросам, также сюда можно добавлять дополнительные ограничения, не засоряя код. Такой подход согласуется с видением в Django шаблона модель–вид–контроллер (model-view-controller или MVC), в соответствии с которым функциональность подобного рода следует размещать в models.py, а не скапливать ее в представлениях или шаблонах

Переопределяем изначальный класс QuerySet, возвращаемый пользовательским управляющим классом модели

В другом шаблоне реализации управляющих классов модели собственные методы вообще не создаются. Вместо определения нового метода, возвращающего только HTML-документы, можно определить управляющий класс, который будет работать только на этом ограниченном множестве данных. Пример показан в листинге 8.
Листинг 8. Пользовательский управляющий класс для HTML-документов

	
class HTMLManager(models.Manager):
    def get_query_set(self):
        return super(HTMLManager, self).get_query_set().filter(format__type='html')

class Document(models.Model):
    name = models.CharField(max_length=255)
    format = models.ForeignKey('Format')
html = HTMLManager()
    get_by_format = DocumentManager()
    objects = models.Manager()

 

В этом примере метод get_query_set() наследуется из models.Manager и переопределяется. В этом методе за основу берется базовый запрос (тот же самый, который генерируется методом all()), к которому затем применяется дополнительный фильтр. Все методы, которые мы будем впоследствии добавлять в этот управляющий класс, будут сначала вызывать метод get_query_set(), а затем применять к результату дополнительные методы запросов, как показано в листинге 9.
Листинг 9. Использование управляющего класса, работающего только с документами формата html

# Наш запрос HTML-документов возвращает то же количество
# документов, что и управляющий класс по умолчанию, явно выполняющий фильтрацию 
# данных.

In [1]: Document.html.all().count() 
Out[1]: 10

In [2]: Document.get_by_format.html_format().count()
Out[2]: 10

# Можно доказать, что они возвращают в точности один и тот же результат

In [3]: [d.id for d in Document.get_by_format.html_format()] == 
    [d.id for d in Document.html.all()]
Out[3]: True

# В HTMLManager() уже нельзя работать с нефильтрованными данными

In [4]: Document.html.filter(format__type='epub')
Out[4]: []

 

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

Использование классов и статических методов в моделях

Нет никаких ограничений на типы методов, которые можно добавлять в управляющий класс. Методы могут возвращать объекты QuerySet, как показано выше, или экземпляры соответствующего класса модели (доступного черезself.model).

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

Вот простой пример вспомогательного метода, который относится ко всему классу Format, а не к какому-либо конкретному его экземпляру:

# Возвращает каноническое имя формата для некоторых часто 
# встречающихся в реальной жизни расширений

def check_extension(extension):
    if extension == 'text' or extension == 'txt' or extension == '.csv':
        return 'text'
    if extension.lower() == 'epub' or extension == 'zip':
        return 'epub'
    if 'htm' in extension:
        return 'html'
    raise Exception('Did not get known extension')

 

Этот код не принимает и не возвращает экземпляр класса Format, поэтому он не может быть методом экземпляра класса. Его можно было бы поместить в класс FormatManager, но так как этот метод вообще не обращается к базе данных, это место для него также не совсем подходит.

В качестве решения можно добавить этот метод в класс Format и объявить его статическим методом с помощью декоратора @staticmethod, как показано в листинге 10.
Листинг 10. Добавляем вспомогательную функцию в виде статического метода класса модели 

	
class Format(models.Model):
    type = models.CharField(choices=( ('Text file', 'text'),
                                      ('ePub ebook', 'epub'),
                                      ('HTML file', 'html')),
                            max_length=10)
    @staticmethod
    def check_extension(extension):
        if extension == 'text' or extension == 'txt' or extension == '.csv':
            return 'text'
        if extension.lower() == 'epub' or extension == 'zip':
            return 'epub'
        if 'htm' in extension:
            return 'html'
        raise Exception('Did not get known extension')

    def __unicode__(self):
        return self.type

 

Этот метод можно вызывать в виде Format.check_extension(extension), и для этого не требуется иметь экземпляр класса Format или создавать управляющий класс.

В Python также имеется декоратор @classmethod, который генерирует методы, оперирующие над классами. Такие методы принимают в качестве первого аргумента сам класс. Это может быть полезно, когда вы хотите выполнить какую-либо интроспекцию класса, не создавая экземпляр этого класса.

В начало

Агрегирующие запросы в Django

В Django 1.1 в механизм ORM включены мощные методы запросов, позволяющие реализовывать функциональность, ранее доступную только через использование SQL напрямую. Для разработчиков Python, остерегающихся работать с SQL, и для всех, кто хочет поддерживать работоспособность своего Django-приложения с различными базами данных, это является действительно ценным нововведением.

В современных приложениях, ориентированных на общение пользователей, очень часто данные сортируются не по статическому полю, например по алфавиту или времени создания, а на основе динамических данных. Допустим, в приложении examples мы хотим сортировать документы по популярности, определяемой по количеству комментариев к документу. До Django 1.1 такое можно было сделать либо написав собственный SQL-код, либо реализовав непереносимую хранимую процедуру, либо, что хуже всего, написав несколько неэффективных объектно-ориентированных запросов. При другом подходе можно было бы определить в базе данных фиктивное поле для хранения желаемого значения (например, количества комментариев к документу) и обновлять это поле вручную, переопределив метод save() документа.

Механизм агрегации Django устраняет необходимость прибегать к таким хитростям. Теперь можно упорядочивать документы по количеству комментариев, используя лишь один метод QuerySetannotate(). Пример приведен в листинге 11.
Листинг 11. Использование агрегации для упорядочения результатов по количеству комментариев

       
from django.db.models import Count

# Создадим несколько документов
unpopular = Document.objects.create(name='Unpopular document', format=format_html)
popular = Document.objects.create(name='Popular document', format=format_html)

# Документу "popular" добавим больше комментариев, чем документу "unpopular"
for i in range(0,10):
    Comment.objects.create(document=popular)

for i in range(0,5):
    Comment.objects.create(document=unpopular)

# Если мы возвращаем результаты, сортируя их по времени создания (по умолчанию по id),
# первым будет выведен документ "unpopular".
In [1]: Document.objects.all()
Out[1]: [<Document: Unpopular document>, <Document: Popular document>]

# Если же вместо этого мы аннотируем результат общим количеством комментариев
# у каждого документа и затем упорядочим его по этому вычисленному значению,
# то первым будет выведен документ "popular".

In [2]: Document.objects.annotate(Count('comments')).order_by('-comments__count')
Out[2]: [<Document: Popular document>, <Document: Unpopular document>]

 

Метод annotate() класса QuerySet сам по себе не выполняет никакой агрегации. Вместо этого он командует Django назначить значение переданного выражения псевдостолбцу в полученном результате. По умолчанию именем этого столбца является строка из названия предоставленного поля (здесь значение Comment.document.related_name()) и имени агрегирующего метода. В этом коде вызывается django.db.models.Count – одна из простых математических функций, доступных в библиотеке агрегации (с полным списком методов можно ознакомиться по ссылке в разделе Ресурсы).

Результатом вызова Document.objects.annotate(Count('comments')) является объект QuerySet, имеющий новое свойство comments__count. Чтобы переопределить имя по умолчанию, можно передать желаемое имя в качестве именованного аргумента.

Document.objects.annotate(popularity=Count('comments'))

 

Теперь, когда промежуточный объект QuerySet содержит количество комментариев, ассоциированных с каждым документом, можно упорядочить его по этому новому полю. Так как мы хотим вначале видеть документы с наибольшим количеством комментариев, задаем сортировку по убыванию: .order_by('-comments__count').

Использование агрегации не только сокращает количество кода, который приходится писать, но также гарантирует, что эти операции будут выполняться быстро, так как математические вычисления в них выполняются посредством базы данных. Такая реализация гораздо более эффективна, чем извлечение всех подходящих данных с помощью ORM с последующим выполнением вычислений вручную.

Другие типы агрегации в Django 1.1

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

In [1]: from django.db.models import Avg
In [2]: Document.objects.aggregate(Avg('comments'))
Out[2]: {'comments__avg': 8.0}

 

Агрегацию можно применять как к отфильтрованным, так и неотфильтрованным запросам. Кроме того, можно фильтровать данные по столбцам, сгенерированным с помощью annotate, так же как по обычным столбцам. Также агрегирующие методы можно применять к объединениям данных. Например, можно агрегировать документы на основе рейтинга комментариев к ним, как это сделано в сайтах наподобие Slashdot.

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

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