В предыдущей части я рассказал про представления. Теперь поговорим про контроллеры.
В этой части я расскажу про:
Контроллер обеспечивает связь между пользователем и системой:
Контроллер содержит только логику взаимодействия с пользователем:
Бизнес логика должна храниться отдельно. Ваше приложение может так же взаимодействовать с пользователем через командную строку с помощью 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
В этой части я показал как можно организовать код контроллеров. В следующей части я расскажу про работу с объектами-формами.