Ruby on Rails
July 2011 1

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

То, что контроллеры должны быть «худыми» знают все, но по мере наращивания функционала поддерживать чистоту контроллеров становится все сложнее и сложнее. Мы хотим предложить несколько рекомендаций как содержать свои контроллеры максимально чистыми без ущерба для качества кода.

1. Использовать inherited_resources


Все контроллеры строим на базе inherited_resources, что позволяет избегать банального CRUD кода. Всего две строчки объявления контроллера, унаследованного от InheritedResources::Base, и он умеет выполнять все базовые операции (создание/отображение/обновление/удаление) с ресурсом! Все бы хорошо, но часто возникают проблемы:
  • отфильтровать/отсортировать список
  • постраничная разбивка коллекций
  • разделение доступа между пользователями/группами пользователей

Для решения первой проблемы можно перекрыть соответствующий метод контроллера (index) и внести необходимые изменения в выборку, но есть способ намного более элегантный, который описан в документации inherited_resources, но почему-то им мало кто пользуется.

2. Использовать расширение has_scope


Этот gem позволяет вклинивать в выборку ресурса/колекции ресурсов любые скоупы, описанные в модели.
Как это работает? Например, нам нужно вывести все поста блога, т.е. отфильтровать посты по блогу:

class PostsController < InheritedResources::Base
 has_scope :by_blog, :only => :index
end

class Post < ActiveRecord::Base
 belongs_to :blog
 scope :by_blog, lambda{|blog_id| where(:blog_id => blog_id)}
end

* This source code was highlighted with Source Code Highlighter.

Теперь при запросе /posts?by_blog=1 коллекция ресурсов будет автоматически отфильтрована по блогу с id=1. Но выглядит это не очень красиво, поэтому пропишем в роутах следующее:

get "blogs/:blog_id(/page/:page)(.:format)" => "posts#index", :constraints => { :page => /\d+/ }, :defaults => { :page => 1 }
resources :posts


* This source code was highlighted with Source Code Highlighter.

И тот же самый результат можно будет получить по URLу /blogs/1.

Если необходимо постоянно сортировать коллекции, то можно использовать параметр default — тогда скоуп будет применяться всегда с указанным дефолтным значением:

class PostsController < InheritedResources::Base
 has_scope :ordered, :default => 'created_at DESC'
end

class Post < ActiveRecord::Base
 scope :ordered, lambda{|field| order(field)} # это может быть не безопастно
end


* This source code was highlighted with Source Code Highlighter.

Аналогичным образом можно истреблять проблему N+1 запросов:

class PostsController < InheritedResources::Base
 has_scope :eager_loading, :default => 'true', :only => :index
end

class Post < ActiveRecord::Base
 scope :eager_loading, preload(:blog, :user, :tags)
 scope :eager_loading2, includes(:blog, :user)
end


* This source code was highlighted with Source Code Highlighter.
Если нужно указать несколько скоупов сразу, проверить какие-то дополнительные условия или просто нет желания плодить скоупы в модели, то можно указать их прямо при задании параметров has_scope в блоке:

class PostsController > InheritedResources::Base
 has_scope :blog do |controller, scope, value|
    value != "all" ? scope.where(:blog_id => value) : scope
 end
end


* This source code was highlighted with Source Code Highlighter.

Подробнее ознакомиться с has_scope можно на странице проекта. Эффект от него как от HAMLа — стоит потратить немного времени на привыкание к особенностям, зато потом экономится много времени.

3. Для постраничной разбивки использовать kaminari/will_paginate


Эти гемы очень популярны и только ленивый их не использовал. Как они интегрируются с inherited_resources? С kaminari вообще нет никаких проблем — его можно применить в контроллере как обычный скоуп :page точно также как в предыдущих примерах. А вот с will_paginate — придется немного повозиться, т.к. метод paginate, который он предоставляет, не является скоупом модели. Но и здесь найдется вполне элегантное решение — необходимо перекрыть метод collection в контроллере следущим образом:

class PostsController < InheritedResources::Base
 protected
 def collection
    @posts ||= end_of_association_chain.paginate(:page => params[:page])
 end
end


* This source code was highlighted with Source Code Highlighter.

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

4. Для аутентификации и авторизации использовать devise и cancan соответственно


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

5. По возможности избегайте перекрытия стандартных методов контролера


Все дополнительные проверки, нормализацию параметров и прочие действия для стандартных операций можно вынести в before_filter:

class PostsController < InheritedResources::Base
 before_filter lambda{ resource.user = current_user }, :only => :create
 before_filter lambda { resource.thumb = nil if params[:thumb_delete] }, :only => :update
end


* This source code was highlighted with Source Code Highlighter.


6. Для формирования RSS лент, подгрузки AJAX коллекций и т.п. не нужно плодить отдельные методы


Достаточно запросить коллекцию в нужном формате: /posts.rss и /posts.json, при этом в контроллере достаточно прописать:
class PostsController < InheritedResources::Base
respond_to :html
respond_to :rss, :json, :only => :index
end


* This source code was highlighted with Source Code Highlighter.

Кроме того, для формирования RSS нужно прописать во шаблонах posts/index.rss.builder:
xml.instruct! :xml, :version => "1.0"
xml.rss :version => "2.0" do
 xml.channel do
    xml.title "Заголовок"
    xml.description "Описание"
    xml.link collection_url(:format => :rss)

    for resource in collection
     xml.item do
        xml.title resource.title
        xml.description "#{resource.annotation}\n#{link_to 'Читать дальше...', resource_url(resource)}"
        xml.pubDate resource.published_at.to_s(:rfc822)
        xml.link resource_url(resource)
        xml.guid resource_url(resource)
     end
    end
 end
end


* This source code was highlighted with Source Code Highlighter.


Обработать JSON коллекцию можно следующим кодом на jQuery:
$('#blog_id').live('change', function() {
    $.ajax({
        url: '/posts.json',
        dataType: 'json',
        data: { blog_id: $(this).val() },
        success: function(json) {
            var options = '';
            for (var i = 0; i < json.length; i++) {
                options += '<option value="' + json[i].id + '">' + json[i].title + '</option>';
            }
            $('#destination').html(options);
        }
    });
});


* This source code was highlighted with Source Code Highlighter.

Таким образом можно добиться минимального объема кода в контроллерах с сохранением гибкой функциональности, REST подхода и прозрачности кода. Все данные принципы реализованы в реальных проектах и неплохо показали себя на практике. Немного статистики: спустя 10 месяцев разработки, самый большой контроллер в проекте СмартСорсинг занимает 86 строк, самый маленький — 2 строки, но большинство котроллеров — не более 20 LOC.
+43
3.2k 103
Comments 44
Top of the day