Несколько возможностей метапрограммирования в Ruby на примере ORM «для бедных»
Введение Продолжаем тему. В данной статье задействуем несколько приемов метапрограммирования. Для наглядности напишем простую версию ORM (наподобие ActiveRecord).Уверен, опытные разработчики Ruby не раз встречали различные приемы метапрограммирования изучая исходники gem’ов или стандартной библиотеки Ruby. ActiveRecord бесспорно использует все возможности Ruby, превращая использование сложного ORM в простой и удобный процесс.В нашем примере реализуем простой базовый класс для всех «моделей» — очень упрощенный аналог ActiveRecord: Base, который будет предоставлять следующие возможности:1) Новая модель добавляется наследованием от базового класса2) Таблица именуются в базе по имени класса модели, к примеру: class Pet → table pet; class Person → table person (для упрощения)3) Вставка/сохранение объекта в базе данных4) Возможность поиска объекта по id и выборка всех объектов5) Модель имеет атрибуты, соответствующие колонкам в таблице, а так же access-методы для данных атрибутов (для упрощения поддержка строковых и числовых значений)6) Модель работает напрямую с адаптером mysql
Шаг 1: настройка адаптера mysql2 В качестве адаптера используется gem mysql2. Поэтому убедитесь, что данный пакет у вас установлен: gem install mysql2.В конструктор передаем информацию для подключения к БД. Обычно эти данные хранятся в yml-конфигах, но для простоты используем значения прямо в коде. Далее пересоздаем таблицу pet в базе данных: require 'mysql2' client = Mysql2:: Client.new (: host => «localhost», : username => «root», : password => «password», : database => «ar_sample») results = client.query ('DROP TABLE if exists pet') results = client.query ('CREATE TABLE pet (id INT (11) NOT NULL AUTO_INCREMENT PRIMARY KEY, name CHAR (30), owner_name CHAR (20), age SMALLINT (6));') По классике поле id используется как первичный ключ, у всех моделей, созданных на базе нашего класса. Остальные поля — можно создавать по своему желанию. В частности Pet имеет имя, возраст и имя своего хозяина.Шаг 2: подготовительные работы (+расширение типов) Для обращения к mysql наш класс будет формировать sql запрос и исполнять его через адаптер. При обновлении или вставке новых данных в тексте sql-запроса будут присутствовать значения атрибутов, поэтому для удобства объявим в классах String и Numeric методы to_sql, которые будут форматировать данные для вставки в sql-запрос (число вставляется как есть, строка окружается кавычками: class String def to_sql;»\»#{self.to_s}\»; end end
class Numeric def to_sql; self.to_s; end end Мы задействовали одну из интересных возможностей Ruby — расширение типа. Более того, это не единственный в Ruby способ сделать это, вот еще пример: String.class_eval do define_method (: to_sql) { »\»#{self.to_s}\» } end Шаг 3: основа базового класса (+подмешивание) Код, приведенный ниже описывает открытый интерфейс базового класса. Ничего не обычного, кроме того, что методы модуля ClassMethods расширяю определение класса (точнее расширяют метакласс класса Base), после чего они доступны для использования через класс Base.find (…) module Model module ClassMethods attr_reader: connection #подключение к бд, адаптер mysql # возвращает имя таблицы def table_name end
# выборка всех объектов из БД def all end
# поиск объекта по имени def find (search_id) end end # Базовый класс, расширяется модулем ClassMethods для добавления методов класса class Base extend (ClassMethods) # get-метод для id attr_reader: id
# инициализация объекта def initialize () end
# проверяет, является ли объект новой записью (т.е. не сохранялся в БД) def new_record? end
# вставляет запись в таблицу, в случае, если это новый объект, # либо обновляет данные, в случае, если объект не новый def save end end end В данном примере использование extend, скорее изощрение, ведь можно было бы написать так: module Model class Base class << self def table_name end def all end def find(search_id) end end attr_reader :id def initialize() end def new_record? end def save end end end Но безусловный плюс в использовании модуля и extend в том, то данный модулем возможно расширить другие класса, либо включить модуль в другой модуль или класс (здесь имеется ввиду включение include).Шаг 4: не примечательный код без метапрограммирования Реализуем по порядку незатейливые методы, оставив «вкусненькое» на потом.1) Метод table_name возвращает имя таблицы в БД, реализуем очень просто — возвращаем имя класса. Данный метод используется при формировании sql-запроса. def table_name self.name.downcase end 2) Определим приватный метод materialize, который будет создавать объект из полученного в результате запроса хеша. Он очень простой: устанав private def materialize(hash_data) model_instance = self.new model_instance.each do |k, v| model_instance.instance_variable_set("@#{k}", v) end model_instance end 3) Методы all, find делают select-запросы и возвращают «материализованные» объекты def all connection.query("select * from #{table_name}").collect {|row| materialize(row) } end
def find (search_id) results = connection.query («select * from #{table_name} where id = #{search_id}»).to_a results.size > 0? materialize (results.first) : nil end Шаг 5: определение методов доступа к атрибутам объекта (define_method) Значение каждого атрибута (поля из таблицы) храниться в одноименной внутренней переменной объекта. Для доступа к этим данным, динамически определяем access-методы. При установке значения определенного атрибута, объект так же будет фиксировать имя измененного атрибута (это понадобиться для обновления). Все это действие будет происходить в методе setup, в котором мы получаем информацию о всех колонках в таблице, и на основании этих данных создаем одноименные методы доступа: module ClassMethods def setup (mysql) @connection = mysql
custom_field_names = connection.query («SHOW COLUMNS FROM #{table_name};»).collect{|row| row[«Field»] } — [«id»] custom_field_names.each do |field_name| define_method (field_name) do instance_variable_get (»@#{field_name}») end define_method (»#{field_name}=») do |new_value| old_value = instance_variable_get (»@#{field_name}») instance_variable_set (»@#{field_name}», new_value) @changed_attributes << field_name if old_value != new_value && !@changed_attributes.include?(field_name) end end end end Замечу, что в приведенном выше примере методы доступа создаются заранее, но есть подход, позволяющий определять их по необходимости, он основывается на использовании method_missing (метода, вызывающегося в случае, если у объекта не найден метод).
Шаг 6: определение методов экземпляра (save) Последнее, что осталось сделать — определить метод для сохранения данных, конструктор для инициализации переменных: class Base extend (ClassMethods) attr_reader: id
def initialize () @changed_attributes = [] end
def new_record? @id.nil? end
def save return true if @changed_attributes.size == 0 if (new_record?) self.class.connection.query («INSERT INTO #{self.class.table_name} (#{@changed_attributes.sort.join (»,»)}) VALUES (#{@changed_attributes.sort.collect{|a| »#{instance_variable_get (»@#{a}»).to_sql}» }.join (»,»)})») @id = self.class.connection.last_id else query = «UPDATE #{self.class.table_name} set #{@changed_attributes.sort.collect{|a| »#{a} = #{instance_variable_get (»@#{a}»).to_sql}»}.join (»,»)} where id = #{@id};» r = self.class.connection.query (query) end @changed_attributes = [] end end Здесь, как видите, ничего примечательного. Это не идеальный, но вполне работающий код, ниже пример работы с ним: class Pet < Model::Base end
Pet.setup (client)
p = Pet.new p.name = «Bobik» p.owner_name = «Dmitry» p.age = 10 p.save
pp = Pet.find (1) pp.name = «Sharik» pp.save Заключение В несколько нехитрых шагов, и чуть более 30 строк кода на Ruby мы написали не идеальный, но работающий код собственной ORM. Одной из главных причин его простоты, компактности и «своеобразного изящества» безусловно является использование техники метопрограммирования, которая является очень сильной стороной Ruby.Пример носит показательный характер и не предлагается к рассмотрению как рабочая библиотека (ввиду многих недоработок).Полный исходный текст статьи вы можете посмотреть по этой ссылке: gist.github.com/dsalahutdinov/5dabd8a45992207b0c53