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

Январь 17th, 2012

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

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

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

Информационное безобразие

Январь 12th, 2012

Мы, программисты и айтишники, впрочем, как и люди остальных профессий, ежедневно получаем слишком много информации. Больше того, чем можем переварить. Не сталкивался ли ты с такой штукой, что у тебя в голове играет музыка, самая попсовая которая может быть, хотя ты и любитель рока? Эту музыку ты услышал, когда был в кафешке на обеденном перерыве. И вот она прочно обосновалась в твоих мозгах! Так же происходит с новостными сайтами, газетами, рекламой и телевидением. Однако нам не нужна эта тупая информация - все, что мы хотим узнать, мы можем узнать из книг, общения с нужными людьми, либо интернета (предварительно отключив баннеры).

Меня эта лишняя информация просто сделала уже не мной, я из тех, кто сильно реагирует на нее. Я решил так, что я отказываюсь от новостных и развлекательных сайтов (я их забанил у себя), ТВ я и так редко смотрю, если смотрю - то Дискавери. Перестал совсем брать листовки у раздавальщиков на улице, хотя раньше изредка брал. Срываю рекламу у себя у подъезда и в лифте, чтобы не видеть ее изо дня в день. Не смотрю на мониторы и рекламу в метро (слушаю плеер с закрытыми глазами). Стараюсь не обращать внимания на рекламные билборды на улицах. Перестал смотреть Ютуб. Но стал читать больше бумажных книг.

В общем, избавился от лишнего информационного шума, который ничего не дает, кроме депрессивного состояния. Какая мне разница, что где-то убили 98 человек? Че я должен что-ли говорить, оо бля, какой ужас, как так можно? Я же их не знаю, и мне вообще плевать, зачем мне эту новость знать? Это то же самое, что если бы тебе щас сказали, что на планете Ка-Пэкс убили 980 миллиардов капэксян - какие бы были твои чувства? Тут нужно применять специальный математический метод, который мой друг Иван предложил 8 лет назад - это метод наложения хуя. Как его применять - несложно разобраться самому.

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

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

Может, некоторые знают песню Человек-гора Валера, если не знаете - послушайте. Нужно быть разносторонне развитым человеком, однако, думаю, что нужно вести себя немного как Валера.
Я сейчас убрал от себя подальше все различные, не несущие для меня пользы шумы (реклама, ТВ, видео и т.п.) - стало немного лучше, хотя бывает иногда, по старой привычке, хочется этого шума, но всё-таки я отсекаю его и терплю.

Virtualbox guest ssh

Январь 8th, 2012

Всё время забываю, как делать port-forwarding в Virtualbox для Ubuntu-host в Ubuntu-guest.

Итак. Для начала в настройках сети виртуальной машины устанавливаем Network Adapter как NAT. Делаем это при выключенной виртуальной машине.

Дальше смотрим виртуалки, которые есть:

$ VBoxManage list vms

И делаем, собственно, port forwarding:

$ VBoxManage modifyvm "Ubuntu 11.10" --natpf1 "guestssh,tcp,,2222,,22"

Ubuntu 11.10 - это имя виртуальной машины, для которой пробрасываем порты.

Всё. Дальше, запускаем виртуальную машину и соединяемся с ней:

$ ssh -p 2222 user@localhost

Готово.

Рефакторинг с использованием rails_best_practices

Сентябрь 29th, 2011

Думаешь, твой рельсовый проект идеален в плане кода? Нет? Хорошо, что можешь это признать. Я вот знал, что в моём маленьком проекте contemplate, который я мееедленно но верно разрабатываю, были проблемы. В частности - бизнес-логика в контроллере, не DRY-код в некоторых местах и другие проблемы. Теперь я избавился почти от всех них, и помог мне в этом гем rails_best_practices.

Установить и использовать его просто. Добавляешь в Gemfile в группу development строку:

gem "rails_best_practices"

И делаешь bundle install. Затем, в корневой директории проекта запускаешь:

$ rails_best_practices -g

Это сгенерирует конфиг-файл для rails_best_practices. Для начала работы больше настраивать ничего не нужно. Теперь нужно сгенерировать чеклист:

