0,0
рейтинг
4 сентября 2013 в 19:56

Разработка → 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(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.
Дмитрий Цепелев @DmitryTsepelev
карма
15,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (12)

  • +1
    В Rails 4 встроенно ещё одно удобное средство выделения общего поведения из разных моделей: Concerns
    На самом деле
    Concerns были в Rails уже давно, но только в четвёртой версии для них автоматически создаётся директория при вызове rails new. Поэтому-то все про них и узнали.

    У меня, например, многие объекты (вроде имён и названий) имели двойной набор атрибутов для двух языков и флаг родного языка. Общее поведение — получение значения на языке оригинала и транслитерированное на другой — оказалось очень удобно выделить именно таким образом.

    P.S.: Понимаю некоторую кривизну такого решения в свете потенциальной многоязычности, но здесь языков принципиально ровно два
    P.P.S.: За статью — спасибо!
    • 0
      Большое спасибо!

      Насчет двойных атрибутов — мне кажется немного некрасиво и избыточно, по вашему описанию нашел вот такой gem, вроде бы делает именно так, используя локали.

      Насчет консернов автор оригинальной статьи высказывается довольно негативно — “Any application with an app/concerns directory is concerning.” (какая то игра слов, что то вроде «Любое приложение с консернами — подозрительно»). Я считаю, что в mixin-ы и concern-ы стоит убирать логику, которая в общем то от модели не зависит или зависит слабо, и при этом много где используется (например как в джеме Enumerize)
      • 0
        Не совсем так: локаль не глобальная, а у разных записей — разная. Грубо говоря, у немца оригинальное имя отображается первым, а транслитерированное в скобках, а у русского — наоборот.

        С globalize3 получится что-то в духе Post.with_translations(Post.rus? ? 'ru' : 'en'), тоже костылевато…
        • 0
          Да, действительно, задачка интересная
      • 0
        Концерны это фантастически удобно, если вам нужно переопределять модель.

        К пример, если джем с моделью User. В простом случае он может состоять из одной только строки:
        include UserConcerns::Base

        У вас есть приложение, которе использует этот джем. И на уровне приложения потребовалось добавить в User на дополнительную логику.
        Вы далете концерн и добавляете инишиалайзер с его подключением к User:
        User.send(:include, CustomProjectExtension).

        Кроме того, Concern разруливает подключение модулей к модулям, очень удобно.
  • +3
    Уже был перевод данной статьи
    • 0
      Видать плохо искал
  • +2
    BUG
     if BCrypt::Password.new(@user.password_digest) == unencrypted_password
          @user
        else
          false
        end
    

    а должно быть так
     if BCrypt::Password.new(unencrypted_password) == @user.password_digest
          @user
        else
          false
        end
    


    View objects
    для этого отлично подходит draper gem. github.com/drapergem/draper
    • 0
      Спасибо, поправил
  • 0
    Когда сложность приложения переваливает за определенную грань, то паттерн активных записей становится небольшим злом, которое сидит у вас в самом центре всего и все, что с этим можно сделать — это что-нибудь накостылить. Решением очевидно является выбор более сложных комплексных подходов, например, DDD.

    На практике же конечно сменить в распухлом приложении слой абстракции данных на что-то кардинально иное может быть очень сложно, и это не задача штатного разработчика, а компетенция менеджеров проекта и техлидов.
    • 0
      Ну это понятно :) но вы слишком сказали очевидное, и не забывайте что AR это тот же вид ORM, только с целью на таблицы и БД, если нужен ORM с целью на DDD то вам нужно хибернейт или доктрину например (в других fw тоже есть). Но AR позволяет тоже очень много решать проблем, в общем у всего свои дробеки. Кстати, впринципе хоть AR и нарушает правило PI в DDD, но неплохо устраняет impedance mismatch (в ORM с целью на DDD вы делаете все в xml-файлах, маппинг entity на infrastructure layer БД). Даже в конце книги Джимми Нильсона есть про то что entity может быть AR-model, хотя да не совсем DDD :D
  • +1
    Где-то я уже это видел…

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.