В предыдущей части я рассказал про представления. Теперь поговорим про контроллеры.
В этой части я расскажу про:
Контроллер обеспечивает связь между пользователем и системой:
Контроллер содержит только логику взаимодействия с пользователем:
Бизнес логика должна храниться отдельно. Ваше приложение может так же взаимодействовать с пользователем через командную строку с помощью rake команд. Rake команды, по сути, те же контроллеры и логика должна разделяться между ними.
Я не буду углубляться в теорию REST, а расскажу вещи, имеющие отношение к rails.
Очень часто вижу, что контроллеры воспринимают как набор экшенов, т.е. на любое действе пользователя добавляют новый нестандартный action.
Иногда случается, что программисты не понимают назначение и разницу методов GET и POST. Подробнее об этом написано в статье «15 тривиальных фактов о правильной работе с протоколом HTTP».
Возьмем для примера работу с сессиями. По техническому заданию пользователь может:
Для реализации этого функционала создаем одиночный ресурс session, соответственно, со следующими экшенами: new, create, destroy, update. Таким образом, у нас есть один контроллер, который отвечает только за сессии.
Рассмотрим пример сложнее. Есть сущность проект и контроллер, реализующий crud операции.
Проект может быть активным или завершенным. При завершении проекта нужно указать дату фактического завершения и причину задержки. Соответственно, нам нужно 2 экшена: для отображения формы и обработки данных из формы. Первое очевидное и неверное решение — добавить 2 новых метода в ProjectsController. Правильное решение — создать вложенный ресурс «завершение проекта».
В этом контроллере мы добавим проверку статуса: а можем ли мы вообще завершать проект?
Аналогично можно поступать с пошаговыми формами: каждый шаг — это отдельный вложенный ресурс.
Идеальный случай, это когда используется только стандартные экшены. Понятно, что бывают исключения, но это случается очень редко.
Gem respongers помогает убрать повторяющуюся логику из контроллеров.
Подробное описание есть в статье Кирилла Мокевнина.
Что-то подобное я видел в англоязычном блоге, но ссылку не приведу. Цель этой методики — организовать контроллеры.
Сначала приложение рендерит только 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.
И для вложенных ресурсов используются вложенные роуты и вложенные контроллеры.
Пример кода:
Понятно, что глубокая вложенность — это плохо. Но это касается только ресурсов, а не неймспейсов. Т.е. допустимо иметь такую вложенность: Api::V1::Users::PostsController#create, POST /api/v1/users/1/posts. Вложенность ресурсов необходимо ограничивать только 2-мя уровнями: родительский ресурс и вложенный ресурс. Так же те экшены, которые не зависят от базового ресурса, можно вынести на уровень выше. В случае с users и posts: /api/v1/users/1/posts и /api/v1/posts/1
Можно поспорить, что иерархия наследования классов — лучший выбор для этой задачи. Если у кого-то есть соображения, как организовать контроллеры иначе, то предлагайте свои варианты в комментариях.
Я перепробовал несколько библиотек для формирования хлебных крошек, но ни одна не подошла. В итоге сделал свою реализацию, которая использует иерархическую организацию контроллеров.
В этой части я показал как можно организовать код контроллеров. В следующей части я расскажу про работу с объектами-формами.
В этой части я расскажу про:
- 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
В этой части я показал как можно организовать код контроллеров. В следующей части я расскажу про работу с объектами-формами.