JRuby による Ruby CGI

2008.2/22 (鈴)


1. はじめに

Tomcat 上の JRubytDiary など一般の Ruby CGI スクリプトを実用的な効率で動作させることに成功した。 使用した JRuby のバージョンは 1.1RC2,Tomcat のバージョンは 6.0.16 である。 Mac OS X 10.4.11 上の Java 1.5.0_13 および Windows 2000 SP4 上の Java 1.6.0_04 で動作を確認した。

前回の Comet サーブレットと異なり, Tomcat の独自仕様には依存しないから, Java Servlet Specification を実装する他の任意の Java サーブレット・コンテナでも同様に動作することが期待される。 同仕様書 Version 2.4 を参照した。

2. Ruby CGI のための JRuby サーブレット

システム構成,とりわけ Java と JRuby スクリプト間のインタフェースは, 前回までの JRuby サーブレット・システムを踏襲した。 JRubyServlet のかわりに今回の JRubyDisjunctiveServlet を使っても,さしあたり動作する。

ただし,サーブレット間でモジュールとその変数を共有するものを除く。

なぜならば, 前回までは単一の Ruby インタープリタ (org.jruby.Ruby オブジェクト) を使って複数の Ruby サーブレットをマルチスレッドで動作させていたが, 今回は CGI スクリプトごとに個別の Ruby インタープリタを作成し,それぞれシングルスレッドで動作させるからである (ただし,別々の CGI スクリプトに対応する別々のインタープリタどうしは並列動作する)。 右図に今回のシステムの基本的なクラス構成を示す。

一般に CGI スクリプトを走らせるときは,Ruby の実行時環境が任意のタイミングで 他を省みることなく大域的に変更されることを仮定しなければならない。 単一の Ruby インタープリタのもとで変数を共有することは,普通できない。 個々の環境が分離されている (disjunctive) ことが必要であり, それが本システムのサーブレット・クラス名の由来である。

前回と同じく JRubyDisjunctiveServlet と InnerServlet のソース・ファイルを GNU LGPL (2.1 以降の任意のバージョン) の下におく。


2.1 Tomcat の設定と web.xml

本システムを使うには,まず apache-tomcat-6.0.16 を展開し,その webapps フォルダの下に適当な名前でフォルダを作る。 ここでは仮に r フォルダとする。 r フォルダの下に WEB-INF フォルダを設け,その下に lib と classes のフォルダを設ける。

$ cd webapps
$ mkdir r
$ cd r
$ mkdir WEB-INF
$ cd WEB-INF
$ mkdir lib
$ mkdir classes

次に jruby-bin-1.1RC2 を (Tomcat とは無関係な) 適当なところ (例えばホーム・ディレクトリ) に展開する。展開して得られる jruby-1.1RC2/lib にある bsf.jarjruby.jar を,先ほど設けた webapps/r/WEB-INF/lib にコピーする。 また jruby-1.1RC2 への完全パスを -Djruby.home オプションで java に与えるようにする。

