ochalog

RubyとMediaWikiとIRCが好き。

どどんとふの CGI 処理:トップレベル

TRPG のオンラインセッション環境「どどんとふ」の CGI 処理についての解説。前回はこちら。

ochaochaocha3.hateblo.jp

今回からサーバープログラムが書かれている DodontoFServer.rb(コミット 94c3c04)を見ていく。

DodontoFServer.rb の概要

DodontoFServer.rbRuby で書かれていて、以下の部分から成っている。

  1. 宣言・初期化(1〜99 行
  2. DodontoFServer クラスの定義(101〜6812 行
  3. トップレベルのメソッド定義(6815〜6970 行
  4. 処理の実行(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 つの意味で使われているので注意が必要である。

  1. シリアライズ形式としての MessagePack
  2. クライアントから送られてきたメッセージが詰め込まれたもの。混乱を避けるため、記事ではこれを「メッセージデータ」と呼んでいる。

getCgiParams メソッドに含まれる変数 messagePackedData では 2 番の意味だ。しかし、このメソッドの処理では 1 番の意味も出てくるのでややこしい。その処理とは SWF からのメッセージを Hash に変換する処理である。SWF から送られてくるデータは、ActionScriptJavaScript)のオブジェクトが 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#isJsonResulttrue で初期化され、かつ変更される箇所はないので、結局 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
  1. gzip 圧縮しないように設定されていたり、JSONP 関数が指定されていた場合は圧縮しない。
  2. 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)の静的メソッド getJsonDataFromString73〜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 は RubyArray#packString#unpack に対応していて分かりやすいと思う。

*4:Milkode を使って全文検索した。

*5:おそらく公式サイト「どどんとふ@えくすとり〜む」で使っているのだろう。

*6:そこまで見越して Content-Type を text/plain にしたのだろうか。

*7:委譲などせず単に Utils.getJsonDataFromString() を呼び出すだけで良いのではないだろうか。