$ rails_best_practices -f html .

После запуска ты получишь файл rails_best_practices_output.html в корне проекта. Просто открываешь его в браузере и смотришь список. Тебе повезет, если он будет пуст. В моем случае было не так - я получил 48 ошибок.

Например, у меня был такой экшен:

def create
  anchor = :new_comment

  # Anti-robot protection
  is_robot = !params[:comment][:name].blank?
  params[:comment].delete :name

  @comment = Comment.new(params[:comment])
  @comment.ip = IPAddr.new(request.env["REMOTE_ADDR"]).to_i
  @comment.user_agent = request.env["HTTP_USER_AGENT"]
  @comment.commentable = @post

  if !is_robot
    if @comment.save
      flash[:notice] = 'Ваш комментарий опубликован.'
      flash.now[:comment] = nil
      anchor = view_context.dom_id(@comment)
    else
      flash[:comment] = @comment
    end
  else
    flash[:comment] = nil
  end

  redirect_to polymorphic_path(@post, :anchor => anchor)
end

После того, как я последовал рекомендациям rails_best_practices, у меня получился более чистый код, так как я вынес имевшуюся бизнес-логику из контроллера в модель:

def create
  anchor = :new_comment

  # Anti-robot protection
  is_robot = !params[:comment][:name].blank?
  params[:comment].delete :name

  @comment = @commentable.comments.build(params[:comment])

  if !is_robot
    if @comment.publish(request.env)
      flash[:notice] = 'Ваш комментарий опубликован.'
      flash.now[:comment] = nil
      anchor = view_context.dom_id(@comment)
    else
      flash[:comment] = @comment
    end
  else
    flash[:comment] = nil
  end

  redirect_to polymorphic_path(@commentable, :anchor => anchor)
end

Вот как выглядит метод Comment#publish

def publish(env)
  raise 'Cannot find associated object for this comment!' if self.commentable.nil?
  require 'ipaddr'

  self.ip = IPAddr.new(env["REMOTE_ADDR"]).to_i
  self.user_agent = env["HTTP_USER_AGENT"]

  save
end

Очень полезный гем. Я неплохо улучшил свой код при помощи его рекомендаций. Гем rails_best_practices также позволяет добавлять свои проверки в чеклист, что позволит проще проводить рефакторинг большого проекта, если вы напишите специфичные для него проверки.

В общем, отличная вещь, рекомендую всем, кто еще не пробовал.

Rails: store dates in UTC and display them in local time onto client side

Июнь 27th, 2011

In the Rails all timestamps stored in database in UTC by default. UTC is an improved version of wide-known GMT.

Previously, I have kept the timezone for each individual user for computing local date using UTC and user timezone. Well, you know, when you are asked to specify your timezone, choosen it from a huge drop-down list. Some developers, of course, make smarter, they determine the current user’s timezone with javascript and then send it to the server via AJAX.

Now I come to the conclusion that in most cases I do not necessarily user’s timezone to know (I can’t think where it may be necessary to me.) I decided that will be easier to display all the dates on my site in UTC, then on the client side bring them to local time and format using javascript. As a result, I got a helper and a piece of code in javascript.

Here is rails helper, without any frills:

def utc_date(date)
  raw %Q(<time class="utc-date" title="#{date}">#{date}</time>)
end

This helper just display passed date object and display it wrapped by time HTML5 tag.

And here is a javascript code that convert UTC date to local time and format time:

var Application = {
    processUtcDates: function() {
        $$('.utc-date').each(function(wrapper) {
            wrapper.set('html', Application.utcToLocal(wrapper.get('html')))
        })
    },

    utcToLocal: function(value) {
        var a = /^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2}):(\d{2})\sUTC$/.exec(value);

        if (a) {
            return (new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6]))).format("%d/%m/%Y %H:%M");
        }

        return null;
    }
};

Application.processUtcDates();

This example is written using mootools framework, but I think similar method like format is also available in your favorite framework. If not - it’s easy to write this method yourself.
The script simply looks for all the containers with the class .utc-date and convert contained date to local time with formatting.

