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

в 15:56, , рубрики: active record, rails, ruby on rails, переводы, Проектирование и рефакторинг, рефакторинг, метки: , ,

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

Автор: DmitryTsepelev

Источник

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


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