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

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

ссылка на оригинал статьи http://habrahabr.ru/post/158011/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *