Ruby on Rails

Плагин для подсчета посещаемости страниц в Ruby on Rails 7 июля 10

Я написал плагин для подсчета посещаемости избранных страниц Rails-приложения. Взять его можно на http://github.com/leonid-shevtsov/page_visit_tracker, там же посмотреть пример использования и краткую инструкцию.

Можно, конечно, делать это с помощью Google Analytics и вытягивать информацию оттуда, но, по-моему, это слишком сложное решение для такой простой задачи – потому-то и был написан этот плагин.

Я решил, что посещения надо привязывать к объектам модели. Поэтому

class PostsController
  def show
    # ...
    track_page_view(@post)
  end
end

При этом создается новый объект PageView с данными о посещении. Повторные посещения тем же пользователем не учитываются. Посещения ботов тоже не учитываются.

Кеширование страниц с динамическими элементами средствами Ruby on Rails 3 июля 10

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

Последний раз я занимался кешированием страниц на сайте без аутентификации. Что даже более важно – сайт этот для всех выглядит одинаково, поэтому проблему отдачи страниц из кеша можно было легко сгрузить на Апач. К сожалению, так легко кешируются только самые простые сайты.

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

Перед тем, как заняться ее внедрением, нужно посмотреть по сторонам и составить список динамических элементов, нужных незалогиненному пользователю.

Flash, в смысле уведомления

Наиболее очевидный динамический элемент страницы – флеш. Чтобы выводить флеш на закешированных страницах, Pivotal Labs когда-то делали плагин CacheableFlash, но он рассчитан на Prototype. Потому-то я его и портировал в плагин CacheableFlash for jQuery.

Принцип работы плагина прост: в after_filter флеш заносится в куку, а в яваскрипте забирается оттуда. Надо заметить, что это не работает с flash.now, что, по-моему, вполне логично, поскольку flash.now нужен только на динамических страницах типа форм. Выводить его придется отдельно. Вообще у меня это таким хелпером делается:

def render_flash
  messages = {:error => [], :notice => []}
  flash.each do |type, message|
    messages[type] ||= []
    messages[type] << message
  end
  messages.to_a.map do |m|
    content_tag('div', m[1].join('<br/>'), :class => ['flash',m[0]].join(' '))
  end.join
end

Он и flash.now выводит, и место под динамический флеш подготавливает.

Другие элементы

Остальные динамические элементы – у меня на CarGid это был счетчик объявлений, отобранных пользователем «в блокнот» – выводятся таким же способом. Разумеется, нужно помнить, что объем кук не резиновый и что особо ценную информацию туда лучше не писать (хотя какая ценная информация у незалогиненного пользователя?)

Большие динамические элементы можно подгружать отдельным запросом. Сам понимаешь, что такое решение гораздо хуже.

Обновление кеша в результате действий незалогиненных пользователей

У меня незалогиненные пользователи могут оставлять комментарии. Разумеется, после добавления комментария его нужно показать пользователю – поэтому страницу с комментариями нужно убрать из кеша.

Настройка кеширования в контроллерах

По умолчанию рельсы кладут кеш прямо в public. Разумеется, это нам не подходит, потому кеш нужно перенести в public/cache:

# config/environments/production.rb
config.action_controller.page_cache_directory = File.join(RAILS_ROOT, :public, :cache)

Чтобы кеширование происходило только без наличия залогиненного пользователя, замечательно подходит условно-стандартный метод Authlogic current_user:

class MainController < ActionController::Base
  caches_page :index, :unless => :current_user
end

Остается растыкать caches_page по контроллерам, чтобы Rails начали складывать в кеш правильные страницы.

Настройка nginx

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

# убираем слеш из конца URL - его любят навешивать некоторые обозреватели
rewrite ^(.+)/$ $1 permanent;
 
# запросы POST можно смело передавать прямо в Rails
if ($request_method !~ ^(GET|HEAD)$) {
  break;
}
 
# эта директива запрещает прямой доступ к каталогу cache извне
location ^~ /cache {
  internal;
}
 
# есть куки Authlogic - отключаем кеширование
if ($cookie_user_credentials) {
  break;
}
 
# наконец, проверяем наличие страницы в кеше и отдаем ее оттуда
if (-f $document_root/cache/index.html) {
  rewrite ^/$ /cache/index.html last;
}
if (-f $document_root/cache/$request_uri) {
  rewrite .* /cache/$request_uri last;
}
if (-f $document_root/cache/$request_uri.html) {
  rewrite .* /cache/$request_uri.html last;
}

Это все, что касается nginx. Наверно, надо заметить, что переместить папку с кешем из-под document_root не получится. Можно сделать симлинк, если очень хочется.

Time.now против Time.zone.now в Rails 4 июня 10

