ActiveModel::Dirty без ActiveRecord

Ковыряясь недавно в модуле ActiveModel::Dirty в Rails я наткнулся на ряд интересных моментов, о чем дальше и пойдет речь. Мне надо было разобраться как подключить ActiveModel::Dirty в обычный Ruby класс без ActiveRecord. Процедура эта хорошо описана в документации, но в моем случае возникли неожиданные подводные камни. Обо всем по порядку.

Зачем нужен ActiveModel::Dirty

В модуле ActiveModel::Dirty реализовано отслеживание изменений атрибутов модели. С небольшими хаками он интегрирован и в ActiveRecord. Когда вы в Rails вызываете методы модели changes, previous_changes или changed?, вы используете методы из ActiveModel::Dirty.

Если кратко, то с ActiveModel::Dirty можно определить какие несохраненные в базу изменения были сделаны. Если развернуто, то доступны следующие методы:

  • changed? - есть ли несохраненные изменения атрибутов?
  • changed - имена измененных атрибутов
  • changed_attributes - старые значения измененных атрибутов в виде хеша
  • changes - старые и новые значения измененных атрибутов
  • previous_changes - старые и новые значения атрибутов перед последним сохранением
  • attribute based accessor methods - все то же но для конкретного атрибута

Но лучше один раз увидеть чем сто раз услышать:

a = Account.create(name: 'Google')
a.changed?           # => false

a.name = 'Facebook'
a.changed?           # => true
a.changed_attributes # => {"name"=>"Google"}
a.changes            # => {"name"=>["Google", "Facebook"]}
a.name_changed?      # => true
a.name_change        # => ["Google", "Facebook"]
a.name_was           # => "Google"

Здесь мы видим видим основные методы из ActiveModel::Dirty вызываемые на ActiveRecord модели. В ActiveRecord модуль значительно расширяется (ActiveRecord::AttributeMethods::Dirty).

Подключение вне ActiveRecord

Согласно документации весь функционал доступный в ActiveRecord можно получить практически бесплатно. Надо просто подключить модуль ActiveModel::Dirty в свой класс и сделать некоторые дополнительные шаги. Перечислим шаги интеграции:

  1. все отслеживаемые атрибуты надо задекларировать вызовом метода define_attribute_methods
  2. при изменении значения атрибуты надо вызывать метод [attr_name]_will_change! (до изменения значения)
  3. в аналоге метода save нужно вызывать changes_applied
  4. в аналоге метода reload нужно вызывать clear_changes_information

ActiveRecord, кстати, подключает ActiveModel::Dirty ровно таким же способом (source).

Приведу пример из Rails Guides:

class Person
  include ActiveModel::Dirty
  define_attribute_methods :first_name, :last_name # <--- 1)

  def first_name
    @first_name
  end

  def first_name=(value)
    first_name_will_change! # <--- 2)
    @first_name = value
  end

  def last_name
    @last_name
  end

  def last_name=(value)
    last_name_will_change!
    @last_name = value
  end

  def save
    # do save work...
    changes_applied # <--- 3)
  end
end

Пример без изменений работает и на стареньких Rails 4.2 и на текущих Rails 5.2. Все выглядит легко до тех пор пока не пытаешься проделать это не с простеньким классом Person, а с огромным классом с кучей методов и сложной логикой и связями. Здесь и начинаются сюрпризы.

Но давайте вначале разберемся как именно работает ActiveModel::Dirty. В последние годы реализация значительно менялась несколько раз - в Rails 5.2 и в Rails 6.0 (пока доступен только RC1).

ActiveModel::Dirty в Rails 4.2

ActiveModel::Dirty работает следующим образом - все изменения модели сохраняются в виде Hash‘а в переменной @changed_attributes. Изначально пустой Hash обновляется при вызовах *_will_change! метода, который, напомню, должен вызываться перед каждым изменением атрибута. Сохраняется имя атрибута и его текущее (т.е. до изменения) значение (source). *_will_change! сохраняет значение атрибута только при первом вызове, когда атрибут еще не был изменен. Следовательно, если атрибуту присвоить несколько значений, то сохранится только оригинальное, которое он имел при первой модификации. При вызовах методов clear_changes_information (source) и changes_applied (source) Hash @changed_attributes очищается.

