Pull to refresh

Управление сложностью в проектах на ruby on rails. Часть 2

Designing and refactoring *Ruby on Rails *
В предыдущей части я рассказал про представления. Теперь поговорим про контроллеры.
В этой части я расскажу про:
  • 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



В этой части я показал как можно организовать код контроллеров. В следующей части я расскажу про работу с объектами-формами.
Tags:
Hubs:
Total votes 13: ↑9 and ↓4 +5
Views 10K
Comments Comments 8