In general, this is a simple way to remove yourself from a headache to support multiple time zones in your application. I’m waiting for comments about where method I described does not work.

Хранение дат в UTC, отображение и форматирование на клиенте

Июнь 23rd, 2011

В Rails по-умолчанию все даты в БД хранятся в UTC. UTC - это, можно сказать, улучшенная версия знакомого всем GMT.

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

Теперь же я пришел к выводу, что мне вообще не обязательно, в большинстве случаев, знать часовой пояс пользователя (навскидку не могу придумать, где это может мне понадобиться). Я подумал, что проще все даты на сайте выводить в UTC формате, затем на клиенте приводить их к локальному времени и форматировать при помощи javascript. В результате, я получил один хелпер и один кусочек кода на javascript.

Вот хелпер. Он пока что без особых изысков:

def utc_date(date)
  raw %Q(<time class="utc-date" title="#{date}">#{date}</time>)
end

Этот хелпер просто получает объект даты в UTC формате и выводит его в HTML5 теге time (я использую HTML5 в своих проектах).

А вот и кусок джаваскрипта, который приводит дату к локальной, а так же форматирует её:

var Application = {
    processUtcDates: function() {
        $$('.utc-date').each(function(wrapper) {
            wrapper.set('html', Application.utcToLocal(wrapper.get('html')))
        })
    },

    utcToLocal: function(value) {
        var a = /^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2}):(\d{2})\sUTC$/.exec(value);

        if (a) {
            return (new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6]))).format("%d/%m/%Y %H:%M");
        }

        return null;
    }
};

Application.processUtcDates();

Этот пример javascript-кода написан с использованием фреймворка mootools, но я думаю, аналог метода format есть и в вашем любимом фреймворке. Если же нет - его легко написать самому.
Скрипт просто ищет все контейнеры с классом .utc-date и приводит содержащуюся в них дату к локальному времени, с форматированием.

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

BundleWatcher - следите за апдейтами гемов, используемых в проекте

Июнь 22nd, 2011

Наткнулся на маленький замечательный проект - BundleWatcher.

Давно искал нечто подобное, так как всегда хочется использовать самые новые версии гемов в своем проекте. Чтобы начать пользоваться BundleWatcher, вы просто заливаете ему Gemfile.lock из своего проекта и он генерирует вам страничку, где указаны ваши версии гемов, а так же самые последние версии используемых гемов. Кроме того, генерируется RSS лента, которую стоит добавить в свой ридер.

В общем, всячески рекомендую, и сам буду пользоваться.

Proper catching controller-level exceptions in Rails

Июнь 21st, 2011

How to handle the exception in the Rails controller best way? Well, everyone knows that:

rescue_from ActiveRecord::RecordNotFound, :with => :page_404

Excellent. But what if in addition we want to handle all other exceptions to show the pretty 500 error page on production? Immediately we’ve got the idea to write:

rescue_from Exception, :with => :internal_error

Here’s the idea that we do not need to handle each exception separately, as well as all exceptions are derived from Exception so we can handle this Exception and calm down. But no such luck.

Let’s take a look what we have:

rescue_from ActiveRecord::RecordNotFound, :with => :page_404
rescue_from Exception, :with => :internal_error

At first glance, all is well. But this will not work as expected. The fact that the ActiveRecord::RecordNotFound exception is inherited from Exception, so the latest rescue_from call will fire for Exception too, so when an any exception raised, :internal_error will be called and this call which will block :page_404 call. So we never see 404 page :)

So, how to handle exceptions properly? I came to this decision:

# coding: utf-8
class ApplicationController < ActionController::Base
  # Catch all exceptions at a stretch
  rescue_from Exception, :with => :handle_exceptions

