Кое-что из моей работы

Мне тут понадобилось написать функциональность для сайта, которая представляет из себя Консультантов, которым можно задавать вопросы.

О чем я хочу рассказать:

  • Как использовать state_machine в моделях ActiveRecord
  • Как использовать counter_cache “с условиями”

Итак, распишем задачу более подробно:

  • Будет две модели, Consultor и ConsultorQuestion
  • ConsultorQuestion может быть опубликованным и неопубликованным. Используем state_machine.
  • По-умолчанию показываются только опубликованные вопросы. Используем default_scope.
  • У Consultor будет счетчик counter_cache, с количеством опубликованных вопросов

Так как контроллеры никому особо не интересны, я их не буду описывать. Начну сразу с моделей.

Модель Consultor (консультант):

# Консультант наследуется от Пользователя (STI).
class Consultor < User
  has_many :questions, :class_name => 'ConsultorQuestion', :dependent => :destroy

  default_scope where('type = ?', 'Consultor')
end

Модель Consultor использует STI (Single Table Inheritance) - наследуется от модели User, так как консультант это такой же пользователь, как и все остальные. Я создал отдельную модель для консультантов потому, что у консультантов есть вопросы (has_many :questions), а у обычных пользователей их нет. Незачем засорять модель User ненужными связями.

Я также определил default_scope, чтобы всегда по-умолчанию выбирались только консультанты, исключая других пользователей. С default_scope сразу начинаются проблемы, так как этот скоуп добавляет условие в каждый SQL-запрос к таблице consultors. И счетчик counter_cache перестаёт работать. Но об этом позже.

Об использовании state_machine

Давайте посмотрим на модель ConsultorQuestion. Я приведу сразу полную и законченную версию модели, конечно убрав оттуда то, что к делу не относится:

class ConsultorQuestion < ActiveRecord::Base
  state_machine :state, :initial => :unpublished do
    # Состояния
    state :unpublished
    state :published

    # Коллбэки переходов
    after_transition :unpublished => :published,   :do => :inc_counter_cache
    after_transition :published   => :unpublished, :do => :dec_counter_cache

    # События
    event :publish do
      transition :unpublished => :published
    end

    event :unpublish do
      transition :published => :unpublished
    end
  end

  # Перед удалением снимаем с публикации. Если состояние изменится
  # на unpublished, то автоматически произойдет декремент counter_cache
  before_destroy { unpublish! }

  belongs_to :consultor
  attr_protected :state, :consultor_id

  validates :questioner_name, :questioner_email, :question,  :presence => true
  default_scope with_state(:published).order('created_at DESC')

  def inc_counter_cache
    Consultor.unscoped.increment_counter('questions_count', self.consultor.id)
  end

  def dec_counter_cache
    Consultor.unscoped.decrement_counter('questions_count', self.consultor.id)
  end
end

Первое, на что обращаешь внимание, это state_machine. У вопроса могут быть два состояния: опубликован или не опубликован. state_machine очень облегчает переходы от состояния к состоянию, хотя это можно сделать и без неё. У вас получится странный код с кучей условий. Я попытаюсь изобразить, что может получиться, если не использовать state_machine:

class ConsultorQuestion < ActiveRecord::Base
  scope :published, where('state = ?', :published)
  scope :unpublished, where('state = ?', :unpublished)

  default_scope published.order('created_at DESC')

  before_save   :set_counter_cache
  after_destroy :set_counter_cache_for_destroy

  belongs_to :consultor
  attr_protected :state, :consultor_id

  validates :questioner_name, :questioner_email, :question,  :presence => true

  def published?
    state == 'published'
  end

  def unpublished?
    state == 'unpublished'
  end

  def state_was?(value)
    state_was == value.to_s
  end

  def set_counter_cache
    # Для добавляемых записей со статусом published
    if new_record? && published?
      # инкрементируем счетчик
      method = :increment_counter
    end

    # Для изменяемых записей.
    # Если запись была снята с публикации, декрементируем счетчик
    # Здесь можно воспользоваться aasm и переходом состояния (transition)
    if persisted? && state_was?('published') && unpublished?
      method = :decrement_counter
    end

    # Если запись была опубликована, декрементируем счетчик
    if persisted? && state_was?('unpublished') && published?
      method = :increment_counter
    end

    # Обновляем счетчик
    Consultor.unscoped.send(method, 'questions_count', self.consultor.id) if method
  end

  # Для удаляемых записей, которые были опубликованы
  def set_counter_cache_for_destroy
    Consultor.unscoped.decrement_counter('questions_count', self.consultor.id) if published?
  end
end

Ужасно, не правда ли? Зацените, сколько условий в коде и сравните это с предыдущим листингом, где нет ни одного условия и все реализуется при помощи коллбэков! В этом и есть сила state_machine - в вызове коллбэков при смене состояния. Чтобы сменить состояние, нам нужно всего лишь вызвать событие publish! или unpublish! в нужный момент. В нашем же случае мы используем коллбэки чтобы менять значение счетчика counter_cache. Помните, в условии было сказано, что нам нужно подсчитывать только опубликованные вопросы?

О счётчике counter_cache

Вы уже заметили в коде вызовы Consultor.unscoped.decrement_counter и Consultor.unscoped.increment_counter. Так мы меняем значение счетчика counter_cache вручную. Почему? Это стало необходимо потому, что мы используем default_scope в модели и этот скоуп добавляет своё условие к запросу, который обновляет счетчик counter_cache. В результате, счетчик не то что не обновляется, а даже происходит ошибка при выполнении SQL-запроса. Чтобы это исправить, мы вызываем через unscoped стандартные методы increment_counter и decrement_counter. Unscoped отменяет действие default_scope и запрос выполняется без ошибки.

Еще хочется заметить, что метод sizе берет значение из колонки counter_cache. Чтобы получить реальное количество записей в таблице, можно использовать метод length или метод count.

Немного о default_scope

Стоит с осторожностью пользоваться default_scope и не забывать, что он добавляет условие к каждому запросу. Поэтому, что-то может сломаться или работать не так, как вы ожидаете. Но зато, default_scope помогает улучшить безопасность вашего приложения, помогает защитить данные, которые требуют защиты от случайного просмотра.

Also interesting

Tags: , , , , , , ,

3 Responses to “Кое-что из моей работы”

  1. use_rifle says:

    Использовал гем state_machine в парочке проектов, в последнем решил от него отказаться.

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

  2. Poul says:

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

    К тому же, в сложных моделях со сменой сразу нескольких состояний, SM сильно роняет производительность. Плюс сложно держать объект валидным, если для разных состояний нужно делать разные валидаторы. Так же решили от него отказаться на критичном к производительности направлении проекта. Хотя, в конкретно нашем случае архитектура данной части приложения тоже не самая удачная.

  3. quaternion says:

    >> Напрягает, что он при смене состояния сразу же сохраняет модель…

    Автоматическое сохранение при смене состояний можно отключить, передав в state_machine параметры :action => nil, :use_transactions => false.

Leave a Reply