Amrita2はどのように動くのか
2.0.1をリリースして、同時にいくつかドキュメントを追加しました。
この中で、HowAmrita2Worksというのを翻訳(プラス英語でうまく言えなかったニュアンスを追加)します*1。
内部構造に関する説明なのですが、中身を知りたい人だけではなくて、普通にユーザとして使う方にも、これが一番わかりやすいのではないかと思います。
概略
Amrita2は、以下の3つのパートで構成されています。
つまり、次のように動作します。
テンプレート→(プリプロセッサ)→ピュア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を参照してください。
*1:だれか英語に(再)翻訳してくれる人いないかな
amrita_form_for
form_forのラッパーでこんなことができた。
<h1>Editing product</h1> <% form = amrita_form_for :product, @product, :url=>{:action=>:update, :id=>@product} do |f| f.text_field :title f.text_field :price f.text_area :description f.text_field :image_url end %> << :form | AcceptData[:hook] < <<p< <<:title>> <<p< <<:description>> <<p< <<:image_url>> <<p< <<:price>> <%= submit_tag 'Update' %> <%= link_to 'Show', :action => 'show', :id => @product %> | <%= link_to 'Back', :action => 'list' %>
フィールドの生成と配置を分離できる。これはいいかも。
詳しくは後で。
RamazeでAmrita2が使えるようになりました
id:keita_yamaguchiさんが、RamazeのテンプレートエンジンをAmrita2 2.0.0 で動くように直してくれました。
これに便乗して、Ramazeのサンプルにあった132行のブログエンジンのAmrita2バージョンを作ってみました。
コントローラのソースを一部抜粋するとこんな感じです。
def index slug = nil title = nil if slug.nil? @posts = BlogPost.collect raise Ramaze::Error::NoAction, 'No blog posts found, create entries/YYYY.MM.DD.My.First.Blog.Post' unless @posts.any? else raise Ramaze::Error::NoAction, 'Invalid blog post' unless post = BlogPost[slug] post = BlogPost[slug] title = post.title @posts = [ post ] end title_hook = Amrita2::Core::Hook.new do if title render_child(:post_title, :href=>'/', :text=>title) else render_child(:index_title, :text=>TITLE) end end @data = binding %( <<h1 :title_hook | AcceptData[:hook] < <<a :post_title | Attr[:href] < <<:text>> <<span :index_title < <<:text>> <<div.hentry :posts < <<h2 { :url=> '/' + $_.slug } < <<abbr.updated title="$1" :| Eval["Time.parse($_.date).iso8601", :date] < $2 <<a.entry-title href="$1" rel="bookmark" :| NVar[:url, :title] < $2 <<div.entry-content :body | NoSanitize >> ) end
あえて、Amrita2のいろんな機能を詰め込んでみました。特に、Hookという機能は、けっこう面白いと思います。
Hook オブジェクトを作成すると、テンプレートエンジンがレンダリングしている途中で、ユーザコードに一時的に制御を戻すことができます。
上記の例だと、title_hookというタイトルを作成する所に介入しています。ここで、title というローカル変数がnilかどうかで、:index_titleか:post_titleの、どちらの要素を使うかを選択しています。また、その要素のレンダリングはAmrita2が行なうわけですが、そこに渡すデータもこの処理の中で作成しています。
テンプレートの中に条件判断を書かないで、Rubyのコードとして書けますので、これをうまく使うとプレゼンテーションとプレゼンテーションロジックがきれいに分離できるのではないかと思います。
また、これを使うとevalを使わないでテンプレートの処理を行なえるので、ruby1.9では少しは早くなるかもしれません。(その代わりに ブロックのネストが深くなるので、どうなるかはわかりません)
なお、Amrita2は今の所はruby1.9には未対応ですが、レビュアブルマインドの開発が落ち着いたら、1.9をターゲットにして最適化していこうと思っています。HTMLをRubyにコンパイルして動かしているので、YARVが喜ぶようなRubyコードを吐くように直していけば、いくらでも速くできるのではないかと考えています。
Railsアプリにtimelineを組みこむ
レビュアブルマインドにSIMILE | Timelineを入れてみたので、そのメモ。
ダウンロードと組みこみ
$ svn checkout http://simile.mit.edu/repository/timeline/
timeline/tags/1.2/src/webapp/api 以下を public/javascript/timelineにコピー
layoutの修正
%= javascript_include_tag 'timeline/api/timeline-api' if @need_timeline
bodyタグに、onloadとonresizeを入れる。
% onload = onresize = nil % if @need_timeline % onload = "onLoad();" % onresize = "onResize();" % end <<body :| Attr[:onload=>:onload, :onresize=>:onresize ] <
コントローラの修正
ページ全体を表示する timeline というメソッドと、スケジュール項目をtimeline-apiの指定する形式のXMLで返す schedules という二つメソッドを追加。
class SchedulesController < ApplicationController before_filter :authorize around_filter :with_users_scope def timeline @need_timeline = true end def schedules @schedules = @user.schedules response.content_type = Mime::XML render :partial=>'schedules' end end
ビューの修正
ビューは、タイムラインを表示するdivと二つのjavascript関数定義です。
<<div< <<div< This is an experimental feature. <div id="my-timeline" style="height: 400px; border: 1px solid #aaa"></div> <<%< <script type="text/javascript"> (onLoadとonResizeとイベントハンドラを定義、下記参照)
javascript部分は、ほとんど本家のチュートリアルのまる写しですが、Timeline.loadXMLという所のパスだけ、今回定義したアクションに変更しました。
// (schedule/timeline.a2html) var tl; function onLoad() { var eventSource = new Timeline.DefaultEventSource(); var bandInfos = [ Timeline.createBandInfo({ eventSource: eventSource, width: "70%", intervalUnit: Timeline.DateTime.DAY, intervalPixels: 100 }), Timeline.createBandInfo({ eventSource: eventSource, showEventText: false, trackHeight: 0.5, trackGap: 0.2, width: "30%", intervalUnit: Timeline.DateTime.MONTH, intervalPixels: 200 }) ]; bandInfos[1].syncWith = 0; bandInfos[1].highlight = true; tl = Timeline.create(document.getElementById("my-timeline"), bandInfos); Timeline.loadXML("schedules", function(xml, url) { eventSource.loadXML(xml, url); }); } var resizeTimerID = null; function onResize() { if (resizeTimerID == null) { resizeTimerID = window.setTimeout(function() { resizeTimerID = null; tl.layout(); }, 500); } }
// (schedule/_schedules.a2html) <<data< <<event :schedules | ModuleExtendFilter[TaskHelper] | Attr[:start=>:sch_date, :title=>:text, :taskId=>:id] < <<:text>>
このビューがこんな感じのXMLを生成します。
<data> <event title="ロードマップ作成" taskId="428" start="Mon Jan 14 00:00:00 2008">ロードマップ作成</event> <event title="日本 Ruby 会議 2008 - FrontPage" taskId="217" start="Sun Feb 17 00:00:00 2008">日本 Ruby 会議 2008 - FrontPage</event> ... </data>
taskIdという属性値は、この後のカスタマイズの為に設定しています。
クリックした時の表示内容をカスタマイズ
以上で、timeline自体は表示されますが、そのままだとtimeline内のイベントをクリックした時に、アプリケーション側に制御を渡すことなく、既定のポップアップウィンドウでevent要素のデータが表示されてしまいます。
そこで、イベントハンドラを入れ替えて、アプリケーション側で指定した処理をさせています。
Timeline.DurationEventPainter.prototype._onClickInstantEvent = function(icon, domEvt, evt) { var task = evt._node.getAttribute("taskId") ; new Ajax.Updater('task_work', '../main/show_task_window/' + task.toString(), { asynchronous:true, evalScripts:true, method:'post', onSuccess: function(o) { TaskWindow.show(); } } ); }
evtというのは、ソースとして指定したXMLのevent要素です。これが、イベントのコールバックに渡されるので、アプリケーション側の処理に必要な情報(この場合はtaskID)を含めておいて、それを取り出して処理しています。
Amrita2 2.0.0をリリースしました
本日、Amrita2 version 2.0.0をリリースしました。
Amrita2は、1.9.6をベータ版としてリリースしてからしばらく開発が滞っていましたが、今回リリースしたものは、一度書き直してレビュアブルマインドで動いているバージョンです。
仕様も実装も大幅に変更していますが、新しいコンセプトで練り直し、スッキリさせたものです。これまでにも何度か作り直しをしていましたので、また新しいバージョンを中途半端に公開すると混乱をまねくと考え、実際のアプリケーションで動かしある程度の完成度にしてからリリースすることにしました。
まだ、ドキュメントは不十分ですが、とりあえずは、下記を参照してください。
開発とサポートは、retrospectivaとrubyforgeで行ないます。
日本語のサポートは、このブログとレビュアブルマインドの掲示板で行います。質問などはこちらにお願いします。
SimpleHatenaModeのテスト
SimpleHatenaModeのテスト中。
このエントリは後で削除します。
デイブトーマス本のサンプルをAmrita2化してみた
Agile Web Development With Rails: A Pragmatic Guide (The Facets Of Ruby Series)
これのチュートリアル部分で使われている depot というアプリを、開発中のAmrita2最新版で書いてみました。
storeコントローラーのViewだけ rhtml版と対比して示します。View以外はほぼ無修正で、a2html版(Amrita2で作成したビュー)とrhtml版は同じ結果を表示します。
それ以外のソースは、こちらのサポートページにあります。
(index.rhtml)
<h1>Your Pragmatic Catalog</h1> <% for product in @products -%> <div class="entry"> <%= image_tag(product.image_url) %> <h3><%= h(product.title) %></h3> <%= product.description %> <div class="price-line"> <span class="price"><%= number_to_currency(product.price) %></span> <% form_remote_tag :url => { :action => :add_to_cart, :id => product } do %> <%= submit_tag "Add to Cart" %> <% end %> </div> </div> <% end %>
(index.a2html)
<h1>Your Pragmatic Catalog</h1> <<div.entry :products < %= image_tag($_.image_url) <<h3:title>> <<:description>> <<div.price-line< <<span.price :price | :to_currency >> % form_remote_tag :url => { :action => :add_to_cart, :id => $_ } do %= submit_tag "Add to Cart" % end
(_cart.rhtml)
<div class="cart-title">Your Cart</div> <table> <%= render(:partial => "cart_item", :collection => cart.items) %> <tr class="total-line"> <td colspan="2">Total</td> <td class="total-cell"><%= number_to_currency(cart.total_price) %></td> </tr> </table> <%= button_to "Checkout", :action => :checkout %> <%= button_to "Empty cart", :action => :empty_cart %>
(_cart.a2html)
<<div.cart-title< Your Cart <<table:cart< %= render(:partial => "cart_item", :collection => $_.items) <<tr class="total-line"<------------------------------------- | colspan || 2 | | | class || | total-cell | | || Total | %= $_.total_price.to_currency | ----------------------------------------------------------- %= button_to "Checkout", :action => :checkout %= button_to "Empty cart", :action => :empty_cart
(_cart_item.rhtml)
<% if cart_item == @current_item %> <tr id="current_item"> <% else %> <tr> <% end %> <td><%= cart_item.quantity %> X </td> <td><%= h(cart_item.title) %></td> <td class="item-price"><%= number_to_currency(cart_item.price) %></td> </tr>
(_cart_item.a2html)
<< :cart_item { :current => ($_ == @current_item), :price => $_.price.to_currency } < <<tr id="current_item" : | Attr[:id=>:current] <------------------------------------------------ |class|| | | item-price | | ||<<:quantity>> X |<<:title>>| <<:price>> | -------------------------------------------------
ここで使っている to_currency というメソッドは、次のように定義してあります。
module PriceHelper include ActionView::Helpers::NumberHelper def to_currency number_to_currency(self) end end class BigDecimal include PriceHelper end class Integer include PriceHelper end
(checkout.rhtml)
<div class="depot-form"> <%= error_messages_for 'order' %> <fieldset> <legend>Please Enter Your Details</legend> <% form_for :order, :url => { :action => :save_order } do |form| %> <p> <label for="order_name">Name:</label> <%= form.text_field :name, :size => 40 %> </p> <p> <label for="order_address">Address:</label> <%= form.text_area :address, :rows => 3, :cols => 40 %> </p> <p> <label for="order_email">E-Mail:</label> <%= form.text_field :email, :size => 40 %> </p> <p> <label for="order_pay_type">Pay with:</label> <%= form.select :pay_type, Order::PAYMENT_TYPES, :prompt => "Select a payment method" %> </p> <%= submit_tag "Place Order", :class => "submit" %> <% end %> </fieldset> </div>
(checkout.a2html)
<%(BeforeCompile) use_macro(StandardForm) %> <div class="depot-form"> %= error_messages_for 'order' << standard_form record="order" < <form_title>Please Enter Your Details</form_title> <url action = "save_order" /> <<header< % pay_select = select "order", :pay_type, % Order::PAYMENT_TYPES, :prompt => "Select a payment method" <<<-------------------------------------------------------------- ||| Name || <text id="name" size="40" /> | <<<-------------------------------------------------------------- ||| Address || <text_area id="address" rows="3" cols="40" /> | <<<-------------------------------------------------------------- ||| E-Mail || <text id="email" size="40" /> | <<<-------------------------------------------------------------- ||| Pay with || <<field id="pay_type"< | ||| || <<:pay_select\|NoSanitize>> | --------------------------------------------------------------- <<fotter< %= submit_tag "Place Order", :class => "submit"
このビューでは、standarad-form というAmrita2のマクロを定義して、使用しています。
standard-formのソースは次のとおりです。
(helper/standard_form.rb)
class StandardForm < Amrita2::Macro::Base TemplateText = <<-END_OF_TEMPLATE <<fieldset< <<legend:title>> <%%= form_tag(<%= $_[:url].inspect %>) %%> <<:header>> <<p:items< <<label:label|Attr[:for]< <<:text>>: <<:input>> <<:fotter>> <%%= '</form>' %%> END_OF_TEMPLATE def macro_data(element) root = element.as_amrita_dictionary record = root[:record].intern title = element.search("form_title").first url = element.search("url").first header = element.search("header").first items = element.search("tr").collect do |tr| tr_to_data(record, tr) end fotter = element.search("fotter").first ret = { :title => title.contents, :record => record, :url => url ? url.as_amrita_dictionary : {}, :header => header.contents, :items => items, :fotter => fotter.contents } ret end def tr_to_data(record, tr) label = tr.search("th").first td = tr.search("td").first input = td_to_input(record, td) || { } { :label => { :for => "#{record}_#{input[:field_id]}", :text => label.contents.strip, }, :input => input[:input] } end def td_to_input(record, td) x = td.children.find { |c| c.kind_of?(Hpricot::Elem) } raise "input was not found in <td>" unless x attr = x.as_amrita_dictionary field_id = attr.delete(:id) input = case x.name when "text" "<%= text_field #{record.to_s.inspect}, #{field_id.to_s.inspect}, #{attr.inspect} %>" when "text_area" "<%= text_area #{record.to_s.inspect}, #{field_id.to_s.inspect}, #{attr.inspect} %>" when "field" x.contents else raise "unknown input field #{x.name} " end { :input => Amrita2::SanitizedString[input], :field_id => field_id } end end
(layout/store.rhtml)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <!-- ! Excerpted from "Agile Web Development with Rails, 2nd Ed." ! We make no guarantees that this code is fit for any purpose. ! Visit http://www.pragmaticprogrammer.com/titles/rails2 for more book information. --> <html> <head> <title>Pragprog Books Online Store</title> <%= stylesheet_link_tag "depot", :media => "all" %> <%= javascript_include_tag :defaults %> </head> <body id="store"> <div id="banner"> <%= image_tag("logo.png") %> <%= @page_title || "Pragmatic Bookshelf" %> </div> <div id="columns"> <div id="side"> <% hidden_div_if(@cart.items.empty?, :id => "cart") do %> <%= render(:partial => "cart", :object => @cart) %> <% end %> <a href="http://www....">Home</a><br /> <a href="http://www..../faq">Questions</a><br /> <a href="http://www..../news">News</a><br /> <a href="http://www..../contact">Contact</a><br /> </div> <div id="main"> <% if flash[:notice] -%> <div id="notice"><%= flash[:notice] %></div> <% end -%> <%= yield :layout %> </div> </div> </body> </html>
(layout/store.rhtml)
<%(BeforeCompile) opt[:process_xhtml]=true %> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <!-- ! Excerpted from "Agile Web Development with Rails, 2nd Ed." ! We make no guarantees that this code is fit for any purpose. ! Visit http://www.pragmaticprogrammer.com/titles/rails2 for more book information. --> <<html< <<head< <title>Pragprog Books Online Store</title> %= stylesheet_link_tag "depot", :media => "all" %= javascript_include_tag :defaults <<body#store< <<div#banner< %= image_tag("logo.png") %= @page_title || "Pragmatic Bookshelf" <<div#columns< <<div#side< % style = @cart.items.empty? ? "display: none" : false <<div#cart : | Attr[:style] < %= render(:partial => "cart", :object => @cart) <a href="http://www....">Home</a><br /> <a href="http://www..../faq">Questions</a><br /> <a href="http://www..../news">News</a><br /> <a href="http://www..../contact">Contact</a><br /> <<div#main< <<div#notice ?[flash[:notice]]< %= flash[:notice] << :content_for_layout | NoSanitize >>