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

    В предыдущей части я рассказал про представления. Теперь поговорим про контроллеры.
    В этой части я расскажу про:
    • 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
    • +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
        > не получится обратиться к "/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. Вариант с нэймспэйсом просто выдаст ошибку, а со скоупом — вернёт неверный урл. Вроде бы был ещё какой-то тонкий момент связанный с этим, но сейчас не вспомнил.
        • 0
          > Вот честно, затея цикла публикаций может и хорошая, но реализация ни о чём: ни одна затронутая тема не раскрыта в достаточном объёме. Прочитал и не понял что хотел сказать автор и кто есть целевая аудитория. Новичкам слишком мало данных, а более опытным людям — слишком скучно и очевидно.

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

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

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

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

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

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

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

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

          А еще, я слышал, что если использовать нераскрытые неймспейсы в названиях классов, то боженька покарает.
          • 0
            Скажите пожалуйста, а как вписывается в REST личный кабинет пользователя но без id в адресе?
            • 0
              http://guides.rubyonrails.org/routing.html#singular-resources
              • 0
                Спасибо!
                А в примере с сессиями пользователя это одиночный ресурс или множественный?
                • 0
                  Одиночный. (Но можно и множественный, если нужно дать юзеру возможность управлять сессиями, например, как в контактике)

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

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