7 паттернов рефакторинга толстых моделей в Rails

Толстые модели сложны в поддержке. Они, конечно, лучше, чем контроллеры, захламленные логикой предметной области, но, как правило, нарушают Single Responsibility Principle(SRP). “Всё, что делает пользователь” не является single responsibility.
В начале проекта SRP соблюдается легко. Но со временем модели становятся де-факто местом для бизнес-логики. И спустя два года у модели User больше 500 строчек кода и 50 методов в public.
Цель проектирования — раскладывать растущее приложение по маленьким инкапсулированным объектам и модулям. Fat models, skinny controllers — первый шаг в рефакторинге, так давайте сделаем и второй.
Вы, наверное, думаете, что в Rails тяжело применять ООП. Я тоже так думал. Но после некоторых объяснений и опытов я понял, что Rails не так уж и мешает ООП. Соглашения Rails изменять не стоит. Но мы можем использовать OOП и best practices там, где Rails соглашений не имеет.

Не разбивайте модель по модулям


Давайте без этого. Я не одобряю вынесение методов в модули, если они подключаются только в одну модель. Использовать модули таким образом — это как будто распихивать все вещи в комнате под кровать и в шкаф. Конечно, кода в модели становится меньше, но отлаживать и рефакторить такой код тяжело.
Теперь о рефакторинге.

1. Выделяйте Value Objects


Value Objects — это простые объекты для хранения величин, таких как деньги или диапазон дат, равенство которых зависит от их значений. Date, URI, Pathname — примеры из стандартной библиотеки Ruby, но вы можете определять свои.
В Rails Value Objects — отличное решение, если у вас есть несколько атрибутов и связанная с ними логика. Например, в моем приложении для обмена СМС был PhoneNumber. Интернет-магазину нужен Money. У Code Climate есть Rating — оценка класса. Я мог бы использовать String, но в Rating определены и данные, и логика:
class Rating  include Comparable
  def self.from_cost(cost)
    if cost <= 2 new("A")
    elsif cost <= 4 new("B")
    elsif cost <= 8 new("C")
    elsif cost <= 16 new("D")
    else new("F")
    end
  end

  def initialize(letter)
    @letter = letter 
  end

  def better_than?(other) 
    self > other 
  end 

  def <=>(other) 
    other.to_s <=> to_s
  end

  def hash
    @letter.hash 
  end 

  def eql?(other)
    to_s == other.to_s
  end 

  def to_s
    @letter.to_s
  end 
end

И у ConstantSnapshot есть Rating:
class ConstantSnapshot < ActiveRecord::Base
  #…
  def rating
    @rating ||= Rating.from_cost(cost)
  end
end 

Плюсы такого подхода:
  • Логика рейтинга вынесена из ConstantSnapshot
  • С методами #hash и #eql? можно использовать рейтинг как хэш. Code Climate использует это для упорядочивания классов по рейтингу, используя Enumerable#group_by.

2. Выделяйте Service Objects


Я создаю Service Objects если действие:
  • сложное(например, закрытие всех книг по истечению an accounting period)
  • использует несколько моделей(например, покупка в интернет-магазине, использующая объекты Order, CreditCard и Customer)
  • является взаимодействием с внешним сервисом (например, использование API социальной сети)
  • не принадлежит чётко одной модели(например, удаление всех устаревших данных).
  • может быть выполнено не единственным способом(например, аутендификация пользователя). Это Strategy pattern GoF.

Например, можно вынести метод User#authenticate в UserAuthenticator:
class UserAuthenticator
  def initialize(user)
    @user = user
  end

  def authenticate(unencrypted_password)
    return false unless @user
    if BCrypt::Password.new(@user.password_digest) == unencrypted_password @user
    else false 
    end
  end
end 

И SessionsController будет выглядеть так:
class SessionsController < ApplicationController
  def create
    user = User.where(email: params[:email]).first
    if UserAuthenticator.new(user).authenticate(params[:password])
      self.current_user = user redirect_to dashboard_path
    else
      flash[:alert] = "Login failed."
      render "new"
    end
  end
end 

3. Выделяйте Form Objects