Функции Time.now и Time.zone.now в Ruby on Rails практически идентичны. Возвращают одни и те же значения, ведут себя одинаково. Из-за этого легко перепутать и использовать более короткую формулировку Time.now. А зря – по крайней мере, если ты используешь часовой пояс, отличный от UTC.

В чем же разница между этими функциями? А вот в чем:

>> Time.now.class
=> Time
>> Time.now.to_s :db
=> "2010-06-04 19:00:00"
>> Time.zone.now.class
=> ActiveSupport::TimeWithZone
>> Time.zone.now.to_s :db
=> "2010-06-04 16:00:11"

Дело в том, что Rails, начиная с версии 2.1, хранят время в базе в UTC, а при сохранении и чтении переводят его из и в часовой пояс, используемый в приложении.

За перевод времени в UTC отвечает, как ни странно, метод to_s(:db). Точнее странно то, что, хоть он и доступен у объекта типа Time, работает он неверно и отдает время в текущем часовом поясе. Если использовать Time.now в условиях запроса – они сместятся на определенное количество часов. Если записывать Time.now в базу – в базу запишется неправильное значение, потому что при чтении рельсы лишний раз добавят к нему смещение часового пояса.

Короче, наиболее простым и адекватным решением этой проблемы будет замена по всему проекту Time.now на Time.zone.now.

…А еще из-за это проблемы не стоит использовать в запросах функцию MySQL NOW() и аналогичные, поскольку они ничего не знают о настройках рельсов.

Как Hoptoad помогает на тестовом сервере 26 марта 10

Итак, Hoptoad – это такая продвинутая штука для записывания исключений в Rails-приложениях. Его использование выливается в то, что каждое новое исключение логируется, а также при желании отправляется тебе на почту. Удобно.

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

По умолчанию Hoptoad логирует ошибки только тогда, когда не показывает (consider_all_requests_local = false). Самый простой способ это изменить – переопределить метод в ApplicationController:

#consider_all_requests_local должен быть true
class ApplicationController < ActionController::Base
  def rescue_action_locally(exception)
    if Rails.env == "staging" # or something else
      unless hoptoad_ignore_user_agent?
        HoptoadNotifier.notify_or_ignore(exception, hoptoad_request_data)
      end
      rescue_action_locally_with_hoptoad(exception)
    end
    super(exception)
  end
end

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

Чего не хватает? Ссылки на Hoptoad прямо со страницы ошибки, конечно!

UPD: Чтоб появились ссылки, нужно стянуть с гитхаба патченный файл vendor/plugins/hoptoad_notifier/lib/templates/rescue.erb и подложить в проект.

Теперь каждое сообщение об ошибке на staging будет сопровождаться вот такой вот красной ссылкой прямо на ошибку:

 title=

Предотвращение повторного запуска Rake-скрипта 25 февраля 10

А еще мне нужно было сделать так, чтобы долгоиграющий Rake-скрипт не запускался повторно (по крону). Я это сделал с помощью PID-файла, хотя не исключаю, что есть варианты и попроще.

В начале задачи – проверяем, запущена ли она уже, и если нет, создаем PID-файл.

pid_filename = "#{RAILS_ROOT}/tmp/#{task.name.gsub ':', '.'}.pid" # или любой другой
 
if File.exists?(pid_filename) && system("kill -0 #{File.read(pid_filename).to_i}")
  fail "Task already running"
else
  File.open(pid_filename,'w') {|f| f.write(Process.pid) }
end

В конце задачи – удаляем PID-файл:

File.unlink(pid_filename) rescue nil

Кеширование страниц средствами Ruby on Rails 25 февраля 10

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

Рельсы умеют такое делать «из коробки», практически одной строчкой:

class SomeController < ApplicationController
  caches_page :index
 
  def index
  end
end

Все! Кеш уже работает. Правда, почему-то рельсы не предусматривают две вещи. Кеш не работает с GET-параметрами – раз. Хранится прямо в public – два.

GET-параметры

Тут все просто: хочешь, чтобы для разного набора параметров кешировались разные страницы – помещай параметры в путь. Кстати, к таким ссылкам и Google лучше относится.

Для меня это в первую очередь касалось постраничной разбивки. Если ты используешь will_paginate, то тебе достаточно вынести URL с номером страницы в таблицу роутов:

map.resources :items
map.paged_items '/items/page/:page', :controller => 'items', :action => 'index'

Перемещаем кеш в более удобное место (например, в public/cache)

Путь к кешу указывается в конфиге:

# config/environments/production.rb
config.action_controller.page_cache_directory = RAILS_ROOT+"/public/cache/"

Осталось научить Apache забирать кеш из нужного каталога. Решается это довольно простыми реврайтами, не считая того, что отдавать нужно только файлы, которые точно лежат в кеше – иначе Rails станет получать неправильные пути и перестанет работать.

