В предыдущей части я рассказал про представления. Теперь поговорим про контроллеры.
В этой части я расскажу про:
  • REST
  • gem responders
  • иерархию контроллеров
  • хлебные крошки

Контроллер обеспечивает связь между пользователем и системой:
  • получает информацию от пользователя,
  • выполняет необходимые действия,
  • отправляет результат пользователю.

Контроллер содержит только логику взаимодействия с пользователем:
  • выбор view для отображения данных
  • вызов процедур обработки данных
  • отображение уведомлений
  • управление сессиями

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

REST


Я не буду углубляться в теорию REST, а расскажу вещи, имеющие отношение к rails.

Очень часто вижу, что контроллеры воспринимают как набор экшенов, т.е. на любое действе пользователя добавляют новый нестандартный action.

resources :projects do
  member do
    get :create_act
    get :create_article_waybill
    get :print_packing_slips
    get :print_distributions
  end

  collection do
    get :print_packing_slips
    get :print_distributions
  end
end

resources :buildings do
  [:act, :waybill].each do |item|
    post :"create_#{item}"
    delete :"remove_#{item}"
  end
end

Иногда случается, что программисты не понимают назначение и разницу методов GET и POST. Подробнее об этом написано в статье «15 тривиальных фактов о правильной работе с протоколом HTTP».

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

Для реализации этого функционала создаем одиночный ресурс session, соответственно, со следующими экшенами: new, create, destroy, update. Таким образом, у нас есть один контроллер, который отвечает только за сессии.

Рассмотрим пример сложнее. Есть сущность проект и контроллер, реализующий crud операции.
Проект может быть активным или завершенным. При завершении проекта нужно указать дату фактического завершения и причину задержки. Соответственно, нам нужно 2 экшена: для отображения формы и обработки данных из формы. Первое очевидное и неверное решение — добавить 2 новых метода в ProjectsController. Правильное решение — создать вложенный ресурс «завершение проекта».

resources :projects do
  scope module: :projects do
    resource :finish
    # GET /projects/1/finish/new
    # POST /projects/1/finish
  end
end

В этом контроллере мы добавим проверку статуса: а можем ли мы вообще завершать проект?

class Web::Projects::FinishesController < Web::Projects::ApplicationController
  before_action :check_availability

  def new
  end

  def create
  end

  private
  def check_availability
    redirect_to resource_project unless resource_project.can_finish?
  end
end

Аналогично можно поступать с пошаговыми формами: каждый шаг — это отдельный вложенный ресурс.

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

Responders


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


class Web::ApplicationController < ApplicationController
  self.responder = WebResponder # потомок ActionController::Responder
  respond_to :html
end

class Web::UsersController < Web::ApplicationController
  def update
    @user = User.find params[:id]
    @user.update user_params
    respond_with @user
  end
end 

Иерархия контроллеров


Подробное описание есть в статье Кирилла Мокевнина.
Что-то подобное я видел в англоязычном блоге, но ссылку не приведу. Цель этой методики — организовать контроллеры.

Сначала приложение рендерит только html. Потом появляется ajax, те же html, только без layout.
Потом появляется api и вторая версия api, первую версию оставляем для обратной совместимости. Api использует для аутентификации токен в заголовке, а не cookie. Потом появляются rss ленты, для гостей и зарегистрированных, причем rss клиенты не умеют работать с cookies. В ссылку на rss feed нужно включать токен пользователя. После требуется использовать js фреймворк, и написать json api для этого с аутентификацией через текущую сессию. Затем появляется раздел сайта с отдельным layout и аутентификацией. Так же у нас появляются логически вложенные сущности с вложенными url.

Как это решается.
Все контроллеры раскладываются по неймспейсам: web, ajax, api/v1, api/v2, feed, web_api, promo.
И для вложенных ресурсов используются вложенные роуты и вложенные контроллеры.

Пример кода:

Rails.application.routes.draw do
  scope module: :web do
    resources :tasks do
      scope module: :tasks do
        resources :comments
      end
    end
  end

  namespace :api do
    namespace :v1, defaults: { format: :json } do
      resources :some_resources
     end
   end
end

class Web::ApplicationController < ApplicationController
  include UserAuthentication # подключаем специфичную для web аутентификацию
  include Breadcrumbs # подключаем хлебные крошки, зачем они нужны в api?

  self.responder = WebResponder
  respond_to :html  # отвечаем всегда в html

  add_breadcrumb {{ url: root_path }}

  # в случае отказа в доступе
  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "You are not authorized to perform this action."
    redirect_to(request.referrer || root_path)
  end
end

# базовый класс для ресурсов, вложенных в ресурс task
class Web::Tasks::ApplicationController < Web::ApplicationController
  # базовый ресурс доступен во view
  helper_method :resource_task

  add_breadcrumb {{ url: tasks_path }}
  add_breadcrumb {{ title: resource_task, url: task_path(resource_task) }}

  private

  # используем этот метод для получения базового ресурса
  def resource_task
    @resource_task ||= Task.find params[:task_id]
  end
