В большинстве рельсовых проектов основная концентрация кода приходится на модели. Все наверняка читали про Slim controllers & fat models и стараются впихать в модели как можно больше, а в контроллеры как можно меньше. Что ж, это похвально, однако в стремлении утолстить модели многие часто забывают про принцип DRY — don't (fucking) repeat yourself.
Я тут постараюсь вкратце расписать, как в районе моделей и рыбку съесть, и про DRY не забыть.
Итак, модель у нас — руби класс. Как и всякий руби класс, модель состоит их трех вещей:
Самый простой способ проDRYить модель, как, собственно, и любой другой класс в руби, сводится к вынесению повторяющихся частей в отдельные модули. На самом деле модули полезны не только для этого. Иногда недурственно бывает просто огромную портянку кода вынести в отдельный файл, чтобы модель выглядела чище и тонны нерелевантной фигни не путались под ногами.
Итак, с определениями инстанс методов все максимально просто и понятно:
С методами класса слегка интереснее. Вот так не заработает:
Тут мы определим метод
Поэтому надо делать как-то так:
Вместе интанс- и классовые методы можно вынести в модуль, например, так:
Нерешенным остается вопрос — что делать с «классовым» кодом. Обычно его как раз больше всего и он чаще всего нуждается в DRYинге.
Проблема в том, что он должен выполняться в контексте объявляемого класса (модели в нашем случае). Просто написать его внутрь модуля нельзя — он попытается выполниться сразу и скорее всего сломается, поскольку простому модулю ничего не известно ни про валидации, ни про многочисленные плагины, ни про
У объекта Module определен метод included, который вызывается каждый раз, когда мы куда-нибудь инклудим модуль. Таким образом, мы можем под это дело заставить исполняться нужным нам код. Типа вот так:
Теперь весь код валидаций не будет исполняться в момент определения модуля, а будет спокойно лежать и ждать, пока мы этот модуль куда-нибудь не заинклудим.
Теперь как бы это совместить с определениями методов, которые были выше? А вот как:
Итого, у нас получился модуль, в который смело можно вынести любой сколько угодно сложный кусок модели, а потом сколько нужно раз этот кусок использовать. Все почти классно. Не классно только то, что код получился некрасивый и в этих бесконечных included/send :include/extend легко можно запутаться.
В руби-коммьюнити сильно ценится читаемость и красота кода, а так же принцип hiding complexity — прятать сложные штуки за каким-нибудь простым и красивым API/DSL. Если заглянуть в код RoR, сразу становится видно, что указанный выше подход там используется почти везде. Поэтому, естественно, ребята решили облегчить себе жизнь и придумали
С помощью этого самого
На самом деле более красивый код — не единственная фишка этого хелпера. У него есть еще полезные особенности, но о них я может быть когда-нибудь напишу отдельно. Кому не терпится, тот пускай идет читать сырец active_support в районе
Еще важный момент, с которым сразу столкнется программер, который впервые решит раздербанить свои модели на отдельные модули — куда класть код и как именовать модули?
Тут однозначного мнения скорее всего быть не может, но я для себя придумал следующую схему:
Имена файлов имеют значение, поскольку позволяют не грузить сразу весь код, а юзать рельсовый autoload — механизм, который когда встречает неопределенную константу, типа
Я надеюсь кто-нибудь вынесет для себя что-то полезное из статьи и в мире станет чуть меньше хреново-читаемого кода и моделей на полторы тыщи срок.
Update:
Тут privaloff обратил внимание, что я тупанул в коде, который без сoncern, а именно — забыл про
Я тут постараюсь вкратце расписать, как в районе моделей и рыбку съесть, и про DRY не забыть.
Итак, модель у нас — руби класс. Как и всякий руби класс, модель состоит их трех вещей:
1) определения инстанс-методов
class User < ActiveRecord::Base
def name
[ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
end
end
2) определения методов класса
class User < ActiveRecord::Base
def self.find_old
where('created_at < ?', 1.year.ago).all
end
end
3) «классового» кода
class User < ActiveRecord::Base
attr_accessor :foo
has_many :authentications, :dependent => :destroy
has_many :invitees, :class_name => 'User', :as => :invited_by
delegate :city, :country, :to => :location
attr_accessible :admin, :banned, :as => :admin
mount_uploader :userpic, UserPicUploader
scope :admin, where(:admin => true)
validates :username,
:uniqueness => true,
:format => /^[a-z][a-z\d_-]+$/,
:length => { :within => 3..20 },
:exclusion => { :in => USERNAME_EXCLUSION }
end
Самый простой способ проDRYить модель, как, собственно, и любой другой класс в руби, сводится к вынесению повторяющихся частей в отдельные модули. На самом деле модули полезны не только для этого. Иногда недурственно бывает просто огромную портянку кода вынести в отдельный файл, чтобы модель выглядела чище и тонны нерелевантной фигни не путались под ногами.
Методы
Итак, с определениями инстанс методов все максимально просто и понятно:
# user_stuff.rb
module UserStuff
def name
[ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
end
end
# user.rb
class User < ActiveRecord::Base
include UserStuff
end
С методами класса слегка интереснее. Вот так не заработает:
# user_stuff.rb
module UserStuff
def self.find_old
where('created_at < ?', 1.year.ago).all
end
end
# user.rb
class User < ActiveRecord::Base
include UserStuff
end
Тут мы определим метод
find_old
самому модулю UserStuff
, а в модель он в итоге не попадет.Поэтому надо делать как-то так:
# user_stuff.rb
module UserStuff
def find_old
where('created_at < ?', 1.year.ago).all
end
end
# user.rb
class User < ActiveRecord::Base
extend UserStuff # внимание, не include, а extend
end
Вместе интанс- и классовые методы можно вынести в модуль, например, так:
# user_stuff.rb
module UserStuff
module InstanceMethods
def name
[ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
end
end
module ClassMethods
def find_old
where('created_at < ?', 1.year.ago).all
end
end
end
# user.rb
class User < ActiveRecord::Base
include UserStuff::InstanceMethods
extend UserStuff::ClassMethods
end
Классовый код
Нерешенным остается вопрос — что делать с «классовым» кодом. Обычно его как раз больше всего и он чаще всего нуждается в DRYинге.
Проблема в том, что он должен выполняться в контексте объявляемого класса (модели в нашем случае). Просто написать его внутрь модуля нельзя — он попытается выполниться сразу и скорее всего сломается, поскольку простому модулю ничего не известно ни про валидации, ни про многочисленные плагины, ни про
has_many
итд. Поэтому надо засунуть код в модуль таким образом, чтобы он выполнился только в момент подключения этого самого модуля в модель. К счастью, в руби это очень легко сделать.У объекта Module определен метод included, который вызывается каждый раз, когда мы куда-нибудь инклудим модуль. Таким образом, мы можем под это дело заставить исполняться нужным нам код. Типа вот так:
module UserValidations
def self.included(base)
# base в данном случае — то, куда инклудится модуль.
base.instance_eval do
# выполняем код в контексте base
# только в момент подключения модуля
validates :username,
:uniqueness => true,
:format => /^[a-z][a-z\d_-]+$/,
:length => { :within => 3..20 },
:exclusion => { :in => USERNAME_EXCLUSION }
validates :gender,
:allow_blank => true,
:inclusion => { :in => %w(m f) }
end
end
end
Теперь весь код валидаций не будет исполняться в момент определения модуля, а будет спокойно лежать и ждать, пока мы этот модуль куда-нибудь не заинклудим.
Все в кучу
Теперь как бы это совместить с определениями методов, которые были выше? А вот как:
# user_stuff.rb
module UserStuff
def self.included(base)
# экстендим модель методами класса
base.extend ClassMethods
# инклудим методы экземпляра
# Module#include — приватный метод. Его по задумке можно вызывать только внутри определения класса
# Поэтому мы не можем написать base.include(InstanceMethods), а приходится делать так:
base.send :include, InstanceMethods
# а дальше пошли валидации и прочее
base.instance_eval do
validates :gender, :presence => true
end
end
# методы класса
module ClassMethods
def find_old
where('created_at < ?', 1.year.ago).all
end
end
# инстанс-методы
module InstanceMethods
def name
[ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
end
end
end
# user.rb
class User < ActiveRecord::Base
include UserStuff
end
Итого, у нас получился модуль, в который смело можно вынести любой сколько угодно сложный кусок модели, а потом сколько нужно раз этот кусок использовать. Все почти классно. Не классно только то, что код получился некрасивый и в этих бесконечных included/send :include/extend легко можно запутаться.
Можно красивше!
В руби-коммьюнити сильно ценится читаемость и красота кода, а так же принцип hiding complexity — прятать сложные штуки за каким-нибудь простым и красивым API/DSL. Если заглянуть в код RoR, сразу становится видно, что указанный выше подход там используется почти везде. Поэтому, естественно, ребята решили облегчить себе жизнь и придумали
ActiveSupport::Concern
.С помощью этого самого
ActiveSupport::Concern
наш код можно переписать так:module UserStuff
extend ActiveSupport::Concern
included do
validates :gender, :presence => true
end
# методы класса
module ClassMethods
def find_old
where('created_at < ?', 1.year.ago).all
end
end
# инстанс-методы
module InstanceMethods
def name
[ first_name, last_name ].filter(&:presence).compact.map(&:strip) * ' '
end
end
end
На самом деле более красивый код — не единственная фишка этого хелпера. У него есть еще полезные особенности, но о них я может быть когда-нибудь напишу отдельно. Кому не терпится, тот пускай идет читать сырец active_support в районе
lib/active_support/concern.rb
, там очень крутые и развесистые комментарии с примерами кода и все такое.Куда класть?
Еще важный момент, с которым сразу столкнется программер, который впервые решит раздербанить свои модели на отдельные модули — куда класть код и как именовать модули?
Тут однозначного мнения скорее всего быть не может, но я для себя придумал следующую схему:
# lib/models/validations.rb
module Models
module Validations
# общий модуль c валидациями для нескольких моделей
end
end
# lib/models/user/validation.rb
module Models
module User
module Validations
# валидации для модели User
end
end
end
# app/models/user.rb
class User < ActiveRecord::Base
include Models::Validations
include Models::User::Validations
end
Имена файлов имеют значение, поскольку позволяют не грузить сразу весь код, а юзать рельсовый autoload — механизм, который когда встречает неопределенную константу, типа
Models::User::Validations
, сначала пытается поискать файл models/user/validations.rb
и попробовать загрузить его, и только потом, в случае неудачи, впадать в панику и кидать исключение NameError
.Заключение
Я надеюсь кто-нибудь вынесет для себя что-то полезное из статьи и в мире станет чуть меньше хреново-читаемого кода и моделей на полторы тыщи срок.
Update:
Тут privaloff обратил внимание, что я тупанул в коде, который без сoncern, а именно — забыл про
instance_eval
. Код поправил. Товарищу плюс в карму за внимательность.