Введение
Продолжаем тему. В данной статье задействуем несколько приемов метапрограммирования. Для наглядности напишем простую версию 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
Автор: dsalahutdinov