7 もっとWebで使おう
本章では、dRubyを積極的に応用したWebアプリケーションを作成します。 はじめに4章でも説明したeRuby(ERB)の例題を、 よりERB的でdRuby的な作戦で書き直します。 次にDivというWebアプリケーションフレームワークを利用して、 セッション管理を行なうアプリケーションを作成します。
7.1 プロセスの分割とオブジェクトの配置
4章では、eRuby(ERB)を紹介しdRubyと組み合わせて簡単なリマインダを 作成しました。 この節ではERBのさらなる応用 −よりdRubyを積極的に利用する応用− に ついて説明します。
7.1.1 オブジェクトの配置
4章で作成したリマインダをおさらいしながらERBの応用例を見ていきましょう。
ReminderのWebアプリケーションは次のようなオブジェクトで構成されます。
図7.1 Reminder Webアプリケーションの構成
Reminderはリマインダのデータを管理するオブジェクトで、 MVC(Model-View-Controller)パターンではModelに当たる アプリケーション本体です。備忘録の項目を永続的に管理します。
Web UI部分はWebページの記述と操作(リンクやフォームなど)を司ります。 MVCパターンではViewとControllerに相当します。 ViewであるERBはeRubyを利用してページを記述します。 操作をリンクやフォームで表現するために、ERBのスクリプトと ReminderCGIはある程度お互いの詳細をしらなくてはなりません。
[CGI]はCGIのパラメタへのアクセスや、作成したページの出力を担当します。 Rubyに標準添付されるCGIクラスのインスタンスです。 MVCパターンでは登場しませんが、画面やマウス、キーボードの様な 描画デバイスやインプットデバイスと考えることもできます。
通常のCGIスクリプトでは、これらを全て一つのプロセスに配置し、 Reminderをデータベースや外部ファイルなどで実現するのが一般的ですが、 dRubyではオブジェクトを複数のプロセスに配置できるので また違った戦略を選べます。 どのような配置にするかちょっと考えてみましょう。
reminder0-cgi5.rbでは、 常駐するリマインダサーバと、リクエストごとに起動されるCGIの 二つでプロセス構成しました。
図7.2 4章のReminderCGIの配置
このプロセス構成のうれしいところは、リマインダのデータベースを あまり深く考えなくてよいところです。 Reminder本体は、常駐するサーバプロセスに配置されます。 プロセスが常駐することで、データベースなどを使用することなく、 備忘録の項目を永続的なオブジェクトとしています。
CGIプロセスにはWeb UI部分とCGI部分の両方が配置されます。 CGIプロセスはWebブラウザからの要求のたびに生成されるプロセスです。 ERBの生成はCGI側で行なわれているので、 リクエストのたびにeRubyスクリプトからRubyスクリプトへ変換が行なわれています。 この処理は若干もったいない気もしますね。
ここでは、Web UI部分をCGIプロセスから独立させ常駐させる、という作戦を採ります。
図7.3 各部分を別のプロセスに分割しWebユーザインターフェイスも常駐させる。
ReminderとWeb UI部分をそれぞれ常駐プロセスに配置し、 CGIだけをリクエストごとに起動する、あるいは ReminderとWeb UIを一つの常駐プロセスに配置するのです。
reminder0-cgi5.rbをもとに、 CGI部分とWeb UI部分に分割してみましょう。
まずその前に、Reminder本体を起動させましょう。
以下に紹介するreminder02.rbは、6章でMonitorMixinを用いてマルチスレッドセーフと したものです。 ReminderクラスはMonitorMixinをincludeし、 複数のスレッドが進入すると危険な箇所をsynchronizeで囲んで保護しています。
List 7.1 reminder02.rb
# reminder02.rb require 'monitor' class Reminder include MonitorMixin def initialize super @item = {} @serial = 0 end def [](key) @item[key] end def add(str) synchronize do @serial += 1 @item[@serial] = str @serial end end def delete(key) synchronize do @item.delete(key) end end def to_a synchronize do @item.keys.sort.collect do |k| [k, @item[k]] end end end end if __FILE__ == $0 require 'drb/drb' front = Reminder.new DRb.start_service('druby://localhost:12345', front) puts DRb.uri DRb.thread.join end
4章ではReminderのアプリケーション本体はirbによって起動しましたが、 今回のreminder02.rbはRubyスクリプトとして普通に起動できるようにしました。 if __FILE__ ... endの部分がそうです。 Reminderを生成して12345のポートで公開します。 最後のDRb.thread.joinは、プログラムが終了してしまわないようにするための 「待ち」です。 Rubyではメインのスレッドが終了してしまうと、 たとえサブスレッドが処理を行なっていたとしても プロセス全体が終了してしまいます。 irbは対話のスレッドがずっと終了しないので、プロセスは終了しませんが、 このスクリプトではDRb.thread.joinがなければメインスレッドが終了してしまい、 プロセスが終ってしまうのです。
つぎのように起動します。
% ruby -Ke reminder02.rb druby://localhost:12345
次にreminder0-cgi5.rbの分割です。 mainメソッド以外の部分を独立させて、 reminderpage0.rbとします。
# reminderpage0.rb require 'erb' require 'drb/drb' require 'cgi' require 'nkf' 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 class ReminderPage include ERB::Util def initialize(reminder) @reminder = reminder @erb = ERB.new(erb_src) end def script_name(cgi) cgi.script_name end def make_param(hash) hash.collect do |k, v| u(k) + '=' + u(v) end.join(';') end def anchor(cgi, hash) %Q+<a href="#{script_name(cgi)}?#{make_param(hash)}">+ end alias :a :anchor def a_delete(cgi, key) anchor(cgi, {'cmd'=>'delete', 'key'=>key}) end def erb_src <<EOS <% bg = BGColor.new %> <table border="0" cellspacing="0"> <% @reminder.to_a.each do |k, v| %> <tr <%= bg %>> <td><%= k %></td> <td><%=h v %></td> <td>[<%=a_delete(cgi, k)%>X</a>]</td> </tr> <% end %> <form action="<%=script_name(cgi)%>" method="post"> <input type="hidden" name="cmd" value="add" /> <tr <%= bg %>> <td><input type="submit" value="add" /></td> <td><input type="text" name="item" value="" size="30" /></td> <td> </td> </tr> </form> </table> EOS end def to_html(cgi) @erb.result(binding) rescue DRb::DRbConnError %Q+<p>It seems that the reminder server is downed.</p>+ end def kconv(str) NKF.nkf('-edXm0', str.to_s) end def do_add(cgi) item ,= cgi['item'] return if item.nil? || item.size == 0 @reminder.add(kconv(item)) end def do_delete(cgi) key ,= cgi['key'] return if key.nil? || key.size == 0 @reminder.delete(key.to_i) end def do_request(cgi) cmd ,= cgi['cmd'] case cmd.to_s when 'add' do_add(cgi) when 'delete' do_delete(cgi) end end end if __FILE__ == $0 there = DRbObject.new_with_uri('druby://localhost:12345') front = ReminderPage.new(there) DRb.start_service('druby://localhost:12346', front) puts DRb.uri DRb.thread.join end
CGIのインスタンス(の参照)をパラメタとするため、 reminder0-cgi5.rbから若干変更されています。 reminderpage0.rbはつぎのように起動します。
% ruby -Ke reminderpage0.rb druby://localhost:12346
最後にCGIの部分です。 実行時エラーをレポートするUnknownErrorPageとmainメソッドで構成されます。
# reminderpagecgi.rb require 'cgi' require 'erb' require 'drb/drb' class UnknownErrorPage include ERB::Util def initialize(error=$!, info=$@) @erb = ERB.new(erb_src) @error = error @info = info end def erb_src <<EOS <p><%=h @error%> - <%=h @error.class%></p> <ul> <% @info.each do |line| %> <li><%=h line%></li> <% end %> </ul> EOS end def to_html @erb.result(binding) end end def main DRb.start_service reminder = DRbObject.new_with_uri('druby://localhost:12346') cgi = CGI.new('html3') begin reminder.do_request(cgi) content = reminder.to_html(cgi) rescue content = UnknownErrorPage.new($!, $@).to_html end cgi.out({"charset"=>"euc-jp"}) { cgi.html { cgi.head { cgi.title { 'Reminder' } } + cgi.body { content } } } end main
次に紹介するのは、ERBの処理をCGIから常駐するサーバへ移して、 効率をあげようという作戦です。
ERBのメソッド化
ERBはeRubyスクリプトを実行するだけでなく、 eRubyスクリプトをRubyスクリプトに変換する機能を持っています。 ERBの生成したRubyスクリプトをeval()で評価すると、 eRubyスクリプトを処理したStringが手に入ります。 ERBにはeRubyスクリプトをメソッド化するメソッドも用意されています。
次のリストはreminderpage0.rbから クラスReminderPageのERB部分の抜粋です。
List 7.2 reminderpage0.rb抜粋
class ReminderPage def initialize(reminder) @reminder = reminder @erb = ERB.new(erb_src) end def erb_src <<EOS <% bg = BGColor.new %> <table border="0" cellspacing="0"> <% @reminder.to_a.each do |k, v| %> <tr <%= bg %>> <td><%= k %></td> <td><%=h v %></td> <td>[<%=a_delete(cgi, k)%>X</a>]</td> </tr> <% end %> <form action="<%=script_name(cgi)%>" method="post"> <input type="hidden" name="cmd" value="add" /> <tr <%= bg %>> <td><input type="submit" value="add" /></td> <td><input type="text" name="item" value="" size="30" /></td> <td> </td> </tr> </form> </table> EOS end def to_html(cgi) @erb.result(binding) rescue DRb::DRbConnError %Q+<p>It seems that the reminder server is downed.</p>+ end end
このReminderPageでは、initializeでeRubyスクリプトを変換(ERB.new)し to_htmlメソッドで実行しています。 eRubyスクリプトはerb_srcメソッドが返します。
まず、ERBが変換したRubyスクリプトを見てみます。 これは見て面白いものでもなく退屈ですから、見るのは一度きりにしましょう。 変換したスクリプトを見るにはERBのsrcメソッドを使います。
- eRubyスクリプトをerb_srcに覚えて
- ERB.newで生成し、
- puts erb.src で変換されたスクリプトを印字します。
% irb -r erb >> erb_src = <<EOS <% bg = BGColor.new %> <table border="0" cellspacing="0"> <% @reminder.to_a.each do |k, v| %> <tr <%= bg %>> <td><%= k %></td> <td><%=h v %></td> <td>[<%=a_delete(cgi, k)%>X</a>]</td> </tr> <% end %> <form action="<%=script_name(cgi)%>" method="post"> <input type="hidden" name="cmd" value="add" /> <tr <%= bg %>> <td><input type="submit" value="add" /></td> <td><input type="text" name="item" value="" size="30" /></td> <td> </td> </tr> </form> </table> EOS >> erb = ERB.new(erb_src) >> puts erb.src _erbout = ''; bg = BGColor.new ; _erbout.concat "\n" _erbout.concat "<table border=\"0\" cellspacing=\"0\">\n" @reminder.to_a.each do |k, v| ; _erbout.concat "\n" _erbout.concat "<tr "; _erbout.concat(( bg ).to_s); _erbout.concat ">\n" _erbout.concat " <td>"; _erbout.concat(( k ).to_s); _erbout.concat "</td>\n" _erbout.concat " <td>"; _erbout.concat((h v ).to_s); _erbout.concat "</td>\n" _erbout.concat " <td>["; _erbout.concat((a_delete(cgi, k)).to_s); _erbout.concat "X</a>]</td>\n" _erbout.concat "</tr>\n" end ; _erbout.concat "\n" _erbout.concat "<form action=\""; _erbout.concat((script_name(cgi)).to_s); _erbout.concat "\" method=\"post\">\n" _erbout.concat " <input type=\"hidden\" name=\"cmd\" value=\"add\" />\n" _erbout.concat " <tr "; _erbout.concat(( bg ).to_s); _erbout.concat ">\n" _erbout.concat " <td><input type=\"submit\" value=\"add\" /></td>\n" _erbout.concat " <td><input type=\"text\" name=\"item\" value=\"\" size=\"30\" /></td>\n" _erbout.concat " <td> </td>\n" _erbout.concat " </tr>\n" _erbout.concat "</form>\n" _erbout.concat "</table>\n" _erbout
長いスクリプトが印字されたでしょうか。 _erboutという変数に覚えたStringに対して、次々文字列を連結していき、 最後に_erboutを返すスクリプトですね。ERBはこのように実現されています。 ReminderPageのto_htmlメソッドで行なうresult(binding)は、 ReminderPageのインスタンスのスコープで先ほど印字したスクリプトをeval() することと同じです。
def to_html(cgi) @erb.result(binding) rescue DRb::DRbConnError %Q+<p>It seems that the reminder server is downed.</p>+ end
eval()では毎回スクリプトの読み込み(構文の解析)、実行を行ないます。
変換したスクリプトをdef method_name() … endではさんだら、 eRubyスクリプトを実行するメソッドが定義できそうですね。
def build_page(cgi) _erbout = ''; bg = BGColor.new ; _erbout.concat "\n" ... ... _erbout end
繰り返しこのeRubyスクリプトを評価する場合、 メソッドとして定義してしまえば、eval()による実行の構文の解析処理を 省くことができます。
ERBにはeRubyスクリプトをメソッド化するユーティリティ的なメソッド、 def_methodが用意されています。 def_methodは次のように使用します。
erbを実行するメソッドを、modで指定したモジュール/クラスのメソッドとして 定義します。メソッド名と仮引数は methodname で指定します。
erb = ERB.new(script) erb.def_method(MyClass, 'foo(bar)', 'foo.erb')
fnameは省略可能な引数です。エラーが発生した際のバックトレースに 使用されるファイル名を指定します。 上記の例では、MyClassのメソッドfooを定義しています。 fooは一つの引数barをとります。 エラーが発生した際は'foo.erb'というファイル名でバックトレースを 印字します。
ReminderPageのinitializeとto_htmlで行なっているERBの初期化と評価の代わりに、 def_methodを使って書き直してみましょう。 ちょっと慣れない感じですが、ERB.newとdef_methodを行なうのはclass定義の中です。
class ReminderPage ... ERB.new(erb_src).def_method(self, build_page(cgi)') .. end
以下に書き直したreminderpage1.rbを載せます。
# reminderpage1.rb require 'erb' require 'drb/drb' require 'cgi' require 'nkf' 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 class ReminderPage include ERB::Util def initialize(reminder) @reminder = reminder end def script_name(cgi) cgi.script_name end def make_param(hash) hash.collect do |k, v| u(k) + '=' + u(v) end.join(';') end def anchor(cgi, hash) %Q+<a href="#{script_name(cgi)}?#{make_param(hash)}">+ end alias :a :anchor def a_delete(cgi, key) anchor(cgi, {'cmd'=>'delete', 'key'=>key}) end erb_src = <<EOS <% bg = BGColor.new %> <table border="0" cellspacing="0"> <% @reminder.to_a.each do |k, v| %> <tr <%= bg %>> <td><%= k %></td> <td><%=h v %></td> <td>[<%=a_delete(cgi, k)%>X</a>]</td> </tr> <% end %> <form action="<%=script_name(cgi)%>" method="post"> <input type="hidden" name="cmd" value="add" /> <tr <%= bg %>> <td><input type="submit" value="add" /></td> <td><input type="text" name="item" value="" size="30" /></td> <td> </td> </tr> </form> </table> EOS ERB.new(erb_src).def_method(self, 'build_page(cgi)') def to_html(cgi) build_page(cgi) rescue DRb::DRbConnError %Q+<p>It seems that the reminder server is downed.</p>+ end def kconv(str) NKF.nkf('-edXm0', str.to_s) end def do_add(cgi) item ,= cgi['item'] return if item.nil? || item.size == 0 @reminder.add(kconv(item)) end def do_delete(cgi) key ,= cgi['key'] return if key.nil? || key.size == 0 @reminder.delete(key.to_i) end def do_request(cgi) cmd ,= cgi['cmd'] case cmd.to_s when 'add' do_add(cgi) when 'delete' do_delete(cgi) end end end if __FILE__ == $0 there = DRbObject.new_with_uri('druby://localhost:12345') front = ReminderPage.new(there) DRb.start_service('druby://localhost:12346', front) puts DRb.uri DRb.thread.join end
dRubyではオブジェクトを任意のプロセスに配置することが簡単にできます。 CGIではすべてを(多くのことを)実行時に行なう必要がありましたが、 dRubyを使うと何度も使用するオブジェクトを常駐するプロセスに配置することで 前処理の一度だけ行なえば良いようにすることができますし、 排他制御や永続化の問題を単純化することもできます。
Reminderでは、二つのオブジェクトを常駐するプロセスに配置しました。 一つはApplication本体であるReminderです。 データの永続化や排他制御を簡単に記述することができました。 もう一つはこの節で説明してきたeRubyによるページの生成です。 eRubyを事前に変換しておくことで効率を上げる例を説明しました。
交換するオブジェクトの選択
ERBはちょっと忘れて、Reminderの処理の流れをおさらいしてみましょう。
まず、ReminderのCGI部では、CGIクラスのインスタンスを生成して、 httpのリクエストを取得します。 GETでは環境変数、POSTでは標準入力より与えられるクエリの内容をCGI.newで解釈し、 扱いやすいオブジェクトとするのです。
リクエストの情報が詰まっているCGIのインスタンスを、常駐しているプロセスに 配置されたReminderPageに渡し(do_request(cgi)とto_html(cgi))、 ページを組み立てます。 この段階では、まだページはStringのオブジェクト(content)としてのみ存在します。
conentを実際のレスポンスとして出力するために、CGIのインスタンスのoutメソッドを 使います。適切なHTMLのheadやtitleを付加したレスポンスが出力されます。
この流れのなかで交換されるオブジェクトは、リクエストの情報の詰まったCGIと 組み立てたられたコンテンツです。
do_requestとto_htmlに注目してみましょう。 どちらもパラメータはCGIのインスタンスです。 シーケンス図を書いてCGIに関する操作を明らかにしてみましょう。
図7.4 CGIインターフェイスとWebUIサーバの間のメッセージ
do_requestではCGIの[](key)メソッドが何度か呼ばれ、リクエストの変数の内容が 問い合わせられます。to_htmlでは、ページ中のリンクを生成するために script_nameが繰り返し呼ばれます。
リモートメソッド呼び出しが多かったり、交換するオブジェクトが多い場合 パフォーマンスの低下が懸念されます。
ruby-1.6.8のCGIはMarshal不可能であるので、オブジェクトの参照が 渡されます。図中のCGIへの問い合わせは、リモートメソッド呼び出しとなりますから、 問い合わせる変数が多かったり、埋め込むリンクが多い場合などにdRubyの オーバヘッドが問題となるでしょう。
図7.5 CGIインターフェイスとWebUIサーバの間のメッセージ。1.6ではRMIが多数発生する。
これに対して、 ruby-1.8.0のCGIはMarshal可能となったため、WebUIのプロセスへはオブジェクトの 参照ではなくコピーが渡ります。図中のCGIへの問い合わせは全てローカルでの 呼び出しとなるため、オーバヘッドを気にする必要はありません。
図7.6 CGIインターフェイスとWebUIサーバの間のメッセージ。1.8ではRMIが少ない。
いずれのバージョンにも言えることですが、リクエストの処理からページの 組み立てまで、do_requestとto_htmlの二つのリモートメソッド呼び出しが 行われるのはーわかりやすいとは言えー少しもったいないように感じられます。
ReminderPageに次のようなユーティリティメソッドを追加し、 プロセス間でのメソッド呼び出しを減らすというチューニングも 選択肢の一つです。
def do_request_and_build_page(context) do_request(context) to_html(context) end
CGIのプロセスもこれに合わせて次のように修正します。
begin context = reminder.do_request_and_build_page(cgi) rescue content = UnknownErrorPage.new($!, $@).to_html end
複数のリモートメソッド呼び出しをまとめて、一つのメソッドとすることで パフォーマンスを改善できます。この作戦はスクリプトの分かりやすさを 犠牲にすることがありますので、アプリケーションが完成に近付いてから、 あるいはパフォーマンスが問題となってから手を付けると良いでしょう。
コラム - Rubyのバグトラッキングシステムの報告より。
Rubyのバグトラッキングシステムの報告の中で、CGIとdRubyを組み合わせた スクリプトを見つけました。 このスクリプトの作戦は興味深いものでしたので紹介しておきましょう。 CGIのプロセスとWeb UIのプロセスに分割させるのは先ほど説明した Reminderと同様ですが、このスクリプトは、 CGIオブジェクトを参照としてWeb UIに与え、Web UIが直接CGIのoutメソッドを 呼び出すという作戦をとっています。
CGI側 cgi = CGI.new('html3') cgi.extend(DRbUndumped) reminder.do_request_and_build_page(cgi) WebUI側 def do_request_and_build_page(cgi) do_request(cgi) cgi.out({"charset"=>"euc-jp"}) { cgi.html { cgi.head { cgi.title { 'Reminder' } } + cgi.body { to_html(cgi) } } } end
この作戦の特徴は、CGIのコピーではなく参照を与え、 Web UI側からCGIのoutを呼ぶ点です。 outメソッドは与えられたブロックの結果を連結して 標準出力に印字し、HTTPのレスポンスとするメソッドです。 もしも cgi.extend(DRbUndumped) でMarshal不能にせずに 参照でなくオブジェクトのコピーが渡ったとすると、 このoutの結果はCGIのレスポンスではなく、常駐するWeb UI側に 印字されてしまうでしょう。
プロセスの境界に着目したシーケンス図を用意して呼び出しの流れを見てみましょう。
図7.7 CGIを参照渡しにした場合の間のメッセージ
意外なほどリモートメソッド呼び出しが多いのがわかります。 はじめの cgi.out はWebUIからCGIへの呼びだしです。 outメソッドはoutに渡されたブロックを評価しますから、 CGIからWebUIへ呼び出しが発生します。 このあと、cgi.html, cgi.head, cgi.title とブロックからCGIへ、 またブロックへ、と呼び出しが入れ子で繰り返しおきています。 入れ子の呼び出しでは新しいソケットのコネクションを確立するので、 入れ子でない連続した呼び出しよりもパフォーマンスが劣ります。 複雑な呼び出しの入れ子があっても正しく動作するところがdRubyの面白さですが、 実用的なアプリケーションを書こうとする場合には注意が必要です。
この節では複数のプロセスにオブジェクトを配置する作戦について 考えてきました。リクエスト毎に生成されるCGI部分、 常駐しページの生成を担当するWeb UI、 アプリケーション本体で構成し、オブジェクトの交換とチューニングの 一部を説明しました。
Web UI部分を独立させることで、Web UIが状態を持つことができるようになる という点もメリットです。 次は、DivというWeb UIのためのフレームワークを中心に、 状態を持つWeb UIとセッション管理について説明していきます。
7.2 DivとTofu
Reminderはセッション管理のないシンプルなWebアプリケーションでした。 このようなシンプルなアプリケーションは、 レスポンスに引き継ぐべき状態を含めたページを返し、 続くリクエストではその状態を送り返すようにします。 リクエストとリクエストの関連は希薄で、URLを使って状態を引き継いでいくのです。 このため、多数の画面、状態を持つ複雑なwebアプリケーションの構築は苦手です。 これから説明するライブラリ、Divはクッキーを使ったセッション管理と 複雑なWebページ作成を支援するフレームワークです。 歴史的な経緯からDivはDiv::Divというユーザーインターフェイスを司る部分と、 Tofuと呼ばれるセッション管理を行う部分から構成されています。
CGIなどでちょっとしたツールを書いたことのある方ならご存知と思いますが、 一見簡単そうなアプリケーションでも、いざ書こうとするといくつも面倒な 部分に出会ってつまづきます。Divが解決しようとする「面倒な部分」は次の点です。
- 動的な画面の生成が面倒
- フォーム/リンクとアプリケーションの接続が面倒
- 画面の状態の管理が面倒
Divと他のWebアプリケーションフレームワークの大きく違う点は、 DivはWebアプリケーションを作成するためのライブラリではなく、 アプリケーションにWebユーザインターフェイスを与えるための ライブラリである点です。 このため、RDBを使ったオブジェクトの永続化、RDBの検索など しばしばWebアプリケーションフレームワークの一般的な機能と思われている ものもあえて用意されていません。
現在、Divを使ったアプリケーションとして以下のものが存在します。
- divip - ユーザインターフェイスとしてWebを使用するIP Messenger。
- saifu - バックエンドにPostgreSQLを使用する簡単なお小遣い帳。
- hako - Tkを使用して書かれたパズル「箱入り娘」をWebのtableを 使って実現したもの。
- amida - ImageMagickのRuby拡張ライブラリを利用した、インタラクティブな アミダくじ。
saifu、hakoはDivの配布パッケージにサンプルとして付属しています。
7.2.1 Divのセッション管理の戦略
CGIのプロセスはリクエストにより起動され、処理を行い、 終了時にレスポンスを返すというとても短命なものです。 このため、リクエストを越えた状態を持つアプリケーションを作成するには 状態をどこかに保存し、復元する機構が必要となります。
状態を保存するには一般的にデータベースやファイル、クッキーなどいくつかの 戦略が知られています。 本来は(CGIと比較して)長命であるアプリケーションを、 リクエストと同じ寿命の幾世代ものプロセスと、 プロセスの世代間で状態を伝える「遺言」で実現しようとする戦略です。
図7.8 状態の永続化によるWebアプリケーションの実装
この戦略は状態の永続化・復元が必要となり、大げさな仕組みが必要になりがちです。 この辺りに簡単なアプリケーションを書くのを躊躇させる面倒くささがあるかも しれません。
そもそも長い期間に渡って存在するべきなアプリケーションの寿命を、 リクエスト単位に分断してしまうところに問題があるのではないでしょうか。 次に示すアプローチは、アプリケーションとCGIのプロセスを分離して、 アプリケーションをリクエストを越えてサーバとして起動しつづける戦略です。 これまでdRubyを用いてアプリケーションをサーバ化し、アプリケーションとCGIの プロセスを連携させてきました。 これは「遺言」を用いた状態の永続化ではなく、 アプリケーションオブジェクトを本当に長生きさせる戦略と言えます。
図7.9 アプリケーションのサーバ化によるWebアプリケーションの実装
さらによく観察すると、サーバ化したアプリケーションは二つの種類のオブジェクトに 分けることができることに気付きます。 一つはそれぞれのユーザインターフェイスに固有のオブジェクト、 もう一つはアプリケーションそのもののオブジェクトです。 前者はセッションごと(画面ごと)に独立した状態を持つもので、 これまでWeb UI部分と呼んでいた部分です。 後者はユーザインターフェイスとは独立したアプリケーション本来の状態です。
図7.10 ユーザインターフェイスとアプリケーションの分離(divipの注釈を削除しないと‥)
Divが主に支援するのは、前者のWebアプリケーションの ユーザインターフェイス部分です。 こういったことから、DivはWebアプリケーションフレームワークと呼ぶよりも、 Webユーザインターフェイスフレームワークと呼ぶ方が適切かもしれません。
7.2.2 Div::Div
Divの名前はHTMLのdiv要素を由来としています。 小さなHTML片で表現されるWebユーザインターフェイスの部品を 台紙に載せてアプリケーションを開発する様子から、Divという名前をつけました。 Divの中でも、特にこの部品を示す際にDiv::Divと呼びます。 Div::DivはERBによるビューと、リンクやフォームから呼び出されるハンドラを 持っています。
以下はDivを使ったIP Messengerであるdivipの例です。 divipのログインフォームはDipLoginDivというクラスで、 その外観はdip_login.erbで定義されています。
<% unless @dip.server %> <%=form('login', context)%> <p> Phrase: <input type='password' name='phrase' value='' /> <input type="submit" name="login" value="login"/> </p> </form> <% else %> <small>[<%=a('login', {'phrase'=>'(bad phrase)'}, context)%>Logout</a>]</small> <% end %>
リスト中のform('login', ...)やa('login', ...)メソッドは、 このDiv::Divへのハンドラとなるメソッド(do_login)を起動するための フォームやリンクを生成するメソッドです。
class DipLoginDiv < Div::Div set_erb('dip_login.erb') def initialize(session, dip_div) @dip = dip_div super(session) end def do_login(context, params) phrase, = params['phrase'] begin @dip.server = @dip.front.server(phrase) rescue @dip.server = nil @message = "oops! bad pass-phrase." end end end
上に示したスクリプトはDipLoginDivの定義です。 DipLoginDivはERBで生成したフォームやリンクから起動される ハンドラdo_loginを持ちます。 リンクやフォームのリクエストから、適切なDiv::Divのハンドラを 見つけて起動するのはDivのフレームワークの仕事で、 アプリケーションは気にする必要はありません。
Div::Divは別の部品を内側に持つことが可能です。 DipDivはdivipの部品が載る土台となるDiv::Divで、 DipSendDivやDipListDivなど部品を載せています。
図7.11 DipDivのインスタンス階層
図7.12 画面とDiv::Divの対応
7.2.3 Tofu
初期のDivのセッション管理のメカニズムは、CGIに依存していたものでした。 その後、Rubyで記述されたWebサーバであるWEBrickを知り、 Divのセッション管理をどちらからも利用できるように抽象化するレイヤーを 設けました。これがTofuです。 TofuはCGIに依存していたDivのセッション管理機構を、抽象化したものです。
Tofuの名前の由来はWEBrickのBrick(レンガ)からきています。 レンガのような形状でありながら脆いもの、という意味でTofuとなりました。 Tofuは豆腐ですがJavaのBeanとはもちろん無関係です。
Tofuの役目はクッキーを用いたセッションIDとセッションオブジェクトを 関連づけることです。 TofuはWebブラウザからのリクエストを、そのクッキーのセッションIDをキーに 適切なセッションオブジェクトに伝える係です。
図7.13 TofuとDivの役割
Tofuは通常のCGIだけでなく、WEBrickのサーブレットからも利用できるので アプリケーションの変更をせずにhttpdを変更することができます。 同時に両方から利用することも可能です。
Div::Div、Tofuの詳細は後に紹介していきます。
7.2.4 Divのインストール
DivはRubyのみで書かれたクラスライブラリですから、 インストールは簡単です。
アーカイブを展開し、install.rbを実行します。 必要に応じてrootユーザになって作業して下さい。
% tar xzvf div-1.3.0.tar.gz % cd div-1.3.0 % sudo ruby install.rb
7.2.5 First Div
ここから、簡単なアプリケーションを作成しながら、Divの使い方を学んで 行きましょう。
DivはWeb UI部を担当する常駐プロセスとCGI部で構成します。 CGIの代わりに、ruby-1.8.0から標準添付となったWEBrickのサーブレットを 使って構成することもできますが、WEBrickを用いる方法は後で説明します。
これから作るDivのアプリケーションは、 数値を足し算していくごく簡単なアプリケーション、Sumです。 合計する数値は画面に履歴として表示され、直前の値を取り消しすることも可能です。 数値の履歴はブラウザごとに独立しているものとします。 つまり、他のユーザーの入力した数値の並びと、自分の操作している数値の並びは 独立しているということです。 これはDivのセッション管理機構を利用して実現しますが、 特別なコードを準備必要はありません。 Divで自然に記述すれば自動的にセッションごとのWeb UIが実現されます。
Web UI部分は常駐し、druby://localhost:12345というURIでサービスを 提供することとします。
はじめにCGI部分からはじめましょう。 すでにこれまでの章でCGIが起動できるようになっていることと思いますが 大丈夫ですか?
List 7.3 div-cgi.rb
# div-cgi.rb #!/usr/local/bin/ruby require 'cgi' require 'tofu/cgicontext' require 'tofu/proxy' cgi = CGI.new DRb.start_service context = Tofu::CGIContext.new(cgi) bartender = DRbObject.new(nil, 'druby://localhost:12345') bartender.service(Tofu::ContextProxy.new(context))
div-cgi.rbは主にHTTPのリクエストを、Web UIのリクエスト処理、 セッション管理サーバへ回送し、そのレスポンスを出力します。 はじめにリクエストとレスポンスを対にした処理全体を示すコンテキストを 生成します。 Web UIの持つセッション管理を担当するのがバーテンダーというオブジェクトで、 Web UIに配置されます。 druby://localhost:12345 はこのバーテンダーを示すURIです。
CGI版のDivではおそらくどのアプリケーションでもバーテンダーの URIを除きほぼ同じdiv-cgi.rbを使用できます。
div-cgi.rbをあなたの環境(OSやhttpサーバ)に合わせて設置してください。 Web UIの準備ができるまでdiv-cgi.rbは動作しません。 続く手順が終るまでもう少し待って下さい。
次にWeb UIとアプリケーション部のセットアップへと移ります。 今回作成するSumアプリケーション自体がとても単純なものなので Web UIの中でアプリケーションのインスタンスを生成することにします。
Web UIのために用意するファイルは三つです。
- sum-div1.rb − Web UIとアプリケーション部分を配置する常駐プロセス
- sum1.erb − SumアプリケーションのHTML片を生成するeRubyスクリプト
- base1.erb − sum1.erbを載せる台紙となるeRubyスクリプト
はじめに三つのスクリプトを示し、 サンプルアプリケーションを実際に試してから スクリプトの説明にはいります。
List 7.4 sum-div1.rb
# sum-div1.rb require 'div/div' require 'div/tofusession' require 'tofu/proxy' class SumTotal def initialize @history = [] @amount = 0 end attr_reader :history, :amount def add(value) f = value.to_f @history.push(f) @amount += f end def undo tail = @history.pop return unless tail @amount -= tail end end class SumDiv < Div::Div set_erb('sum1.erb') def initialize(session) super(session) @model = SumTotal.new end def do_add(context, params) value, = params['value'] @model.add(value) end def do_reset(context, params) @model = SumTotal.new end def do_undo(context, params) @model.undo end end class BaseDiv < Div::Div set_erb('base1.erb') def initialize(session) super(session) @contents = [] @contents.push(SumDiv.new(session)) end end class YourTofuSession < Div::TofuSession def initialize(bartender, hint=nil) super(bartender, hint) @base = BaseDiv.new(self) end def do_GET(context) update_div(context) context.res_header('content-type', 'text/html; charset=euc-jp') context.res_body(@base.to_html(context)) end end if __FILE__ == $0 tofu = Tofu::Bartender.new(YourTofuSession) DRb.start_service('druby://localhost:12345', tofu) DRb.thread.join end
List 7.5 sum1.erb
# sum1.erb <%=form('add', context)%> <table> <% @model.history.each do |v| %> <tr><td> </td><td align='right'><%=h v%></td><td> </td></tr> <% end %> <tr><th>total</th><th align='right'><%=h @model.amount%></th><td> </td></tr> <tr> <th align='right'>add</th> <th><input size="10" type="text" name="value" value="" /></th> <th><input type="submit" name="Add" value="Add"/></th> </tr> <tr><td align="right" colspan="3"><%=a('undo', {}, context)%>undo</a></td></tr> <tr><td align="right" colspan="3"><%=a('reset', {}, context)%>reset</a></td></tr> </table> </form>
List 7.6 base1.erb
# base1.erb <html> <head> <title>First App</title> </head> <body> <h1>First App</h1> <% @contents.each do |div| %> <%= div.to_div(context) %> <% end %> </body> </html>
これら三つ(sum-div1.rb, sum1.erb, base1.erb)のファイルは同じ ディレクトリに置いて下さい。 sum-div1.rbとdiv-cgi.rbはdRubyを用いてのみ通信しますから、 これらのファイルをCGIのプロセスからアクセス(read)できる場所に 置く必要はありません。
ファイルの配置の一例を示します。
- /home/httpd/cgi-bin/div-cgi.rb
- /home/yourname/develop/first_div/sum-div1.rb
- /home/yourname/develop/first_div/sum1.erb
- /home/yourname/develop/first_div/base1.erb
それではWeb UIのプロセスを起動しましょう。
% ruby -dv -Ke sum-div1.rb
続いてWebブラウザからdiv-cgi.rbを表示させてみてください。 次のような画面が表示されたでしょうか?
図7.14 First Appの様子
フォームに数値を入力して[Add]すると数値が追加されていくと思います。 また、undoで最後に入力した数値を取り消したり、 resetですべての数値を削除したりできるはずです。
もしWebブラウザが複数インストールされている場合、 あるいは複数のPCが準備できる場合には、それぞれ異なるブラウザから CGIにアクセスしてみてください。 それぞれのブラウザから行なうSumアプリケーションへの操作が、 他のブラウザに影響しないことを確認してください。
sum-div1.rb、sum1.erb、base1.erbのスクリプトを眺めていきましょう。
sum-div1.rbがメインのスクリプトでロジック等を持ち、 他の二つのeRubyスクリプトは画面の生成を担当するスクリプトです。
SumTotalクラスはアプリケーションに相当します。 追加された数値の履歴(@history)と合計(@amount)を管理するクラスで、 数値の追加、最後の追加の取り消し操作と、 数値の合計、追加の履歴の問合せができます。 とても短いクラスですから、読み切れますよね。
SumDivとBaseDivは画面の部品(HTMLの断片)を生成するクラスです。 どちらもDiv::Divクラスを継承しています。 Div::DivはクラスライブラリDivの提供するクラスで 画面の生成とリンクやフォームなどユーザからのアクションを管理します。 Divでは、基本的にDiv::Divのサブクラスに画面の生成方法と操作のハンドラを 用意することでWeb UIを記述してます。
SumDivはSumTotalのユーザインターフェイスを担当するDiv::Divです。 アプリケーション本体となるSumTotalのインスタンス(@model)を一つ持ちます。
class SumDiv < Div::Div set_erb('sum1.erb') ...
set_erb()によって、画面を生成するeRubyスクリプトを与えます。 これはSumDivのクラス定義のコンテキストの中で実行される式です。 set_erbによって sum1.erb に記述したeRubyスクリプトが SumDivのインスタンスメソッドとして定義されます。
sum1.erbは主にSumTotalのインスタンスの持つ数値と合計と、 数値の追加、undo、resetなどのフォーム/リンクの印字を行ないます。 sum1.erbはSumDivのインスタンスのメソッドとして動作しますから、 @modelなどのインスタンス変数にアクセスすることができます。
formやaといったメソッドはDiv::Divに定義されているユーティリティメソッドです。 Div::Divがユーザのアクションに反応できるようなフォームやリンクを 簡単に生成するためのメソッドです。
以下はundo、resetのためのリンクを生成するスクリプトです。
<tr><td align="right" colspan="3"><%=a('undo', {}, context)%>undo</a></td></tr> <tr><td align="right" colspan="3"><%=a('reset', {}, context)%>reset</a></td></tr>
a('undo', {}, context)がaメソッドの呼び出しです。 aメソッドのパラメータは三つです。 前から順に、コマンド、コマンドへのパラメータ、現在のリクエスト/レスポンスを 示すコンテキストです。 このリンクがアクセスされると、コマンドはDiv::Divへの「'do_' + コマンド名」と いうメソッドへの呼び出しに変換されコールバックます。 コールバックには、コンテキストとCGIの変数が渡されます。
a()メソッドの第二引数は生成するリンクに含めるCGIの変数を指定します。 a()で生成したリンクをアクセスする限り、第二引数に渡した内容がコールバック時に 渡されるのですが、ユーザがURLを意図的に/事故で変更してしまう可能性も ありますから、同じ内容が渡ると信じてはいけません。
SumDivではdo_add、do_reset、do_undoの三つのメソッドを定義しています。
class SumDiv < Div::Div ... def do_add(context, params) value, = params['value'] @model.add(value) end def do_reset(context, params) @model = SumTotal.new end def do_undo(context, params) @model.undo end end
a('undo', {}, context)で生成したリンクへのアクセスは、 do_undoメソッドへと変換されSumDivのインスタンスのdo_undoが呼び出されます。 do_undoの引数には現在の処理のコンテキストと、CGIのパラメータが渡ります。 do_undoの実装は簡単なもので、アプリケーションのundoメソッドを呼ぶだけです。
def do_undo(context, params) @model.undo end
form()メソッドによるフォームの生成も同様です。
<%=form('add', context)%>
で生成したフォームがsubmitされるとき、do_add()メソッドが呼び出されます。 do_addメソッドでは、パラメータ'value'を取り出し、SumTotalにaddします。
def do_add(context, params) value, = params['value'] @model.add(value) end
BaseDivを見てみましょう。 BaseDivはSumDivを載せる台紙となるDivです。
def initialize(session) super(session) @contents = [] @contents.push(SumDiv.new(session)) end
ページの「具」となるSumDivを生成して@contentsに保持します。 BaseDivのビューとなるeRubyスクリプトはbase1.erbです。 base1.erbはもっとも外側のDivとなるので、<html>から記述されています。 <body>の中は次のように
<body> <h1>First App</h1> <% @contents.each do |div| %> <%= div.to_div(context) %> <% end %> </body>
@contentsを順にto_divメソッドでHTML片に変換して挿入します。
いまは@contentsがSumDivのインスタンスが一つ入っているだけですから、 一つだけSumDivが表示されることになります。 @contentsに要素を増やしてどうなるか実験してみましょう。 BaseDivにさらに二つのSumDivを追加し、sum-div1.rb を再起動して どのように表示されるか試して下さい。
class BaseDiv < Div::Div ... def initialize(session) super(session) @contents = [] @contents.push(SumDiv.new(session)) @contents.push(SumDiv.new(session)) @contents.push(SumDiv.new(session)) end
ページの中に三つのSumDivが表示されましたか? それぞれの三つのDivは独立しているので、それぞれの状態を持つことができます。 SumDivに数値を追加するなど操作をして、一つのSumDivへの操作が他に影響しない ことを確認して下さい。
今度は同じSumDivのインスタンスを三つにしてみます。
class BaseDiv < Div::Div ... def initialize(session) super(session) @contents = [] sum_div = SumDiv.new(session) @contents.push(sum_div) @contents.push(sum_div) @contents.push(sum_div) end
同じインスタンスですから、同じものが三つ表示されることになります。 SumDivを操作してどれも同じように変化していくことを確認して下さい。
7.2.6 Div::Div more.
Divのユーザーインタフェースとしての側面を見ていきます。
BaseDivにSumDivではない別のDivを書いて追加してみましょう。 簡単な信号機を書いてみます。 これはユーザーインタフェースとしてのDivの機能を確認するもので、 これといった機能はありません。 この信号機(SignalDiv)には赤と青の状態があり、Webページから変更できます。 SignalDivの定義は次の通りです。sum-div1.erbに追加してください。
class SignalDiv < Div::Div set_erb('signal.erb') def initialize(session) super(session) @blue = false end def do_change(context, params) @blue = ! @blue end def blue? @blue end def red? ! @blue end def blue_bgcolor if blue? "bgcolor='#0000ff'" else "bgcolor='#ddddff'" end end def red_bgcolor if red? "bgcolor='#ff0000'" else "bgcolor='#ffdddd'" end end end
ビューは次のeRubyスクリプトとなります。
List 7.7 signal.erb
# signal.erb <table border='1' cellspacing='0'> <tr> <td <%=red_bgcolor%>> </td> <td <%=blue_bgcolor%>> </td> </tr> <tr> <td colspan='2' align='center'> <%=a('change', {}, context)%>change</a> </td> </table>
BaseDivを次のように変更して、SignalDivを混ぜてみましょう。
class BaseDiv < Div::Div ... def initialize(session) super(session) @contents = [] @contents.push(SumDiv.new(session)) @contents.push(SignalDiv.new(session)) end
どうですか?SumDivと一緒にtableで表現したSignalDivが表示されているでしょうか。 [change]のリンクをたどって、信号機の赤と青を切り替えてみてください。
図7.15 信号を追加したFirst Appの様子
複数のSignalDivを組み合わせて、交差点にしてみましょう。
class CrossRoad < Div::Div set_erb('crossroad.erb') def initialize(session) super(session) @signal_vert = SignalDiv.new(session) @signal_hori = SignalDiv.new(session) end end
CrossRoadのビューは次の通りです。
List 7.8 crossroad.erb
# crossroad.erb <table border='0' cellspacing='0'> <tr> <td> </td> <td><%=@signal_vert.to_div(context)%></td> <td> </td> </tr> <tr> <td><%=@signal_hori.to_div(context)%></td> <td> </td> <td><%=@signal_hori.to_div(context)%></td> </tr> <tr> <td> </td> <td><%=@signal_vert.to_div(context)%></td> <td> </td> </tr> </table>
BaseDivのinitializeを変更して、CrossRoadを表示してみましょう。
class BaseDiv < Div::Div ... def initialize(session) super(session) @contents = [] @contents.push(SumDiv.new(session)) @contents.push(CrossRoad.new(session)) end
十字に配置されたSignalDivが並んでいると思います。 向かい合ったSignalDivは同じDiv::Divですから、一方を変更すると もう一方も変化します。
図7.16 信号をさらに追加し交差点となったFirst Appの様子
このままでは、交差する道路の信号がともに青になってしまいます。 SignalDivとCrossRoadDivを協調させ、交差する道路が同時に青にならない ように変更しましょう。
今回はSignalDivはCrossRoadDivに、青になれるか試してもらうようにします。 青になれないときはなにも変化しません。 SignalDivにCrossRoadDivを紹介するためにinitializeに引数を追加します。
class SignalDiv < Div::Div set_erb('signal.erb') def initialize(session, cross_road) super(session) @blue = false @cross_road = cross_road end
do_chaneメソッドで青になる場合(現在が赤の場合)には、CrossRoadDivに 青にしてもらうようにします。
def do_change(context, params) if red? @cross_road.try_blue(self) else @blue = false end end def set_blue @blue = true end ...
CrossRoadDivはSignalDivに自身を与えます。 try_blueメソッドが同時呼ばれても破綻しないように保護するMutexを準備します。
class CrossRoad < Div::Div set_erb('crossroad.erb') def initialize(session) super(session) @mutex = Mutex.new @signal_vert = SignalDiv.new(session, self) @signal_hori = SignalDiv.new(session, self) end def try_blue(signal_div) @mutex.synchronize do return if @signal_vert.blue? return if @signal_hori.blue? signal_div.set_blue end end end
try_blueメソッドは誰も青でなければ、引数のSignalDivを青に変更します。
今度の交差点は全てが赤、あるいは一組だけが青になるように調整されました。 sum-div1.rbを再起動して確かめてください。
GUI部品としてのDiv::Divの使用例を示しました。 Divの配布パッケージには古典的なパズルゲーム「箱入り娘」を Webアプリケーションとして実装したサンプルが同梱されています。 「箱入り娘」は一つのスクリプトでRuby/Tk版とDivによるWebアプリケーション版の 二つのGUIをサポートしています。 DivのGUI部品としての可能性を示すサンプルとなっています。
7.2.7 セッション管理
Divは主に二つのCookieを使ってセッションを管理しています。 一つは有効期間が短い、画面を維持するためのCookie、 もう一つはユーザがセッションを再開するためのヒントとするCookieです。 前者はDivのセッションが特定のブラウザと関連していることを管理しています。 ユーザがDiv::Divの部品に配置されたリンクへアクセスしたものが、 適切なDiv::Divへのメソッド呼び出しに変換できるのは前者のCookieのおかげです。 後者のヒントは、前者の有効期限が切れた後で再びサイトに訪れた際に 前回の状態に復帰するための情報となります。 例えば、ログインの画面に前回のユーザ名を表示する、といった機能に使用できます。
ログインとセッション
Divの管理するセッションはあくまでも画面、ブラウザの状態を維持するもので、 いわゆるログインの操作とDivのセッションは独立しています。 Divでは、セッションの一つの状態としてログインがある、と考えます。 そうは言っても、Webアプリケーションではログイン処理はしばしば現れるもの ですからDivでも簡単なサポートクラスが用意してあります。
require 'div/login'
とすると、二つのクラス、Div::LoginとDiv::LoginDivが定義されます。 Div::Loginはユーザ名とパスフレーズ(パスワード)を検査する機能と、 現在、ログインしているユーザ名を保持します。 Div::Loginはそのセッションのログインの状態を管理するものですから、 セッションごとに生成する必要があります。
Div::Loginの主なメソッドを紹介します。
Login#get_user(user, pass)
-
ユーザ名とパスフレーズの組み合わせを検査して、正当な組み合わせなら ユーザを示すオブジェクトを返します。 もしも不正なユーザ名/パスフレーズであればnilを返します。 Loginクラスではいつでもnilを返すメソッドが定義されていますから、 アプリケーションはこのメソッドを上書きしてください。
Login#login(user)
-
ユーザuserとしてログインします。
Login#user
-
ユーザを返します。
Login#name
-
ユーザをto_sした文字列を返します。
Login#login?
-
ログインしているか調べます。
Login#logout
-
ログアウトします。
Login#guest_login
-
ゲストアカウントでログインします。
Login#guest?
-
ゲストアカウントでログインしているかどうかを調べます。
Div::LoginDivはDiv::Loginに対するユーザインターフェイスです。
LoginDiv.new(session, db, hint)
-
Divのセッション、Loginオブジェクト、ヒント情報をパラメータに LoginDivを生成します。
LoginDiv#to_args(params)
-
CGIのメタ変数から'user'と'pass'の値を取り出します。 user, passの順に返します。
LoginDiv#do_login(context, params)
-
loginします。
LoginDiv#do_logout(context, params)
-
logoutします。
LoginDiv#do_guest(context, params)
-
ゲストでログインします。
Div::LoginDivにはeRubyスクリプトが設定されていません。 あなたのアプリケーションで定義する必要があります。 以下の約束事に従ってeRubyを記述してください。
- ユーザ名、パスフレーズのメタ変数の名前は'user'、'pass'とする。
- ログイン、ログアウトのコマンド名は'login', 'logout'
- ゲストログインのコマンド名は'guest'
LoginDivのビューとなるeRubyスクリプトの簡単な例をあげます。 次のeRubyスクリプトではログイン状態ではログアウトするリンクを表示し、 非ログイン状態ではログインフォームを表示します。
<% if @login.login? %> <table border="0" width="100%" cellpadding="0" cellspacing="0" bgcolor="#eeeedd"> <tr align="center"> <td><%=h @login.name %> <%=a 'logout', {}, context %>Logout</a></td> </tr> </table> <% else %> <%= form('login', context) %> <table border="0" width="100%" cellpadding="0" cellspacing="0" bgcolor="#eeeedd"> <tr> <th align="right">user</th> <td><input size="8" type="text" name="user" value="<%=h @hint %>"/></td> </tr> <tr> <th align="right">password</th> <td><input type="password" name="pass" size="8"/></td> </tr> <tr align="center"> <td colspan="2"><input type="submit" value="Login" /></td> </tr> </table> </form> <% end %>
3回続けてログインに失敗すると、ゲストアカウントでのログインを促し、 さらに失敗するとゲストアカウントでログインするDivを書いてみましょう。
List 7.9 countlogin.rb
# countlogin.rb require 'div' require 'div/login' class CountLoginDiv < Div::LoginDiv set_erb('countlogin.erb') def initialize(session, model, hint) super(session, model, hint) @retry_count = 0 end attr_reader :retry_count def do_login(context, params) super(context, params) if @login.login? @retry_count = 0 else @retry_count += 1 end @login.guest_login if @retry_count > 3 end def do_logout(context, params) super(context, params) @retry_count = 0 end end
do_loginとdo_logoutの二つのメソッドをオーバーライドして、 ログインに失敗した回数を数えてるようにしました。 試行回数が3回を超えると、ゲストでログインします。
CountLoginDivのビューの定義は次のようになります。 体裁はみなさんの自由に変更してください。
List 7.10 countlogin.erb
# countlogin.erb <% if @login.login? %> <table border="0" width="100%" cellpadding="0" cellspacing="0" bgcolor="#eeeedd"> <tr align="center"> <td><%=h @login.name%> <%=a 'logout', {}, context %>Logout</a></td> </tr> </table> <% else %> <%= form('login', context) %> <table border="0" width="100%" cellpadding="0" cellspacing="0" bgcolor="#eeeedd"> <% case(retry_count) when 0 %> <caption>Login</caption> <% when 1..2 %> <caption>Login: <%= retry_count %> </caption> <% else %> <caption>oops! <%=a 'guest', {}, context%>login guest</a></caption> <% end %> <tr> <th align="right">user</th> <td><input size="8" type="text" name="user" value="<%=h @hint %>"/></td> </tr> <tr> <th align="right">password</th> <td><input type="password" name="pass" size="8"/></td> </tr> <tr align="center"> <td colspan="2"><input type="submit" value="Login" /></td> </tr> </table> </form> <% end %>
CountLoginDivを試すためのメイン部を示します。 先ほどのsum-div1.rbと同様に起動して実験してください。 hintメソッドを定義して、セッションのヒント情報として ログインしているユーザ名を残すようにしています。 ログインしたままでWebブラウザを終了し、 もう一度CountLoginDivを試すと、ユーザ名のデフォルトに ログインしたままにしたユーザ名が表示されると思います。
SimpleLoginクラスはDiv::Loginのサブクラスで、 サンプルプログラム用のデータベースです。
@db = {'foo' => 'foo00', 'bar' => 'bar00'}
このように擬似的なデータを使って認証します。 実アプリケーションでは修正する必要があります。
List 7.11 login_test.rb
# login_test.rb require 'div' require 'div/login' require 'countlogin' class SimpleLogin < Div::Login def initialize super @db = {'foo' => 'foo00', 'bar' => 'bar00'} end def get_user(user, pass) return nil if user.nil? || pass.nil? return nil unless @db[user] == pass user end end class BaseDiv < Div::Div set_erb('login_base.erb') def initialize(session) super(session) @contents = [] @contents.push(CountLoginDiv.new(session, session.login, session.hint)) end end class YourTofuSession < Div::TofuSession def initialize(bartender, hint=nil) super(bartender, hint) @login = SimpleLogin.new @base = BaseDiv.new(self) end attr_reader :login def hint if @login.login? && !@login.guest? @login.name else super end end def do_GET(context) update_div(context) context.res_header('content-type', 'text/html; charset=euc-jp') context.res_body(@base.to_html(context)) end end if __FILE__ == $0 tofu = Tofu::Bartender.new(YourTofuSession) DRb.start_service('druby://localhost:12345', tofu) DRb.thread.join end
login_base.erbは次のようにしました。 SumDivで紹介したbase1.erbをコピーして使用してもかまいません。
List 7.12 login_base.erb
# login_base.erb <html> <head> <title>login_base.erb</title> </head> <body> <% @contents.each do |div| %> <h1><%=h div.class %></h1> <%= div.to_div(context) %> <hr /> <% end %> <h1>Inspect</h1> <ul> <li>@session.login.login? = <%=@session.login.login?.inspect%></li> <li>@session.login.user = <%=@session.login.user.inspect%></li> <li>@session.login.name = <%=@session.login.name.inspect%></li> <li>@session.login.guest? = <%=@session.login.guest?.inspect%></li> </ul> </body> </html>
セッションの有効期限とヒント
セッションのCookieの有効期限はデフォルトで一日、 ヒントのCookieの有効期限は60日です。
セッションの有効期限はDiv::TofuSessionのインスタンスの寿命とほぼ同じです。 Div::TofuSessionの有効期限はexpiresメソッドの返す時刻によって決まります。 アプリケーションが有効期限を変更したい場合には、expiresメソッドを オーバーライドします。
セッションの寿命を延長するExtendDivを書いてちょっと実験してみましょう。
class ExtendDiv < Div::Div set_erb('extend.erb') def do_extend(context, params) min ,= params['min'] return if min == '' min = min.to_i rescue 0 @session.extend = min * 60 end end
do_extendメソッドはメタ変数'min'で渡される延長する「分」を @sessionに設定します。
YourTofuSessionもこれにあわせて変更します。 @extendというインスタンス変数を追加して、アクセサを用意します。
class YourTofuSession < Div::TofuSession def initialize(bartender, hint=nil) super(bartender, hint) @login = SimpleLogin.new @base = BaseDiv.new(self) @extend = 5 * 60 # 追加 end attr_reader :login attr_accessor :extend # 追加 def expires Time.now + @extend end ... end
BaseDivにExtendDivを追加します。
class BaseDiv < Div::Div def initialize(session) super(session) @contents = [] @contents.push(CountLoginDiv.new(session, session.login, session.hint)) @contents.push(ExtendDiv.new(session)) end end
ExtendDivのビューとなるextend.erbを書きます。 このビューでは0分、1分、5分の延長時間を設定することができます。
List 7.13 extend.erb
# extend.erb <%=form('extend', context) %> Extend: <select name='min'> <% extend = @session.extend / 60 ary = [0, 1, 5] ary.each do |m| if m == extend %><option selected><%= m%></option><% else %><option><%= m%></option><% end end %> </select> min. <input type="submit" /> </form>
修正、追加が完了したらlogin_test.rbを再起動して実験してみましょう。 ExtendDivという見出しの下に0、1、5分間の設定ができるフォームが 表示されます。ログインしたまま有効期限がすぎた後で、 リロードすると最後のログインのユーザ名がログインのフォームに 表示されると思います。 1分も待てないひとは0分に設定してブラウザを再起動すればすぐに 効果を確認できるでしょう。
7.2.8 dCal
最後にログイン操作のあるちょっと複雑で実用的なアプリケーションを 紹介しましょう。 このアプリケーションは個人の作業予定を管理するカレンダーで、 簡単なメモと見積もり時間、実際にかかった時間を 記録することができます。
tableと背景色を使ったシンプルな表エディタを持ち、 DivのGUI部品の応用例となっています。 また、簡易な認証の仕組みを持ち、ユーザごとに個別に管理された データを表示することができます。 認証に必要なユーザ名/パスフレーズのデータや、 カレンダーのデータファイルの保存はMarshalを用います。
それではメインのスクリプト、ビューから順に見ていきましょう。
List 7.14 divcal.rb
# divcal.rb require 'div' require 'div/login' require 'singleton' require 'date' require 'nkf' require 'fake_login' module DivCal $login_db = FakeLogin.new('fake_login.dat') class Login < Div::Login def get_user(user, phrase) $login_db.get_user(user, phrase) end end class Event def initialize(text=nil, estimate=nil, actual=nil) @text = text @estimate = estimate @actual = actual end attr_accessor :text, :estimate, :actual def update(hash) @text = hash[:text] if hash.include?(:text) @estimate = hash[:estimate] if hash.include?(:estimate) @actual = hash[:actual] if hash.include?(:actual) end def ==(other) return false unless self.class == other.class @text == other.text && @actual == other.actual && @estimate == other.estimate end end class EventTable def initialize @event = {} end attr_reader :event def store(date, event) @event[date.to_s] = event end def delete(date) @event.delete(date.to_s) end def fetch(date) @event[date.to_s] end def query_month(year, month) head = Date.new(year, month) tail = (head >> 1) - 1 ary = [] query(head, tail) do |date, event| ary.push([date, event]) end ary end def query(head, tail) head.step(tail, 1) do |date| yield(date, fetch(date)) end end def save(fname) tmp_fname = fname + '.tmp' File.open(tmp_fname, 'w') do |fp| fp.write(Marshal.dump(@event)) end File.rename(tmp_fname, fname) rescue end def load(fname) File.open(fname) do |fp| @event = Marshal.load(fp.read) end rescue end end class EventDB include Singleton def initialize @table = {} end def [](name) @table[name] ||= load(name) end def filename(str) s = str.gsub(/([^a-zA-Z0-9_-])/n){ sprintf("%%%02X", $1.unpack("C")[0]) } s + '.dat' end def save(name) @table[name].save(filename(name)) end def load(name) @table[name] = EventTable.new @table[name].load(filename(name)) @table[name] end end class Front include MonitorMixin def initialize super @db = EventDB.instance end def query_month(name, year, month) synchronize do table = @db[name] table.query_month(year, month).collect do |event| event end end end def fetch(name, date) synchronize do table = @db[name] table.fetch(date) end end def delete(name, date) synchronize do table = @db[name] table.delete(date) @db.save(name) end end def update(name, date, hash) synchronize do event = fetch(name, date) event = entry(name, date) unless event event.update(hash) @db.save(name) end end private def entry(name, date) synchronize do table = @db[name] event = Event.new(date) table.store(date, event) event end end end class DivCalSession < Div::TofuSession def initialize(bartender, hint=nil) super(bartender, hint) @login = Login.new @base = BaseDiv.new(self) end attr_reader :login def hint if @login.login? && !@login.guest? @login.user else super end end def do_GET(context) update_div(context) context.res_header('content-type', 'text/html; charset=euc-jp') context.res_body(@base.to_html(context)) end def kconv(str) NKF.nkf('-e', str.to_s) end end class LoginDiv < Div::LoginDiv set_erb('login.erb') end class BaseDiv < Div::Div set_erb('base.erb') def initialize(session) super(session) @cal = DivCalDiv.new(session) @login = LoginDiv.new(session, session.login, session.hint) end end class DivCalDiv < Div::Div set_erb('divcal.erb') class BGAttr def initialize(colors = ["#eeeeee", "#ffffff"]) @colors = colors @cur = -1 end def succ @cur = (@cur+1) % @colors.size end def to_s succ "bgcolor=\"#{@colors[@cur]}\"" end end def initialize(session) super(session) @cal = Front.new @div_seq = self.object_id.to_i @curr = Date.today end def query @cal.query_month(user, @curr.year, @curr.month) end def each empty_event = Event.new query.each do |date, event| yield(date, event || empty_event) end end def user return nil unless @session.login.login? @session.login.user end def to_args(param) date ,= param['date'] text ,= param['text'] estimate ,= param['estimate'] actual ,= param['actual'] args = {} args[:date] = Date.parse(date.to_s) rescue nil args[:text] = @session.kconv(text) if text if estimate.to_s.size > 0 args[:estimate] = estimate.to_f rescue nil end if actual.to_s.size > 0 args[:actual] = actual.to_f rescue nil if actual end args end def do_delete(context, param) args = to_args(param) @cal.delete(user, args[:date]) rescue end def do_update(context, param) args = to_args(param) @cal.update(user, @curr, args) rescue end def do_detail(context, param) args = to_args(param) @curr = args[:date] if args[:date] end end end if __FILE__ == $0 tofu = Tofu::Bartender.new(DivCal::DivCalSession) DRb.start_service('druby://localhost:12345', tofu) DRb.thread.join end
divcal.rbはメインのスクリプトです。これをDivのサーバとして 起動します。 データを管理するクラス群と、Div::DivとしてGUIを司るクラス群が 記述されています。
DivCal::Loginクラスは、Div::Loginのサブクラスで 後で説明するFakeLoginクラスを用いたユーザ認証機能を提供します。
DivCal::Eventクラスは予定を表し、DivCal::EventTableによって管理されます。 DivCalでは予定は一日に一つだけ持てるようになっています。 指定した月の予定の一覧を検索することができます。 EventTableはMarshalを利用してデータをファイルに保存します。
DivCal::EventDBはユーザ毎のEventTableを管理するクラスです。 EventTableはユーザごとに別々のインスタンスが用意されます。 ユーザ名とEventTableを関連づけるのがEventDBの仕事です。
DivCal::FrontはEventDBのフロントエンドとなるクラスです。 MonitorMixinを用いてスレッド間の同期をとることで、 複数アクセスによるデータの破壊を防ぎます。 DivCalのアプリケーション部分とユーザインターフェイス部分を dRubyを用いて分割する際には、Frontはその境界となります。
ここまでがDivCalのアプリケーション部分、これから説明するのが ユーザインターフェイス部分です。
DivCal::DivCalSessionはDiv::TofuSessionを継承した Divのセッションです。hintメソッドを定義してCookieにヒントを 残せるようにしました。
DivCal::LoginDivはDiv::LoginDivのサブクラスで、実際には 'login.erb'をロードするだけです。
DivCal::BaseDivはログイン用のDivと予定を表示/編集するDivの 二つのDivを載せた土台となるDivです。 BaseDivのビューであるbase.erbを示します。
List 7.15 base.erb
# base.erb <html> <% if @session.login.login? %> <head> <title>dCal</title> </head> <body> <div align="right"> <table border="0" cellpadding="0" cellspacing="0" width="80%" bgcolor="#eeeedd"> <tr><td align="right"><%= @login.to_div(context) %></td></tr> </table> </div> <h1>dCal</h1> <%=@cal.to_div(context)%> <% else %> <head> <title>dCal / Login</title> </head> <body> <%=@login.to_div(context)%> <% end %> </body> </html>
DivCal::DivCalDivクラスは、カレンダーのデータを表示/編集するDivです。 tableと背景色の切り替えを利用した表エディタを持ちます。 DivCalDivのビュー、divcal.erbを示します。
List 7.16 divcal.erb
# divcal.erb <% highlight = 'bgcolor="#ddddaa"' if user bg = BGAttr.new found = {} estimate = 0.0 actual = 0.0 %> <h2><%=h user %></h2> <table border="0" cellpadding="0" cellspacing="0" width="100%" <%=highlight%>> <tr> <td valign="top" bgcolor="white"> <table border="0" cellpadding="0" cellspacing="0" width="100%"> <% each do |date, event| estimate += event.estimate if event.estimate actual += event.actual if event.actual if date == @curr found = event bg.succ bgattr = highlight else bgattr = bg end %> <tr <%=bgattr%>> <td> [<%=a('delete', {'date'=>date.to_s}, context)%>X</a>] <%=h date.to_s %> </td> <td><%=h event.text %></td> <td align="right"><%=h event.estimate %></td> <td align="right"><%=h event.actual %></td> <td align="center">[<%=a('detail', {'date'=>date.to_s}, context)%>>></a>]</td> </tr> <% end %> <tr <%=bg%>> <td colspan="2">total:</td> <td align="right"><%=h estimate %></td> <td align="right"><%=h actual %></td> <td></td> </tr> </table> </td> <td valign='top'> <%=form('update', context)%> <input type="hidden" name="key" value="<%=h @curr.to_s %>" /> <table border="0" cellpadding="0" cellspacing="0" width="100%" <%=highlight%>> <tr><th colspan="2">edit</th></tr> <tr> <th>text</th> <td> <input size="24" type="text" name="text" value="<%=h found.text %>" /> </td> </tr> <tr> <th>estimate</th> <td> <input size="24" type="text" name="estimate" value="<%=h found.estimate %>" /> </td> </tr> <tr> <th>actual</th> <td> <input size="24" type="text" name="actual" value="<%=h found.actual %>" /> </td> </tr> <tr> <td align="center" colspan="2"> <input type="submit" value="Update" /> </td> </tr> </table> </td> </tr> </table> <% end %>
説明を省いたFakeLoginを説明します。 FakeLoginはMarshalとMD5を用いて作成したユーザ名/パスフレーズの組を 管理して、ユーザ認証を行うクラスです。
List 7.17 fake_login.rb
# fake_login.rb require 'digest/md5' class FakeLogin def initialize(fname) @fname = fname @db = {} load end def set_phrase(user, phrase) load @db[user.to_s] = Digest::MD5.hexdigest(phrase.to_s) save end def get_user(user, phrase) load if @db[user.to_s] == Digest::MD5.hexdigest(phrase.to_s) user else nil end end def save tmp_fname = @fname + '.tmp' File.open(tmp_fname, 'w') do |fp| fp.write(Marshal.dump(@db)) end File.rename(tmp_fname, @fname) end def load File.open(@fname) do |fp| @db = Marshal.load(fp.read) end rescue end end if __FILE__ == $0 filename = ARGV.shift || raise("#{$0} filename username") user = ARGV.shift || raise("#{$0} filename username") phrase = gets.chomp db = FakeLogin.new(filename) db.set_phrase(user, phrase) end
fake_login.rbは、単体で起動するとユーザ/パスフレーズを登録するモードに なります。DivCalを起動する前に、'fake_login.dat'というファイル名で ユーザ名/パスフレーズを保存してください。 次のようにします。
% ruby fake_login.rb fake_login.dat seki seki00
fake_login.rbには二つの起動引数を与えます。一つ目はデータファイル名で、 今回は 'fake_login.dat' という名前を与えてください。 二つ目の引数はパスフレーズを変更/追加したいユーザ名です。 上記の使用例では、sekiというユーザ名のパスフレーズを設定しようとしています。 パスフレーズは引数でなく、標準入力から与えます。 *1
DivCalでは、FakeLoginのインスタンスをグローバル変数$login_dbに保持します。 DivCal::Loginはこの$login_dbを用いて認証を行います。
最後にDivCal::LoginDivのビューを示します。
List 7.18 login.erb
# login.erb <% if @login.guest? %> Hello, guest's Div. (if you have account, <%=a('logout', {}, context)%>click here.</a>) <% elsif @login.login? %> Hello, <%=h @login.name%>. (if you're not <%=h @login.name%>, <%=a('logout', {}, context)%>click here.</a>) <% else %> <%= form('login', context) %> <table border="0" width="100%" cellpadding="5" cellspacing="0" bgcolor="#eeeedd"> <tr> <th align="right">user</th> <td><input size="16" type="text" name="user" value="<%=h @hint %>"/></td> </tr> <tr> <th align="right">password</th> <td><input type="password" name="pass" size="16"/></td> </tr> <tr align="center"> <td colspan="2"><input type="submit" value="Login" /></td> </tr> <tr align="center"> <td colspan="2"><%=a('guest', {}, context)%>login guest account.</a></td> </tr> </table> </form> <% end %>
これらのファイルを一つのディレクトリに配置して、divcal.rbを起動して いつものCGIを使ってDivCalを試してください。
% ruby -Ke divcal.rb
- スクリーンショット
7.2.9 まとめ
この章でははじめにWebアプリケーションの実現にdRubyを用い、 プロセスへのオブジェクトの配置に様々なバリエーションを持たせることが できることを示しました。 次にWebアプリケーションフレームワークDivを説明し、 簡単なスケジュール管理アプリケーションDivCalを紹介しました。 より複雑なDivの応用は最後の章でもう一度とりあげたいと思います。
*1パスフレーズがエコーされてしまいます。