@m_seki の

I like ruby tooから引っ越し

ERBとdRuby / 草稿前半部分2。そのうち消すよ。

(日本先行発売された本を改訂するとしたらごっこ)



この章ではERBとdRubyの連携について紹介します。
文書にRubyスクリプトを埋め込むeRubyは、定型文書の生成やWebページの生成に便利です。
dRubyが力を発揮する用途としてWebアプリケーションが挙げられますが、eRubyを組み合わせて使うとさらに便利です。
この章ではeRubyの紹介とERBの使用方法を説明し、dRubyと組み合わせた簡単なサンプルを示します。

定型文書の生成とERB

請求書の様なメール、会議通知、HTMLで囲まれた文書、ドメイン特化言語のようなプログラムの自動生成などなど、定型文書の出番は多いものです。みなさんも定型的な文書の生成をする機会は多いのではないかと思います。
ここでは定型文書の生成の作戦について延べながらERBを紹介します。
次のようなメールの生成を考えてみましょう。

Dear Masatoshi SEKI

This note is just to let you know that we've shipped
some items that you ordered.

-----------------------------------------
Qty Item
      Shipping Status
-----------------------------------------
1 Recollections of erb
       - Shipped June 22, 2008
1 Great BigTable and my toys
       - Shipped July 18, 2009
1 The last decade of RWiki and lazy me
       - Just shipped
-----------------------------------------

You can print the receipt and check the status of this
order (and any of your other orders) online by visiting
your Your Acount at http://www.druby.org/m_seki	

http://www.druby.org

単純にStringの連結とヒアドキュメント、文字列リテラルの式展開の組合せで次のように記述できます。

require 'date'

class ShippingNotify
  def initialize
    @account = ''
    @customer = ''
    @items = []
  end
  attr_accessor :account, :customer, :items

  def to_s
    str = <<EOS
Dear #{customer}

This note is just to let you know that we've shipped
some items that you ordered.

-----------------------------------------
Qty Item
      Shipping Status
-----------------------------------------
EOS
    items.each do |qty, item, shipped|
      if shipped == Date.today
        status = 'Just shipped'
      elsif shipped < Date.today
        status = 'Shipped ' + shipped.strftime("%B %d, %Y")
      else
        status = 'NA'
      end
      str << "#{qty} #{item}\n"
      str << "       - #{status}\n"
    end
    str << <<EOS
-----------------------------------------

You can print the receipt and check the status of this
order (and any of your other orders) online by visiting
your Your Acount at http://www.druby.org/#{account}

http://www.druby.org
EOS
    return str
  end
end

if __FILE__ == $0
  greetings = ShippingNotify.new

  greetings.account = 'm_seki'
  greetings.customer = 'Masatoshi SEKI'
  items = [[1, 'Recollections of erb', Date.new(2008, 6, 22)],
           [1, 'Great BigTable and my toys', Date.new(2009, 7, 18)],
           [1, 'The last decade of RWiki and lazy me',  Date.today]]
  greetings.items = items

  puts greetings.to_s
end

たくさんの文字列リテラルスクリプト中に散らばっています。スクリプトの中に定型文の破片を埋め込む作戦には二つ問題があります。

  • 読みにくい。
  • 散らばった定型文を交換するのが面倒。

文書埋め込み型言語と呼ばれるDSLの一種では、この文書とスクリプトの関係を逆転させます。スクリプトの中に文書を埋め込む代わりに、文書の中にスクリプトを埋め込みます。
任意のテキストファイルにRubyスクリプトを埋め込む書式がeRuby、そしてeRubyをRubyのライブラリとして実装したのがERBです。他にCで実装されたerubyもあります。どちらもrubyに標準添付されています。ERBとerubyでは標準出力の扱いなど、一部仕様が異なっています。ERBとerubyの仕様の違いは後述します。

eRubyはHTMLに限らず、任意のテキストファイルの生成に使用できます。HTML以外にも使用できる反面、文法として壊れたHTMLファイルを出力する可能性もあります。

次のマークアップを使ってRubyスクリプトを埋め込みます。

  • <% ... %> - Rubyスクリプト片をその場で実行
  • <%= ... %> - 式を評価した結果をその場に挿入
  • これ以外 - 文字列をその場に挿入

式の結果を埋め込むだけでなく、制御構文や繰り返しにを埋め込むこともできます。先ほどの例をERBを使って書き直してみましょう。
この例では定型文shipping_notify.erbとデータの準備や主処理を行うshipping_notify2.rbに分割しました。

shipping_notify.erb

Dear <%= customer %>

This note is just to let you know that we've shipped
some items that you ordered.

-----------------------------------------
Qty Item
      Shipping Status
-----------------------------------------<%
    items.each do |qty, item, shipped|
      if shipped == Date.today
        status = 'Just shipped'
      elsif shipped < Date.today
        status = 'Shipped ' + shipped.strftime("%B %d, %Y")
      else
        status = 'NA'
      end
%>
<%= qty %> <%= item %>
       - <%= status %><%
    end       
%>
-----------------------------------------

You can print the receipt and check the status of this
order (and any of your other orders) online by visiting
your Your Acount at http://www.druby.org/<%= account %>

http://www.druby.org

上のリストの「items.each」周辺に注目して下さい。文字列リテラルの式展開と異なり、繰り返しや条件分岐などの制御構文も埋め込むことができます。

shipping_notify2.rb

require 'date'
require 'erb'

class ShippingNotify
  def initialize
    @account = ''
    @customer = ''
    @items = []
    @erb = ERB.new(File.read('shipping_notify.erb'))
  end
  attr_accessor :account, :customer, :items

  def to_s
    @erb.result(binding)
  end
end

if __FILE__ == $0
  greetings = ShippingNotify.new

  greetings.account = 'm_seki'
  greetings.customer = 'Masatoshi SEKI'
  items = [[1, 'Recollections of erb', Date.new(2008, 6, 22)],
           [1, 'Great BigTable and my toys', Date.new(2009, 7, 18)],
           [1, 'The last decade of RWiki and lazy me',  Date.today]]
  greetings.items = items

  puts greetings.to_s