end

# вложенный ресурс
class Web::Tasks::CommentsController < Web::Tasks::ApplicationController
  add_breadcrumb

  def new
    @comment = resource_task.comments.build
    authorize @comment
    add_breadcrumb
  end

  def create
    @comment = resource_task.comments.build
    authorize @comment
    add_breadcrumb
    attrs = comment_params.merge(user: current_user)
    @comment.update attrs
    CommentNotificationService.on_create(@comment)
    respond_with @comment, location: resource_task
  end
end

Понятно, что глубокая вложенность — это плохо. Но это касается только ресурсов, а не неймспейсов. Т.е. допустимо иметь такую вложенность: Api::V1::Users::PostsController#create, POST /api/v1/users/1/posts. Вложенность ресурсов необходимо ограничивать только 2-мя уровнями: родительский ресурс и вложенный ресурс. Так же те экшены, которые не зависят от базового ресурса, можно вынести на уровень выше. В случае с users и posts: /api/v1/users/1/posts и /api/v1/posts/1

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

Хлебные крошки


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

class Web::ApplicationController < ApplicationController
  include Breadcrumbs
  # Добавляем хлебную крошку для главной страницы, первая ссылка в списке
  # Заголовок подставляется из локали, ключ основан на класса контроллера
  # {{}} означает блок, возвращающий хэш
  add_breadcrumb {{ url: root_path }}
end

class Web::TasksController < Web::ApplicationController
  # добавляем вторую хлебную крошку
  add_breadcrumb {{ url: tasks_path }}

  def show
    @task = Task.find params[:id]
    # добавляем крошку для конкретного ресурса
    add_breadcrumb model: @task
    respond_with @task
  end
end

class Web::Tasks::ApplicationController < Web::ApplicationController
  # крошки для вложенных ресурсов
  add_breadcrumb {{ url: tasks_path }}
  add_breadcrumb {{ title: resource_task, url: task_path(resource_task) }}

  def resource_task; end # опустим
end

class Web::Tasks::CommentsController < Web::Tasks::ApplicationController
  # т.к. не указали url, то будет выведен только заголовок
  add_breadcrumb

  def new
    @comment = resource_task.comments.build
    authorize @comment
    add_breadcrumb # добавит крошку "Создание новой записи"
  end
end

# ru.yml
ru:
  breadcrumbs:
    defaults:
      show: "%{model}"
      new: Создание новой записи
      edit: "Редактирование: %{model}"
    web:
      application:
        scope: Главная
      tasks:
        scope: Задачи
        application:
          scope: Задачи
        comments:
          scope: Комментарии

Реализация
# app/helpers/application_helper.rb
# Хэлпер, отображающий крошки
def render_breadcrumbs
  return if breadcrumbs.blank? || breadcrumbs.one?
  items = breadcrumbs.map do |breadcrumb|
    title, url = breadcrumb.values_at :title, :url

    item_class = []
    item_class << :active if breadcrumb == breadcrumbs.last

    content_tag :li, class: item_class do
      if url
        link_to title, url
      else
        title
      end
    end
  end

  content_tag :ul, class: :breadcrumb do
    items.join.html_safe
  end
end

# app/controllers/concerns/breadcrumbs.rb
module Breadcrumbs
  extend ActiveSupport::Concern

  included do
    helper_method :breadcrumbs
  end

  class_methods do
    def add_breadcrumb(&block)
      controller_class = self
      before_action do
        options = block ? instance_exec(&block) : {}
        title = options.fetch(:title) { controller_class.breadcrumbs_i18n_title :scope, options }
        breadcrumbs << { title: title, url: options[:url] }
      end
    end

    def breadcrumbs_i18n_scope
      [:breadcrumbs] | name.underscore.gsub('_controller', '').split('/')
    end

    def breadcrumbs_i18n_title(key, locals = {})
      default_key = "breadcrumbs.defaults.#{key}"
      if I18n.exists? default_key
        default = I18n.t default_key
      end

      I18n.t key, locals.merge(scope: breadcrumbs_i18n_scope, default: default)
    end
  end

  def breadcrumbs
    @_breadcrumbs ||= []
  end

  # используется внутри экшена контроллера
  def add_breadcrumb(locals = {})
    key =
        case action_name
        when 'update' then 'edit'
        when 'create' then 'new'
        else action_name
        end
    title = self.class.breadcrumbs_i18n_title key, locals
    breadcrumbs << { title: title }
  end
end



В этой части я показал как можно организовать код контроллеров. В следующей части я расскажу про работу с объектами-формами.

