Толстые модели сложны в поддержке. Они, конечно, лучше, чем контроллеры, захламленные логикой предметной области, но, как правило, нарушают 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