$ cd lib
$ cp -p ~/jruby-1.1RC2/lib/*.jar .
$ export JAVA_OPTS="-Djruby.home=C:/cygwin/home/rei/jruby-1.1RC2"

これは Windows (Cygwin) での操作例である。Mac ならば export JAVA_OPTS="-Djruby.home=/Users/rei/jruby-1.1RC2" のようにする。

別の方法として,jruby-1.1RC2/lib フォルダをそのまま (jar ファイルと Ruby ライブラリ込みで) webapps/r/WEB-INF/lib としてコピーしてもよい。 この場合,-Djruby.home の設定は不要である。

リスティング に示すような web.xml を webapps/r/WEB-INF に置く。

2.2 JRubyDisjunctiveServlet.java

リスティング に示すような JRubyDisjunctiveServlet.java を webapps/r/WEB-INF/classes にコピーして下記のようにコンパイルする。

$ cd webapps/r/WEB-INF/classes
$ javac -cp '../../../../lib/servlet-api.jar;../lib/jruby.jar' JRubyDisjunctiveServlet.java

これは Windows (Cygwin) での操作例であり, -cp オプションで与えるクラスパスの区切り文字がセミコロンである。Mac ならばコロンで区切る。

JRubyDisjunctiveServlet の中心的なメソッド service(HttpServletRequest req, HttpServletResponse res) は,まず,CGI スクリプトのファイル名 String spath = res.getServletPath() をキーとしてハッシュ表から Ruby インタープリタと InnerServlet オブジェクトのペアを取り出す。 ハッシュ表に無ければ,初回呼出しだから,インタープリタと InnerServlet オブジェクトを作成し,ハッシュ表に登録する。

次に,ハッシュ表から取り出した InnerServlet オブジェクトの service メソッドに処理を委譲する。 このとき,同一 CGI スクリプトが同一インタープリタのもとで二重に走らないように InnerServlet オブジェクトで排他する。

                synchronized (servlet) {
                    servlet.service(req, res);
                }

servlet.service(req, res) の実体は JRuby メソッドであり, 異常時には Ruby の raise 経由で org.jruby.exceptions.RaiseException インスタンスが送出される。 このとき,自動的な縮退操作として,ハッシュ表の登録を取り消し, 対応するインタープリタを停止 (terminate) する。

さらに本システムの取り決めとして,CGI スクリプトの更新を検出したとき,JRuby からは raise "OBSOLETED"service メソッドを終了する。 このときは,ハッシュ表の登録を取り消した後,リトライする必要がある (need to retry)。

これらの動作を実装する実際の catch 節を下記に示す。

            } catch (RaiseException ex) {
                RubyException rex = ex.getException();
                boolean needToRetry = "OBSOLETED".equals(rex.toString());
                RsPair p;
                synchronized (this) {
                    p = pairs.remove(spath);
                }
                if (p != null) { // NB possibly two threads may come here
                    log("erred - destroy: " + spath);
                    p.servlet.destroy();
                    JavaEmbedUtils.terminate(p.ruby);
                }
                if (! needToRetry) throw ex;
            }

2.3 _init.rb

最後に リスティングに示すような _init.rb を webapps/r/WEB-INF に置く。

準備はこれで完了である。webapps/r フォルダに Ruby で書かれた CGI スクリプト・ファイルを置いて Tomcat を起動すれば,ブラウザから CGI スクリプトにアクセスできる。 このとき webapps/r/WEB-INF と環境変数は,例えば,下記のようになっているはずである。

$ cd webapps/r/WEB-INF
$ ls -F
_init.rb  classes/  lib/  web.xml
$ ls classes/*.class
classes/JRubyDisjunctiveServlet.class  classes/RsPair.class
$ ls lib
bsf.jar  jruby.jar
$ echo $JAVA_OPTS
-Djruby.home=C:/cygwin/home/rei/jruby-1.1RC2
$

本システムの _init.rb は InnerServlet だけを定義している。 つまり,CGI スクリプトの大域的な名前空間に InnerServlet という名前だけが,ここでの実装の都合として入り込んでいる。

InnerServletservice(req, res) メソッドは, スクリプト・ファイルのパス名と内容をインスタンス変数 @path@script にそれぞれ文字列として格納し, eval メソッドで評価することによって,CGI スクリプトを実行する。 主要部を示す。

    _set_env(req)
    _copy_input_to_file(req, res, @tmp_i) {|rf|
      open(@tmp_o, "wb") {|wf|
        oldin, oldout = $stdin, $stdout
        begin
          $stdin, $stdout = rf, wf
          eval(@script, TOPLEVEL_BINDING, @path, 1)
        ensure
          $stdin, $stdout = oldin, oldout
        end
      }
    } or return

3. 例1: ファイル・アップロード CGI

ファイルをアップロードするための Python CGI スクリプト を移植した例を示す。

リスティング のような upload.rb を (upload.rb-20219.tar.bz2 をダウンロードし,展開して) webapps/r/upload.rb として置き, upload.rb にある定数 FOLDER の定義を適当に書き換える (Windows で絶対パスを指定するときは C: などのドライブ名も欠かさずに指定するとよい)

もしなければ FOLDER 値に対応するフォルダを実際に作成する。 r/upload.rb をブラウザからアクセスする。

右図は,同一マシン上で,ファイル web.xml を UPLOAD ボタンを押下してアップロードした直後のブラウザ画面である。

この移植では,オリジナルにあった UTF-8 の正規化 (Mac の Normalization Form D から Normalization Form C への変換) と,Unix からみて他プラットフォームである Windows 用の basename 取得処理 (ntpath.basename) を欠いているが,前者は Java が自動的に行い, 後者は JRuby が両プラットフォームに対応した処理を自動的に行うから問題はない。


3.1 非 ASCII ファイル名のためのパッチ

この CGI スクリプトを使って,ファイル名に非 ASCII 文字を含むファイルを アップロードすると,HTTP ステータス 500 で異常終了する。 このとき,Tomcat のログファイル (例えば logs/localhost.2008-02-22.log) には No such file or directory というメッセージが残されている。

これは JRuby 1.1RC2 のバグである。

JRuby は文字列を Ruby の文字列として扱うときは iso-8859-1 エンコーディングを使ってバイト透過的に扱う。 一方,チュートリアルの例が示すように,Java API に文字列を引き渡すときは,そのバイト列を UTF-8 と見なして (ユーザが本来意図した文字からなる) Unicode 文字列を構成する。 つまり,文字列 s に対し下記に相当する変換を行う。

s = new String(s.getBytes("ISO-8859-1"), "UTF-8");

ところが JRuby 1.1RC2 およびそれ以前のバージョンでは,Ruby の File クラスや Dir クラスを実装するために Java クラス・ライブラリにファイル名文字列を引き渡すとき,なんらこういった変換をしない。

また,Ruby の Dir.glob メソッド等でファイル・システムからファイル名を 取得するとき,Java クラス・ライブラリの返した Unicode 文字列からデフォルト・エンコーディングでバイト列を取り出す。 実際には UTF-8 エンコーディングで取り出す必要があるが,そうしていない。

たまたま Java 処理系のデフォルト・エンコーディングが iso-8859-1 や UTF-8 のとき, これらの問題は部分的に解決するが,そのときでも全体としては矛盾が生じる。 全く問題が起こらないのは,共通部分集合である ASCII でファイル名が構成されているときだけである。

要するに,現在まで JRuby は非 ASCII ファイル名をまともに扱うことができない。

バグとそれに対する仮のパッチは OKI Software から http://jira.codehaus.org/browse/JRUBY に報告済みだが,私たちが JRuby を実用的に使う上で緊急の必要性があるから,ここに再掲する。

JRuby 1.1RC2 のソースを展開し, patch コマンドで JRubyFile.java.diff (リスティング) と Dir.java.diff (リスティング) を適用し, ant コマンドでビルドする。 便宜のため,Mac の Java 1.5.0_13 で作成したバイナリファイルを用意した。 webapps/r/WEB-INF/lib/jruby.jar と置き換えれば,当座,問題は解決する。

4. 例2: tDiary 2.2.0

Ruby の代表的な CGI スクリプトであり, Ruby の普及にも寄与してきた tDiary [wikipedia] を移植した例を示す。

これは JRuby による CGI スクリプトの実行に挑んだ先駆的な試みである NISHIMOTO 氏の Web Flavor でも取り上げられた題材である。

www.tdiary.org から安定版である tdiary-full-2.2.0.tar.gz または tdiary-2.2.0.tar.gz をダウンロードする。 特に理由がなければ前者をダウンロードすればよい。

右の図は 前者をダウンロードして JRuby 1.1RC2 上で動かした例である。 後者でも問題ないが, 設定画面で言及され,同梱の初心者用設定ファイル tdiary.conf.beginner で参照されているカレンダー機能が使えないなど, 些細な不具合がある。


4.1 Tomcat の設定と web.xml

webapps フォルダで tdiary-2.2.0 を展開する。 このままでは名前が長くて不便なので tdiary-2.2.0 フォルダを tdiary に改名する。

webapps/tdiary に WEB-INF を設ける。 今まで作った webapps/r/WEB-INF をコピーすればよい。

$ cd webapps
$ gzcat ~/Downloads/tdiary-full-2.2.0.tar.gz | tar xf -
$ mv tdiary-2.2.0 tdiary
$ cd tdiary
$ cp -pr ../r/WEB-INF . 

普通,tDiary の更新用ページにパスワードを掛ける必要がある。 また,index.rb がデフォルト・ページとしてアクセスできるようにする必要がある。 webapps/tdiary/WEB-INF/web.xmlリスティング のような内容にする。 さしあたり,この web.xml を使えばよい。

この例では sample_admin という role を作成している。 リスティング のような内容の conf/tomcat-users.xml を作成して, sample_admin に対応するユーザ名とパスワード (この例では foobar) を設定する。 さしあたり,この tomcat-users.xml を書き換えて使えばよい。 このユーザ名とパスワードは,実際のユーザ名と無関係に決めてよい。 conf フォルダには他に server.xml 等が置かれている。 conf/tomcat-users.xml を変更したら,Tomcat を再起動する。

tDiary のドキュメントに従って webapps/tdiary/tdiary.conf を設定する。 さしあたり tdiary.conf.beginner を tdiary.conf としてコピーし, 第 11 行の @data_path を

@data_path = 'C:/cygwin/home/rei/diary'

第 30 行の @update を

@update = 'update.rb'

のようにするだけでよい。

@data_path に相当する空のディレクトリ (この例では C:/cygwin/home/rei/diary) を作成しておく。

4.2 tdiary.rb へのパッチ

本来ならば,ここまでの準備を終えて,同一マシンのブラウザから http://localhost:8080/tdiary/ にアクセスすると,tDiary を開始できるはずである。

実際には JRuby の微妙な非互換性のために,設定の変更が反映されない, 1日ずれた日付が参照される,protected メソッドが使われたとしてエラーが起こる, などの不具合が多発する。

対症療法的なパッチ tdiary.rb.diff (リスティング) を用意したので,下記のように適用すればよい。

$ cd webapps/tdiary
$ patch < tdiary.rb.diff
patching file tdiary.rb
$

パッチ適用後,MD5 値は次のようになる。

$ md5sum tdiary.rb
7cacef3c43e9d338d8c511f02ba31b2f  tdiary.rb
$

[2008.2/29: 2/19 に作成したパッチは変数名がブロークンだったので差し替えました。上記 MD5 値も差し替えた値です]

JRuby の微妙な非互換性はおそらく次の3点にまとめられる。

  1. rhtml ファイルの動的評価時に self が自分を見失う (?) ため protected による保護が発動する (パッチではメタクラス Moduleprotected メソッドを何もしないメソッドにすげかえている)。
  2. メソッド内に静的に出現せず動的評価だけで定義されるローカル変数について, ブロックの出入りがあるとき (?),その振舞が JRuby では完全には再現されない (パッチではブロック内で動的評価を行うように変更している)。
  3. gmtime 等を呼び出したわけでもないのに Time インスタンスが世界時モードになる (パッチでは,gmtime を実際に呼び出したかどうかフラグを設け, strftime メソッドで時刻の文字列表現を得るとき, もしもフラグが立っていなかったから localtime を強制している)。

5. おわりに

Java Servlet Specification に従ったサーブレットとして,一般の Ruby CGI スクリプトを実行するシステムを作成した。 エラー発生時の自動的な切り離しや,CGI スクリプト・ファイル入れ替え時の自動的な更新を行う機能も実現した。

いったん読み込まれたスクリプトは,そのまま次回以降も使われるため, リクエストが多重しない間は十分に実用的な速度で動作する。 しかし,1スクリプトは同時に1スレッドでしか処理されない (別々のスクリプトならば同時に別々のスレッドで処理される) から, 一つのスクリプトにアクセスが集中したときのスループットの低下が懸念される。 厳密な性能評価と改良はこれからの課題である。

ソース・ファイルの行数で測ったシステムの大きさは Java 121 行,Ruby 157 行 (空行,コメント込み) であり,様々な実験や応用の手軽な素材として適当であると考えられる。



Copyright (c) 2008 OKI Software Co., Ltd.