Когда отправка одной формы изменяет несколько моделей, логику можно вынести в Form Object. Это куда чище, чем accepts_nested_attributes_for, который, имхо, вообще надо убрать. Вот пример формы регистрации, которая создаёт Company и User:
class Signup 
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations
 
  attr_reader :user
  attr_reader :company
  attribute :name, String
  attribute :company_name, String
  attribute :email, String
  validates :email, presence: true
  # … more validations …

  # Forms are never themselves persisted
  def persisted?
    false
  end
 
  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

  private

  def persist!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(name: name, email: email)
  end
end

Я использовал Virtus, чтобы получить атрибуты с поведением, как у ActiveRecord. Так что в контроллере я могу сделать так:
class SignupsController < ApplicationController
  def create
    @signup = Signup.new(params[:signup])
    if @signup.save
      redirect_to dashboard_path
    else
      render "new"
    end
  end
end

Для простых случаев это работает в таком виде. Если логика сохранения данных сложна, можно совместить этот подход с Service Object. В качестве бонуса: здесь же можно разместить валидации, а не размазывать по валидациям моделей.

4. Выделяйте Query Objects


Для сложных SQL запросов, утяжеляющих ваши модели, выделяйте Query Objects. Каждый Query Object выполняет одно бизнес-правило. Например, Query Object, возвращающий заброшенные аккаунты:
class AbandonedTrialQuery
  def initialize(relation = Account.scoped)
    @relation = relation
  end

  def find_each(&block)
    @relation. where(plan: nil, invites_count: 0).find_each(&block)
  end
end 

Можно использовать в background job для отправки почты:
AbandonedTrialQuery.new.find_each do |account|
  account.send_offer_for_support
end

ActiveRecord::Relation являются объектами первого класса в Rails 3, так что их можно передать как входные параметры в Query Object. И мы можем использовать комбинацию Relation и Query Object:
old_accounts = Account.where("created_at < ?", 1.month.ago)
old_abandoned_trials = AbandonedTrialQuery.new(old_accounts)

Не увлекайтесь изолированным тестированием таких классов. Используйте в тестах и объект, и базу данных, чтобы убедиться в корректности ответа и отсутствии неожиданных эффектов типа N+1 SQL-запроса.

5. Выделяйте View Objects


Если метод нужен только для отображения данных, он не должен принадлежать модели. Спросите себя: “Если у приложения будет, например, голосовой интерфейс, будет ли этот метод нужен?”. Если нет, выносите его в хелпер или во View Object.
Например, кольцевая диаграмма в Code Climate показывает рейтинги всех классов в проекте(например, Rails on Code Climate), и основана на снэпшоте кода проекта:
class DonutChart
  def initialize(snapshot)
    @snapshot = snapshot
  end

  def cache_key
   @snapshot.id.to_s
  end
 
 def data
   # pull data from @snapshot and turn it into a JSON structure
  end
end

Чаще всего у одному View Object у меня соответствует один шаблон ERB(HAML/SLIM). Поэтому сейчас я разбираюсь с применением в Rails паттерна Two Step View.

6. Выделяйте Policy Objects


Иногда сложные операции чтения заслуживают собственных объектов. В этом случае я создаю Policy Object. Это позволяет выносить из моделей логику, не имеющую прямого отношения к модели. Например, пользователи, которые оцениваются как активные:
class ActiveUserPolicy
  def initialize(user)
    @user = user
  end
 
 def active?
   @user.email_confirmed? && @user.last_login_at > 14.days.ago
  end
end 

Policy Object описывает одно бизнес-правило: пользователь считается активным, если его почта подтверждена и он логинился не раньше чем две недели назад. Можно использовать Policy Objects для набора бизнес-правил, таких как Authorizer, описывающий, к каким данным пользователь имеет доступ.
Policy Objects похожи на Service Objects, но я использую Service Object для операций записи и Policy Object для чтения. Также они похожи на Query Objects, но Query Objects выполняют SQL-запросы, а Policy Objects используют модель, загруженную в память.

7. Выделяйте Decorators


Decorators позволяют использовать существующие методы: они похожи на коллбеки. Decorators полезны в случаях, когда коллбек должен быть выполнен при некоторых условиях или включение его в модель загрязняет её.
Комментарий, написанный в блоге, может быть опубликован на стене Facebook автора комментария, но это не значит, что эта логика должна быть определена в классе Comment. Признак того, что у вас слишком много коллбеков, это медленные и хрупкие тесты или необходимость стабить эти коллбеки во многих местах.
Вот как, например, вынести логику постинга на Facebook в Decorator:
class FacebookCommentNotifier
  def initialize(comment)
    @comment = comment
  end
 
 def save
   @comment.save && post_to_wall
  end
 
 private

  def post_to_wall
    Facebook.post(title: @comment.title, user: @comment.author)
  end
