Александр Кошелев
Александр Кошелев Python-разработчик

Красивая композиция

Нет, я не про музыку решил написать. А про композицию данный, агрегацию если хотите.

В джанге на данный момент агрегации в ORM нет. Но как известно скоро должна появиться, а значит жизнь в очередной раз станет проще.

Зачем?

Но на много ли? Да, писать запросы с агрегацией станет проще. Но в большинстве случаешь выполнять запрос для сервера легче не станет. Вот ту на помощь приходит техника денормализации.

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

Наверно многие из вас в своих проектах писали код наподобие такого, тем более, если писали “свой блог движок”:

class Post(models.Model):
   comment_count = models.PositiveIntegerField(default=0)

class Comment(models.Model):
   post = models.ForeignKey(Post, related_name="comments")

   def save(self):
      super(Comment, self).save()
      self.post.comment_count = self.post.comments.count()

   def delete(self):
      super(Comment, self).delete()
      self.post.comment_count = self.post.comments.count()
      self.post.save()

Либо добивались подобного функционала с использованием сигналов. Ещё наверно дописывали метод update_что-то там чтобы в случае чего, можно было скопом посчитать денормализованное поле:

class Post(models.Model):
   #...
   def update_comment_count(self);
      self.comment_count = self.comments.count()
      self.save()

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

Пример с count() мне кажется самым распространенным. Денормализовать можно всё что угодно. Можно подсчитывать какие-то интервалы, записывать результаты сложных выборок. В общем как-бы кешировать трудно вычисляемые данные. Но это каждый раз приходится писать самому. А сценарии-то очень похожие обычно встречаются.

Меня всегда мучал вопрос - можно ли это как-то автоматизировать? Оказывается можно и даже вполне красиво.

Долгий путь

Этим вопросом озадачивался конечно не только я. На прошедшем ДжангоКоне тоже встал этот вопрос и он был помечен как надо подумать.

Также, совсем недавно Эндрю Годвин из джанговского сообщества написал в своем блоге про некий DenormField, который магическим образом кешировал в себе некий атрибут ForeignKey объекта. Т.е. да, в некотором роде это денормализация, но уж больно вырожденная. Ведь это по сути замена select_related. А если учесть что джоин по первичному ключу достаточно дешёвый, то смысла делать такую денормализации мало. Но тем не менее это первый шаг в сторону решения задачи. Вскорости автор данной реализации написал о ней в девелоперсах и получил поддержку со стороны участников. Но всем показалось, что решение достаточно локальное и узкоспециализированное. Да тут и казаться нечему - так и есть. Надо улучшать.

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

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

Как?

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

За базовую систему контроля за состояниями решил выбрать сигналы. Тем более у меня, как я уже понял, некая страсть скрещивать что-то с сигналами в декларативном стиле:) В результате у меня получился CompositionField.

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

Для начал возьмем исходный вариант с постом и комментариями и перепишем его с применением CompositionField:

D = dict

class Comment(models.Model):
   post = models.ForeignKey("Post", related_name="comments")

class Post(models.Model):
   comment_count=CompositionField(
                native=models.PositiveIntegerField(default=0),
                trigger=D(
                        on=(signals.post_save, signals.post_delete),
                        do=lambda post, comment, signal: post.comments.count(),
                        sender_model=Comment,
                        field_holder_getter=lambda comment: comment.post,
                    ),
            )

Получилось немного громоздко, да? Но что мне нравится, даже не смотря на объем, это декларативность, которой удалось добиться. Теперь давайте разберемся, что же здесь происходит.

CompostionField на самом деле не класс, а функция, которая возвращает специально обработанный объект поля в native, принимаемый в качестве параметра. Как легко догадаться, native это непосредственно поле, которое будет создано и куда будет записан некий композиционный результат. Дальше самое интересное - мы описаваем тригер, который по возникновению неких сигналов будет производить операцию и записывать значение в поле. on список сигналов для отслеживания. В данном случае мы будет отслеживать сохранение и удаление объекта. Но в принципе сигналов может быть и больше, либо всего один. do - как раз это та функция, которая будет вызвана при поступлении сигнала. Она то и производит операцию подсчета комментариев. Принимает три параметра, которые говорят сами за себя. sender_model - модель которая должна отослать сигнал. Все логично, у нас это Comment. Ну и последний параметр - field_holder_getter это функция, которая по объекту комментария должна возвращать соответствующий пост.

Вот и всё. Весь тот функционал, который я описал в первом примере кода. К чему я и стремился.

Да, ещё бесплатный бонус - мы получаем автоматически сгенерированный update_comment_count, который может быть использован в крайнем случае.

Ну как вам? Нравится? Но это ещё не всё. Система намного гибче чем я показал в этом примера. Давайте посмотрим ещё одну ситуацию.

Допустим, что есть некоторое событие Event и есть несколько пришедших на него - Visit. И мы хотим денормализовать количество пришедших. В принципе по структуре ситуация идентична случаю с постом и комментариями, но на ней я покажу другие возможности CompositionFIeld.

D = dict

class Visit(models.Model):
   event = models.ForeignKey("Event")

class Event(models.Model):
   visit_count = CompositionField(
                native=models.PositiveIntegerField(default=0),
                trigger=[
                    D(
                        on=signals.post_save,
                        do=lambda event, visit, signal: event.visit_count + 1
                    ),
                    D(
                        on=signals.post_delete,
                        do=lambda event, visit, signal: event.visit_count - 1
                    )
                ],
                commons=D(
                    sender_model="app.Visit",
                    field_holder_getter=lambda visit: visit.event,
                ),
                commit=True,
                update_method=D(
                    do=0,
                    initial=0,
                    queryset=lambda event: event.visit_set.all(),
                    name="sync_visit_count"
                )
            )

Кода стало ещё больше, но он за-то покажет другие интересные моменты. Как вы заметили, если раньше был один тригер, то теперь их два - т.е. тригеров может быть несколько. Теперь каждый из них повешен на свой сигнал и считают они комментарии на базе уже имеющегося значения счетчика. Хочу отметить, что лучше так не делать, поскольку если вы работает в транзакции, то можете получить неожиданные результаты по завершению:) Но в качестве демонстрации, это не особо важно. Далее, поскольку два тригера похожи, то общие их свойства выделены в отдельный параметр commons, который создан для упрощения объявления родственных триuгеров. commit=True - говорит о том, что объект после применения обработчика нужно сохранить. Кстати, тут это просто для наглядности, поскольку это поведение по-умолчанию.

Ну и, наконец, параметр update_method, о котором расскажу более подробно. Он позволяет кастомизировать генерацию стандартного метода обновления. Т.к. обновление счетчика у нас инкрементальное, то во-первых перед выполнением метода, счетчик надо обнулить(initial=0). Также нужно знать какой тригер применять. В данном случае счетчик мы будем каждый раз увеличивать, то я выбрал первый тригер с индексом 0 (do=0).

queryset это очень интересный параметр. Поскольку тут выполнение действия идет не по сигналу, а по факту вызова метода, то для правильной работы имеющегося тригера, нужно указать множество объктов для его применения. В этом случае множество посетителей заданного события. Ну и name это переопределение стандартно сгенерированного update_FOO имени метода на какое-то своё.

Классно, да? Но и это не всё. Рассмотрим ещё одну ситуацию, похожую на ту которую предложил автор DenormField. Есть кинофильм и есть его режиссер. Режиссер может быть автором нескольких фильмов, поэтому модель такая:

class Person(models.Model):
   name = models.CharField(max_length=250)

class Movie(models.Model):
   title = models.CharField(max_length=250)
   director = models.ForeignKey(Person)

   headline=CompositionField(
        native=models.CharField(max_length=250),
        trigger=D(
             sender_model=Person,
             field_holder_getter=lambda director: director.movie_set.all(),
             do=lambda movie, _, signal: "%s, by %s" % (movie.title, movie.director.name)
          )
    )

Тут уже меньше кода. Нового тут объяснять особо нечего. Это простой пример другой ситуации. Подсчет некого хедлайна получился достаточно простой.

Ну вот пожалуй пока всё.

Что дальше?

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

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

Путь упрощения - написание более высокоуровневых шорткатов, которые покрывали бы самые распространенные варианты использования, т.е. что-то типа CountField, RelatedAttrField и т.п. Но это уже другая история.

Сейчас я очень доволен тем что получилось. А получился именно достаточно общий и универсальный инструмент для денормализации некоторых наборов данных. А то что он завязан на сигналы дает огромнейший простор для творчества.

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

comments powered by Disqus