end

定型文が取り除かれたRubyスクリプトびはデータの準備を行う部分だけが残りました。また、ShippingNotifyのinitialize メソッドにはERBオブジェクトを生成する式が追加されています。ここでERBクラスの使い方を説明しましょう。

    @erb = ERB.new(File.read('shipping_notify.erb'))

ERB.newは引数のeRubyスクリプトRubyスクリプトに翻訳して、それを評価するERBオブジェクトを生成します。この例では 'shipping_notify.erb'というファイルからERBオブジェクトを生成しました。ERBオブジェクトは何度でも評価することができますから、initializeの中でインスタンス変数に仕舞っておくことにしました。eRubyからRubyへの翻訳は一度きりで充分ですからね。

  def to_s
    @erb.result(binding)
  end

続いてto_sメソッドを見てみましょう。奇妙な引数を伴ってERBのresultメソッドを起動していますね。 bindingは実行中のself、変数・メソッドなどの環境情報を返す組み込み関数です。これはevalの第二引数に与えることができる、特別なオブジェクトです。ERBのresultメソッドは与えられたbindingの環境情報を使ってeRubyスクリプトを評価します。この結果、eRubyスクリプトの中からeRubyスクリプトを評価するインスタンス変数やインスタンスメソッドにアクセスすることができます。
このケースでは、ShippingNotifyのto_sが呼ばれたのと同じ環境で評価されることになります。eRubyスクリプトの中で、「customer」「account」「items」といったメソッドを呼んでも動作するのはそのおかげです。

ERBではその評価に際し、任意のbindingを与えることができます。つまり、eRubyスクリプトからアプリケーションに触れることができるということです。このおかげで、eRubyスクリプトをサポートするメソッドの配置に困ったり、eRubyスクリプトにパラメータを渡すために腐心したりする必要は少なくなります。

サポートのメソッドをeRubyスクリプトから表示を司るクラス(ここではShippingNotifyクラス)へ移動させてみましょう。shipping_notify.erbの中の出荷日から表示用文字列を求める条件分岐に注目して下さい。周囲の景色に溶け込まない、違和感がありますね。

      if shipped == Date.today
        status = 'Just shipped'
      elsif shipped < Date.today
        status = 'Shipped ' + shipped.strftime("%B %d, %Y")
      else
        status = 'NA'
      end

これをメソッドとして抽出し、ShippingNotifyへ配置します。

class ShippingNotify
  ...
  
  def shipping_status(shipped)
    if shipped == Date.today
      'Just shipped'
    elsif shipped < Date.today
      'Shipped ' + shipped.strftime("%B %d, %Y")
    else
      'NA'
    end
  end

  ...
end

eRubyスクリプトでこのメソッドを呼ぶように修正すれば移動の完了です。

-----------------------------------------
Qty Item
      Shipping Status
-----------------------------------------<%
    items.each do |qty, item, shipped|
%>
<%= qty %> <%= item %>
       - <%= shipping_status(shipped) %><%
    end       
%>
-----------------------------------------

ShippingNotifyクラスはeRubyスクリプトと組になって表示機能(実際には文字列の生成ですが)を提供する、Viewクラスです。Rubyスクリプトで記述すると見通しの悪くなる定型文書部分をeRubyスクリプトとして外に追い出しています。別の視点で言い換えてみます。eRubyスクリプトに書いてしまいがちは表示のためのスクリプト片を実行環境となるRubyスクリプト側に追い出すことで、定型文書をすっきりしたものにできます。

ここまでをふりかえってみましょう。
定型文書を生成するスクリプトを素朴に書くと、無数の文字列の連結になりがちです。ヒアドキュメントと式展開を組み合わせると、散らばった文字列をまとめやすくなり、だいぶマシになります。これらはスクリプトの中に文字列を埋め込むスタイルです。逆に、文字列の中にスクリプトを埋め込むのがeRubyです。ERBでは任意のbindingを渡してeRubyを評価できるので、アプリケーションとの連携が容易です。単体のeRubyスクリプトにすべてを押し込む必要はありません。
どこまでをeRubyが担当し、どこまでをRubyが担当するのか、その切り分けには様々な流派や信仰があると聞きます。
eRubyスクリプトから徹底的にRubyを排除して文字列の置換になってしまえばStringと式展開と違いがなくなるでしょうし、新たに条件分岐、繰り返しのための新しい規則を導入していけば、新たな言語を導入しているのと違わないかもしれません。
ここで説明したbindingを使ったアプリケーションとeRubyスクリプトの協調は、ERBの使い方のひとつです。あなたの選択肢に加えてもらえるとうれしいです。

ERBではresultメソッドの他に、変換したRubyスクリプトを返すメソッドなども用意されています。以下によく使われるメソッドのリファレンスを示します。

http://www2a.biglobe.ne.jp/%7eseki/ruby/d2erb.jpg
fig. eRubyスクリプトから生成されたERBオブジェクトは、結果の出力だけでなく、result、srcを用いて変換できる

ERB.new(eruby_script, safe_level=nil, trim_mode=nil)
eruby_scriptからERBオブジェクトを生成する。eval時のセーフレベル、trim_mode(後述)を指定できる

run(b=TOPLEVEL_BINDING)
ERBをbのbindingで実行し、出力する

result(b=TOPLEVEL_BINDING)
ERBをbのbindingで実行し、文字列を返す

src
変換したRubyスクリプトを返す

srcメソッドを試してみましょう。

% irb -r erb
irb(main):001:0> ERB.new('Hello, World. <%= Time.now %>').src
=> "#coding:UTF-8\n_erbout = ''; _erbout.concat \"Hello, World. \"; _erbout.concat(( Time.now ).to_s); _erbout.force_encoding(__ENCODING__)"

