まえおき
完全に自分用メモ。
80%くらい同じだけど20%くらい中身が異なるDockerfileを量産したかったので、
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"]
<% content_for(:base_image, 'ruby:2.6-buster') %>
<% content_for(:additional_install) do %>
RUN apt-get install libxslt-dev libsqlite-dev
<% end %>
みたいな感じのことをやりたかった。
ただ、Railsを入れるほどではないので、Rails非依存で(Ruby標準のERBだけで)サクッと自作できないかなと考えた話。
※ 結果だけを知りたい方は「まとめ」のところに一気にどうぞw
Railsの実装を参考にしてみる
content_forはどういう定義になっているかをちらっと見ると、
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 ができればいい。
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
メソッドが定義されている必要がある。
すっっごい雑にやるなら、
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 を定義しましょう )
template_erbのyield呼び出し時に、保持していた値を入れ込む
template_erb側で
<%= yield(:hoge) %>
のような指定があった際に、 @binding_values[:hoge]
を入れ込むようにする必要がある。
そもそも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指定でも正しく動かないといけない。
そう、正解は
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
だ。
まとめ
ここまでの内容だとまとまらないので、一応クラス化しとこうと思う。
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 ができる。