В начале проекта SRP соблюдается легко. Но со временем модели становятся де-факто местом для бизнес-логики. И спустя два года у модели User больше 500 строчек кода и 50 методов в public.
Цель проектирования — раскладывать растущее приложение по маленьким инкапсулированным объектам и модулям. Fat models, skinny controllers — первый шаг в рефакторинге, так давайте сделаем и второй.
Вы, наверное, думаете, что в Rails тяжело применять ООП. Я тоже так думал. Но после некоторых объяснений и опытов я понял, что Rails не так уж и мешает ООП. Соглашения Rails изменять не стоит. Но мы можем использовать OOП и best practices там, где Rails соглашений не имеет.
Не разбивайте модель по модулям
Давайте без этого. Я не одобряю вынесение методов в модули, если они подключаются только в одну модель. Использовать модули таким образом — это как будто распихивать все вещи в комнате под кровать и в шкаф. Конечно, кода в модели становится меньше, но отлаживать и рефакторить такой код тяжело.
Теперь о рефакторинге.
1. Выделяйте Value Objects
Value Objects — это простые объекты для хранения величин, таких как деньги или диапазон дат, равенство которых зависит от их значений. Date, URI, Pathname — примеры из стандартной библиотеки Ruby, но вы можете определять свои.
В Rails Value Objects — отличное решение, если у вас есть несколько атрибутов и связанная с ними логика. Например, в моем приложении для обмена СМС был PhoneNumber. Интернет-магазину нужен Money. У Code Climate есть Rating — оценка класса. Я мог бы использовать String, но в 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 есть Rating:
class ConstantSnapshot < ActiveRecord::Base #… def rating @rating ||= Rating.from_cost(cost) end end
Плюсы такого подхода:
- Логика рейтинга вынесена из ConstantSnapshot
- С методами #hash и #eql? можно использовать рейтинг как хэш. Code Climate использует это для упорядочивания классов по рейтингу, используя Enumerable#group_by.
2. Выделяйте Service Objects
Я создаю Service Objects если действие:
- сложное(например, закрытие всех книг по истечению an accounting period)
- использует несколько моделей(например, покупка в интернет-магазине, использующая объекты Order, CreditCard и Customer)
- является взаимодействием с внешним сервисом (например, использование API социальной сети)
- не принадлежит чётко одной модели(например, удаление всех устаревших данных).
- может быть выполнено не единственным способом(например, аутендификация пользователя). Это Strategy pattern GoF.
Например, можно вынести метод 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
Когда отправка одной формы изменяет несколько моделей, логику можно вынести в Form Object. Это куда чище, чем accepts_nested_attributes_for, который, имхо, вообще надо убрать. Вот пример формы регистрации, которая создаёт 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
Я использовал Virtus, чтобы получить атрибуты с поведением, как у ActiveRecord. Так что в контроллере я могу сделать так:
class SignupsController < ApplicationController def create @signup = Signup.new(params[:signup]) if @signup.save redirect_to dashboard_path else render "new" end end end
Для простых случаев это работает в таком виде. Если логика сохранения данных сложна, можно совместить этот подход с Service Object. В качестве бонуса: здесь же можно разместить валидации, а не размазывать по валидациям моделей.
4. Выделяйте Query Objects
Для сложных SQL запросов, утяжеляющих ваши модели, выделяйте Query Objects. Каждый Query Object выполняет одно бизнес-правило. Например, Query Object, возвращающий заброшенные аккаунты:
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
Можно использовать в background job для отправки почты:
AbandonedTrialQuery.new.find_each do |account| account.send_offer_for_support end
ActiveRecord::Relation являются объектами первого класса в Rails 3, так что их можно передать как входные параметры в Query Object. И мы можем использовать комбинацию Relation и Query Object:
old_accounts = Account.where("created_at < ?", 1.month.ago) old_abandoned_trials = AbandonedTrialQuery.new(old_accounts)
Не увлекайтесь изолированным тестированием таких классов. Используйте в тестах и объект, и базу данных, чтобы убедиться в корректности ответа и отсутствии неожиданных эффектов типа N+1 SQL-запроса.
5. Выделяйте View Objects
Если метод нужен только для отображения данных, он не должен принадлежать модели. Спросите себя: “Если у приложения будет, например, голосовой интерфейс, будет ли этот метод нужен?”. Если нет, выносите его в хелпер или во View Object.
Например, кольцевая диаграмма в Code Climate показывает рейтинги всех классов в проекте(например, Rails on Code Climate), и основана на снэпшоте кода проекта:
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
Чаще всего у одному View Object у меня соответствует один шаблон ERB(HAML/SLIM). Поэтому сейчас я разбираюсь с применением в Rails паттерна Two Step View.
6. Выделяйте Policy Objects
Иногда сложные операции чтения заслуживают собственных объектов. В этом случае я создаю Policy Object. Это позволяет выносить из моделей логику, не имеющую прямого отношения к модели. Например, пользователи, которые оцениваются как активные:
class ActiveUserPolicy def initialize(user) @user = user end def active? @user.email_confirmed? && @user.last_login_at > 14.days.ago end end
Policy Object описывает одно бизнес-правило: пользователь считается активным, если его почта подтверждена и он логинился не раньше чем две недели назад. Можно использовать Policy Objects для набора бизнес-правил, таких как Authorizer, описывающий, к каким данным пользователь имеет доступ.
Policy Objects похожи на Service Objects, но я использую Service Object для операций записи и Policy Object для чтения. Также они похожи на Query Objects, но Query Objects выполняют SQL-запросы, а Policy Objects используют модель, загруженную в память.
7. Выделяйте Decorators
Decorators позволяют использовать существующие методы: они похожи на коллбеки. Decorators полезны в случаях, когда коллбек должен быть выполнен при некоторых условиях или включение его в модель загрязняет её.
Комментарий, написанный в блоге, может быть опубликован на стене Facebook автора комментария, но это не значит, что эта логика должна быть определена в классе Comment. Признак того, что у вас слишком много коллбеков, это медленные и хрупкие тесты или необходимость стабить эти коллбеки во многих местах.
Вот как, например, вынести логику постинга на Facebook в Decorator:
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
Decorators отличаются от Service Objects тем, что они используют уже существующие методы. Экземпляры FacebookCommentNotifier используются так же, как экземпляры Comment. Ruby дает возможность делать декораторы легче, используя метапрограммирование.
В заключение
В Rails приложениях есть много техник управления сложностью в моделях. Они не заменяют Rails. ActiveRecord отличная библиотека, но не стоит полагаться только на неё. Попробуйте при помощи этих техник вынести часть логики из моделей, и ваши приложения станут проще.
Вы можете заметить, что эти паттерны довольно просты. Объекты — это просто объекты Ruby. Это то, что я хотел до вас донести. Не все задачи нужно решать фреймворком или библиотеками.
Оригинал статьи тут.
ссылка на оригинал статьи http://habrahabr.ru/post/158011/
Добавить комментарий