文字のエンコード処理に挟まれてわかりにくいですが、_erboutというローカル変数の文字列に文字列を連結するスクリプトが返されました。resultメソッドではこのスクリプトをevalに与えて評価を行います。evalを呼ぶということは、その都度Rubyスクリプトのパーズが行われた後に実行されるということです。このパーズがもったいないので、srcメソッドの返す文字列をメソッド定義の文で包み込むことで、eRubyスクリプトをメソッドにすることができます。

ShippingNotifyをメソッド化してみましょう。eRubyスクリプトに変更はありません。

class ShippingNotify
  def initialize
    @account = ''
    @customer = ''
    @items = []
  end
  attr_accessor :account, :customer, :items

  def shipping_status(shipped)
    if shipped == Date.today
      'Just shipped'
    elsif shipped < Date.today
      'Shipped ' + shipped.strftime("%B %d, %Y")
    else
      'NA'
    end
  end

  extend ERB::DefMethod
  def_erb_method('to_s', 'shipping_notify3.erb')
end

ERB::DefMethodモジュールをextendすることで、このクラスでdef_erb_methodが利用できるようになります。このメソッドはメソッド名(メソッドのシグネチャ)とeRubyスクリプトのファイル名、あるいはERBオブジェクトのいずれかを引数にとり、このメソッドを呼び出した環境、ここではShippingNotifyクラスにメソッドを定義します。

  extend ERB::DefMethod
  def_erb_method('to_s', 'shipping_notify3.erb')

def_erb_methodの呼び手(self)はShippingNotifyクラスです。ShippingNotifyクラスにshipping_notify3.erbというファイルの内容をto_sメソッドとして定義します。

WEBrick::CGIとERB、dRuby

WEBrick::CGIは私のお気に入りのクラスライブラリです。WEBrickサーブレットと同様のアプリケーションインターフェイスCGIスクリプトを記述できるのが特徴です。同様なコンセプトを持つフレームワークとしてはRackが人気ですね。ここではWEBrick::CGIによるCGIスクリプトの作成を紹介し、dRubyと協調する様子を示します。

まずWEBrick::CGIの簡単なサンプルを示します。

#!/usr/bin/env ruby
require 'webrick/cgi'
require 'erb'

class MyCGI < WEBrick::CGI
  def initialize(*args)
    super(*args)
    @erb = create_erb
  end

  def do_GET(req, res)
    build_page(req, res)
  rescue
    error_page(req, res)
  end
  alias :do_POST :do_GET

  def build_page(req, res)
    res["content-type"] = "text/html"
    res.body = @erb.result(binding)
  end

  def error_page(req, res)
    res["content-type"] = "text/plain"
    res.body = 'oops'
  end

  def create_erb
    ERB.new(<<EOS)
<html>
 <head><title>Hello</title></head>
 <body>Hello, World.</body>
</html>
EOS
  end
end

MyCGI.new.start()

WEBrickサーブレットを書いている方にはおなじみのスクリプトですね。WEBrick::CGIを継承した自分用のクラスを作り、そこにdo_GET()メソッドを用意します。そしてインスタスを作ってstartメソッドを呼び出せばCGIの完成です。do_GETの引数のリクエストやレスポンスはWEBrickサーブレットと同様のものです。

みなさんの環境で自分のCGIを実行するにはどうしますか?
私の環境(OSX)では次のように準備しました。

$ vi my_cgi.rb
$ chmod +x my_cgi.rb
$ sudo cp my_cgi.rb /Library/WebServer/CGI-Executables/

Safariから「http://localhost/cgi-bin/my_cgi.rb」にアクセスすると「hello world」が印字されました。

説明を簡単にするためにeRubyスクリプトCGIスクリプトの中に配置していますが、別のファイルに追い出す方が良いでしょう。

Reminder CGIインタフェース

前章で作成したReminderにERBを使ったCGIインタフェースを作成します。

作成するCGIは、前章のCUI版と同様に、

  • ToDoの一覧表示
  • アイテムの削除
  • アイテムの追加

が可能なものとします。

まず一覧表示です。下のように<ul>を使った箇条書きで表示するERBを書くことにしましょう。

 <ul>
 <li>1: 13:00 ミーティング</li>
 <li>3: 土曜日にDVDを返す</li>
 <li>4: 15:00 進捗報告</li>
 <li>5: 図書館にRHGをリクエストする</li>
 </ul>

<li>の内容を変えながら繰り返し行を挿入しておけばよさそうです。aryに項目が入っているとすると、次のように書けます。

 <ul>
 <% ary.each do |k, v| %>
 <li><%= k %>: <%= v %></li>
 <% end %>
 </ul>

その前にReminderサーバの準備が必要です。前章の最後に作ったreminder0.rbを用いて、テスト用のサーバを作りましょう。実験用のデータも与えます。

# -*- coding: utf-8 -*-
require 'reminder0'
require 'drb/drb'
require 'pp'

reminder = Reminder.new
reminder.add('RubyKaigi 2011応募')
reminder.add('ポモドーロタイマーを買う')
reminder.add('<や>はどうなるの?')
DRb.start_service('druby://localhost:12345', reminder)

while true
  sleep 10
  pp reminder.to_a
end

test_reminder.rbです。今回は気まぐれにirbでなく一般的なRubyスクリプトにしてみました。Reminderを生成しテストデータを与え、dRubyのサービスを起動します。今回は実験用に10秒おきにReminderの中身を印字します。実際のサービスではDRb.thread.joinとするのが適切でしょう。

 [ターミナル1]
 % ruby -I. test_reminder.rb
 [[1, "RubyKaigi 2011応募"], [2, "ポモドーロタイマーを買う"], [3, "<や>はどうなるの?"]]
 ....


ではさっそくERBを使って表示するスクリプトを書きます。

require 'erb'
require 'drb/drb'

class ReminderView
  extend ERB::DefMethod
  def_erb_method('to_html(there)', 'erb_reminder.erb')
