Загрузка...

Ruby & Rails: веб-разработка с удовольствием

Ruby on Rails — фреймворк для создания веб-приложений. Является открытым программным обеспечением (лицензия MIT). Здесь мы обсуждаем новости RoR, делимся учебными материалами и интересными находками С RoR даже сложные веб-приложения могут быть написаны за считанные дни. Это действительно разработка с удовольствием!
     

9. Metaprogramming patterns — 13 кю. Метод serialize и управление методами dump & load отдельных частей

31.03.09, 02:24
Автор artem.voroztsov

В этом уроке рассматривается метод serialize из Ruby on Rails, который позволяет  добиться храния в базе данных в определённом столбце определённой таблицы почти произвольного объекта Ruby. Но соль урока не в методе serialize, а в изучении способа управления тем, как "дампятся" и "ресторятся" отдельные части сериализуемого объекта, а также в повторном обращении Вашего внимания к задаче контекстно-зависимого изменения пространства имен.

 Предыдущие уроки:

1. Metaprogramming patterns — 25кю. Метод eval
2. Metaprogramming patterns — 22кю. Reuse в малом — bang!
3. Metaprogramming patterns — 20 кю. Замыкания
4. Metaprogramming patterns 19 кю. Спасение утопающих дело рук самих утопающих
5. Metaprogramming patterns — 18 кю. Примеси
6. Metaprogramming patterns — 17 кю. Как параллелить потоки данных и почему в Ruby не нужны колбэки
7. Metaprogramming patterns — 15 кю. without_feature - управление фичастостью методов
8. Metaprogramming patterns. 18 кю - Методы модификаторы: postprocess_value


Использование метода serialize выглядит следующим образом:

class Project < ActiveRecord::Base
  serialize :settings
 
  has_many :tickets
  has_many :users
  belongs_to creator
  ....
end

 

10 кю. Для тех, кто не в курсе

библиотека ActiveRecord представляет собой ORM для Ruby. Она предоставляет программистом прозрачные средства для хранения объектов в реляционных базах данных, при этом избранным вами классам (наследникам класса ActiveRecord::Base) сопоставляются таблицы (обычно, каждому классу взаимно однозначно ставиться в соответствие  одна таблица, но иногда нескольким похожим классам сопоставляется одна и та же таблица), а объектам - строчки этой таблицы.
ORM делает ваши данные (объекты этих избранных классов) персистентными, то есть неисчезающими после перезапуска виртуальной машины Ruby. Операции с объектами (создание, удаление, модификация) автоматически отображаются (момент отображения контроллируется программистом) в соответствующие операции над строками.
Но можно посмотреть и с другой стороны: ActiveRecord - это инструмент,  который позволяет отобразить таблицы, которые хранятся в вашей базе данных на классы, и работать со строками этих таблиц, как с объектами этих классов.
Как бы там ни было, программист просто работает абстракцией - он может использовать следующий набор команд
 

user = User.find_by_login('alex')
 user.email = 'alex@mail.ru'
 user.admin = true
 user.save # только сейчас пошел запрос на изменение соответствующей строки
 
 a = User.create(:login => 'petr', :status => 'active')
 
 s = User.find_by_login('skripach')
 s.detroy # удаляется соответствующая строка в таблице users
          # и удаляются зависящие объекты (тоже строки в некоторых таблицах)
          # например, аватары, письма, сообщения в блоге, ...,
          # (если конечно программист указал в классе User эти зависимости)
         
 User.delete_all(:visited_at => Time.now - 1.year) # одним махом удаляем кучу,
                                                   # но зависимости при этом не удаляются
 
 User.find(:all, :limit => 10, :order => 'visited_at DESC', :conditions => {:status => User::STATUS_ACTIVE})
 ...
 # см. подробнее http://ar.rubyonrails.org/


Команды поиска, удаления и изменения невидимо для программиста приводят к нужным SQL запросам к базе данных (но никто не мешает в соседней консоли сделать tail -f log/development.log и наблюдать за тем, что происходит за сценой). Персистентным хранилищем данных может могут выступать базы данных (в том числе SQlite, PostgreSQL, Oracle, MySQL), или RESTful сайт (см. ActiveResource), позволяющий через HTTP запросы осуществлять CRUD операции с данными на сервере.
Методы Project.create, Project.find, Project.find_by_*, Project#destroy, Project#destroy, Project#save,  ... наследуются, либо создаются автоматически. Их логика в универсальном виде (в виде, работающем для разных наследников и разных драйверов базы данных) описана в классе ActiveRecord::Base.
Программист может дописывать классы, наследники ActiveRecord::Base, то есть создавать новые  методы,  добавлять обычные, не персистентные атрибуты, добавлять валидации и колбэки на сохранение, создание и удаление, и ряд других вещей.

