7 рефакторингов для больших ActiveRecord — моделей

от автора

От переводчика: предлагаю вашему вниманию вольный перевод статьи из блога 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(@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)

Когда несколько моделей могут быть обновлены одной отправкой формы, это действие может быть инкапсулировано в объекте-форме. Это намного чище, чем использование 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.

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


Комментарии

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

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