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

в 11:14, , рубрики: ruby on 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. Это то, что я хотел до вас донести. Не все задачи нужно решать фреймворком или библиотеками.

Оригинал статьи тут.

Автор: jaturken

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js