В общем, нужен вот такой .htaccess:

# public/.htaccess
RewriteEngine On
 
# убираем слеш в конце пути
RewriteCond %{REQUEST_URI} ^([^.]+)/$
RewriteRule ^[^.]+/$ /%1 [QSA,L]
 
# перенаправляем в кеш, только если там есть файл
RewriteCond %{THE_REQUEST} ^(GET|HEAD)
RewriteCond %{DOCUMENT_ROOT}/cache/%1 -f
RewriteRule ^.+$ /cache/%1 [QSA,L]
 
# то же для страниц без расширения (к ним Rails приписывает .html)
RewriteCond %{THE_REQUEST} ^(GET|HEAD)
RewriteCond %{REQUEST_URI} ^([^.]+)$
RewriteCond %{DOCUMENT_ROOT}/cache/%1.html -f
RewriteRule ^[^.]+$ /cache/%1.html [QSA,L]
 
# то же для пустого пути
RewriteCond %{THE_REQUEST} ^(GET|HEAD)
RewriteCond %{DOCUMENT_ROOT}/cache/index.html -f
RewriteRule ^$ /cache/index.html [QSA,L]

Теперь можно очищать весь кеш вместе обыкновенным rm -rf public/cache/*.

Почему в Rails нет метода для создания HABTM-таблицы? 15 января 10

В очередной раз набирая те шесть строчек, которыми – по соглашению – нужно описать таблицу для связи «многие ко многим», я подумал: а почему в Rails нет готового метода для этого? Почему каждый раз надо переписывать один и тот же код, попутно вспоминая, как сделать таблицу без ID – ведь используются они только для HABTM?

Подумал и написал:

# config/initializers/create_habtm_table.rb
 
class ActiveRecord::Migration
  # create_habtm_table :posts, :tags
  def self.create_habtm_table(first_model,second_model)
    table_name = [first_model.to_s,second_model.to_s].sort.join('_').to_sym
    create_table table_name, :id => false do |t|
      t.belongs_to first_model.to_s.singularize.to_sym
      t.belongs_to second_model.to_s.singularize.to_sym
    end
    add_index table_name, (first_model.to_s.singularize+'_id').to_sym
    add_index table_name, (second_model.to_s.singularize+'_id').to_sym
  end
 
  # drop_habtm_table :posts, :tags
  def self.drop_habtm_table(first_model, second_model)
    table_name = [first_model.to_s,second_model.to_s].sort.join('_').to_sym
    drop_table table_name
  end
end

Как добавить в конкретный Ruby-объект какое-нибудь свойство 11 января 10

Вообще задача такая:

class Book < ActiveRecord::Base
  has_many :ratings
  has_many :tags, :through => :ratings
end
 
class Rating < ActiveRecord::Base
  belongs_to :book
  belongs_to :tag
  validates_presence_of :score
end

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

Ущербные решения

Затык был не в том, как закодить саму выборку суммы, а в каком виде возвращать результат. Первая мысль – массивом хешей типа {:tag => tag, :score => score}. Мало того, что это попахивает PHP, так с такими хешами еще и неудобно работать. Потом я хотел сделать total_score методом Tag:

class Tag < ActiveRecord::Base
  def total_score(book)
    return score_computed_some_way
  end
end
 
@book.tags.first.total_score(@book)

Но это тоже бредовая тавтология, как по мне.

Расширение Ruby-объектов на уровне объекта

Итак. Есть такой метод Object#instance_eval – он позволяет запихнуть в объект практически все, что угодно. (Если бы что-то угодно было чем-то более сложным, чем возвращение объекта, можно было бы вынести его в модуль и подключать через Object#extend)

# scores = {tag_id => score, ...}
def inject_scores_into_tags(tags, scores)
  tags.each do |tag|
    tag.instance_eval do
      @total_score = scores[tag.id]
      def total_score
        @total_score
      end
    end
  end
end

После этого у тегов появляется метод total_score.

Этакий DSL для прикрывания неиспользуемых блоков во view 29 октября 09

-if @we_decide_to_implement_some_feature
  %div markup related to the feature

Или же, на менее человечном языке

<% if @we_decide_to_implement_some_feature %>
  markup goes here
<% end %>

Само собой, неопределенная инстанс-переменная возвращает nil и условие не проходит.

Через пару лет открываем код, видим переменную, внедряем функционал.

Украинский хостинг с mod_rails 26 августа 09

Существует ли в Украине хостинг для Ruby on Rails на основе mod_rails/Passenger? Мне казалось, что так на shared-хостинге использовать Passenger удобнее, чем mongrel, но тем не менее все пересмотренные хостинги (hostpro.ua, tophost.com.ua, 1gb.ua) используют последний.

Задача – перевезти клиента с моего сервера. VDSки не предлагать.