Вы, наверное, догадываетесь, что за реализацией ActiveRecord, как и всякого ORM, стоит много метапрограммирования - то есть написания методов, которые отвечают за создание методов на лету. Например, writer & reader методы для аттрибутов создаются для класса Project после выполнения SQL запроса "SHOW FIELDS FROM projects" автоматически.

 

13 кю. Какое-никакое, но решение

Для того, чтобы этот код работал, аттрибут settings в таблице projects следует объявить с типом text. Наличие строки serialize :settings приведёт к тому, что перед сохранением ActiveRecord будет преобразовывать его в текст (с помощью  value.to_yaml), а при первом запросе значения этого аттрибута у загруженного в память объекта будет выполнять обратное преобразование (YAML.load(value)).
Вместо одной строки serialize :settings мы могли бы сами написать c десяток несложных строк:

class Project < ActiveRecord::Base
  before_save :serialize_settings
 
  def serialize_settings
    if @settings_deserialized
      self[:settings] = @settings_cache.to_yaml
    end
  end
 
  def deserialize_settings
    self[:settings].blank? ? {} : YAML.load(self[:settings])
  end
 
  def settings
    @settings_deserialized ||= (deserialize_settings rescue nil)
  end
 
  def settings=(v)
    @settings_deserialized = v
  end
end


Есть неправильное решение, в котором код @settings_deserialized = deserialize_settings пишется в очереди  after_initialize. Это неэффективно. Наличие объекта в памяти не означает, что кто-то будет пользоваться аттрибутом settings, а потому не стоит тратить процессорное время на его десериализацию.
В приведённом коде явно предполагается, что settings имеет значение класса Hash.

 

Этот код можно сделать "универсальным" (испольуемым не только для аттрибута с именем settings) следующей нехитрой манипуляцией:

ActiveRecord::Base.class_eval do
  @@serialize_code = < < -END_CODE
  before_save :serialize_settings
 
  def serialize_settings
    if @settings_deserialized
      self[:settings] = @settings_cache.to_yaml
    end
  end
 
  def deserialize_settings
    self[:settings].blank? ? {} : YAML.load(self[:settings])
  end
 
  def settings
    @settings_deserialized ||= (deserialize_settings rescue nil)
  end
 
  def settings=(v)
    @settings_deserialized = v
  end
  END_CODE
 
  def self.serialize_hash(*args)
    args.each do |attrib|
      class_eval @@serialize_code.gsub('settings', attrib)
    end
  end
end


Мы определили здесь метод serialize_hash, который делает то же самое, что и serialize, но значение аттрибута по умолчанию устанавливает в {}. Данный код не является примером правильного подхода. В частности, он не содержит защиту от дурака-программиста, который может написать что-нибудь типа  serialize_hash "settings, history" или serialize_hash "settings `rm -rf ~`".
Важно не использовать eval и class_eval от строки, так как стороки в принципе имеют бОльший потенциал быть попорченными вредителями (особенно когда в их конструировании участвуют пользовательские данные), чем предкомпилированный (в байткод) код, написанный самим программистом.
Кроме того, важно, чтобы в backtrace эксепшенов не писалось, что проблема возникла в строке, которая не присутствует в коде, а когда-то была в какой-то строке, переданной в eval (require 'ruby-debug' и метод debugger решает и эту проблему и позволяют отобразить строки программисту). В различных IDE есть большой потенциал проблем отладки кода, выполненого в рамках метода eval str.  

 

10 кю. Пишем правильно

 

ActiveRecord::Base.class_eval do
  def self.serialize_hash(*attributes)
    attributes.each do |a|
      a = a.to_s
      raise ArgumentError.new("Bad value of attribute name '#{a}'") unless a =~/^\w+$/
      s_method = "serialize_#{a}"
      d_method = "deserialize_#{a}"
      var_name = "@#{a}_deserialized"
     
      before_save s_method
     
      define_method(s_method) do
        if instance_variable_defined?(var_name)
          v = instance_variable_get(var_name)
          self[a] = v.to_yaml
        end
      end
     
      define_method(d_method) do
        (self[a].blank? ? {} : YAML.load(self[a]))
      end
     
      define_method(a) do
        if !instance_variable_defined?(var_name)
          instance_variable_set(var_name, send(d_method))
        end
        instance_variable_get(var_name)
      end
 
      define_method("#{a}=") do |v|
        instance_variable_set(var_name, v)
      end
    end
  end
end

 

9 кю. Преобразование вложенных ActiveRecord объектов

Вполне возможно, что Вы захотите в качестве значений хеша settings использовать и другие объекты вашей модели данных (другие объекты классов, наследующих от ActiveRecord).
В результате операции to_yaml эти объекты будут помещены в текстовый дамп settings со всеми своими атрибутами и другими внутренностями (например, значениями instance-переменных, которые использовались для временных/внутренних нужд). Всё это дампить не хотелось бы, так как информация о значениях атрибутов может устареть (истинные значения атрибутов хранятся в базе данных,  в другой таблице, в отдельной строке, и могут меняться) и нет смысла её хранить.
В дампе хотелось бы хранить лишь класс и идентификатор.
Давайте перед сериализацией осуществлять замену объектов на хеш {:active_record => true, :id => id, :type => class_name}, а при десериализации делать обратную замену, осуществляя поиск методом find_by_id.

