Статьи

Один класс на действие контроллера Rails с Aldous

Контроллеры часто являются бельмом на глазу приложения Rails. Действия контроллера раздуты, несмотря на наши попытки сохранить их худыми, и даже когда они выглядят худыми, это часто иллюзия. Мы перемещаем сложность к различным before_actions , не уменьшая упомянутую сложность. На самом деле, это часто требует значительных размышлений и умственной компиляции, чтобы почувствовать поток управления конкретным действием.

После некоторого использования сервисных объектов в команде разработчиков Tuts + стало очевидно, что мы можем применить некоторые из тех же принципов к действиям контроллера. В конце концов мы придумали шаблон, который работал хорошо и подтолкнул его в Aldous . Сегодня я рассмотрю действия контроллера Aldous и преимущества, которые они могут принести вашему приложению Rails.

Разбить каждое действие на отдельный класс было первым, о чем мы подумали. Некоторые из более новых фреймворков, такие как Lotus, делают это «из коробки», и, приложив немного усилий, Rails также может воспользоваться этим.

Действия контроллера, которые являются единственным оператором if..else являются соломенными чучелами. Даже в приложениях небольшого размера гораздо больше, чем это, которые проникают в домен контроллера. Существует аутентификация, авторизация и различные бизнес-правила на уровне контроллера (например, если человек заходит сюда и не вошел в систему, перенесите его на страницу входа). Некоторые действия контроллера могут быть довольно сложными, и вся эта сложность находится на уровне контроллера.

Учитывая то, за что может отвечать действие контроллера, кажется естественным, что мы инкапсулируем все это в класс. Затем мы можем намного проще проверить логику, так как мы надеемся, что у нас будет больше контроля над жизненным циклом этого класса. Это также позволило бы нам сделать эти классы действий контроллера гораздо более связными (сложные контроллеры RESTful с полным набором действий имеют тенденцию довольно быстро терять сцепление).

Существуют и другие проблемы с контроллерами Rails, такие как распространение состояния на объектах контроллера через переменные экземпляра, тенденция к формированию сложных иерархий наследования и т. Д. Перенос действий контроллера в их собственные классы также может помочь нам решить некоторые из них.

контроллер
Изображение Мак Мале

Без большого количества сложных взломов кода Rails мы не сможем избавиться от контроллеров в их текущем виде. Что мы можем сделать, так это превратить их в шаблон с небольшим количеством кода для делегирования классам действий контроллера. В Aldous контроллеры выглядят так:

1
2
3
4
5
class TodosController < ApplicationController
  include Aldous::Controller
 
  controller_actions :index, :new, :create, :edit, :update, :destroy
end

Мы включаем модуль, чтобы у нас был доступ к методу controller_actions , и затем мы определяем, какие действия должен выполнять controller_actions . Внутри Aldous будет сопоставлять эти действия с соответственно именованными классами в папке controller_actions/todos_controller . Пока это не настраивается, но это легко сделать, и это разумное значение по умолчанию.

Первое, что нам нужно сделать, это сообщить Rails, где найти действие нашего контроллера (как я уже упоминал выше), поэтому мы изменим наш app/config/application.rb следующим образом:

1
2
3
4
5
6
7
config.autoload_paths += %W(
  #{config.root}/app/controller_action
)
 
config.eager_load_paths += %W(
  #{config.root}/app/controller_action
)

Теперь мы готовы написать действия контроллера Aldous. Простой может выглядеть так:

1
2
3
4
5
class TodosController::Index < BaseAction
  def perform
    build_view(Todos::IndexView)
  end
end

Как вы можете видеть, это выглядит несколько похожим на сервисный объект, который разработан. Концептуально действие — это, по сути, услуга, поэтому для них имеет смысл иметь аналогичный интерфейс.

Однако есть две вещи, которые сразу неочевидны:

  • откуда BaseAction и что в ней
  • что такое build_view

Мы расскажем о BaseAction ближайшее время. Но в этом действии также используются объекты вида Aldous, откуда build_view . Мы не рассматриваем здесь объекты Aldous, и вам не нужно их использовать (хотя вы должны серьезно рассмотреть это). Ваше действие может выглядеть так:

1
2
3
4
5
class TodosController::Index < BaseAction
  def perform
    controller.render template: ‘todos/index’, locals: {}
  end
end

Это более знакомо, и мы будем придерживаться этого отныне, чтобы не замуровать воды материалами, связанными с видом. Но откуда берется переменная контроллера?

Давайте поговорим о BaseAction который мы видели выше. Это Aldous эквивалент ApplicationController , поэтому настоятельно рекомендуется иметь его. BaseAction является:

1
2
class BaseAction < ::Aldous::ControllerAction
end

Он наследует от ::Aldous::ControllerAction и одна из вещей, которые он наследует, — это конструктор. Все действия контроллера Aldous имеют одинаковую сигнатуру конструктора:

1
2
3
4
5
attr_reader :controller
 
def initialize(controller)
  @controller = controller
end

Будучи тем, чем они являются, мы тесно связали действия Aldous с контроллером, и поэтому они могут делать практически все, что может делать контроллер Rails. Очевидно, у вас есть доступ к экземпляру контроллера и вы можете получать любые данные оттуда. Но вы не хотите вызывать все на экземпляре контроллера — это было бы перетаскиванием для общих вещей, таких как параметры, заголовки и т. Д. Итак, с помощью немного магии Олдоса, прямо в действии доступны следующие вещи:

  • params
  • headers
  • request
  • response
  • cookies

И вы также можете сделать больше вещей доступным таким же образом через config/initializers/aldous.rb :

1
2
3
Aldous.configuration do |aldous|
  aldous.controller_methods_exposed_to_action += [:current_user]
end

Действия контроллера Aldous хорошо работают с объектами вида Aldous, но вы можете отказаться от использования объектов вида, если вы следуете нескольким простым правилам.

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

1
controller.render :index

Вместо этого вы должны сделать:

1
controller.render template: ‘todos/index’

Кроме того, поскольку действия Aldous не являются контроллерами, вы не сможете автоматически использовать переменные экземпляра из этих действий в шаблонах представлений, поэтому вам необходимо предоставить все данные как локальные, например:

1
controller.render template: ‘todos/index’, locals: {todos: Todo.all}

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

Сложный
Изображение Говард Лейк

Давайте рассмотрим более сложное действие контроллера Aldous и поговорим о некоторых других вещах, которые дает нам Aldous, а также о некоторых лучших практиках написания действий контроллера Aldous.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class TodosController::Update < BaseAction
  def default_view_data
    super.merge(todo: todo)
  end
 
  def perform
    controller.render(template: ‘home/show’, locals: default_view_data) and return unless current user
    controller.render(template: ‘defaults/bad_request’, locals: {errors: [todo_params.error_message]}) and return unless todo_params.fetch
    controller.render(template: ‘todos/not_found’, locals: default_view_data.merge(todo_id: params[:id])) and return unless todo
    controller.render(template: ‘default/forbidden’, locals: default_view_data) and return unless current_ability.can?(:update, todo)
 
 
    if todo.update_attributes(todo_params.fetch)
      controller.redirect_to controller.todos_path
    else
      controller.render(template: ‘todos/edit’, locals: default_view_data)
    end
  end
 
  private
 
  def todo
    @todo ||= Todo.where(id: params[:id]).first
  end
 
  def todo_params
    TodosController::TodoParams.build(params)
  end
end

Ключевым моментом здесь является то, что метод execute содержит всю или большую часть соответствующей логики уровня контроллера. Во-первых, у нас есть несколько строк для обработки локальных предварительных условий (то есть того, что должно быть правдой, чтобы у действия даже был шанс на успех). Все они должны быть одинарными, похожими на то, что вы видите выше. Единственная неприглядная вещь — это «и возвращение», которую мы должны продолжать добавлять. Это не было бы проблемой, если бы мы использовали представления Aldous, но сейчас мы застряли с этим.

Если условная логика для локального предусловия становится слишком сложной, ее следует извлечь в другой объект, который я называю объектом предиката, — таким образом, сложная логика может быть легко разделена и протестирована. Предикатные объекты могут стать концепцией в Aldous в какой-то момент.

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

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

Еще одна вещь, которую вы видите в приведенном выше классе действий:

1
TodosController::TodoParams.build(params)

Это еще один объект, унаследованный от базового класса Aldous, и он здесь для того, чтобы несколько действий могли использовать логику строгих параметров. Это выглядит так:

1
2
3
4
5
6
7
8
9
class TodosController::TodoParams < Aldous::Params
  def permitted_params
    params.require(:todo).permit(:description, :user_id)
  end
 
  def error_message
    ‘Missing param :todo’
  end
end

Вы предоставляете свою логику params в одном методе и сообщение об ошибке в другом. Затем вы просто создаете экземпляр объекта и вызываете fetch, чтобы получить разрешенные параметры. Он вернет nil в случае ошибки.

Еще один интересный метод в классе действий выше:

1
2
3
def default_view_data
  super.merge(todo: todo)
end

