LoginSignup
0
0

More than 3 years have passed since last update.

Railsのビューテンプレートで使うcontent_forとyieldっぽいものを、Ruby標準のERBだけを使って自作する

Last updated at Posted at 2019-08-04

まえおき

完全に自分用メモ。
80%くらい同じだけど20%くらい中身が異なるDockerfileを量産したかったので、

Dockerfile.base.erb

FROM <%= yield(:base_image) || 'debian:buster' %>

RUN apt-get update && apt-get -y install ...
RUN ...
RUN ...

<%= yield(:additional_install) %>

USER user
RUN git clone ...
RUN ...
ENV ...

<%= yield(:additional_install_for_user) %>

RUN ...
RUN ...

CMD ["/bin/bash", "start_server.sh"]
ruby2.6/Dockerfile.binding.erb
<% content_for(:base_image, 'ruby:2.6-buster') %>

<% content_for(:additional_install) do %>
RUN apt-get install libxslt-dev libsqlite-dev
<% end %>

みたいな感じのことをやりたかった。

content_forとyieldでテンプレート

ただ、Railsを入れるほどではないので、Rails非依存で(Ruby標準のERBだけで)サクッと自作できないかなと考えた話。

※ 結果だけを知りたい方は「まとめ」のところに一気にどうぞw

Railsの実装を参考にしてみる

content_forはどういう定義になっているかをちらっと見ると、

/actionview/lib/action_view/helpers/capture_helper.rb
      def content_for(name, content = nil, options = {}, &block)
        if content || block_given?
          if block_given?
            options = content if content
            content = capture(&block)
          end
          if content
            options[:flush] ? @view_flow.set(name, content) : @view_flow.append(name, content)
          end
          nil
        else
          @view_flow.get(name).presence
        end
      end

         (中略)

      def content_for?(name)
        @view_flow.get(name).present?
      end

テンプレートのバインディングの方で使われる content_for(:key) は、単純に @view_flow[:key] 的な場所に値を入れているだけだ。

app/views/layouts/application.html.erb などテンプレート側は yield を使うだけなので、ブロックを何らかの形で渡してるんだろう。
https://medium.com/rubyinside/disassembling-rails-template-rendering-2-a99214c6fde8
あたりに詳しく説明があるが、 template.render {|*name| ... } みたいな感じでブロックを渡しているらしい。

複雑なことをしない、自分だけの yield, content_for の要件

今回の自分の用途では

  • yieldは必ず1つのキーを指定される
  • content_forは必ず1つのキーを指定され、valueもしくはブロックで値が指定される
  • content_forを書くファイルには、content_for以外の内容は書かない

という、仕様を削ぎ落とした版のyield/content_forがあればよい。

この程度であれば、Ruby標準のERBでできそうだ。

実コードでいうと

冒頭で書いた Dockerfile.base.erb ruby2.6/Dockerfile.binding.erb を読み込んで、以下のようなRubyスクリプトでいい感じに ruby2.6/Dockerfile ができればいい。

generate_dockerfile.rb

require 'erb'

def apply_template(template_erb, binding_erb)
  # どう書く?
end

template_erb = ERB.new(File.read("Dockerfile.base.erb")) # yield指定がある
binding_erb = ERB.new(File.read("ruby2.6/Dockerfile.binding.erb")) # content_for指定のみがある
dockerfile = apply_template(template_erb, binding_erb)
File.write("ruby2.6/Dockerfile", dockerfile)

さて、apply_templateをどうすればいいんだ?というのがこの記事の本題。

実装するぞ

binding_erbにあるcontent_forの内容をどこかの変数に一時的に持っておく

Railsだとcapture_helper.rbで @view_flow に保持していた。

https://magazine.rubyist.net/articles/0017/0017-BundledLibraries.html をみるとなんとなく想像がつくが、ERBのバインディングはresultメソッドを叩いたその瞬間の評価コンテキストで行われる。(内部でevalをしているだけ)
なので、 binding_erb.result を呼ぶ時には、そこから見える場所に content_for メソッドが定義されている必要がある。

