2008-02-07
■[Amrita2] Amrita2はどのように動くのか
2.0.1をリリースして、同時にいくつかドキュメントを追加しました。
この中で、HowAmrita2Worksというのを翻訳(プラス英語でうまく言えなかったニュアンスを追加)します*1。
内部構造に関する説明なのですが、中身を知りたい人だけではなくて、普通にユーザとして使う方にも、これが一番わかりやすいのではないかと思います。
概略
Amrita2は、以下の3つのパートで構成されています。
- AmXMLプリプロセッサ
- XMLをRubyに変換するコンパイラ
- コンパイラの動作をコントロールするフィルター
つまり、次のように動作します。
テンプレート→(プリプロセッサ)→ピュアXMLのテンプレート→(Hpricotパーサ+Amrita2コンパイラ)→Rubyコード→モデルデータを与えて実行→結果のHTML。
コンパイラ
Amrita2の基本的なアイディアは、特定の属性が設定されたHTMLエレメントをRubyのコードに変換して実行するということです。
たとえば
<span am:src="xxx" />
というテンプレートを下のようなコードに変換するとか
def render_with(x) "<span>" + x[:xxx] + "</span>" end
あるいは
<p am:src="body"> <span am:src="template" /> is a template library for <span am:src="lang" /> </p>
というテンプレートを下のようなコードに変換し
def render_with(x) $_ = x[:body] __stream__ = "<p>" __stream__ << $_[:template] __stream__ << " is a template library for " __stream__ << $_[:lang] __stream__ end
次のように実行するということです。
template.render_with :body=>{ :template=>'Amrita2', :lang => 'Ruby' }
そして、次のような結果を出力するわけです。
<p>Amrita2 is a template library for Ruby</p>
hello.rbというサンプルの、set_trace()という行を生かせば、実際に内部でどのようなRubyコードを生成するか確認することができます。
実装の都合とかいろいろな理由で実際のRubyコードはずっと複雑ですが、テンプレートを上記のようなRuby コードにするというのが、Amrita2の基本的なアイディアです。
プリプロセッサ
テンプレートの動的な部分を、属性で指定するという方法は、テンプレートが整形式のXMLになるというメリットがありますが、実際にやってみると、属性をたくさん指定するというのは結構見づらかったので、同じ意味のことをもっと簡単に書けるAmXMLという記述方法をサポートしました。
AmXMLの記述は、全て、プリプロセッサによっていったんXMLに変換されてから処理されます。
たとえば、次のようなテンプレートは
<<html<
<<body<
<<h1 :title>>
<<p :body<
<<:template>> is a html template libraly for <<:lang>>
こうなります。
<html> <body> <h1 am:src="title" /> <p am:src="body"> <span am:src="template" /> is a template library for <span am:src="lang" /> </p> </h1> </body> </html>
XMLに慣れていたりツールが揃っている場合には、最初からこの記述方式で書くこともできます。
詳細はhttp://retro.brain-tokyo.net/projects/amrita2/wiki/AmXML:AmXMLを参照してください。
フィルター
Amrita2(のXML to Ruby コンパイラ)は、フィルターによって、どのように Ruby に変換するかを、コントロールすることができます。
<<a :link | Attr[:href=>:url, :body=>:text]>>
これを同等なXMLで記述するとこうなります。
<a am:src="link" am:filter="Attr[:href=>:url, :body=>:text]" />
am:filter属性は、Rubyのソースとして評価され、Filterオブジェクトを生成します。このFilterオブジェクトが、コンパイルを制御します。
この例では、Attrというフィルターが、次のコードと同等の Rubyコードを生成します。
def render_with(x) $_ = x[:link] __stream__ << "<h1 href='" __stream__ << $_[:url] __stream__ << "' >" __stream__ << $_[:text] __stream__ << "</a>" __stream__ end
つまり、動的な要素をどのようにテンプレートに埋めこむかについては、Filterが自由にコントロールできるので、いろいろなことができます。
詳しくは次のソースを参照してください。
http://retro.brain-tokyo.net/projects/amrita2/browse/trunk/amrita2/specs/filters.rb
また、今後、最適化の為のフィルターを追加して性能を改善してく予定です。与えるデータの形式が決まっているのであれば、それ専用のフィルターを作ることで、ほとんどの場合に最速のRubyコードを出力できるはずです。
- 普通に書きやすい形でテンプレート作成
- ホットスポットを見つける
- 作者(私)に「このテンプレートから、これこれのRuby コードを吐くフィルタ作ってくれ」と頼む
- 作者が作ったフィルタを受け取ってインストール
- そのフィルタの指定をテンプレートに追加
この手順で、性能問題は解決できます。
データタイプ
Amrita2は、さまざまなRubyのビルトインタイプとユーザ定義のオブジェクトを受け取ることができます。言いかえれば、コンパイラはどのよようなデータを渡されるかをコンパイル時に知ることができません。
そこで、データを読み取る時には、amrita_valueというメソッドを使用するようになっています。
def render_with(x) "<span>" + x.amrita_value(:xxx) + "</span>" end
amrita_valueは、たとえば、Hashクラスでは次のように定義されています。
def amrita_value(name) self[name.to_sym] end
しかし、Structオブジェクトは、次のように定義された同じ名前の別のメソッドを使います。
def amrita_value(name) self.__send__(name) end
Bindingクラスは、さらに別の定義を持っています。
class Binding def amrita_value(name) eval "begin; #{name}; rescue(NameError); @#{name};end;", self end end
Bindingオブジェクトを渡すと、ローカル変数をテンプレートに埋め込むことができます。
t = Amrita2::Template.new('<strong am:src="aaa" />') aaa = 'Amrita2' t.render_with(binding) # => "<strong>Amrita2</strong>"
詳しくは下記を参照してください。
http://retro.brain-tokyo.net/projects/amrita2/browse/trunk/amrita2/specs/datatypes.rb
インラインERb
ERbを含んだテンプレートの扱いについては、InlineERbを参照してください。
■[Amrita2] form_forブロックの中のrender :partialの悩みを解決
この悩みとは、次のようなことではないかと思います。
- newとeditは似たページであるので、似た部分を一つの記述ですませたい
- 基本的には共通部分をパーシャルテンプレートにすればよい
- ボタンやテキストフィールド等の要素の配置は共通だが、要素の生成方法が微妙に違う
- 生成方法の違いを、テンプレート間で受け渡す記述が必要になるが、:localsによる受け渡しがもう一つしっくり来ない
Amrita2を使えば、この問題は解決します。
実際のアプリケーションでは、これと同じパターンでもう少し複雑な問題になると思うのですが、それに柔軟に対応しつつDRYに書けると思います。
というのは、上の例ではnewとeditの違いがボタン一つだったので、受け渡すパラメータも一つだけで済みます。パーシャルテンプレートが複数のページから使用されて、それぞれに微妙に違う要求があったような場合に、Amrita2は有効です。
Amrita2のsample/depotのadminコントローラから引用します。
(view/admin/new.html.a2)
<h1>New product</h1>
<%
form = amrita_define_form(
:product,
@product,
:url=>{:action=>:create }
) do |f|
f.text_field :title
f.text_area :description
f.text_field :image_url
f.text_field :price
f.add_field_element :submit, submit_tag("Create")
end
%>
<< :form | AcceptData[:hook] <
%= render :partial => 'form', :object=>$_
(view/admin/edit.html.a2)
<h1>Editing product</h1>
<%
form = amrita_define_form(
:product,
@product,
:url=>{:action=>:update, :id=>@product}
) do |f|
f.text_field :title
f.text_area :description
f.text_field :image_url
f.text_field :price
f.add_field_element :submit, submit_tag("Update")
end
%>
<< :form | AcceptData[:hook] <
%= render :partial => 'form', :object=>$_
(view/admin/_form.html.a2)
%= error_messages_for 'product'
<< :form <
<<p<
<label for="product_title">Title</label><br/>
<<:title>>
<<p<
<label for="product_description">Description</label><br/>
<<:description>>
<<p<
<label for="product_image_url">Image url</label><br/>
<<:image_url>>
<<p<
<label for="product_price">Price</label><br/>
<<:price>>
<<:submit>>
まずパーシャルテンプレートの _form.html.a2 から説明すると、ここでは、title description 等の要素をどういう形で、どこに配置するかだけ指定しています。個々の要素の生成は一切行なっていません。
それを生成しているのが、newとeditの中にある次の部分です。
form = amrita_define_form(
:product,
@product,
:url=>{:action=>:update, :id=>@product}
) do |f|
f.text_field :title
f.text_area :description
f.text_field :image_url
f.text_field :price
f.add_field_element :submit, submit_tag("Update")
end
amrita_define_formは、form_forのラッパーで、同じパラメータを同じ順番で受け取ります。そして、こちらでは、逆に「どこに配置するか(WHERE)」ということは後回しにして、「何を配置するか(WHAT)」についての記述だけを行います。
fのメソッドも、form_forの時とほぼ同じですが、
- add_field_elementというメソッドが追加されている
- それ以外は、FormHelperと同じだが、一つ目のパラメータがフィールドのIDとなり、後程、そのIDに対応した場所に配置される
ということになります。
例えば、修正時には、titleが変更できなくて、テキストフィールドの代わりに単なる文字列で表示するとしたら、次のようにします。
form = amrita_define_form(
:product,
@product,
:url=>{:action=>:update, :id=>@product}
) do |f|
f.add_field_element :title, @product.title # ← ココ
f.text_area :description
f.text_field :image_url
f.text_field :price
f.add_field_element :submit, submit_tag("Update")
end
こうすると、テキストフィールドが表示されていた位置に、その内容の文字列がそのまま表示されます。
それで、親子で受け渡しされる変数は常に一個で同じ記述ですみます。
<< :form | AcceptData[:hook] < %= render :partial => 'form', :object=>$_
$_ は、Amrita2のコンテキストバリューと言って、その時点で評価に使われている値です。この場合は、フィールドと要素の対応を示すハッシュになっています。:object を使っているので、このハッシュがパーシャルテンプレートに(テンプレート名と同じ名前の変数として)渡されます。
ここは、Amrita2の中でも最もトリッキーな所なので、かえってわかりにくいと感じるかもしれませんが、
- ベーステンプレートでWHATを指定する
- パーシャルテンプレートでWHEREを指定する
という機能の分離はきれいにできていると思います。WHATの部分(amrita_define_form)はヘルパーメソッドにすることもできるので、
- 一つのWHATを複数のWHEREで共有する
- 一つのWHEREを複数のWHATで共有する
という両方のDRYが実現できます。
*1:だれか英語に(再)翻訳してくれる人いないかな
form_forブロックの中のrender :partialの悩みについて、タイムリーな解決策を頂きましてありがとうございます!
さっそく、amrita2を自分のサンプルプロジェクトのプラグインに取り込んでみました。
使いこなすには自分自身でもう少し勉強が必要ですが、シンプルに書けるAmXML記法には魅力を感じています。使い慣れたERbとの合わせ技で、さらに簡潔に書けそうです。