end

there = DRbObject.new_with_uri('druby://localhost:12345')
view = ReminderView.new
puts view.to_html(there)

erb_reminder.rbです。別ファイルerb_reminder.erb のeRubyスクリプトをターミナル1のリマインダオブジェクトとともに実行します。

<ul>
<% there.to_a.each do |k, v| %>
<li><%= k %>: <%= v %></li>
<% end %>
</ul>

では実験してみましょう。

[ターミナル2]
% ruby erb_reminder.rb
<ul>

<li>1: RubyKaigi 2011応募</li>

<li>2: ポモドーロタイマーを買う</li>

<li>3: <や>はどうなるの?</li>

</ul>

期待したように出力されていますか?

三つめの要素の<と>がそのまま印字されてしまいました。期待している結果はこんな感じですよね?

[ターミナル2]
% ruby erb_reminder.rb
<ul>

<li>1: RubyKaigi 2011応募</li>

<li>2: ポモドーロタイマーを買う</li>

<li>3: &lt;や&gt;はどうなるの?</li>

</ul>
エスケープにまつわる話

HTMLの要素をエスケープしたり、URLのパラメータをエンコードしたりするユーティリティがERB::Utilに用意されています。

  • ERB::Util.html_escape(s) - HTMLの&"<>をエスケープする
  • ERB::Util.h(s) - 同上
  • ERB::Util.url_encode(s) - 文字列をURLエンコードする
  • ERB::Util.u(s) - 同上

h、uと短く奇妙なメソッドがありますが、これは次のように使うものです。

<ul>
<% there.to_a.each do |k, v| %>
<li><%= k %>: <%=h v %></li>
<% end %>
</ul>

eRubyスクリプトで <%=h ... %> と書くことでHTMLエスケープした結果を挿入することができます。
一見eRubyスクリプトの拡張に見えますが、実はこれは

<%= h(...) %>

で、いつものメソッド呼び出しです。

hやuを使用するには、ERB::Utilをincludeしておく必要があります。

require 'erb'
require 'drb/drb'

class ReminderView
  include ERB::Util    # ★追加
  extend ERB::DefMethod
  def_erb_method('to_html(there)', 'erb_reminder.erb')
end

there = DRbObject.new_with_uri('druby://localhost:12345')
view = ReminderView.new
puts view.to_html(there)

実験します。

% ruby erb_reminder.rb
<ul>

<li>1: RubyKaigi 2011応募</li>

<li>2: ポモドーロタイマーを買う</li>

<li>3: &lt;や&gt;はどうなるの?</li>

</ul>

3つ目の項目の<と>がエスケープされていることが分かります。この実験ではhメソッドを使ってHTMLに埋め込む文字列をエスケープしました。
実はまた別の解もあります。

ERBの処理は大雑把にいうと文字列を連結して返すものです。ERBの一部のパラメータを継承などを使って変更することで、連結されるオブジェクト、連結するメソッドなどをカスタマイズ可能です。次に示す小さなクラスERB4Htmlは、HTMLに特化したERBのひとつの例で、自動的にHTMLエスケープを行います。

require 'erb'

class ERB
  class ERBString < String
    def to_s; self; end

    def erb_concat(s)
      if self.class === s
        concat(s)
      else
        concat(erb_quote(s))
      end
    end

    def erb_quote(s); s; end
  end
end

class ERB4Html < ERB
  def self.quoted(s)
    HtmlString.new(s)
  end

  class HtmlString < ERB::ERBString
    def erb_quote(s)
      ERB::Util::html_escape(s)
    end
  end

  def set_eoutvar(compiler, eoutvar = '_erbout')
    compiler.put_cmd = "#{eoutvar}.concat"
    compiler.insert_cmd = "#{eoutvar}.erb_concat"
    compiler.pre_cmd = ["#{eoutvar} = ERB4Html.quoted('')"]
    compiler.post_cmd = [eoutvar]
  end
  
  module Util
    def h(s)
      q(ERB::Util.h(s))
    end

    def u(s)
      q(ERB::Util.u(s))
    end
    
    def q(s)
      HtmlString.new(s)
    end
  end  
end

このクラスの仕掛けの中心となるのは未処理の文字列を連結する際にquote処理を行う特別な文字列ERBStringクラスと、それをHTMLに特化させるHtmlStringです。set_eoutvarはERBがeRubyからRubyに変換したスクリプトの中で使うローカル変数名や文字列の準備、連結のメソッド名などを設定するメソッドです。ERB4HtmlはHtmlStringを使って文字列を組み立てるようにset_eoutvarをオーバーライドします。

自動的にHTMLをエスケープするのERBがいつも役に立つとは言えません。すでにエスケープ済みの文字列を与えることを明示しなくてはなりませんし、URLのエンコードは相変わらず必要です。そのためにERB::Utilのh, uメソッドと同様なメソッドをERB4Html::Utilとして別途用意しました。こちらのメソッド群はERB::Utilのhやuを読んだ後に「処理済み」であるマークをつけます。具体的にはHtmlStringのインスタンスを返すということですが。さらに処理済みマークを着けるためだけのqメソッドも用意します。

ERB4Htmlを使ったReminderViewは次のようになります。

class ReminderView
  include ERB4Html::Util
  ERB4Html.new(File.read('erb_reminder.erb')).def_method(self, 'to_html(there)')
end

自動的なエスケープは、hメソッドを入れ忘れたためにXSSなどの問題を呼び込んでしまうことを避けるためには価値があると言われています。
しかし、ERB4Htmlによる自動的なエスケープで本当にみなさんが幸せになるのかどうか、私にはわかりません。私には考慮することが増えてしまっただけのような気がしてなりませんが、今回はERBのカスタマイズの一例として示しました。

それからもう一つ、汚染された文字列を連結すると例外が発生するERBの例を示します。