end 

И в контроллере:
class CommentsController < ApplicationController 
  def create
    @comment = FacebookCommentNotifier.new(Comment.new(params[:comment]))
    if @comment.save
      redirect_to blog_path, notice: "Your comment was posted."
    else
      render "new"
    end
  end
end

Decorators отличаются от Service Objects тем, что они используют уже существующие методы. Экземпляры FacebookCommentNotifier используются так же, как экземпляры Comment. Ruby дает возможность делать декораторы легче, используя метапрограммирование.

В заключение


В Rails приложениях есть много техник управления сложностью в моделях. Они не заменяют Rails. ActiveRecord отличная библиотека, но не стоит полагаться только на неё. Попробуйте при помощи этих техник вынести часть логики из моделей, и ваши приложения станут проще.
Вы можете заметить, что эти паттерны довольно просты. Объекты — это просто объекты Ruby. Это то, что я хотел до вас донести. Не все задачи нужно решать фреймворком или библиотеками.

Оригинал статьи тут.
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 27

    +1
    github.com/rails/rails/commit/9e4c41c903e8e58721f2c41776a8c60ddba7a0a9 — Вот этот коммит в rails 4 хоронит ActiveModel
      +2
      ну не ActiveModel, а ActiveRecord::Model — между ними большая разница =)
      0
      Косяк:
      class Signup include Virtus extend ActiveModel::Naming include ActiveModel::Conversion include ActiveModel::Validations
      
        0
        Спасибо, поправил.
        0
        Вот кстати неплохой гем для реализации форм через модели — github.com/ClearFit/redtape
          0
          Основанный на этой же статье) надо попробовать где-нибудь.
            0
            Ага, поэтому и кинул:)
          +1
          Про декоратор:
          И в контроллере:

          class CommentsController < ApplicationController 
            def create
              @comment = FacebookCommentNotifier.new(Comment.new(params[:comment]))
              if @comment.save
                redirect_to blog_path, notice: "Your comment was posted."
              else
                render "new"
              end
            end
          end
          


          этот пример меня смущает, получается, мы должны помнить при написании контоллера, что модель должна отправлять нотификации в фейсбук и мы должны использовать декоратор, а не саму модель. По-моему, это наоборот, увеличивает сложность.
          Также ожидал услышать про делегацию, на мой взгляд, самый правильный способ разбивать модель.
            +3
            На самом деле тут даже не нужен декоратор. Есть же Observer, который позволят перехватывать стандартные after и before хуки.
              0
              Если поковыряться в документации Ruby/Rails, то можно найти кучу реализаций паттернов GoF и не только. Имхо это вообще тема для отдельной статьи.
              0
              По-моему довольно логичный ход. Когда я пишу код создания комментария, я знаю что мне нужно продублировать его в ФБ. В данном случае как раз Observer, использование которого многим показалось бы логичным, как раз и усложняет код. А вот декоратор как раз явно показывает намерения разработчика кода.
                0
                А если вам где-то в другом месте понадобится создать комментарий, вы уверены, что вспомните о том, что надо в фейсбук продублировать? Или у вас в одном месте будет дублироваться, а в другом — нет? Логику надо отделять от контроллеров, а тут она во всей красе. Обсервер предлагал не я, мне тоже не нравится, что он неявно обвешивает модель, но для некоторых вещей, непосредственно не связанных с функциональностью модели, наверное, имеет смысл.
                  0
                  Уверен. Мы же не пишем код с ИИ, чтобы он сам помнил о ТЗ. Это моя задача, как разработчика.
                  В данном конкретном месте, зная что необходимо дублировать в ФБ, я использую стратегию создания комментария, заодно дублирующую его в ФБ. В другом месте, если это необходимо — тоже буду. Там, где не нужно — не буду. Проблема то в чём?
                    0
                    В том, что проекты не пишутся раз и навсегда, а поддерживаются и изменяются. Также есть проекты, которые просто не влезут целиком в голову и которые пишутся не один месяц. Также есть проекты, которые пишутся не в одиночку, а командой, поэтому код должен быть понятным и предсказуемым, а не «угадай, куда я это засунул».
                    Если это всё не про вас, вопросов нет, можете писать всё одной портянкой, зачем вам вообще контроллеры и модели, если у вас есть ТЗ и вы всё знаете?
                      0
                      Вы сейчас пытаетесь сказать, что когда команда работает над проектом, каждому из них знать ТЗ и архитектуру проекта — не нужно? И новичка не нужно никак вводить в курс дела, чтобы он как раз и не был главным героем «угадай, куда я это засунул» и не найдя, не засовывал в новое место?

                      Но это всё вторично. Как, если не декоратором (CommentsService с методом create(text) — то же самое, только в профиль)?
                        0
                        Угу, только если код лежит в неожиданных местах, знаний архитектуры и ТЗ окажется недостаточно, в случае чего придётся лопатить весь связанный код, и не только новичку, но и старичку, особенно, если данный код писал его изобретательный коллега )
                        Первое место для поиска — модель и callback на create, конечно, откуда его и вытащили. Если вытаскивать, то вместе с другим кодом, например, в делегат. Можно оставить и в декораторе, только вызывать из модели, а не контроллера. То что тяжёлые коллбеки делают тесты медленнее и более хрупкими, конечно, понятно, но я очень далёк от мысли, что это причина для изменений в архитектуре.
                          0
                          Если у модели наличествует большой и тяжелый callback, я обычно завожу в модели виртуальный атрибут, истинность которого отменяет callback. В обычной работе он совершенно не мешает, а в тестах позволяет сэкономить время и сохранить чистоту.
                            0
                            Я дико извиняюсь. Можете в двух словах объяснить что такое виртуальный аттрибут?
                              0
                              attr_accessor же.
                                0
                                А, понял.
                            0
                            Сразу оговорюсь: я тоже за, что вызов декоратора должен быть не в контроллере.

                    0
                    Декоратор красив только в данном сферическом примере в вакууме. Первый «неудобный» случай уже описан выше — это когда нужно создать комментарий где-то еще. Приходится заново прописывать конструкцию с декоратором, а это нарушение DRY.
                    Второй случай — это когда нужно сделать больше одного действия. Например, нужно отправить коммент в FB, Twitter, отправить оповещение автору статьи на почту, да еще и начислить какие-нибудь баллы. Будете запихивать это все в один декоратор? Или строить монструозную цепочку из декораторов при каждом создании комментария? За оба эти решения вы через месяц проклянете сами себя. Особенно, если вам таки нужно создавать комментарии где-то еще. Создание нескольких обсерверов, обслуживающих одну модель, но выполняющих разную работу, выглядит гораздо красивее.
                    Третий случай — это тестирование. В случае с обсервером вам нужно протестировать обсервер — и все. Обсервер будет отрабатывать всегда, когда создается комментарий. В случае с декоратором вам придется проверять каждый случай на предмет того, не забыли ли вы, ваш коллега или новичок в кофейном угаре использовать декоратор.
                      0
                      Ну вот вы обоснованно ругаете декоратор за «нужно создать комментарий где-то ещё». А что, если где-то ещё мне, создавая комментарий, не нужно посылать сообщение по электропочте? А тут этот Observer, о котором я, кстати, будучи новичком в проекте, ещё и не знаю. Связь то неявная.
                        0
                        Если новичок сел писать код, не изучив хотя-бы тесты, в которых обсервер явно упомянут, то его тимлидер просто обязан поставить его в угол на горох.
                        Если же в каких-то единичных случаях какой-то из обсерверов таки не должен отрабатывать, решение с виртуальным «выключателем» я написал выше.
                  0
                  ППКС. Автору респект. Правда, по-моему это не правила рефакторинга моделей, а правила их написания :) Какая разница, когда их применять!
                    0
                    Я бы почитал как автор это раскидывает по директориям. Я додумался только до /app/services, в котором хранятся корневые сервисы. А остальное куда? Всякие value object и стратегии. В lib что ли? Как-то далеко рыться.
                      +1
                      У меня в /lib, как правило, Ruby-код, никаких рельсов. И мне это нравится)
                      Я думаю эти все вещи хранить в /app/lib.

                    Only users with full accounts can post comments. Log in, please.