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