class ERBRestrict < ERB
  class RestrictString < ERB::ERBString
    def erb_concat(s)
      raise SecurityError if s.tainted?
      concat(s)
    end
  end

  def set_eoutvar(compiler, eoutvar = '_erbout')
    compiler.put_cmd = "#{eoutvar}.concat"
    compiler.insert_cmd = "#{eoutvar}.erb_concat"
    compiler.pre_cmd = ["#{eoutvar} = ERBRestrict::RestrictString.new('')"]
    compiler.post_cmd = [eoutvar]
  end
end

ERB4Htmlと同様にERBStringのサブクラスを使って実現しています。Rubyではオブジェクトが外部のリソースに由来している場合には汚染マーク(tainted?)がつきます。ERBRestrictは汚染された文字列を連結しようとすると例外が発生するものです。不慮の事故を防ぐことを目的とするのであれば、こちらの方が良いかもしれません。エスケープが済んだら汚染を取り除く(untaint)するように変更したh()メソッドと組み合わせて使うと良いでしょう。

ERBをカスタマイズする例として、自動的にHTMLエスケープを行うERB4Html、汚染された文字列を連結すると例外を発生するERBRestrictを紹介しました。これらは以下に続く実験では使用しませんが、「こういうことも可能である」という程度に覚えておくといつかよいことがあるかもしれません。

再びCGI

長い脱線がつづきました。再びCGIにもどります。最初に実験したCGIスクリプトと、先ほど作ったReminderViewをつないでみましょう。
ReminderViewのERBスクリプトは実験の都合上、CGIスクリプトに配置しました。

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
require 'webrick/cgi'
require 'erb'
require 'drb/drb'

class ReminderView
  include ERB::Util

  def self.create_erb
    ERB.new(<<EOS)
<html><head><title>Reminder</title></head>
<body>
<ul>
<% @db.to_a.each do |k, v| %>
<li><%=h k %>: <%=h v %></li>
<% end %>
</ul>
</body>
</html>
EOS
  end

  extend ERB::DefMethod
  def_erb_method('to_html(req, res)', create_erb)

  def initialize(db)
    @db = db
  end
end

class ReminderCGI < WEBrick::CGI
  def initialize(db, *args)
    super(*args)
    @db = db
    @view = ReminderView.new(@db)
  end

  def do_GET(req, res)
    build_page(req, res)
  rescue
    error_page(req, res)
  end
  alias :do_POST :do_GET

  def build_page(req, res)
    res["content-type"] = "text/html; charset=utf-8"
    res.body = @view.to_html(req, res)
  end

  def error_page(req, res)
    res["content-type"] = "text/plain"
    res.body = 'oops'
  end
end

if __FILE__ == $0
  reminder = DRbObject.new_with_uri('druby://localhost:12345')
  ReminderCGI.new(reminder).start()
end

あなたの環境にあわせてこのCGIスクリプトを設定実行してみましょう。どうですか? 項目一覧が表示されたでしょうか。

外観の変更

<ul>による一覧はちょっとさみしい感じがしますね。<table>を使った一覧に変えてみましょう。eRubyスクリプトの部分(ReminderViewのcreate_erbメソッドの中)を次のように変えて、もう一度CGIを表示してみましょう。

  def self.create_erb
    ERB.new(<<EOS)
<html><head><title>Reminder</title></head>
<body>
<table border='1'>
<% @db.to_a.each do |k, v| %>
<tr><td><%= k %></td><td><%=h v %></td></tr>
<% end %>
</table>
</body>
</html>
EOS
  end

このようにちょっとした外観の変更は、eRubyスクリプトを入れ替えることで可能です。
表はなかなか良いのですが、1行ごとに行の背景色を変えておくと見やすくなりそうです。まず、2つの背景色の属性を交互に出力するクラスを準備します。久しぶりのRubyスクリプトですね。

 class BGColor
   def initialize
     @colors = ['#eeeeff', '#bbbbff']
     @count = -1
   end
   attr_accessor :colors
   
   def next_bgcolor
     @count += 1
     @count = 0 if @colors.size <= @count
     "bgcolor='#{@colors[@count]}'"
   end
   
   alias :to_s :next_bgcolor
 end


BGColorクラスは、あらかじめ設定された背景色を順々に繰り返し出力する係です。特別難しい部分はないと思います。

  • colors=(ary) - 背景色の配列をセットする
  • next_bgcolor - 背景色の属性となる文字列("bgcolor='#eeeeff'"など)を返す。呼ぶたびに異なる色を返す
  • to_s - next_bgcolorの別名。eRubyスクリプトの<%= obj %> ではobj.to_sの結果を埋め込むので、to_sを定義しておくと簡潔に書ける局面がある

ReminderViewにBGColorを生成するメソッドを追加し、eRubyスクリプトのtrの部分を変更します。

class ReminderView
  ...
  def bg_color
    BGColor.new
  end
end
<html><head><title>Reminder</title></head>
<body>
<table>
<% bg = bg_color
   @db.to_a.each do |k, v| %>
<tr <%= bg %>><td><%= k %></td><td><%=h v %></td></tr>
<% end %>
</table>
</body>
</html>

<%= 式 %>は式の結果をto_sしたものをここに挿入します。<%= bg %>ではbg.to_sの結果が挿入されます。

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
require 'webrick/cgi'
require 'erb'
require 'drb/drb'

class BGColor
  def initialize
    @colors = ['#eeeeff', '#bbbbff']
    @count = -1
  end
  
  def next_bgcolor
    @count += 1
    @count = 0 if @colors.size <= @count
    "bgcolor='#{@colors[@count]}'"
  end
  alias :to_s :next_bgcolor
end

class ReminderView
  include ERB::Util

  def self.create_erb
    ERB.new(<<EOS)
<html><head><title>Reminder</title></head>
<body>
<table>
<% bg = bg_color
   @db.to_a.each do |k, v| %>