Комментарии (8)


  1. Loriowar
    20.09.2015 00:31
    +2

    Вот честно, затея цикла публикаций может и хорошая, но реализация ни о чём: ни одна затронутая тема не раскрыта в достаточном объёме. Прочитал и не понял что хотел сказать автор и кто есть целевая аудитория. Новичкам слишком мало данных, а более опытным людям — слишком скучно и очевидно.

    Теперь по порядку:

    • «отображение уведомлений» — не является задачами контроллера, это задачи объекта. В контексте системы всегда должен быть целевой объект или группа объектов, с которыми работает пользователь. Именно эти объекты и должны хранить в себе сообщения. Можно сходить в тот же simple_form и посмотреть как замечательно всё работает без участия контроллера. В более продвинутом случае за валидации и сообщения от них должен отвечать отдельный обработчик (привет Trailblazer).
    • Пример со статусами проекта наигран: кто мешает добавить state_machine и пользоваться формой edit/update для проекта сохраняя RESTfull?
    • С орзанизацией контроллеров — это палка о двух концах: получается отдельные контроллеры, но лишаетесь единообразных урлов, то есть не получится обратиться к "/user/1/posts", "/user/1/posts.old_api", "/user/1/posts.new_api2" и тп. Альтернатива — модули в namespace целевого контроллера, например, User::OldApi. И далее при помощи метапрограммирования можно сделать красивые конструкции из `respond_with` с различными `format`.
    • Про хлебные крошки вообще не понятно… Что пробовали? Чем не понравилось? Чем своя реализация лучше всех прочих?


    1. printercu
      20.09.2015 12:18
      +1

      > не получится обратиться к "/user/1/posts", "/user/1/posts.old_api", "/user/1/posts.new_api2"

      Если так делать, в контролере придется хранить код на 3+ реализации апи. А это, рано или поздно — конфликты имен и функционала. По-моему, если решили делать новую версию апи, то лучше сразу в отдельный нэймспэйс вынести, а старый код оставить как есть. А если проект с нуля, то такой подход, просто избавит от этих проблем в будущем.

      Мы только используем namespace: :site вместо scope module: :web. Так урл-хэлперы будут с префиксами, и вызовы их яснее: можно ошибиться и написать в админке form_for user / link_to user / user_path вместо form_for [:admin, user] / link_to [:admin, user] / admin_user_path. Вариант с нэймспэйсом просто выдаст ошибку, а со скоупом — вернёт неверный урл. Вроде бы был ещё какой-то тонкий момент связанный с этим, но сейчас не вспомнил.


    1. mkuzmin
      20.09.2015 14:59

      > Вот честно, затея цикла публикаций может и хорошая, но реализация ни о чём: ни одна затронутая тема не раскрыта в достаточном объёме. Прочитал и не понял что хотел сказать автор и кто есть целевая аудитория. Новичкам слишком мало данных, а более опытным людям — слишком скучно и очевидно.

      Спасибо за отзыв. У меня нет большого опыта в написании статей. Если у кого-то есть желание и время помочь в написании статей о rails, то я всегда открыт предложениям. Можно, например, по очереди публиковать статьи или проставлять ссылки на профиль.

      > «отображение уведомлений»
      тут имелись в виду flash и сообщения об ошибках-исключениях

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

      > С орзанизацией контроллеров
      Тут нужно посмотреть на вашу ситуацию. Моя методика взята из статьи habrahabr.ru/post/136461. Я ее применяю на протяжении 3х лет и ни разу мои контроллеры не распухали. Метапрограммирование тоже полка о двух концах, с ним нужно быть аккуратным.

      > Про хлебные крошки вообще не понятно…
      Пробовал все гемы, которые нашел на github(их штук 5).
      Они как я писал в статье, они все запрашивают слишком много данных. Моя реализация требует указания только самого необходимого. Все, что она может получить самостоятельно — получает сама. Например, по имени контроллера из локали получает заголовок, по названию action так же получает заголовок.


  1. estum
    21.09.2015 07:33
    -2

    Без inherited_resources управление вложенными ресурсами рано или поздно превратится в муку.

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

    Зачем в этом топике про хлебные крошки, я так и не понял, но раньше тоже изобретал велосипед пока не наткнулся на https://github.com/lassebunk/gretel. Оно может и требует небольшого допила, но, в целом, способ, на мой взгляд, элегантный, и не требует залазить с хлебными крошками в контроллер, где им явно не место.

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


  1. Metus
    21.09.2015 10:38

    Скажите пожалуйста, а как вписывается в REST личный кабинет пользователя но без id в адресе?


    1. estum
      21.09.2015 12:21

      http://guides.rubyonrails.org/routing.html#singular-resources


      1. Metus
        21.09.2015 13:30

        Спасибо!
        А в примере с сессиями пользователя это одиночный ресурс или множественный?


        1. estum
          21.09.2015 13:58

          Одиночный. (Но можно и множественный, если нужно дать юзеру возможность управлять сессиями, например, как в контактике)