От переводчика: предлагаю вашему вниманию вольный перевод статьи из блога Code Climate под названием 7 Patterns to Refactor Fat ActiveRecord Models.
Code Climate — мощное средство анализа качества кода и безопасности Ruby on Rails — приложений.
Введение
Когда разработчики начинают использовать Code Climate для улучшения качества их Rails-кода, им приходится избегать «распухания» кода их моделей, так как модели с большим количеством кода создают проблемы при сопровождении больших приложений. Инкапсуляция логики предметной области в моделях лучше, чем помещение этой логики в контроллеры, однако такие модели обычно нарушают Принцип единственной обязанности (Single Responsibility Principle). К примеру, если поместить в класс User все что относится к пользователю — это далеко не единственная обязанность.
На ранних этапах следовать принципу SRP довольно легко: классы моделей управляют только взаимодействием с БД и связями, однако постепенно они растут, и объекты, которые изначально отвечали за взаимодействие с хранилищем становятся фактически и владельцами всей бизнес-логики. Спустя год-два вы получите класс User с более чем 500 строками кода и сотнями методов в public-интерфейсе. Разобраться в этом коде очень тяжело.
По мере роста внутренней сложности (читай: добавления фич) в ваше приложение, необходимо распределять код между набором небольших объектов или модулей. Для этого требуется постоянный рефакторинг. В результате следования такому принципу у вас будет набор небольших превосходно взимодействующих объектов с хорошо определенными интерфейсами.
Возможно вы думаете, что в Rails очень тяжело следовать принципам ООП. Я думал так же, однако, потратив немного времени на экперименты, я обнаружил, что Rails как фреймворк абсолютно не мешает ООП. Всему виной — соглашения Rails, а точнее — отсутствия соглашений, регламентирующих управления сложностью ActiveRecord — моделей, которым было бы легко следовать. К счастью, в этом случае мы можем применить объектно-ориентированные принципы и практики.
Не выделяйте mixin-ы из моделей
Давайте сразу исключим этот вариант. Я категорически не советую перемещать часть методов их большой модели в concern — ы или модули, которые потом будут включены в эту же модель. Композиция предпочтительнее наследования. Использования mixin-ов похоже на уборку грязной комнаты путем расталкивания мусора по углам. Сперва это выглядит чище, однако подобные «углы» усложняют понимание и без того запутанной логики в модели.
Теперь приступим к рефаторингам!
Рефакторинги
1. Выделение объектов-значений (Value Objects)
Объект-значение — простой объект, который можно легко сравнить с другим по содержащемуся значению (или значениям). Обычно такие объекты являются неизменными. Date, URI и Pathname — вот примеры объектов-значений из стандартной библиотеки Ruby, но ваше приложение может (и почти наверняка будет) определять объекты — значения, специфичные для предметной области. Выделение их из моделей — один из самых простых рефакторингов.
В Rails объекты-значения прекрасно подходят для использования в качестве атрибутов или небольших групп атрибутов, имеющих связанную с ними логику. Атрибут, являющийся чем-то большим, чем текстовое поле или счетчик — отличный кандидат на выделение в отдельный класс.
У примеру, в приложении для обмена сообщениями можно использовать объект-значение PhoneNumber, а в приложении, связанном с денеждыми операциями может пригодиться объект-значение Money. Code Climate имеет объект — значение под названием Rating, который представляет собой простую шкалу оценок от A до F, которую получает каждый класс или модуль. Я мог бы (в начале так и было сделано) использовать экземпляр обычной строки, но класс 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 предоставляет доступ к объекту рейтинга в своем публичном интерфейсе следующим образом:
class ConstantSnapshot < ActiveRecord::Base
# …
def rating
@rating ||= Rating.from_cost(cost)
end
end
Кроме уменьшения размера класса ConstantSnapshot, такой подход имеет еще несколько плюсов:
- Методы #worse_than? и #better_than? обеспечивают более выразительный способ сравнения рейтингов, чем встроенные Ruby — операторы > и <
- Определение методов #hash и #eql? дает возможность использовать объект класса Rating как ключ хэша. CodeClimate использует это для удобной группировки классов и модулей по рейтингу с помощью Enumberable#group_by.
- Метод #to_s позволяет интерполировать объект класса Rating в строку без дополнительных усилий
- Данный класс является удобным местом для фабричного метода, возвращающего правильный рейтинг для данной «цены исправления» (время, требуемое для устранения всех «запахов» данного класса)
2. Выделение объектов-сервисов (Service Objects)
Некоторые действия в системе оправдывают их инкапсуляцию в объекты-сервисы. Я использую такой подход, когда действие удовлетворяет одному или более критериям:
- Действие сложное (например закрытие бухгалтерской книги в конце периода учета)
- Действие включает работу с несколькими моделями (к примеру, электронная покупка может включать объекты классов Order, CreditCard и Customer)
- Действие имеет взаимодействие с внешним сервисом (например, шаринг в социальные сети)
- Действие не имеет прямого отношение к нижележащей модели (к примеру, очистка просроченных заказов после определенного периода времени)
- Есть несколько способов выполнения этого действия (например, аутенификация посредством токена доступа или пароля). В таком случае стоит применить GoF-паттерн Strategy.
К примеру, мы можем перенести метод User#authenticate в класс UserAuthenticator:
class UserAuthenticator
def initialize(user)
@user = user
end
def authenticate(unencrypted_password)
return false unless @user
if BCrypt::Password.new(unencrypted_password) == @user.password_digest
@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)
Когда несколько моделей могут быть обновлены одной отправкой формы, это действие может быть инкапсулировано в объекте-форме. Это намного чище, чем использование accepts_nested_attributes_for, который, по моему мнению, должен быть объявлен как deprecated. Хорошим примером может служить отправка формы регистрации, в результате действия которой должны быть созданы записи 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
Для достижения схожего с ActiveRecord поведения атрибутов я использую gem Virtus. Объекты-формы выглядят как обычные модели, поэтому контроллер остается неизменным:
class SignupsController < ApplicationController
def create
@signup = Signup.new(params[:signup])
if @signup.save
redirect_to dashboard_path
else
render "new"
end
end
end
Это хорошо работает для простых случаев, как в показанном примере, однако, если логика взаимодействия с БД становится слишком сложной, можно комбинировать этот подход с созданием объекта-сервиса. Кроме того, валидации часто являются контекстно зависимыми, поэтому их можно определить непосредственно там, где они применяются, вместо того, чтобы помещать все валидации в модель, к примеру валидация на наличие пароля у пользователя требуется только при создании нового пользователя и при изменении пароля, нет необходимости проверять это при каждом изменении данных пользователя (вы же не собираетесь поместить на один вид изменение данных пользователя и форму смены пароля?)
4. Выделение объектов-запросов (Query Objects)
При появлении сложных SQL-запросов (в статических методах и scope-ах) стоит вынести их в отдельный класс. Каждый объект запроса отвечает за выборку по определенному бизнес-правилу. К примеру, объект — запрос для нахождения завершенныъ пробных периодов (видимо имеются в виду trial-периоды ознакомления с Code Climate) может выглядеть так:
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
Такой класс можно использовать в фоновом режиме для рассылки писем:
AbandonedTrialQuery.new.find_each do |account|
account.send_offer_for_support
end
С помощью методов класса ActiveRecord::Relation удобно комбинировать запросы, используя композицию:
old_accounts = Account.where("created_at < ?", 1.month.ago)
old_abandoned_trials = AbandonedTrialQuery.new(old_accounts)
При тестировании таких классов необходимо проверять результат запроса и выборку из БД на наличие строк расположенных в правильном порядке, а также на наличие join-ов и дополнительных запросов (чтобы избежать багов типа N + 1 query).
5. Объекты вида (View Objects)
Если какие-то методы используются только в представлении, то им не место в классе модели. Спросите себя: «Если бы я реализовывал альтернативный интерфейс для этого приложения, к примеру управляемый голосом, потребовался ли бы мне этот метод?». Если нет — стоит перенести его в хелпер или (даже лучше) в объект вида.
Например, кольцевая диаграмма в Code Climate разбивает рейтинги классов, основываясь на снимке (snapshot) состояния кода. Данные действия искапсулированы в объекте вида:
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
Я часто обнаруживаю отношения вида один к одному между видами и шаблонами ERB (или Haml/Slim). Это натолкнуло меня на мысль об использовании шаблона Двухшагового построения вида (Two Step View), однако у меня еще нет сформулированного решения для Rails.
Заметка: в Ruby-сообществе принят термин «Presenter», но я избегаю его из — за его неоднозначности. Термин «Presenter» был предложен Jay Fields для описания того, что я назваю объектом — формой. Кроме того, Rails использует термин «вид» (view) для описания того, что обычно называют «шаблон» («template»). Чтобы избежать двусмысленности я иногда называю объекты вида моделями вида («View Models»).
6. Выделение объектов-правил (Policy Objects)
Иногда сложные операции чтения заслуживают отдельных объектов. В таких случаях я использую объекты-политики. Это позволяет вам убрать из модели побочную логику, например проверку пользователей на активность:
class ActiveUserPolicy
def initialize(user)
@user = user
end
def active?
@user.email_confirmed? &&
@user.last_login_at > 14.days.ago
end
end
Такой объект инкапсулирует одно бизнес-правило, проверяющее, подтвержден ли email пользователя и использовал ли он приложение в течение последних двух недель. Вы также можете использовать объекты-правила для группировки нескольких бизнес правил, например объект Authorizer, определяющий, к каким данным пользователь имеет доступ.
Объекты-правила похожи на объекты-сервисы, однако я использую термин «объект-сервис» для операций записи, а «объект — правило» для операций чтения. Они также похожи на объекты-запросы, но объекты запросы используются только для выполнения SQL — запросов и возвращения результатов, тогда как объекты-правила оперируют моделями предметной области, уже загруженными в память.
7. Выделение декораторов
Декораторы позволяют наращивать функциональность на существующие операции и следовательно схожи по своему действию с колбэками. Для случаев, когда логика колбэков используется однократно или когда их включение в модель возлагает на нее слишком много обязанностей, полезно использовать декоратор.
Создание комментария к посту в блоге может вызвать создание комментария на стене в Facebook, но это не значит, что данная логика обязательно должна быть в классе Comment. Медленные и хрупкие тесты или странные побочные эффекты в не связанных тестах — знак того, что вы поместили слишком много логики в колбэки.
Вот как вы можете вынести в декоратор логику размещения комментария в Facebook:
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
Декораторы отличаются от объектов — сервисов, так как они расширяют функциональность существующих объектов. После оборачивания объект декоратора используется так же, как обычный объект класса Comment. Стандартная библиотека Ruby предоставляет набор средств для упрощения создания декораторов с помощью метапрограммирования.
Заключение
Даже в Rails приложении есть множество средств управления сложностью моделей. Ни один из них не потребует нарушения принципов фреймворка.
ActiveRecord — превосходная библиотека, однако и она может подвести, если вы будете полагаться только на нее. Не всякая проблема может быть решена средствами библиотеки или фреймворка. Попробуйте ограничить ваши модели только логикой взаимодействия с БД. Использование представленных техник поможет распределить логику вашей модели и в результате получить более легкое в сопровождении приложение.
Вы наверняка обратили внимание, что большинство описанных шаблонов очень просты, эти объекты — всего лишь Plain Old Ruby Objects (PORO), что отлично иллюстрирует удобство применения ООП-подхода в Rails.