<tr <%= bg %>><td><%= k %></td><td><%=h v %></td></tr>
<% end %>
</table>
</body>
</html>
EOS
  end

  extend ERB::DefMethod
  def_erb_method('to_html(req, res)', create_erb)

  def initialize(db)
    @db = db
  end
  
  def bg_color
    BGColor.new
  end
end

class ReminderCGI < WEBrick::CGI
  def initialize(db, *args)
    super(*args)
    @db = db
    @view = ReminderView.new(@db)
  end

  def do_GET(req, res)
    build_page(req, res)
  rescue
    error_page(req, res)
  end
  alias :do_POST :do_GET

  def build_page(req, res)
    res["content-type"] = "text/html; charset=utf-8"
    res.body = @view.to_html(req, res)
  end

  def error_page(req, res)
    res["content-type"] = "text/plain"
    res.body = 'oops'
  end
end

if __FILE__ == $0
  reminder = DRbObject.new_with_uri('druby://localhost:12345')
  ReminderCGI.new(reminder).start()
end

最後にBGColorを使ったバージョンの完全なスクリプトと実行結果を載せます(List 3.7, 図3.6)。

項目の追加と削除

一覧が表示できるようになりました。この節では項目の追加ができるようにします。
まず画面を決めます。とりあえず項目一覧の下に追加用のテキストフィールドを置きましょう。

class ReminderView
  ...
  def self.create_erb
    ERB.new(<<EOS)
<html><head><title>Reminder</title></head>
<body>
<form method='post'>
<table>
<% bg = bg_color
   @db.to_a.each do |k, v| %>
<tr <%= bg %>><td><%= k %></td><td><%=h v %></td></tr>
<% end %>
<tr <%= bg %>>
<td><input type="submit" name="cmd" value="add" /></td>
<td><input type="text" name="item" value="" size="30" /></td>
</tr>
</table>
</form>
</body>
</html>
EOS
  end
  ...

ReminderViewのクラスメソッド、create_erbを修正します。この段階で一度CGIを実行し、フォームが追加されていることを確認して下さい。
次にCGIのリクエストを分析して、Reminderサーバに項目を追加する部分を考えます。

  1. queryのコマンド種をチェックする。今回は'add'だけ対応する。
  2. テキストフィールドの文字列を取り出す。
  3. 文字列があれば、文字列のエンコーディングを正規化し、Reminderサーバに項目を追加する。

これに従ってメソッドを記述します。

class ReminderCGI < WEBrick::CGI
  ...
  def do_request(req, res)
    cmd ,= req.query['cmd']             # (1)
    case cmd
    when 'add'
      do_add(req, res)
    end
  end

  def do_add(req, res)
    item ,= req.query['item']           # (2)
    return if item.nil? || item.empty?
    item.force_encoding('utf-8')        # (3)
    @db.add(item)
  end
  ...

ところで(1)と(2)に「,=」という変わった表記があります。この ,= req.query[key] は、RubyによるCGIスクリプトでよく見られる多重代入を利用したイディオムで、
keyと名前の付いたパラメータの先頭の要素を取り出すことができます。
itemに文字列が与えられていた場合、外部から与えられた文字列のエンコーディングを正規化して(3)Reminderサーバに要素を追加します。
そうそう。言い忘れていましたが、このCGIとReminderサーバはエンコーディングが「utf-8」で動作します。マジックコメントやhtmlのContent-Typeなどにも書いてありますね。このため、外部から与えられた文字列のエンコーディングutf-8に正規化する必要があります。最近のブラウザではContent-Typeのcharsetに合わせたリクエストを発生させることが多いので、今回は安易にforce_encodingメソッドでutf-8としました。

ここまでのスクリプトを載せます。*1

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
require 'webrick/cgi'
require 'erb'
require 'drb/drb'

class BGColor
  def initialize
    @colors = ['#eeeeff', '#bbbbff']
    @count = -1
  end
  
  def next_bgcolor
    @count += 1
    @count = 0 if @colors.size <= @count
    "bgcolor='#{@colors[@count]}'"
  end
  alias :to_s :next_bgcolor
end

class ReminderView
  include ERB::Util

  def self.create_erb
    ERB.new(<<EOS)
<html><head><title>Reminder</title></head>
<body>
<form method='post'>
<table>
<% bg = bg_color
   @db.to_a.each do |k, v| %>
<tr <%= bg %>><td><%= k %></td><td><%=h v %></td></tr>
<% end %>
<tr <%= bg %>>
<td><input type="submit" name="cmd" value="add" /></td>
<td><input type="text" name="item" value="" size="30" /></td>
</tr>
</table>
</form>
</body>
</html>
EOS
  end

  extend ERB::DefMethod
  def_erb_method('to_html(req, res)', create_erb)

  def initialize(db)
    @db = db
  end
  
  def bg_color
    BGColor.new
  end
end

class ReminderCGI < WEBrick::CGI
  def initialize(db, *args)
    super(*args)
    @db = db
    @view = ReminderView.new(@db)
  end

  def do_GET(req, res)
    do_request(req, res)
    build_page(req, res)
  rescue
    error_page(req, res)
  end
  alias :do_POST :do_GET

  def do_request(req, res)
    cmd ,= req.query['cmd']
    case cmd
    when 'add'
      do_add(req, res)
    end
  end

  def do_add(req, res)
    item ,= req.query['item']
    return if item.nil? || item.empty?
    item.force_encoding('utf-8')
    @db.add(item)
  end

  def build_page(req, res)
    res["content-type"] = "text/html; charset=utf-8"
    res.body = @view.to_html(req, res)
  end

  def error_page(req, res)
    res["content-type"] = "text/plain"
    res.body = 'oops'
  end
end

if __FILE__ == $0
  reminder = DRbObject.new_with_uri('druby://localhost:12345')
  ReminderCGI.new(reminder).start()
end


項目の一覧表示、追加ができるようになりました。項目の削除ができたら、この課題はゴールです。
表の各行に削除を行うリンクを設けて、項目の削除を可能にします。*2

queryの仕様は、コマンド種が'delete'の際にキーで指定された項目を削除することします。

  def do_request(req, res)
    cmd ,= req.query['cmd']
    case cmd
    when 'add'
      do_add(req, res)
    when 'adlete'
      do_delete(req, res)
    end
  end

そして、do_deleteを定義します。

  def do_delete(req, res)
    key ,= req.query['key']
    return if key.nil? || key.empty?
    @db.delete(key.to_i)
  end

これで操作を担当する部分は完成しました。あとは、このパラメータに沿った形式でページを生成する必要があります。それぞれの行の最後の列に削除のリンクを追加します。

<% bg = bg_color
   @db.to_a.each do |k, v| %>
<tr <%= bg %>>
  <td><%= k %></td>
  <td><%=h v %></td>
  <td><%=a_delete(k)%>X</a><td>
</tr>
<% end %>

a_deleteというReminderViewのメソッドは、削除の操作へのアンカーの生成を助けるユーテリィティです。引数は削除対象の項目のキーです。

class ReminderView
  ...
  def make_param(hash)
    hash.collect do |k, v|
      u(k) + '=' + u(v)
    end.join(';')
  end
  
  def anchor(query)
    %Q+<a href="?#{make_param(query)}">+
  end

  def a_delete(key)
    anchor('cmd' => 'delete', 'key'=>key)
  end

完全なスクリプトを示します。

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
require 'webrick/cgi'
require 'erb'
require 'drb/drb'

class BGColor
  def initialize
    @colors = ['#eeeeff', '#bbbbff']
    @count = -1
  end
  
  def next_bgcolor
    @count += 1
    @count = 0 if @colors.size <= @count
    "bgcolor='#{@colors[@count]}'"
  end
  alias :to_s :next_bgcolor
end

class ReminderView
  include ERB::Util

  def self.create_erb
    ERB.new(<<EOS)
<html><head><title>Reminder</title></head>
<body>
<form method='post'>
<table>
<% bg = bg_color
   @db.to_a.each do |k, v| %>
<tr <%= bg %>>
  <td><%= k %></td>
  <td><%=h v %></td>
  <td><%=a_delete(k)%>X</a><td>
</tr>
<% end %>
<tr <%= bg %>>
<td><input type="submit" name="cmd" value="add" /></td>
<td><input type="text" name="item" value="" size="30" /></td>
</tr>
</table>
</form>
</body>
</html>
EOS
  end

  extend ERB::DefMethod
  def_erb_method('to_html(req, res)', create_erb)

  def initialize(db)
    @db = db
  end
  
  def bg_color
    BGColor.new
  end

  def make_param(hash)
    hash.collect do |k, v|
      u(k) + '=' + u(v)
    end.join(';')
  end
  
  def anchor(query)
    %Q+<a href="?#{make_param(query)}">+
  end

  def a_delete(key)
    anchor('cmd' => 'delete', 'key'=>key)
  end
end

class ReminderCGI < WEBrick::CGI
  def initialize(db, *args)
    super(*args)
    @db = db
    @view = ReminderView.new(@db)
  end

  def do_GET(req, res)
    do_request(req, res)
    build_page(req, res)
  rescue
    error_page(req, res)
  end
  alias :do_POST :do_GET

  def do_request(req, res)
    cmd ,= req.query['cmd']
    case cmd
    when 'add'
      do_add(req, res)
    when 'delete'
      do_delete(req, res)
    end
  end

  def do_add(req, res)
    item ,= req.query['item']
    return if item.nil? || item.empty?
    item.force_encoding('utf-8')
    @db.add(item)
  end

  def do_delete(req, res)
    key ,= req.query['key']
    return if key.nil? || key.empty?
    @db.delete(key.to_i)
  end

  def build_page(req, res)
    res["content-type"] = "text/html; charset=utf-8"
    res.body = @view.to_html(req, res)
  end

  def error_page(req, res)
    res["content-type"] = "text/plain"
    res.body = 'oops'
  end
end

if __FILE__ == $0
  reminder = DRbObject.new_with_uri('druby://localhost:12345')
  ReminderCGI.new(reminder).start()
end

エラーページ

この節ではERBを利用して、開発中に使用するデバッグ用のエラーページを作ります。
これまで作成してきたスクリプトには実行時エラーの際に"oops"という文字列を返すerror_pageメソッドが用意されていました。
do_GETメソッドを見て下さい。リクエストを受け付け、ページを作成する処理の中で例外が発生すると、error_pageメソッドが呼ばれます。

  def do_GET(req, res)
    do_request(req, res)
    build_page(req, res)
  rescue
    error_page(req, res)
  end

現在のerror_pageメソッドは、プレーンテキストで"oops"と返すだけです。Reminderサーバを停止させてCGIを実行してみて下さい。"oops"と表示されましたか?

  def error_page(req, res)
    res["content-type"] = "text/plain"
    res.body = 'oops'
  end

実際の運用中には静的なエラーページへのリダイレクトとしたり、もうちょっと気の利いたエラーページを表示するべきでしょう。

error_pageを変更して汎用のデバッグ用のエラー表示を作成してみましょう。Webサーバによってはエラーログに、どのようなエラーが発生したか記録されることもあります。
これから作成するエラー表示クラスは、デバッグが効率良く行えるように、Rubyの実行時エラーと同様な表示をWebページに出力するクラスです。

class ErrorView
  include ERB::Util

  def self.create_erb
    ERB.new(<<EOS)
<html><head><title>error</title></head>
<body>
<p><%=h error %> - <%=h error.class %></p>
<ul>
<% info.each do |line| %>
<li><%=h line %></li>
<% end %>
</ul>
</body>
</html>
EOS
  end

  extend ERB::DefMethod
  def_erb_method('to_html(req, res, error=$!, info=$@)', create_erb)
end

ErrorViewは、Rubyのrescueによって捉えられた例外情報($!、$@)をHTMLに変換するクラスです。*3

