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コードを出力できるはずです。

  1. 普通に書きやすい形でテンプレート作成
  2. ホットスポットを見つける
  3. 作者(私)に「このテンプレートから、これこれのRuby コードを吐くフィルタ作ってくれ」と頼む
  4. 作者が作ったフィルタを受け取ってインストール
  5. そのフィルタの指定をテンプレートに追加

この手順で、性能問題は解決できます。

データタイプ

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の修正

api/timeline-apiを含める。

    %= 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をベータ版としてリリースしてからしばらく開発が滞っていましたが、今回リリースしたものは、一度書き直してレビュアブルマインドで動いているバージョンです。

仕様も実装も大幅に変更していますが、新しいコンセプトで練り直し、スッキリさせたものです。これまでにも何度か作り直しをしていましたので、また新しいバージョンを中途半端に公開すると混乱をまねくと考え、実際のアプリケーションで動かしある程度の完成度にしてからリリースすることにしました。

まだ、ドキュメントは不十分ですが、とりあえずは、下記を参照してください。

開発とサポートは、retrospectivarubyforgeで行ないます。

日本語のサポートは、このブログとレビュアブルマインドの掲示板で行います。質問などはこちらにお願いします。

デイブトーマス本のサンプルをAmrita2化してみた

Agile Web Development With Rails: A Pragmatic Guide (The Facets Of Ruby Series)
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 >>