Вместе с этим доступны и предыдущие изменения, который сохраняются в переменной @previously_changed тоже в виде Hash‘а. В него переносятся текущие несохраненные изменения при вызове метода changes_applied, который должен вызываться при сохранении данных в базу данных. При вызове clear_changes_information изменения аналогично очищаются.

При вызове restore_attributes всем измененным атрибутам присваиваются оригинальные значения, которые были сохранены в @changed_attributes, и изменения очищаются (вызывается метод clear_changes_information) (source).

Все остальные публичные методы, как общие так и специфичные для атрибутов, основываются на этих двух Hash‘ах - @changed_attributes и @previously_changed.

ActiveModel::Dirty также ожидает, что для атрибутов доступны и getter‘ы, и setter‘ы (attr_name и attr_name=).

Специфичные для атрибута методы

Любопытно каким способом генерируются специфичные для атрибутов методы. Например, для атрибута title будут доступны:

  • title_changed?
  • title_change
  • title_will_change!
  • title_was
  • restore_title!

ActiveModel::Dirty опирается на другой модуль ActiveModel::AttributeMethods, который умеет генерировать методы для атрибута, добавляя префиксы и суффиксы к его имени. Метод define_attribute_methods из описания подключения ActiveModel::Dirty как раз и реализован в ActiveModel::AttributeMethods. Все методы производные от имени атрибутов генерируются в нем. ActiveModel::Dirty просто декларирует какие префиксы и суффиксы надо использовать:

module ActiveModel
  module Dirty
    include ActiveModel::AttributeMethods

    # ...
    included do
      attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
      attribute_method_affix prefix: 'restore_', suffix: '!'
    end

    # ...
  end
end

source

Методы сгенерированные define_attribute_methods всего лишь проксируют вызов специфичного для атрибута метода к универсальному методу (source). Например title_changed? будет вызывать метод attribute_changed?(attr, options = {}) (source), а title_will_change! - attribute_will_change!(attr) (source). Эти универсальные методы реализованы в ActiveModel::Dirty и выполняют всю работу.

Это не описано явно в документации ActiveModel::AttributeMethods, но можно не декларировать атрибуты вызовом define_attribute_methods если добавить метод #attributes. #attributes должен возвращать Hash, ключи в котором - имена атрибутов. В этом случае сработает механизм основанный на method_missing (source). Несмотря на то, что методы для атрибутов не будут сгенерированы, если имя вызываемого несуществующего метода соответствует имени атрибута возвращаемого методом #attributes и зарегистрированному шаблону (с суффиксом или префиксом (source)), то вызов будет проксирован к соответствующему общему методу (source).

Приведем пример. Допустим есть только один атрибут title, #attribute_methods возвращает { 'title' => 'Manager' } с текущим его значением и зарегистрирован суффикс _changed?. В этом случае вызов метода title_changed? будет обработан в method_missing и будет вызван метод attribute_changed?('title').

Генерация методов

Интерес представляет еще один трюк с генерацией методов. Все специфичные атрибутам генерируемые методы при вызове define_attribute_methods добавляются не в текущий класс или модуль, а в специальный анонимный модуль (переменная @generated_attribute_methods), который подключается в уже в сам ActiveModel::AttributeMethods (source).

Таким образом method lookup выполняется по следующей схеме:

Из-за этого трюка становится возможным переопределить сгенерированные методы в других модулях, которые подключаются позже (между Person и ActiveModel::AttributeMethods в нашем примере), или даже в самом конечном классе. В противном случае методы бы добавлялись непосредственно в конечный класс (Person).

Приведем пример кастомизации генерируемого метода. Допустим, мы хотим изменить метод [attr_name]_change для атрибута first_name но не непосредственно в самом классе Person, в отдельном модуле. Это сделать достаточно просто:

module PersonConcern
  # override generated by ActiveModel::Dirty method
  def first_name_change
    super.tap do |pair| # [old, new]
      pair[1] += ' [Customised]'
    end
  end
end

class Person
  include ActiveModel::Dirty
  include PersonConcern

  define_attribute_methods :first_name

  # ...

  def first_name=(value)
    first_name_will_change!
    @first_name = value
  end

  # ...
end