error_pageメソッドで'oops'を返す代わりに、ErrorViewのto_htmlの結果を返すように変更します。

  def error_page(req, res)
    res["content-type"] = "text/html; charset=utf-8"
    res.body = ErrorView.new.to_html(req, res)
  end

UnknownErrorPageの追加と上の変更を加え実行してみましょう。DRb::DRbConnErrorがどこで発生したか、といった情報が表示されるはずです。

プロセスの配置を変更する

ここでちょっと趣向を変えてCGIdRubyの変わった使い方を紹介します。プロセス間でオブジェクトの配置を調整して、違った世界を垣間見ます。
現在登場しているプロセスはReminderサーバとCGIクライアントです。Reminderは長命でCGIは短命ですね。CGIは短命な割に仕事の準備が多いのが気になります。

今回はCGIを極めて小さいCGIプロセスと、CGIサーバに分割します。

まずCGIを改造してCGIサーバを作ります。CGIの最後の部分を覚えていますか?

if __FILE__ == $0
  reminder = DRbObject.new_with_uri('druby://localhost:12345')
  ReminderCGI.new(reminder).start()
end

ReminderCGIを生成してstartするのではなく、ReminderCGIを公開するサーバを書きましょう。cgi_reminder_d.rbです。

# -*- coding: utf-8 -*-
require 'cgi_reminder'
require 'drb/drb'

reminder = DRbObject.new_with_uri('druby://localhost:12345')
cgi = ReminderCGI.new(reminder)
DRb.start_service('druby://localhost:12346', cgi)
DRb.thread.join

ReminderCGIを生成しfrontオブジェクトとして、druby://localhost:12346で公開します。
これでCGIサーバは完成です。

次に極小のCGIを書きます。

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
require 'drb/drb'

DRb.start_service('druby://localhost:0')
ro = DRbObject.new_with_uri('druby://localhost:12346')
ro.start(ENV.to_hash, $stdin, $stdout)

まず、自分自身のdRubyサーバの準備をします。次に、CGIサーバのリモートオブジェクトを生成してstartメソッドを呼びます。startメソッドの引数に魔法があります。startメソッドの引数は、環境変数、標準入力、標準出力を与えます。リモートのReminderCGIオブジェクトのstartメソッドに、CGIのENVと$stdin, $stdoutを与えると、リモートのReminderCGIはまるでこのCGIのコンテキストで動作するかのように振る舞います。
この三つの引数には後の章で説明する「参照渡しと値渡し」のトリックが深く関係しています。その仕掛けは後で述べるので、ここでは簡単な解説だけすることします。$stdin, $stdoutはFileオブジェクトです。これはRMIの際に自動的に参照渡し(DRbObjectの値渡し)となります。また、ENVは特異オブジェクトで値渡しにはなりません。ENVをto_hashメソッドでHashに変換するのは、ENVがそのままでは参照渡しとなるところを、値渡しとなるように一般的なHashのオブジェクトとするためです。

Reminderサーバ、ReminderCGIサーバを起動した後に、CGIを実行して実験して下さい。

 [ターミナル1]
 % ruby -I. test_reminder.rb
 [[1, "RubyKaigi 2011応募"], [2, "ポモドーロタイマーを買う"], [3, "<や>はどうなるの?"]]
 ....
 [ターミナル2]
 % ruby -I. cgi_reminder_d.rb

この方式の利点は、複数回に分断されたCGIリクエストを超えて生き残るオブジェクトを持てること、その結果Webのユーザーインターフェイスの準備に必要な作業を一度きりにすることができることです。この例ではReminderCGIオブジェクトをCGIのリクエストを越えて保持すること、ERBからRubyスクリプトへの変換をただ一度きりですますことができること、がこれにあたります。

さいごに、ReminderサーバとReminderCGIサーバを同じプロセスに配置してこの章を終わります。test_reminder.rbの中にReminderCGIサーバを持たせてみましょう。新しいバージョンは次の通りです。

# -*- coding: utf-8 -*-
require 'reminder0'
require 'drb/drb'
require 'pp'
require 'cgi_reminder'

reminder = Reminder.new
reminder.add('RubyKaigi 2011応募')
reminder.add('ポモドーロタイマーを買う')
reminder.add('<や>はどうなるの?')

cgi = ReminderCGI.new(reminder)
DRb.start_service('druby://localhost:12346', cgi)

while true
  sleep 10
  pp front.to_a
end

これはどう考えればよいでしょうか。

Reminderというアプリケーションに、ユーザインターフェイスとしてのReminderCGIを持たせたように見えます。このプロセス構成は一般的なGUIをもつアプリケーションの形によく似ています。アプリケーションがUIとしてのCGIをもち、UIへのイベントはWebブラウザからのCGIへのアクセスと対応します。*4
今回はセッション管理を準備していませんが、ユーザのWebブラウザごとにセッションを用意し、GUIアプリケーションのウィンドウのように考えるフレームワークも存在します。

まとめ

この章ではeRubyとその実装ERBを紹介し、dRubyと組み合わせた簡単なCGIを作成しました。
ERBとはどのようなものか感じることができたでしょう。

また、この章の実験では、Reminder、ReminderCGIのプロセス群への配置を3通り実験しましたが、いとも簡単に変更できたことに気付かれたでしょうか?これはdRubyRubyに溶け込んでしまうようにシームレスにデザインされた結果です。オブジェクトのプロセスへの配置の戦略はたくさんあると思いますが、dRubyではその試行錯誤の助けとなるでしょう。

*1:冗長だったら削除

*2:なお、リンクで項目を削除する、というのはWebロボットなどが リンクをたどる際に実行されてしまうかもしれないので危険な仕様です。実際に運用するシステムで採用するのは注意が必要です。

*3:ulによるリスト表示はちょっとかっこわるいですね。かっこ良く直してみて下さい。

*4:イベントの粒度の大小はありますが