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

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

    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.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 44

      +4
      За kaminari спасибо.

      Путь к красивому коду протекает через бурелом говнокода ))))
      0
      Статья направляет человека на верный путь, однако реальность сурова и некоторые вещи просто не укладываются в концепцию REST, к которой строго принуждают эти ваши inherited_resources. Хотя к этому надо стремиться.
        0
        Давайте примеры. Разберем более предметно.
          0
          Яркий пример: главная страница сайта (где есть новости, блоги, последние комментарии к чему-либо). В общем где контроллер обращается к более чем одной модели.

          P.S. Это, конечно, резко с моей стороны утверждать, что что-то не укладывается в REST — при желании можно уложить почти все, тут вопрос в том, стоит ли овчинка выделки.
            0
            Проще всего это организовать через before_filter. Вот пример контроллера главной страницы, который как раз показывает новости и последнии комментарии:

            class PostsFeedsController < InheritedResources::Base
             respond_to :html, :rss, :only => :index
             actions :index

             before_filter lambda{ @latest_comments = Comment.by_domain(Domain.current_domain).latest(10) }
             before_filter lambda{ @latest_posts = Post.by_domain(Domain.current_domain).latest(10) }
             caches_page :index, :unless => lambda {|c| c.user_signed_in?}

             include Platform::Controllers::Paginate
             include Platform::Controllers::Domain
             include Platform::Controllers::EagerLoading
            end


            * This source code was highlighted with Source Code Highlighter.
              +1
              Фильтры кажутся, как бы это сказать, wrong place for that.
              В RoR есть какая-нибудь возможность вызвать другой Action из View и поместить его результат в месте вызова?
                0
                partials
                  0
                  Что-то мне подсказывает, что Partials — это не то. Мы же должны передать в Partial данные из «основного» шаблона, разве не так?
                    0
                    А, ну тогда cells, но это нестандартный механизм
                      0
                      Посмотрел, да, Cells именно для таких сценариев. Странно только, что он нестандартный.

                      Кстати, они их описывают как mini-контроллеры. А нет ли возможности вызывать один и тот же Action одного и того же контроллера:
                      — как из браузера напрямую (и тогда будет сформирована страница этого ресурса), и
                      — из другого View (и тогда результат будет в виде Partial вставлен в «основную» страницу)?
            +1
            А почему вы думаете, что тут должен быть один контроллер?
              0
              Потому что контроллер — это штука формирующая ответ на запрос. Одно действие — один ответ. Можно конечно выдать скелет страницы в ответе, а блоки с новостями, блогами и т.п. подгружать динамически — по запросу на блок — это тоже вариант. Эдакая система виджетов.
                0
                «Потому что контроллер — это штука формирующая ответ на запрос. Одно действие — один ответ.»

                Я бы так не сказал. Front Controller (независимо от того, как реализован на конкретной платформе) — да, выдает один ответ. А каждый отдельный контроллер вовсе не обязан выдавать только свой результат сразу в браузер пользователя, их можно комбинировать. В ASP.NET MVC это RenderAction, уверен, в RoR это делается похожим образом. В выходящий поток (в браузер пользователя) выдается уже полная страница, независимо от того, сколько контроллеров над ней «поработало».
                  –1
                  Тут можно уйти в теорию (что не хотелось бы). Но по-моему концепция формирования одного view несколькими контроллерами — это уже не MVC. Хотя это удобно — спору нет. Кажется cells в Rails делает нечто подобное.
                    0
                    Не, в теорию уходить не нужно. Концепция формирования кусочков View несколькими контроллерами — вполне укладывается в MVC, сохраняется и Separation of Concerns, и паттерн Front Controller, и все прочее. ASP.NET MVC именно так и устроен. И да, это чертовски удобно :)
                      0
                      Cells — да, миниатюрные контроллеры со своими views, вызовы которых можно встроить в любой view.

                      Но мне кажется, тут больше подходит использование составной модели.
              0
              Создайте модель для этого.
                0
                тут бы как раз очень Engines пригодилось
                  0
                  Что за Engines?
                    0
                    Refinery CMS очень их использует, это так сказать собранные в один «модуль» MVC кусочки, которые можно вызывать из «центральной» системы :)
                    refinerycms.com/engines
              0
              Имхо главное правильно разбить логику на сущности. Да, иногда при этом сущности плодятся, но зато потом не возникает проблем с расширением.
                +1
                Все верно. тогда эти советы работают максимально эффективно
                0
                А при чем тут REST? Если приложение поддерживает этот архитектурный стиль, вовсе не значит, что не может быть эдаких «обобщенных ресурсов» вроде «главной страницы» или какой-нибудь с виджетами и Pagelet-ами.
                0
                Спасибо, хорошая статья. Вот еще нашел интересную штуку в вики по Devise: github.com/kristianmandrup/cream
                Объединяет аутенфикацию юзеров с помощью Devise, распределению ролей и групп через Roles и авторизации через CanCan.

                Если буду создавать новый проект, то этот гем будет установлен в первую очередь.
                  0
                  Пробовали его использовать, но мне он показался сырым и глючным
                  +1
                  А есть ли практический смысл уменьшать код контроллеров? Его там и так не много, это модели разрастаются до сотен строк, а контроллеры обычно тонкие.
                    +2
                    Смысл есть когда в 5 контроллерах 90% кода — это нагенеренный CRUD, а остальные 10% — кастомные before filters.
                      +2
                      Ну эстетически будет красивее, да. Но практически толку от этого мало, читаемость кода не повысит (что там читать в сгенерированом контроллере), скорость разработки это не увеличит, а вот если к проекту подключится новый человек не знакомый с методикой, то ему придется потратить какое-то время на то что бы понять принципы.
                        –1
                        После этой статьи времени новичку потребуется минимум.
                        Если кто-то добавил в сгенерированный контроллер какие-то правки (скоуп, дополнительная проверка или инициализация), то читать придется все в полном объеме и вникать. А таким способом — все отклонения от стандартного поведения видны сразу.
                          0
                          Ну согласен, это плюс, но вот стоит ли овчинка выделки?
                            –2
                            Стоит. На банальщину не отвлекаешься.
                    +1
                    Забыли упомянуть Rabl для генерации json, xml ответов. Для создания API приложения, ему нет равных, очень удобен.
                      +1
                      inherited_resources очень спорный, ни одно приложение (кроме блога за пять минут) не укладывается в стандартный crud-контроллер. А inherited_resources с нестандартным контроллером — это изучение дополнительного API, сложность чтения кода и лишний, достаточно тяжеловесный гем наполненный метапрограмминговой магией. Зачем? Вам трудно написать def edit; ...; end;?
                        –1
                        Основные CRUD действия, как правило, остаются. Конечно приходится добавлять и другие, но inherited_resources здесь совсем не мешает, зато дисциплинирует.
                        Насчет сложности чтения, как с HAML — вопрос привычки.
                        def edit; ...; end; — написать не сложно, но зачем писать одно и тоже многократно?
                          +1
                          Нет, я говорю о том, что дело никогда не ограничивается чистыми методами crud. Т.е. и index и update и пр. методы всегда расширяются: добавляются фильтры, работа с конечными автоматами, да хоть та же пагинация. По большому счету вы всю логику контроллере выносите в before_filter's и приватные методы которые дергаются IR. Это дезориентирует и в конечном счете превращает код в лапшу. Не говоря уже о том, что приходится писать больше кода.
                            0
                            Никто не мешает общие части вынести в плагины, гемы или модули.
                              +1
                              Общие то понятно, но что делать с индивидуальными. Мы просто все переносим с больной головы на здоровую. Вместо методов мы все пишем в бифор-фильтры, super do и всячески экстендим collection; Зачем тогда вообще брать руби и рельсы?
                          +1
                          Согласен, inherited_resources — это вещь в стиле «как сделать простые вещи сложными». Если в проекте много однотипных простеньких контроллеров, то в разы лучше будет создать обычный базовый класс контроллера с минимумом магии и метапрограммирования, который идеально подойдёт под потребности конкретного проекта.
                          А использовать inherited_resources для нетривиальных контроллеров == снижать читабельность кода в разы.
                          +2
                          … заслуживают отдельной статьи

                          организуете отдельную статью?
                          • НЛО прилетело и опубликовало эту надпись здесь
                              –3
                              Паш, прикольно что статья в топ вышла :)
                              Твой подход помню, хотя скажу что у меня он не прижился.

                              Но вот очень понравилось, как ты объединял edit и new views, до сих пор использую.

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

                              Самое читаемое