person = Person.new
person.first_name = "First Name"
person.first_name_change # => [nil, "First Name [Customised]"]

Если бы метод first_name_change при вызове define_attribute_methods добавлялся непосредственно в класс Person, он был бы недоступен в других модулях подключенных в Person (например в PersonConcern).

Такой же подход используется и в ActiveRecord - все генерируемые методы, getter‘ы (source) и setter‘ы (source) в том числе, добавляются в этот же самый анонимный модуль (@generated_attribute_methods).

ActiveModel::Dirty в Rails 5.2

В Rails 5.0 появилось так называемое Attributes API. Это позволяло изменять тип атрибута ActiveRecord модели, вводить новые пользовательские типы и создавать новый атрибуты без соответствующей колонки в таблице базы данных.

В Rails 5.2 этот функционал перенесли из ActiveRecord в ActiveModel (в этом pull request‘е) что привело к значительным изменениям в ActiveModel::Dirty. Теперь начали отслеживаться как изменения обычных атрибутов зарегистрированных через ActiveModel::AttributeMethods модуль (вызовом метода define_attribute_methods) так и изменения атрибутов созданных через Attributes API (вызовом метода attribute из модуля ActiveModel::Attributes). То есть вызовы changes или previous_changes, например, вернут вперемешку и изменения атрибутов из ActiveModel::AttributeMethods и атрибутов из ActiveModel::Attributes (source).

Изменения атрибутов ActiveModel::AttributeMethods аккумулируются все так же в переменных @attributes_changed_by_setter и @previously_changed. Если в моделе подключен модуль ActiveModel::Attributes, то изменения в атрибутах Attributes API сохраняются в переменных @mutations_from_database и @mutations_before_last_save (source). В них хранится не обычный Hash, а трекер изменений - экземпляр класса ActiveModel::AttributeMutationTracker (source). Так как трекер теперь вычисляет сделанные изменения на лету, изменения кешируются в переменной @cached_changed_attributes (source).

Факт подключения ActiveModel::Attributes определяется весьма прямолинейным образом - проверяется существование переменной @attributes (source), экземпляра класса ActiveModel::AttributeSet (source). В отличии от старого подхода с Hash‘ами атрибутов и оригинальных значений, в ActiveModel::AttributeSet вместо непосредственных значений атрибутов хранятся объекты класса ActiveModel::Attribute (source), каждый из которых содержит как оригинальное так и измененное значение атрибута, может определить change in place, выполнять type casting итд.

В Rails 5.0 появилось еще одно небольшое изменение в ActiveModel::Dirty - добавили пару специфичных для атрибутов методов - <name>_previously_changed? и <name>_previous_change ( source).

Таким образом мы получили две независимые реализации одного и того же механизма. Поскольку ActiveRecord использует только новый, старый механизм с @attributes_changed_by_setter и @previously_changed был оставлен скорее для совместимости, так как ActiveModel::Dirty это публичный интерфейс и может использоваться вне ActiveRecord другими библиотеками. В дальнейшем в Rails 6 были сделаны еще более серьезные изменения.

ActiveModel::Dirty в Rails 6.0

В Rails 6.0 один добрый фей пришел и сделал всем хорошо. Была значительно улучшена производительность методов из модуля ActiveModel::Dirty и согласно приведенным в PR‘е измерениям ускорение получилось порядка 2х-10х (pull request). Старый механизм отслеживание изменений атрибутов (сгенерированных используя ActiveModel::AttributeMethods) был вынесен в отдельный компонент - ForcedMutationTracker (source).

Ускорение было достигнуто из-за того, что теперь работает только один из механизмов отслеживания изменений атрибутов - либо старый либо новый. Если подключен модуль ActiveModel::Attributes, то отслеживаются только его атрибуты. В противном случае - только атрибуты ActiveModel::AttributeMethods (source). Следовательно, теперь нельзя использовать ActiveModel::Dirty в классе, в котором атрибуты декларировались используя оба подхода.

Заключение

Можно только приветствовать развитие Rails и модуля ActiveModel::Dirty в частности. Это естественно, что периодическое усложнение функционала сопровождается последующим рефакторингом и упрощением кода. Единственное в чем можно упрекнуть разработчиков Rails - это неполнота описания контракта на использование ActiveModel::Dirty.

