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
в свой класс и сделать некоторые дополнительные
шаги. Перечислим шаги интеграции:
- все отслеживаемые атрибуты надо задекларировать вызовом метода
define_attribute_methods
- при изменении значения атрибуты надо вызывать метод
[attr_name]_will_change!
(до изменения значения) - в аналоге метода
save
нужно вызыватьchanges_applied
- в аналоге метода
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
Методы сгенерированные 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.