Pull to refresh

Как DRYить модели

Reading time5 min
Views17K
В большинстве рельсовых проектов основная концентрация кода приходится на модели. Все наверняка читали про Slim controllers & fat models и стараются впихать в модели как можно больше, а в контроллеры как можно меньше. Что ж, это похвально, однако в стремлении утолстить модели многие часто забывают про принцип DRY — don't (fucking) repeat yourself.

Я тут постараюсь вкратце расписать, как в районе моделей и рыбку съесть, и про 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. Код поправил. Товарищу плюс в карму за внимательность.
Tags:
Hubs:
+56
Comments22

Articles

Change theme settings