Когда вы используете объекты представлений Aldous, существует некоторая магия, которая использует этот метод, но мы не используем их, поэтому нам нужно просто передать его как хеш-код локального объекта любому представлению, которое мы отображаем. Базовое действие также отменяет этот метод:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
class BaseAction < ::Aldous::ControllerAction
  def default_view_data
    {
      current_user: current_user,
      current_ability: current_ability,
    }
  end
 
  def current_user
    @current_user ||= FindCurrentUserService.perform(session).user
  end
 
  def current_ability
    @current_ability ||= Ability.new(current_user)
  end
end

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

Все вышеперечисленное великолепно, но иногда у вас есть глобальные предварительные условия, которые должны повлиять на все или большинство действий в системе (например, мы хотим сделать что-то с сеансом перед выполнением какого-либо действия и т. Д.). Как мы справимся с этим?

Это хорошая часть причины наличия BaseAction . У Aldous есть концепция объектов предусловий — это в основном действия контроллера во всем, кроме имени. Вы настраиваете, какие классы действий должны выполняться перед каждым действием в методе BaseAction , и Aldous автоматически сделает это за вас. Давайте посмотрим:

01
02
03
04
05
06
07
08
09
10
11
12
13
class BaseAction < ::Aldous::ControllerAction
  def preconditions
    [Shared::EnsureUserNotDisabledPrecondition]
  end
 
  def current_user
    @current_user ||= FindCurrentUserService.perform(session).user
  end
 
  def current_ability
    @current_ability ||= Ability.new(current_user)
  end
end

Мы переопределяем метод предусловий и предоставляем класс нашего объекта предусловия. Этот объект может быть:

01
02
03
04
05
06
07
08
09
10
11
class Shared::EnsureUserNotDisabledPrecondition < BasePrecondition
  delegate :current_user, :current_ability, to: :action
 
  def perform
    if current_user && current_user.disabled && !current_ability.can?(:manage, :all)
      controller.render template: ‘default/forbidden’,
        status: :forbidden,
        locals: {errors: [‘Your account has been disabled’]}
    end
  end
end

Вышеуказанное предварительное условие наследуется от BasePrecondition , которое просто:

1
2
class BasePrecondition < ::Aldous::Controller::Action::Precondition
end

Вам это на самом деле не нужно, если все ваши предварительные условия не потребуют совместного использования некоторого кода. Мы просто создаем его, потому что писать BasePrecondition проще, чем ::Aldous::Controller::Action::Precondition .

Вышеуказанное предварительное условие прекращает выполнение действия, поскольку оно отображает представление — Aldous сделает это за вас. Если ваше предварительное условие ничего не отображает и не перенаправляет (например, вы просто устанавливаете переменную в сеансе), тогда код действия будет выполнен после выполнения всех предварительных условий.

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

1
2
3
def preconditions
  super.reject{|klass|
end

Не так, как в обычном Rails before_actions , но в красивой оболочке ‘objecty’.

ошибка
Изображение Дункана Халла

Последнее, что нужно знать, это то, что действия контроллера безошибочны, как и объекты служб. Вам никогда не нужно спасать какой-либо код в методе выполнения действий контроллера — Aldous позаботится об этом за вас. Если возникает ошибка, Aldous спасет ее и использует default_error_handler для обработки ситуации.

default_error_handler — это метод, который вы можете переопределить в BaseAction. При использовании объектов вида Aldous это выглядит так:

1
2
3
def default_error_handler(error)
  Defaults::ServerErrorView
end

Но так как мы не, вы можете сделать это вместо этого:

1
2
3
4
5
6
7
def default_error_handler(error)
  controller.render(
    template: ‘defaults/server_error’,
    status: :internal_server_error,
    locals: {errors: [error]}
  )
end

Таким образом, вы обрабатываете нефатальные ошибки для своего действия как локальные предварительные условия и позволяете Олдосу беспокоиться о непредвиденных ошибках.

Используя Aldous, вы можете заменить свои контроллеры Rails более мелкими, более связными объектами, которые намного меньше черного ящика и их гораздо проще тестировать. В качестве побочного эффекта вы можете уменьшить связывание во всем приложении, улучшить работу с представлениями и стимулировать повторное использование логики на уровне контроллера с помощью композиции.

Более того, действия контроллеров Aldous могут сосуществовать с контроллерами vanilla Rails без чрезмерного дублирования кода, поэтому вы можете начать использовать их в любом существующем приложении, с которым вы работаете. Вы также можете использовать действия контроллера Aldous, не принимая на себя обязательство использовать объекты или сервисы просмотра, если не хотите.

Aldous позволил нам отделить нашу скорость разработки от размера приложения, над которым мы работаем, и в то же время предоставил нам лучшую, более организованную базу кода. Надеюсь, он может сделать то же самое для вас.