どどんとふの CGI 処理:トップレベル
TRPG のオンラインセッション環境「どどんとふ」の CGI 処理についての解説。前回はこちら。
今回からサーバープログラムが書かれている DodontoFServer.rb(コミット 94c3c04)を見ていく。
DodontoFServer.rb の概要
DodontoFServer.rb は Ruby で書かれていて、以下の部分から成っている。
- 宣言・初期化(1〜99 行)
DodontoFServer
クラスの定義(101〜6812 行)- トップレベルのメソッド定義(6815〜6970 行)
- 処理の実行(6972〜6974 行)
処理の流れ
ファイルを読み込んだ際にまず実行される上記の 1、4 を見ていき、残りの部分は必要に応じて取り上げることにする。
宣言・初期化(1〜99 行)
ここではライブラリの読み込みや定数(?)の宣言がほとんどで、大したことはしていない。おそらく定数として使われているものをグローバル変数として定義している部分は、クラスかモジュールに分けてその定数として定義した方が良さそう。
39〜44 行にある設定読み込みは全体の動作に大きく影響するので、取り上げておく。
require "config.rb" begin require "config_local.rb" rescue Exception end
最初に読み込まれる src_ruby/config.rb では、グローバル変数に設定値を代入している。src_ruby/config_local.rb があれば読み込まれ、その内容で設定値が上書きされる。このようにすることで、バージョンアップ時に src_ruby/config.rb が上書きされてもサーバー固有の設定は残る。地味ながら便利だ。
いくつかの gem を見てみると、rescue Exception
の部分は rescue LoadError
となっていることが多いようだ。rescue Exception
だとすべての例外が捕捉されてしまうので、config_local.rb で記述ミスがあって動作しなかった場合に原因が分かりにくくなると思う。
処理の実行
DodontoFServer
クラスやトップレベルのメソッドの宣言は置いておいて、ここでいきなり最後(6972〜6974 行)に飛ぶ。
if( $0 === __FILE__ ) executeDodontoServerCgi() end
このファイル(DodontoFServer.rb)が直接実行された場合に限り、executeDodontoServerCgi
メソッドを実行する。if
で囲っているのは、他のファイルから DodontoFServer.rb が読み込まれた際に CGI 処理が行われてしまわないようにするため。
executeDodontoServerCgi メソッド
executeDodontoServerCgi
メソッドは 6955〜6970 行にある。DodontoFServer
でなく DodontoServer
なのは、前作「どどんと」の名残だろうか*1。
def executeDodontoServerCgi() initLog(); cgiParams = getCgiParams() case $dbType when "mysql" #mod_ruby でも再読み込みするようにloadに require 'DodontoFServerMySql.rb' mainMySql(cgiParams) else #通常のテキストファイル形式 main(cgiParams) end end
initLog
はログファイルの初期化。getCgiParams
では CGI のクエリパラメータを取得している? その後 $dbType
を見て分岐し、データ格納形式に合わせた処理を行うようだ。MySQL 版の方まで追うと大変なので、今回は最もよく使われていると思われる「通常のテキストファイル形式」の方の main
に進むことにする。その前に getCgiParams
を見てみよう。
getCgiParams メソッド
getCgiParams
メソッドはすぐ上の 6933〜6952 行にある。基本的には送られてきたメッセージに含まれるコマンド名やパラメータを Hash に変換して返すメソッドだ。
def getCgiParams() logging("getCgiParams Begin") logging(ENV['REQUEST_METHOD'], "ENV[REQUEST_METHOD]") input = nil messagePackedData = {} if( ENV['REQUEST_METHOD'] == "POST" ) length = ENV['CONTENT_LENGTH'].to_i logging(length, "getCgiParams length") input = $stdin.read(length) logging(input, "getCgiParams input") messagePackedData = DodontoFServer.getMessagePackFromData( input ) end logging(messagePackedData, "messagePackedData") logging("getCgiParams End") return messagePackedData end
メソッド名からはただ CGI のクエリパラメータを取得しているような印象を受けるが、実際には結構複雑な処理となっている。これは前回の「サーバーとの通信」で述べたとおり、リクエストに以下の 3 種類があるため。
- POST で送られてくる SWF からのメッセージ
- POST で送られてくる WEB IF からのメッセージ
- GET で送られてくる WEB IF からのメッセージ
POST で送られてきた場合
if
の中に進む。CGI の規格に従って環境変数の CONTENT_LENGTH
バイト分だけ標準入力から読み取り、DodontoFServer
クラスのクラスメソッド getMessagePackFromData
に渡して Hash に変換している。
SWF からと WEB IF からではメッセージの内容も形式も異なるが、変換メソッド内でどちらも Hash に変換される。詳細は DodontoFServer
クラスに進んだときに取り上げる。
GET で送られてきた場合
if
の中は飛ばされるので、空の Hash {}
を返す。
この GET の処理は分かりにくい。なぜならば、実際には一切「getCgiParams」していないからだ。ではどこで変換されるかというと、DodontoFServer
インスタンスのメソッドが行っている(その辺りも今後取り上げる)。GET の場合も、POST の場合と対称になるように、このメソッドから変換メソッドを呼ぶべきだ。
MessagePack の意味
ところで、メッセージデータの解析周りでは「MessagePack」という語がよく出てくるが、どどんとふのソースコード内では事実上以下の 2 つの意味で使われているので注意が必要である。
- シリアライズ形式としての MessagePack。
- クライアントから送られてきたメッセージが詰め込まれたもの。混乱を避けるため、記事ではこれを「メッセージデータ」と呼んでいる。
getCgiParams
メソッドに含まれる変数 messagePackedData
では 2 番の意味だ。しかし、このメソッドの処理では 1 番の意味も出てくるのでややこしい。その処理とは SWF からのメッセージを Hash に変換する処理である。SWF から送られてくるデータは、ActionScript(JavaScript)のオブジェクトが MessagePack 形式でシリアライズされたものであり、後の回に出てくるが MessagePack.unpack
というメソッド*2で Hash に変換される。そしてその結果が messagePackedData
変数に代入されるのである。この流れを簡単に書けば以下のようになる。
messagePackedData <- MessageUnpacked received data
この文の意味を一瞬で理解できる人はいないのではないだろうか。
分かりやすくするためには、シリアライズ形式としての MessagePack に関連したメソッド名*3は変えられないので、2 番の意味で「MessagePack」を使わないようにするしかないと思う。候補はいくつかありそうだが、とりあえず messageData
にしてみると
messageData <- MessageUnpacked received data
となり、少し分かりやすく見える。
main メソッド
main
メソッドは 6851〜6857 行にある。
def main(cgiParams) logging("main called") server = DodontoFServer.new(SaveDirInfo.new(), cgiParams) logging("server created") printResult(server) logging("printResult called") end
DodontoFServer
のインスタンスを作って、結果を出力するだけ。シンプル。
…ところでコマンドの実行等はどこでやっているのかというと、printResult
メソッドの中である。
printResult メソッド
printResult
メソッドは 6876〜6930 行にある。
def printResult(server) logging("========================================>CGI begin.") text = "empty" header = getInitializedHeaderText(server) begin result = server.getResponse if( server.isAddMarker ) result = "#D@EM>#" + result + "#<D@EM#"; end if( server.jsonpCallBack ) result = "#{server.jsonpCallBack}(" + result + ");"; end logging(result.length.to_s, "CGI response original length") if ( isGzipTarget(result, server) ) if( $isModRuby ) Apache.request.content_encoding = 'gzip' else header << "Content-Encoding: gzip\n" if( server.jsonpCallBack ) header << "Access-Control-Allow-Origin: *\n" end end text = getGzipResult(result) else text = result end rescue Exception => e errorMessage = getErrorResponseText(e) loggingForce(errorMessage, "errorMessage") text = "\n= ERROR ====================\n" text << errorMessage text << "============================\n" end logging(header, "RESPONSE header") output = $stdout output.binmode if( defined?(output.binmode) ) output.print( header + "\n") output.print( text ) logging("========================================>CGI end.") end
結構長い。少しずつ見ていこう。
レスポンスヘッダの内容の初期化
text = "empty" header = getInitializedHeaderText(server)
この 2 つの変数は、それぞれレスポンスボディとレスポンスヘッダを表す。ちなみに、text
の内容は後で必ず書き換えられる。
getInitializedHeaderText
メソッド(6859〜6874 行)は短いので、ここで見てしまおう。
def getInitializedHeaderText(server) header = "" if( $isModRuby ) #Apache::request.content_type = "text/plain; charset=utf-8" #Apache::request.send_header else if( server.isJsonResult ) header = "Content-Type: text/plain; charset=utf-8\n" else header = "Content-Type: application/x-msgpack; charset=x-user-defined\n" end end return header end
mod_ruby が使われているときは Content-Type が空になるようだが、それで良いのかはよく分からない。
mod_ruby が使われていないときは text/plain か application/x-msgpack のどちらかになりそうだ。しかし、ソースコードを全文検索*4してみると、DodontoFServer#isJsonResult
は true
で初期化され、かつ変更される箇所はないので、結局 text/plain にしかならない。application/x-msgpack の部分はデッドコードとなっている。
コマンドの実行結果を得る
begin
result = server.getResponse
念のため例外を捕捉するようにして、コマンドの実行結果を得る。コマンドの実行は DodontoFServer#getResponse
メソッドに含まれている。
コマンドの実行結果を加工する
if( server.isAddMarker ) result = "#D@EM>#" + result + "#<D@EM#"; end if( server.jsonpCallBack ) result = "#{server.jsonpCallBack}(" + result + ");"; end
それぞれ「CGI広告埋め込み対策*5」と JSONP の関数呼び出し化。
必要なら gzip 圧縮する
if ( isGzipTarget(result, server) ) if( $isModRuby ) Apache.request.content_encoding = 'gzip' else header << "Content-Encoding: gzip\n" if( server.jsonpCallBack ) header << "Access-Control-Allow-Origin: *\n" end end text = getGzipResult(result) else text = result end
見出しのとおりだが、gzip 圧縮する場合には少しレスポンスヘッダの追加を行っているようだ。Access-Control-Allow-Origin がここで追加されるのには、何か理由があるのだろうか。
gzip 圧縮するかどうか判定する isGzipTarget
メソッド(6826〜6831 行)は以下のとおり。
def isGzipTarget(result, server) return false if( $gzipTargetSize <= 0) return false if( server.jsonpCallBack ) return ( (/gzip/ =~ ENV["HTTP_ACCEPT_ENCODING"]) and (result.length > $gzipTargetSize) ) end
- gzip 圧縮しないように設定されていたり、JSONP 関数が指定されていた場合は圧縮しない。
- Accept-Encoding ヘッダに gzip が入っていて、かつレスポンスボディのバイト数が設定された閾値を超えていたら圧縮する。そうでなければ圧縮しない。
圧縮処理を行う getGzipResult
メソッドは 6833〜6848 行にある。このメソッドは、標準添付の zlib ライブラリを使って簡潔に書かれている。加工してから標準出力に出力するため、StringIO を使って結果の文字列を取得している。
def getGzipResult(result) require 'zlib' require 'stringio' stringIo = StringIO.new Zlib::GzipWriter.wrap(stringIo) do |gz| gz.write(result) gz.flush gz.finish end gzipResult = stringIo.string logging(gzipResult.length.to_s, "CGI response zipped length ") return gzipResult end
エラー処理
begin # ... rescue Exception => e errorMessage = getErrorResponseText(e) loggingForce(errorMessage, "errorMessage") text = "\n= ERROR ====================\n" text << errorMessage text << "============================\n" end
レスポンスボディに(エラーであることを表す)ヘッダ、エラーメッセージ、フッタを設定している。getErrorResponseText
メソッド(6815〜6823 行)は以下のとおりで、例外の詳細を示してデバッグを容易にすることが狙いのようだ。
def getErrorResponseText(e) errorMessage = "" errorMessage << "e.to_s : " << e.to_s << "\n" errorMessage << "e.inspect : " << e.inspect << "\n" errorMessage << "$@ : " << $@.join("\n") << "\n" errorMessage << "$! : " << $!.to_s << "\n" return errorMessage end
気になるのは、成功時は JSON を返しているが、このエラーが起こった場合のレスポンスボディは明らかに JSON でないこと*6。サーバーが JSON を返すことを期待しているクライアントは、サーバーでエラーが発生したときに JSON でない応答が返ってきて混乱しないのだろうか。
SWF 側では、JSON として解析できなかったらとりあえず null
にするという方針のようだ。サーバーからのデータ受信部は SharedDataReceiver クラス(src_actionScript/SharedDataReceiver.as)である。その中に数箇所から呼ばれる getJsonDataFromString
という静的メソッド(158〜160 行)があり、これが JSON 文字列からオブジェクトへの変換を担っている。この静的メソッドは Utils クラス(src_actionScript/Utils.as)の静的メソッド getJsonDataFromString
(73〜87 行)を呼び出すだけであり*7、それは以下のようになっている。
public static function getJsonDataFromString(jsonString:String):Object { var jsonData:Object = null; try { if( jsonString == null ) { jsonData = new Object(); } else { jsonData = JSON.decode(jsonString); } } catch( e:Error ) { //Log.loggingException("SharedDataReceiver.getJsonDataFromString()", e); } return jsonData; }
JSON.decode()
でエラーが起こったら jsonData
変数が初期値の null
のままなので、null
が返る。SWF 側ではあらゆるところで null ガードが仕込まれているので、これでもうまく動くのだろう。真面目にエラー処理を組むのならば、例えばエラー発生時は error
とか exception
といったキーを含む Hash を JSON に変換して返信し、SWF 側でそのキーの有無をチェックして分岐させるといったことができそうだ。
出力
output = $stdout output.binmode if( defined?(output.binmode) ) output.print( header + "\n") output.print( text )
最後に標準出力にレスポンスヘッダとレスポンスボディを出力して終わり。output.binmode
は、Windows で最後に EOF(ASCII コード 0x1A)を出力しないようにするために必要。
まとめ
今回は DodontoFServer.rb のトップレベルで行われる処理を一通り見た。それほど複雑ではないが、メソッド名と内容が違ったり printResult
メソッドが大きかったりするのは気になる。またトップレベルでは名前の衝突が怖いので、今後はこれらをうまくクラスやモジュールの中に格納していきたい。
次回は大きな DodontoFServer
クラスに入り、コマンド解析の辺りを見る予定。
*1:似ているので、「F」付きにして NameError を起こしてしまったがすぐに違いに気付けなかったときがあったw
*2:MessagePack.pack ではない。
*3:MessagePack.pack と MessagePack.unpack は Ruby の Array#pack と String#unpack に対応していて分かりやすいと思う。
*5:おそらく公式サイト「どどんとふ@えくすとり〜む」で使っているのだろう。
*6:そこまで見越して Content-Type を text/plain にしたのだろうか。
*7:委譲などせず単に Utils.getJsonDataFromString() を呼び出すだけで良いのではないだろうか。