Было задекларировано, что ActiveModel::Dirty можно использовать отдельно от ActiveRecord, но долгое время они были сильно связаны. В документации по ActiveModel::Dirty ничего не сказано о методе attributes, но во всех версиях Rails модуль ActiveModel::AttributeMethods (который используется в ActiveModel::Dirty) меняет свое поведение если метод attributes определен.

В Rails 5.2 наличие переменной @attributes значительно влияет на поведение ActiveModel::Dirty. Он включает механизм отслеживания для ActiveModel::Attributes. Хотя эта переменная может быть совершенно несвязанной с ActiveModel::Attributes и ActiveRecord.

Изменение в Rails 6.0 вообще радикально меняет поведение. При наличие переменной @attributes ActiveModel::Dirty перестает отслеживать атрибуты ActiveModel::AttributeMethods.

Как очевидно, проблема не в нарушении обратной совместимости, а в том, что это нарушение не документируется и делается неявно.

Эпилог

В моем конкретном случае все было несколько сложнее.

Возясь с поддержкой gem‘а Dynamoid (это ORM для AWS DynamoDB) всплыла проблема с поддержкой еще не вышедшей Rails 6. Уже был доступен RC1 и зарепортили issue - при сохранении модели выбрасывается exeption.

Из backtrace сразу было видно, что что-то сломалось в подключенном модуле ActiveModel::Dirty. Dynamoid использовал его для повторения интерфейса ActiveRecord. Заглянув в модуль отвечающий за подключение Dirty API я увидел пачку monkey-patch‘ей для разных версий Rails. К своему стыду вспомнилось, что один из них с год назад налепил я сам.

Подавив в себе малодушный порыв добавить еще один monkey-patch уже для Rails 6 засучив рукава я принялся выправлять кривое и выравнивать неровное. Например, в Dynamoid в модели была своя переменная @attributes, которая конфликтовала с ActiveModel::AttributeMethods. Наконец, на Rails 4.2 все завелось без костылей и monkey-patch‘ей. Написанная пачка тестов (ага - это практически не было покрыто тестами) успешно прошла. Кстати, запустив эти тесты я обнаружил, что в старой реализации некоторые публичные методы из Dirty API работали неправильно.

Затем я запустил тесты на Rails 5.2 и все сломалось. Начав разбираться с изменениями в ActiveModel в Rails 5.2 я приуныл и весь энтузиазм испарился. В Dynamoid в модели для переменной @attributes был объявлен getter, который ломал работу ActiveModel::Dirty. Конфликт именования методов очень не хотелось разрешать глобальным переименовываем метода attributes по всему проекте. Из вариантов я вначале рассматривал добавление вспомогательного минималистичного объекта-трекера, в который бы подмешивался ActiveModel::Dirty и который бы отслеживал все изменения атрибутов для связанной модели. А методы Dirty API можно было просто делегировать из каждой модели к своему объекту-трекеру. Это полностью бы решало вопрос с конфликтами имен и методов и совместимостью с любыми изменениями в будущих версиях Rails. Останавливала только сложность взаимодействия модели и этого объекта-трекера. С одной стороны модель делегирует Dirty API методы трекеру. Но с другой стороны трекер должен читать и менять значения атрибутов самой модели.

Ради интереса я посмотрел на схожий проект Mongoid (ORM для MongoDB), еще один источник вдохновения. К удивлению, я увидел, что у них своя независимая реализация трекинга изменений атрибутов, которая не использует ActiveModel::Dirty. Я не рассматривал раньше эту идею в серьез, но к этому времени уже достаточно хорошо разобрался в реализации ActiveModel::Dirty в Rails. Поэтому просто перенести в Dynamoid реализацию ActiveModel::Dirty из Rails 4.2 с некоторой адаптацией уже не казалось сложной задачей. И тесты уже есть… Один свободный вечер спустя copy-paste версия ActiveModel::Dirty заработала в Dynamoid на всех основных версиях Rails - 4.2, 5.2 и 6.0rc1.

Таким образом я получил поддержку Rails 6, независимость от ActiveModel::Dirty и будущих изменений в нем и заодно исправил ошибки в старой реализации в Dynamoid.

Ссылки