すっっごい雑にやるなら、

generate_dockerfile.rb
def content_for(key, value = nil)
  @binding_values[key] = block_given? ? yield : value
end

def apply_template(template_erb, binding_erb)
  @binding_values = {}
  binding.erb.result(binding)

  puts @binding_values # => { "base_image" => "ruby:2.6-buster", "additional_install" => "..." }
end

こんな感じで、メソッドをもう1つ生やしてみるとよい。中身は、一時的なインスタンス変数にcontent_forで指定されたものをつっこむだけ。
(よいこのみんなはちゃんとクラス化して、プライベートメソッドで content_for を定義しましょう :sweat_smile:

template_erbのyield呼び出し時に、保持していた値を入れ込む

template_erb側で

<%= yield(:hoge) %>

のような指定があった際に、 @binding_values[:hoge] を入れ込むようにする必要がある。

そもそもyieldってなんだっけ?というところがキモなのだが、雑に言うと「ブロックを評価せよ」という命令だ。

yield
def aaa(x)
  puts x, yield(:hoge)
end

data={hoge: 3, fuga: 4}
aaa(1) do |key|
  data[key]
end
# => 1, 3

 

さて、今回は何にブロックを渡せばいいんだっけ?ということになるが、

template_erb.result(binding) do |key|
  @binding_values[key]
end

これでいけるかな?と最初思ってやってみたんだけど、これではうまくいかない。
先にも書いたとおり、 ERB#result は内部的には単なるevalなので、resultを叩いた時点でブロックが見える必要がある、つまり極端に書くと

yield(:hoge)
#template_erb.result(binding)

このyield指定でも正しく動かないといけない。

そう、正解は

generate_dockerfile.rb
def content_for(key, value = nil)
  @binding_values[key] = block_given? ? yield : value
end

def bind_to_template(template_erb)
  # templateの中でyieldされると、bind_to_templateに渡されたブロックが評価される
  template_erb.result(binding)
end

def apply_template(template_erb, binding_erb)
  @binding_values = {}
  binding.erb.result(binding)

  bind_to_template(template_erb) do |key|
    @binding_values[key]
  end
end

だ。

まとめ

ここまでの内容だとまとまらないので、一応クラス化しとこうと思う。

template_binder.rb
require 'erb'

class TemplateBinder
  def initialize(template_erb_string:, binding_erb_string:)
    template_erb = ERB.new(template_erb_string)
    binding_erb = ERB.new(binding_erb_string)
    evaluate_binding(binding_erb)
    @result = evaluate_template(template_erb) do |key|
      @binding_values[key]
    end
  end

  attr_reader :result

  private

  def content_for(key, value = nil)
    @binding_values[key] = block_given? ? yield.strip : value
  end

  def evaluate_binding(binding_erb)
    @binding_values = {}
    # binding_erbの中でcontent_forされたものが @binding_values に保持される
    binding_erb.result(binding)
  end

  def evaluate_template(template_erb)
    # template_erb の中でyieldされると、evaluate_templateに渡されたブロックが評価される。
    template_erb.result(binding)
  end
end

こんなクラスを1つ用意しておけば、

template = 'var1=[<%= yield(:var1) %>], var2=[<%= yield(:var2)%>]'

binding1 = <<~ERB
<% content_for(:var1, 3) %>
ERB

binding2 = <<~ERB
<% content_for(:var1) do %>
    hoge
<% end %>

<% content_for(:var2, :fuga) %>
ERB

TemplateBinder.new(
  template_erb_string: template,
  binding_erb_string: binding1
).result
# => var1=[3], var2=[]

TemplateBinder.new(
  template_erb_string: template,
  binding_erb_string: binding2
).result
# => var1=[hoge], var2=[fuga]

こんな感じで超簡易版 yield / content_for ができる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0