private

  # Handle exceptions
  def handle_exceptions(e)
    case e

    # Handling exception of CanCan gem
    when CanCan::AccessDenied
      authenticate_user!

    # Handling exception when record not found
    when ActiveRecord::RecordNotFound
      not_found

    # Or handle any other exceptions
    else
      internal_error(e)
    end
  end

  def not_found
    # Render 404 error page
    render 'application/not_found', :status => 404
  end

  def internal_error(exception)
    if RAILS_ENV == 'production'
      # Here you can send e-mail to developer or notify Hoptoad
      # ...

      # Render a pretty page for production with an error message
      render :layout   => 'layouts/internal_error',
             :template => 'application/internal_error',
             :status   => 500
    else
      # Forward exception that we were able to see a standard error page with stack for development.
      # With no arguments, it re-raises the recent exception.
      raise
    end
  end
end

There’s all clear from the example. I just added a branching for handling exceptions to avoid ambiguity as in the second example. Someone may say that we can just add rescue_from Exception at the top, but it will not save you if your colleague unwittingly add the desired rescue_from even higher. Therefore, I prefer for branching as the most obvious and intuitive solution.

UPD:
That article was mentioned in RubyShow Episode 171

Правильный перехват исключений уровня контроллера в Rails

Июнь 20th, 2011

Как красиво обработать исключение в контроллере Rails? Ну это все знают:

rescue_from ActiveRecord::RecordNotFound, :with => :page_404

Отлично. А что, если мы захотим вдобавок обработать все остальные исключения, чтобы показать красивую 500-ю страничку на продакшене? Сразу приходит мысль написать так:

rescue_from Exception, :with => :internal_error

Тут идея в том, что нам не нужно обрабатывать каждое исключение в отдельности, а так как все исключения наследуются от Exception, то мы можем обработать именно Exception и успокоиться. Но не тут-то было.

Давайте снова взглянем на то, что у нас получилось:

rescue_from ActiveRecord::RecordNotFound, :with => :page_404
rescue_from Exception, :with => :internal_error

На первый взгляд все хорошо. Но этот код не будет работать так, как от него ожидается. Дело в том, что исключение ActiveRecord::RecordNotFound наследуется от Exception, поэтому последнее объявление rescue_from будет срабатывать и для него, таким образом, при возникновении любого исключения, будет вызываться метод :internal_error, вызов которого будет перекрывать вызов :page_404. Так мы никогда не увидим страничку 404 :)

Как тогда правильно обрабатывать исключения? Я пришел к такому решению:

# coding: utf-8
class ApplicationController < ActionController::Base
  # Перехватываем все исключения подряд
  rescue_from Exception, :with => :handle_exceptions

private

  # "Разруливаем" исключения
  def handle_exceptions(e)
    case e

    # Обрабатываем исключение от гема CanCan
    when CanCan::AccessDenied
      authenticate_user!

    # Обрабатываем исключение при не найденной записи
    when ActiveRecord::RecordNotFound
      not_found

    # Или обрабатываем все остальные исключения
    else
      internal_error(e)
    end
  end

  def not_found
    # Страничка с ошибкой 404
    render 'application/not_found', :status => 404
  end

  def internal_error(exception)
    if RAILS_ENV == 'production'
      # Тут можно уведомить hoptoad или разработчика письмом об ошибке
      # ...

      # Рендерим красивую страничку с сообщением об ошибке для продакшена
      render :layout   => 'layouts/internal_error',
             :template => 'application/internal_error',
             :status   => 500
    else
      # А для девелопмента пробрасываем исключение дальше,
      # чтобы мы смогли увидеть стандартную страничку об ошибке для разработчиков.
      # При вызове raise без аргументов ruby автоматически "перекинет" последний эксепшен.
      raise
    end
  end
end

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

Кстати, напомню, что в Rails 3 перехватить исключение роутера ActionController::RoutingError при помощи rescue_from пока что невозможно.

Rails 3.1.beta: Could not find a JavaScript runtime

Май 12th, 2011

A tiny note for saving time.
If you’re trying new rails 3.1.beta under Ubuntu you can see this error:

Could not find a JavaScript runtime. See https://github.com/sstephenson/execjs for a list of available runtimes.

The solution is quite simple. Just add gem ‘therubyracer’ into your Gemfile (it’s for Ruby MRI). Done.

Also, you can install another javascript runtimes. But who cares?