class Hash
  def compress_active_record!
    self.keys.each do |key|
      value = self[key]
      if ActiveRecord::Base === value
        self[key] = {
          :active_record => true, 
          :id => value.id, 
          :type => value.class.to_s
        }
      end
    end
  end
 
  def decompress_active_record!
    self.keys.each do |key|
      value = self[key]
      if Hash === value && value[:active_record]
        self[key] = value[:type].constantize.find_by_id(value[:id])
      end
    end
  end
end
ActiveRecord::Base.class_eval do
  def self.serialize_hash(*attributes)
    attributes.each do |a|
      s_method = "serialize_#{a}"
      d_method = "deserialize_#{a}"
      var_name = "@#{a}_serialize_cache"
     
      before_save s_method
     
      define_method(s_method) do
        if instance_variable_defined?(var_name)
          v = instance_variable_get(var_name)
          v.compress_active_record!
          self[a] = v.to_yaml
        end
      end
     
      define_method(d_method) do
        (self[a].blank? ? {} : YAML.load(self[a])).decompress_active_record!
      end
     
      define_method(a) do
        if !instance_variable_defined?(var_name)
          instance_variable_set(var_name, send(d_method))
        end
        instance_variable_get(var_name)
      end
 
      define_method("#{a}=") do |v|
        instance_variable_set(var_name, v)
      end
    end
  end
end


Ну чтож, неплохо! Только нужно понимать, что settings может в качестве значений содержать также массивы или множества (объекты класса Set), которые, в свою очередь, могут уже содержать ActiveRecord::Base объекты. Могут быть вложенные конструкции из хешей и массивов. Как бы так решить эту проблему раз и навсегда?

 

Это несложно:

  define_method(s_method) do
    if instance_variable_defined?(var_name)
      v = instance_variable_get(var_name)
      ActiveRecord::Base.class_eval do        
        define_method(:to_yaml_with_compression) do |*args|
          {
            :active_record = true,
            :id => self.id,
            :type => self.class.to_s
          }.to_yaml(*args)                       
        end
        alias_method_chain :to_yaml, :compression
      end
      self[a] = v.to_yaml
      ActiveRecord::Base.class_eval do
        alias :to_yaml :to_yaml_without_compression
      end
    end
  end

  
Здесь новостью для кого-то будет то, что метод to_yaml может получать аргумент. Этот аргумент имеет класс YAML::Syck:Emitter.

 

Если объект является частью какого-то другого объекта, который дампиться, то ему при дампе нужно писать свой дамп не в строку, а в некоторый абстрактный эмиттер. Нам сейчас не так важно, как это  делается - мы просто подсунули этому эмиттору вместо самого объекта нужный хеш, который будет представителем объекта в дампе.

Осталось пропатчить метод YAML.load. Пусть это будет заданием для самостоятельного решения.

6кю. Изменения (глобального) пространства имен лишь в избранном контексте

Отметим здесь ещё один важный момент. Мы изменили метод to_yaml у всех экземпляров классов - наследников ActiveRecord::Base.
Делать так в многопоточном приложении неприлично, так как этим методом в это же самое время пользуются, возможно, другие потоки и они никак не ожидают, что его семантика измениться.
Как бы так изменить метод to_yaml, чтобы эти изменения были видны только в некотором нашем контексте? Под контекстом можно понимать, например, поток (Thread) или некоторый блок (экземпляр класса Proc) и все что вычислялось в рамках блока (в том числе новые треды, которые были ответвлены при выполнении этого блока) или Binding.

Часть секрета того, как реализовать  контекстно-зависимые патчи, уже была рассказана на пятничных лекциях в МФТИ.

PS: почему serialize позволяет хранить не все объекты, а почти все?

Потому что есть такие объекты, которые не дампятся (ни методом to_yaml, ни мeтодом dump). Например, объекты класса Proc, и объекты, содержащие где-либо внутри себя объекты класса Proc. Объекты Proc в свою очередь не дампятся, так как содержат binding, который не дампиться. Почему не дампиться binding? Потому что этот объект суть есть точка зрения на пространство имен и получается, что нужно дампить всё, что видно для данного binding'а в пространстве имен - например, все классы (они видны с любой точки зрения; классы есть частный случай констант, которые суть - глобальные переменные), а значит видны и все экземпляры всех классов, так как есть ObjectSpace.each_object(klass){|o| ..}. Делайте выводы ...

Комментарии

Войдите, чтобы оставить комментарий