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

Когда дело касается моделей или контроллеров, вопрос решается просто – выносим повторяющийся функционал в модули и подключаем его там где нужно. Однако, с шаблонами (views) все обстоит несколько сложнее – они чаще всего не являются ruby-кодом, поэтому не поддерживают расширение модулями. Конечно, есть библиотеки, реализующие разметку с помощью ruby-кода, такие как, например Markaby или Erector, но они используются довольно редко. Гораздо чаще используются ERB и HAML. Именно для таких языков разметки в Rails реализован механизм хелперов (helpers).

Хелперы – это модули, которые подключаются в обработчике шаблонов и автоматически становятся доступными внутри шаблона. Наиболее часто используемые хелперы вам хорошо знакомы – form_for, link_to, content_tag, stylesheet_link_tag, javascript_include_tag и так далее. По сути, каждый хелпер – это ruby-метод, который реализует часть логики генерации ответа. Хелпер может непосредственно генерировать часть ответа или же просто выполнять, например, сложную проверку, которая часто повторяется в нескольких шаблонах.

Например, в коде приложения может встретиться вот такой код, проверяющий наличие у пользователя прав на просмотр части страницы:

Erb Code
1
2
3
4
5
6
7
8
9
<% if user = current_user and user.active? and user.roles.include?(:admin) %>
  This is the secret message for admins
<% end %>

Some content

<% if user = current_user and user.active? and user.roles.include?(:admin) %>
  More information for admins only
<% end %>

Если этот код часто повторяется, то его можно вынести в хелпер. Например, вот таким образом:

Ruby Code
1
2
3
4
5
6
7
module AuthHelper
  def if_admin
    if user = current_user and user.active? and user.roles.include?(:admin)
      yield
    end
  end
end

И затем несколько переработать шаблон, используя хелпер:

Erb Code
1
2
3
4
5
6
7
8
9
<% if_admin do %>
  This is the secret message for admins
<% end %>

Some content

<% if_admin do %>
  More information for admins only
<% end %>

Это довольно простой пример того, как можно избавиться от повторяющегося кода выносом его в хелперы.

Однако, в некоторых случаях не получится обойтись простым хелпером. Например, в том случае, когда генерируемый код достаточно сложен. Давайте рассмотрим вот такой пример: в приложении необходимо сгенерировать блок сообщения о результате действия. Блок должен содержать основное сообщение (текст), одну или несколько ссылок для выполнения дальнейших действий, дополнительную информацию (текст, таблицы, и т.д.). Кроме того, блок может быть просто отображен на странице или загружен через AJAX, и в обоих случаях должен происходить вызов события JavaScript-обработчика.

Если мы не используем хелперы, то наш код должен выглядеть примерно таким образом:

Erb Code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<div id="result_for_action" class="result">
  <div class="message">
    <%= message_text.html_safe %>
  </div>

  <div class="buttons">
    <%= link_to("Some Action", some_action_path) %>

    <%= link_to("Other Action", other_action_path) %>
  </div>

  <% if additional_info %>
    <div class="additional" style="display:none;">
      <%= additional_info.html_safe %>
    </div>

    <%= link_to_function('Additonal Info...',
          '$("#result_for_action").find(".additional, a.show").toggle()',
          :class => :show
        ) %>
  <% end %>
</div>

<% javascript_tag do %>
  <% if request.xhr? %>
    $(document).trigger('result.ajax');
  <% else %>
    $(document).trigger('result.inline');
  <% end %>
<% end %>

На первый взгляд, довольно тривиально, ничего особо страшного. Однако, давайте представим что такой код должен генерироваться в половине случаев ответа на запрос пользователя. Этот код будет из шаблона в шаблон повторяться, обрекая программиста на муки при любой попытке изменить общую логику формирования ответа или даже просто HTML-разметку. Например, при необходимости перенести

Если подобный код встречается в проекте повсеместно, пусть даже с небольшими вариациями, то вполне логично было бы вынести его в хелпер. Например, вот так:

Ruby Code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
module ResultHelper
  def result_for(name, options = {})
    message = content_tag(:div, options[:message], :class => :message)
    buttons = content_tag(:div, options[:buttons].join(' '),
      :class => :buttons
    )

    if options[:additional]
      additional = content_tag(:div, options[:additional],
        :class => :additional,
        :style => "display:none"
      )

      additional << link_to_function('Additonal Info...',
          "$('#result_for_#{ name }').find('.additional, a.show').toggle()",
          :class => :show
        )
    else
      additional = ''
    end

    javascript = javascript_tag(
      "$(document).trigger('result.#{ request.xhr? ? :ajax : :inline }');"
    )

    content_tag(:div,
      [message, buttons, additional, javascript].join(" ").html_safe,
      :class => :result,
      :id => "result_for_#{ name }"
    )
  end
end

А затем использовать вот таким образом:

Erb Code
1
2
3
4
5
6
7
8
<%= result_for(:action,
      :message => message_text,
      :buttons => [
        link_to("Some Action", some_action_path),
        link_to("Other Action", other_action_path)
      ],
      :additional => additional_info
    ) %>

Однако, этот подход накладывает определенные ограничения. Например, если генерация набора параметров будет содержать какую-то логику, то код станет плохо читаемым. Или, например, если для каждого из блоков потребуется передать дополнительный CSS-класс. Вот во что может выродиться использование такого хелпера:

Erb Code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<%= result_for(:action,
      :message        => message_text,
      :message_class  => :success,
      :buttons        => if current_user
        [
          link_to("Some Action", some_action_path),
          link_to("Other Action", other_action_path)
        ]
      else
        [
          link_to("Login", login_path)
        ]
      end,
      :buttons_class  => (:unauthorize unless current_user),
      :additional     => additional_info + (some_text || ""),
      :additional_link_text => "Additional Info and Instructions..."
    ) %>

На мой взгляд, более логично было бы исспользовать подход, близкий к тому, который используется для формирования разметки форм. А именно – создание специального класса, builder-а, который будет предоставлять дополнительный DSL для формирования параметров, которые будет использовать хелпер. Этот класс может предоставлять методы для задания параметров конкретных блоков и их содержания, а так же отвечать непосредственно за формирование ответа.

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

  • Сообщение – принимает CSS-класс, причем чаще всего это классы success, failure и notice
  • Кнопки – принимает CSS-класс
  • Дополнительная информация – принимает CSS-класс и текст для ссылки, включающей отображение блока

Для упрощения работы мы должны создать хелпер, который будет формировать экземпляр класса, передавать ему входные параметры и вставлять результат работы класса в тело ответа:

Ruby Code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
module ResultHelper
  def result_for(action_name, &block)
    builder = Builder.new(self, action_name)

    concat(builder.html(&block))
  end

  class Builder
    def initialize(template, action_name)
      @template     = template
      @action_name  = action_name
    end

    def html(&block)
      "This is HTML code"
    end
  end
end

Далее, нам необходимо добавить методы для каждого из блоков, которые будут принимать параметры и разметку для содержимого блока:

Ruby Code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
module ResultHelper
  def result_for(action_name, &block)
    concat(
      Builder.new(self, action_name).html(&block)
    )
  end

  class Builder
    attr_reader :template

    delegate :capture, :to => :template

    def initialize(template, action_name)
      @template     = template
      @action_name  = action_name
    end

    def html(&block)
      "This is HTML code"
    end

    def message(css_class = nil, &block)
      @message = capture(&block)
      @message_class = css_class
    end

    def buttons(css_class = nil, &block)
      @buttons = capture(&block)
      @buttons_class = css_class
    end

    def additional(css_class = nil, link_text = "Additional Info...", &block)
      @additional = capture(&block)
      @additional_class = css_class
      @additional_link_Text = link_text
    end
  end
end

И наконец, мы должны добавить логику формирования ответа, учитывающую переданные данные:

Ruby Code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
module ResultHelper
  def result_for(action_name, &block)
    concat(
      Builder.new(self, action_name).html(&block)
    )
  end

  class Builder
    attr_reader :template

    delegate :capture, :content_tag, :link_to, :javascript_tag, :request, :to => :template

    def initialize(template, action_name)
      @template     = template
      @action_name  = action_name
    end

    def html(&block)
      yield(self)

      result = [
        content_tag(:div, @message, :class => "message #{@message_class}"),
        content_tag(:div, @buttons, :class => "buttons #{@buttons_class}")
      ]

      if @additional
        result << content_tag(:div, @additional,
          :class => "additional #{@additional_class}",
          :style => "display: none"
        )

        result << link_to(@additional_link_text,
          "$('#{block_id}').find('.additional, a.show').toggle()",
          :class => :show
        )
      end

      result << javascript_tag(
        "$(document).trigger('result.#{ request.xhr? ? :ajax : :inline }');"
      )

      content_tag(:div, result.join(" ").html_safe,
        :class  => "result",
        :id     => block_id
      )
    end

    def block_id
      "result_for_#{@action_name}"
    end

    def message(css_class = nil, &block)
      @message = capture(&block)
      @message_class = css_class
    end

    def success(&block)
      message(:success, &block)
    end

    def failure(&block)
      message(:failure, &block)
    end

    def notice(&block)
      message(:notice, &block)
    end

    def buttons(css_class = nil, &block)
      @buttons = capture(&block)
      @buttons_class = css_class
    end

    def additional(css_class = nil, link_text = "Additional Info...", &block)
      @additional = capture(&block)
      @additional_class = css_class
      @additional_link_text = link_text
    end
  end
end

Стоит обратить внимание на несколько моментов в предложенном коде. Во-первых, это делегирование методов, относящихся к работе с шаблоном, непосредственно объекту template, который и отвечает за обработку шаблона. Этот прием позволяет сделать код более читаемым. Во-вторых, это методы success, failure и notice – они упрощают вывод сообщений с часто используемыми параметрами.

И наконец, давайте посмотрим каким образом мы можем использовать новый хелпер:

Erb Code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<% result_for :action do |r| %>
  <% r.success do %>
    <%= message_text %>
  <% end %>

  <% r.buttons do %>
    <% if current_user %>
      <%= link_to("Some Action", some_action_path) %>
      <%= link_to("Other Action", other_action_path) %>
    <% else %>
      <%= link_to("Login", login_path) %>
    <% end %>
  <% end %>

  <% r.additional(current_user ? nil : :unauthorize, "Additional Info and Instructions...") do %>
    additional
  <% end %>
<% end %>

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

Итог

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