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

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

В джанге на данный момент агрегации в 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 и т.п. Но это уже другая история.

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

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

Комментарии 19

  1. Григорий Петухов написал:

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

    Оставлен 26 Сентябрь 2008 в 08:54
  2. priestley написал:

    Надо попробовать на практике насколько удобно получается. Хотя уже сейчас мне кажется, что без шоткатов не обойтись. Отпишусь через пару дней о том как прошел тест.

    Оставлен 26 Сентябрь 2008 в 10:35
  3. Yuri Baburov написал:

    Я бы этим пользовался, только, плиз, преобразуй это в класс, наследуемый от CompositionField. А то в моделях не очень наглядно выглядят "поля" в 20 строчек :) Кроме того, как обычно, иногда твоих декларативных штучек будет не хватать, а если будет subclassing -- можно будет с лёгкостью изменить поведение.

    p.s. Где можно будет скачать? :)

    Оставлен 26 Сентябрь 2008 в 11:28
  4. Александр Кошелев написал:

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

    То что всё в одном месте и в декларативной форме. Вообще конечно кому как удобнее, поэтому не могу настаивать.

    Оставлен 26 Сентябрь 2008 в 12:47
  5. Александр Кошелев написал:

    Отпишусь через пару дней о том как прошел тест.

    Супер. Очень интересны сценарии использования.

    Оставлен 26 Сентябрь 2008 в 12:49
  6. Александр Кошелев написал:

    Я бы этим пользовался, только, плиз, преобразуй это в класс, наследуемый от CompositionField.

    То что CompositionField не класс, а функция для меня сейчас главная головная боль.

    p.s. Где можно будет скачать? :)

    Следи за обновлениями файла:)

    Оставлен 26 Сентябрь 2008 в 12:52
  7. ilya написал:

    А вообщето уже есть готовый код для denormalized field — Denormalisation Follies примерно двух недельной давности.

    Оставлен 26 Сентябрь 2008 в 13:47
  8. Sergey Kishchenko написал:

    Очень интересно, декларативность эта мне весьма нравится :) В первом примере кода опечатка - ForeigmKey вместо ForeignKey

    Оставлен 26 Сентябрь 2008 в 14:53
  9. Александр Кошелев написал:

    А вообщето уже есть готовый код для denormalized field — Denormalisation Follies примерно двух недельной давности.

    Спасибо, я на него и так сослался в самом начале:) Прост он, а у меня более универсальное решение.

    Оставлен 26 Сентябрь 2008 в 14:57
  10. Михаил написал:
    
    266c266
    <                     raise ValueError("Model with name '%s' must be class instance not string" % foreign_rel.to)
    ---
    >                     raise ValueError("Model with name '%s' must be class instance not string" % foreign_field.rel.to)
    
    Оставлен 06 Октябрь 2008 в 19:34
  11. Александр Кошелев написал:

    266c266

    Ага, спасибо. Застали самый процесс разработки:) Сегодня про ForeignAttributeField напишу.

    Оставлен 06 Октябрь 2008 в 22:26
  12. ramusus написал:

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

    http://svn.turbion.org/turbion/trunk/turbion/core/utils/composition.py

    Может подскажите пути как раздобыть исходники? было бы очень интересно попробовать его в деле

    Оставлен 10 Апрель 2009 в 01:06
  13. Александр Кошелев написал:

    Может подскажите пути как раздобыть исходники? было бы очень интересно попробовать его в деле

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

    Оставлен 10 Апрель 2009 в 19:12
  14. Александр Кошелев написал:

    Поправил ссылку. Теперь код на bitbuсket.org и открыт полностью:-)

    Оставлен 12 Апрель 2009 в 01:10
  15. ramusus написал:

    Александр, а ты пробовал использовать этот класс с каким-нибудь авто-мигратором? Я попробовал создать CompositionField и обновить схему при помощи django_evolution и тот ругнулся, видимо не смог распознать тип поля:

    web_site$ python manage.py evolve
    Traceback (most recent call last):
      File "manage.py", line 11, in <module>
        execute_manager(settings)
      File "/var/www/web_site/django/core/management/__init__.py", line 359, in execute_manager
        utility.execute()
      File "/var/www/web_site/django/core/management/__init__.py", line 304, in execute
        self.fetch_command(subcommand).run_from_argv(self.argv)
      File "/var/www/web_site/django/core/management/base.py", line 195, in run_from_argv
        self.execute(*args, **options.__dict__)
      File "/var/www/web_site/django/core/management/base.py", line 222, in execute
        output = self.handle(*args, **options)
      File "/var/www/web_site/django_evolution/management/commands/evolve.py", line 72, in handle
        current_signature = pickle.dumps(current_proj_sig)
    cPickle.PicklingError: Can't pickle <class 'utils.composition.CompositionField'>: it's not the same object as utils.composition.CompositionField
    
    Оставлен 14 Апрель 2009 в 19:05
  16. Александр Кошелев написал:

    Откровененно говоря, не пробовал. В том проекте, где использовался django-evolution, я CompositionField не применял.

    Тут причем проблема с его "запикливанием". А как вы его в моделе определили?

    Оставлен 14 Апрель 2009 в 21:40
  17. Александр Кошелев написал:

    Можете в принципе первый тикет к проекту на bitbucket завести:-) Тогда там и будем копать проблему.

    Оставлен 14 Апрель 2009 в 21:53
  18. ramusus написал:

    готово. Завел тут

    http://bitbucket.org/daevaorn/turbion/issue/1/using-compositionfield-with

    Оставлен 15 Апрель 2009 в 00:03
  19. Александр Кошелев написал:

    Ок, я посмотрю. Спасибо.

    Оставлен 15 Апрель 2009 в 01:03

Пингбеки 2

  1. От Денормализация 20 Декабрь 2012 в 14:01

    webnewage.org/2008/09/26/krasivaya-kompozitsiya/абстрактные идеи есть но вот все же на практике тяжко

  2. От Как уменьшить количество запросов? 27 Февраль 2009 в 11:53

    [...]ага, было, решение называется денормализациейвот здесь довольно глубоко рассмотрена проблема, но основная идея практически в первом абзаце.http://webnewage.org/2008/9/26/krasivaya-kompozitsiya/[...]