(日本先行発売された本を改訂するとしたらごっこ)
この章では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ファイルを出力する可能性もあります。
式の結果を埋め込むだけでなく、制御構文や繰り返しにを埋め込むこともできます。先ほどの例を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スクリプトを返すメソッドなども用意されています。以下によく使われるメソッドのリファレンスを示します。
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と協調する様子を示します。
#!/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インタフェースを作成します。
- 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: <や>はどうなるの?</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: <や>はどうなるの?</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
外観の変更
<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サーバに項目を追加する部分を考えます。
- queryのコマンド種をチェックする。今回は'add'だけ対応する。
- テキストフィールドの文字列を取り出す。
- 文字列があれば、文字列のエンコーディングを正規化し、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としました。
#!/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がどこで発生したか、といった情報が表示されるはずです。
プロセスの配置を変更する
ここでちょっと趣向を変えてCGIとdRubyの変わった使い方を紹介します。プロセス間でオブジェクトの配置を調整して、違った世界を垣間見ます。
現在登場しているプロセスは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アプリケーションのウィンドウのように考えるフレームワークも存在します。