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_changetitle_will_change!title_wasrestore_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.