cgi.rb がイケてない 12 の理由

著者: 桑田 誠

はじめに

cgi.rb は、Ruby に標準添付されている CGI アプリケーション用のライブラリです。 この cgi.rb (と erb.rb) のおかげで、Ruby でも CGI アプリケーションが簡単に作成できるようになりました。 その功績は計り知れないものがあります。

しかし各所で言われているように、cgi.rb はさまざまな問題点を抱えているのも事実です。 本稿では、cgi.rb の具体的な問題点と解決案を紹介します。

なお、まつもとさんは cgi.rb に代わるライブラリを公募するとおっしゃっています。 締め切りや条件などは特に決まってないようですが、我こそはと思う方は新しいライブラリを提案してみてください。

(注意: 本稿の内容は Ruby 1.8.6 patchlevel 114 に基づいており、より新しいバージョンでは修正されている可能性があります。)

cgi.rb の問題点

ファイルが分割されていない

cgi.rb では、すべての機能を単一のファイルに押し込めています。 そのため、cgi.rb の機能のうち例えば CGI::escapeHTML() だけ使いたいと思っても、cgi.rb 全体を読み込む必要があります。

本来であれば、たとえば次のように機能ごとに複数のファイルに分割すべきです。

  • cgi/core.rb – コアとなる機能
  • cgi/util.rb – CGI::escapeHTML() などのユーティリティ関数郡
  • cgi/cookie.rb – クッキー機能
  • cgi/html.rb – CGI::HtmlExtension モジュールの定義
  • cgi.rb – これらをすべて読み込むファイル

こうすることで、例えば CGI::escapeHTML() だけが必要であれば、cgi/util.rb だけ読み込めばよく、CGI プログラムの動作が軽くなります。

余計な HTML タグ生成機能が含まれている

cgi.rb には、HTML タグを生成する機能 (CGI::HtmlExtension モジュール関連) が含まれています。 しかし、eRuby やテンプレートエンジンを使うのが定番となった現在では、この機能は無用の長物です。

そもそも CGI クラスが担当すべきは HTTP リクエストと HTTP レスポンスのはずです。 HTML タグの生成という機能は範囲外ですし、この機能を CGI クラスが提供しなければならない理由はありません。

しかもこの機能のせいで cgi.rb のコードサイズが膨れ上がっており、「require “cgi”」に時間がかかる原因のひとつとなっています。

しかし過去との互換性を考えると簡単に廃止するわけにもいきません。 妥協案としては、CGI::HtmlExtension 関連を別ファイルに分離し、参照されたときだけ自動的に require されるようにするのがいいでしょう。

class CGI
  ## CGI::HtmlExtension 関連が cgi/html.rb に分離されたとする
  autoload :HtmlExtension, 'cgi/html'
  autoload :Html3,   'cgi/html'
  autoload :Html4,   'cgi/html'
  autoload :Html4Tr, 'cgi/html'
  autoload :Html4Fr, 'cgi/html'
  ...

CGI クラスが HTTP リクエストと HTTP レスポンスの両方を担当している

cgi.rb では、CGI クラスが HTTP リクエストと HTTP レスポンスの両方を担当しています。 しかし、本来であれば両者は別のクラスにすべきでした。 両者が 1 つになっているせいで、リクエストに対する操作とレスポンスに対する操作が混じってしまい、見通しが悪くなります。

HTTP リクエストと HTTP レスポンスの両方を 1 つのクラスでまかなっているライブラリは、珍しいのではないでしょうか。 筆者が知る限り、他のフレームワークやライブラリではリクエストとレスポンスは別クラスに分かれているのが普通です。

例えば Java Servlet でも HttpRequest と HttpResponse という 2 つのクラスが用意されています。 別に「Java でそうだから、分かれているのが正しい」というつもりはありませんが、少なくとも CGI クラスについていえば、Request と Response が分かれている Java のほうが正しいと思います。

コードが洗練されていない

cgi.rb のコードは無駄が多く、洗練されていません。

例えば CGI::Cookie#to_s() は、次のようなコードになっています。

    # Convert the Cookie to its string representation.
    def to_s
      buf = ""
      buf += @name + '='

      if @value.kind_of?(String)
        buf += CGI::escape(@value)
      else
        buf += @value.collect{|v| CGI::escape(v) }.join("&")
      end

      if @domain
        buf += '; domain=' + @domain
      end

      if @path
        buf += '; path=' + @path
      end

      if @expires
        buf += '; expires=' + CGI::rfc1123_date(@expires)
      end

      if @secure == true
        buf += '; secure'
      end

      buf
    end

一見して分かるように、洗練されているとはいい難いコードです。 具体的には次のような点が気になります。

  • buf を空文字列で作成した直後に文字列を追加している
  • String#<< ではなく += を使っている
  • if 文の後置記法を使ってない
  • 無駄に行が空いている

これを書き換えると、次のように大変簡潔になりました。

    # Convert the Cookie to its string representation.
    def to_s
      val = @value.kind_of?(String) ? CGI::escape(@value) \
                    : @value.collect{|v| CGI::escape(v) }.join("&")
      buf = "#{@name}=#{val}"
      buf << "; domain=#{@domain}" if @domain
      buf << "; path=#{@path}"     if @path
      buf << "; expires=#{CGI::rfc1123_date(@expires)}" if @expires
      buf << "; secure"            if @secure == true
      buf
    end

他にも、たとえば 989 行目では「10240」というマジックナンバーがでてきます。 実はその前の 973 行目で「bufsize = 10 * 1024」という変数を設定しているのですが、なぜかそれを使わず、マジックナンバーを直接使ってしまっています。

973:  bufsize = 10 * 1024
...
989:  if 10240 < content_length

これら以外にも、格好悪いコードが目立ちます。 筆者は、標準添付されるライブラリは初心者が読んで勉強になるコードであってほしいと思っているので、cgi.rb のコードには残念な感が否めません。 cgi.rb もぜひ添削してほしいところです。

読者にひとつ問題を出しましょう。 次のコードは CGI::unescapeHTML() のコードです。 これを書き換えるとしたら、みなさんならどうしますか? 挑戦される方はご自分のブログに書いて、本記事に trackback してください。 解答された方から抽選で豪華賞品が! …… 当たるわけありませんのであしからず。

  # Unescape a string that has been HTML-escaped
  #   CGI::unescapeHTML("Usage: foo &quot;bar&quot; &lt;baz&gt;")
  #      # => "Usage: foo \"bar\" <baz>"
  def CGI::unescapeHTML(string)
    string.gsub(/&(amp|quot|gt|lt|\#[0-9]+|\#x[0-9A-Fa-f]+);/n) do
      match = $1.dup
      case match
      when 'amp'                 then '&'
      when 'quot'                then '"'
      when 'gt'                  then '>'
      when 'lt'                  then '<'
      when /\A#0*(\d+)\z/n       then
        if Integer($1) < 256
          Integer($1).chr
        else
          if Integer($1) < 65536 and ($KCODE[0] == ?u or $KCODE[0] == ?U)
            [Integer($1)].pack("U")
          else
            "&##{$1};"
          end
        end
      when /\A#x([0-9a-f]+)\z/ni then
        if $1.hex < 256
          $1.hex.chr
        else
          if $1.hex < 65536 and ($KCODE[0] == ?u or $KCODE[0] == ?U)
            [$1.hex].pack("U")
          else
            "&#x#{$1};"
          end
        end
      else
        "&#{match};"
      end
    end
  end

読み込みが遅い

cgi.rb の読み込みは結構遅いです。 どのくらい遅いか調べるために、次のようなスクリプト「bench.rb」を用意しました。

## usage: ruby -s bench.rb -N=100 -C='require "cgi"'
code = $C || 'require "cgi"'
command = "ruby -e '#{code}'"
ntimes = ($N || 100).to_i
print "command: #{command}"; $stdout.flush
ntimes.times { `#{command}` }

これを使って、

  • Ruby の起動時間
  • 「require “cgi”」を伴う Ruby の起動時間
  • 「require “erb”」を伴う Ruby の起動時間

を調べてみました (erb は比較のためです)。

### Ruby の起動時間
$ time ruby -s bench.rb -N=100 -C='nil'
command: ruby -e 'nil'
real    0m2.329s
user    0m0.954s
sys     0m1.252s
### 「require "cgi"」を伴う Ruby の起動時間
$ time ruby -s bench.rb -N=100 -C='require "cgi"'
command: ruby -e 'require "cgi"'
real    0m4.904s
user    0m2.973s
sys     0m1.640s
### 「require "erb"」を伴う Ruby の起動時間
$ time ruby -s bench.rb -N=100 -C='require "erb"'
command: ruby -e 'require "erb"'
real    0m3.297s
user    0m1.614s
sys     0m1.503s

これを見れば分かるように、「require “cgi”」にかかる時間は Ruby プロセスの起動よりも時間がかかっています。 「require “erb”」にはそれほど時間がかかってませんが、これは cgi.rb が 2303 行あるのに対し、erb.rb が 826 行と少ないためです。 ということは、cgi.rb のサイズが大きい以上、require にかかる時間は早くできないのでしょうか。

実はそうでもありません。 cgi.rb の読み込みが遅いのは、ファイルサイズも原因ですが、最大の原因は CGI::Cookie クラスが親クラスとして DelegateClass(Array) を指定していることです。

  class Cookie < DelegateClass(Array)

CGI::Cookie クラスは、1 つのクッキー名に対して複数の値をとることができるようになっています。 そのため、CGI::Cookie クラスを Array クラスのように見せかけるために、DelegateClass(Array) を使っているのだと思われます。

しかし DelegateClass() を使うのはかなりコストのかかる処理であるため、毎回ライブラリを読み込む必要のある CGI プログラムにはうれしくありません。 そもそもクッキーの仕様 (RFC2965) では複数の値を取るようには書かれていませんし、かりに複数の値を取ることができたとしても Array と完全互換である必要はまったくないはずです。

そこで、DelegateClass(Array) を使わないように書き換えてみましょう。 以下がそのパッチです1

--- /usr/local/lib/ruby/1.8/cgi.rb	2007-05-23 06:58:09.000000000 +0900
+++ cgi.rb	2008-02-05 12:27:26.000000000 +0900
@@ -734,11 +734,10 @@
   #   cgi.print    # default:  cgi.print == $DEFAULT_OUTPUT.print
   def print(*options)
     stdoutput.print(*options)
   end
 
-  require "delegate"
 
   # Class representing an HTTP cookie.
   #
   # In addition to its specific fields and methods, a Cookie instance
   # is a delegator to the array of its values.
@@ -769,11 +768,11 @@
   #   cookie1.value   = ['value1', 'value2', ...]
   #   cookie1.path    = 'path'
   #   cookie1.domain  = 'domain'
   #   cookie1.expires = Time.now + 30
   #   cookie1.secure  = true
-  class Cookie < DelegateClass(Array)
+  class Cookie
 
     # Create a new CGI::Cookie object.
     #
     # The contents of the cookie can be specified as a +name+ and one
     # or more +value+ arguments.  Alternatively, the contents can
@@ -856,10 +855,40 @@
       end
 
       buf
     end
 
+    ##--
+    ## define methods instead of DelegateClass(Array)
+    ##++
+
+    include Enumerable  ##:nodoc:
+
+    def [](*args)  ##:nodoc:
+      @value[*args]
+    end
+
+    def []=(index, value)  ##:nodoc:
+      @value[index] = value
+    end
+
+    def each(&block)  ##:nodoc:
+      @value.each(&block)
+    end
+
+    def method_missing(m, *args)  ##:nodoc:
+      @value.respond_to?(m) ? @value.__send__(m, *args) : super
+    end
+
+    def respond_to?(m)  ##:nodoc:
+      super(m) || @value.respond_to?(m)
+    end
+
+    #def inspect;  @value.inspect;  end
+    #def ==(arg);  @value == arg;  end
+    #def ===(arg);  @value === arg;  end
+
   end # class Cookie
 
 
   # Parse a raw cookie string into a hash of cookie-name=>Cookie
   # pairs.

これを適用して再度計測してみると、筆者の環境で 4.904 秒かかってたのが 4.081 秒 になりました。 約 20 % の改善です。

### 「require "cgi"」を伴う Ruby の起動時間
$ time ruby -s bench.rb -N=100 -C='require "cgi"'
command: ruby -e 'require "cgi"'
real    0m4.081s
user    0m2.311s
sys     0m1.544s

これ以上の改善となると、コードサイズを減らす必要があります。 筆者が試した限りでは、3.5 秒を切るくらいまで高速化できました。

このことから分かるように、CGI プログラムにおいてはプロセスの起動よりもライブラリの読み込みのほうが時間がかかります。 特に Ruby 1.8 ではライブラリを毎回パースする必要があるため、どうしても遅くなります。 Ruby 1.9 ではバイトコードインタプリタになるので、Python のようにバイトコードをファイルにキャッシュするようにすれば、CGI プログラムにおいてもライブラリの読み込みがかなり高速化されるはずです。 今のところ、Ruby 1.9 にはそのような機能がないようですが、将来的には期待したいところです。

動作が遅い

CGI クラスは、動作が遅いです。 特に CGI オブジェクトを生成するのが遅いです。

プロファイラで調べてみると、以下の点がボトルネックになっているようでした。

  • QUERY_STRING の解析
  • HTTP_COOKIE の解析
  • CGI::Cookie オブジェクトの生成

QUERY_STRING と HTTP_COOKIE の解析が遅いのは、結局は CGI::unescape() が遅いのが原因でした。 CGI::unescape() は、たとえば「word=%E6%97%A5%E6%9C%AC%E8%AA%9E」のように URL エンコードされた文字列を「word=日本語」に戻す関数です。 このような処理は、C 言語で文字列の先頭から 1 文字ずつ辿って処理すれば高速なのですが、Ruby は文字列を 1 文字ずつ処理するのが苦手であり、重くなります。 同様のことは CGI::escapeHTML() にも言えます。

そこで、これらを C 言語で書き直してみました。 CGIExt というライブラリがそれです。 結果は目覚ましく、CGI::unescape() や CGI::escapeHTML() が 5 倍から 10 倍高速になり、CGI.new も 2 倍以上高速化しました (ベンチマーク結果の詳細は CGIExt のページを参照してください)。

CGI::escapeHTML() のような関数は、Web ページを 1 つ生成するのに平気で数十回呼び出されます。 こういった基本的な関数は、標準で拡張モジュールとして提供してほしいところです。

また CGI::Cookie オブジェクトの生成が遅い原因として、CGI::Cookie#initialize() とそれを呼び出す CGI::Cookie::parse() での無駄な処理が挙げられます。

まず CGI::Cookie::parse() ですが、CGI::Cookie.new() を呼び出すときに、名前と値から Hash を生成して渡しています。

  def Cookie::parse(raw_cookie)
    cookies = Hash.new([])
    return cookies unless raw_cookie

    raw_cookie.split(/[;,]\s?/).each do |pairs|
      name, values = pairs.split('=',2)
      next unless name and values
      name = CGI::unescape(name)
      values ||= ""
      values = values.split('&').collect{|v| CGI::unescape(v) }
      if cookies.has_key?(name)
        values = cookies[name].value + values
      end
      cookies[name] = Cookie::new({{*{ "name" => name, "value" => values }*}})
    end

    cookies
  end

しかし CGI::Cookie.new() は Hash に変換しなくても名前と値をそのまま受け取ることができるので、Hash に変換するのをやめます2

  def Cookie::parse(raw_cookie)
    cookies = Hash.new([])
    return cookies unless raw_cookie

    raw_cookie.split(/[;,]\s?/).each do |pairs|
      name, values = pairs.split('=',2)
      next unless name and values
      name = CGI::unescape(name)
      values ||= ""
      values = values.split('&').collect{|v| CGI::unescape(v) }
      if cookies.has_key?(name)
        values = cookies[name].value + values
      end
      cookies[name] = Cookie.new({{*name, *values*}})
    end

    cookies
  end

また CGI::Cookie#initialize() のほうも、せっかく名前と値を引数として受け取っても、内部でそれを Hash に変換して使っています。

    def initialize(name = "", *value)
      options = if name.kind_of?(String)
                  {{*{ "name" => name, "value" => value }*}}
                else
                  name
                end
      unless options.has_key?("name")
        raise ArgumentError, "`name' required"
      end

      @name = options["name"]
      @value = Array(options["value"])
      # simple support for IE
      if options["path"]
        @path = options["path"]
      else
        %r|^(.*/)|.match(ENV["SCRIPT_NAME"])
        @path = ($1 or "")
      end
      @domain = options["domain"]
      @expires = options["expires"]
      @secure = options["secure"] == true ? true : false

      super(@value)
    end

これもやはり無駄なので、引数が Hash かどうかを調べ、Hash でないときはより単純で高速な処理となるようにしました3。 これにより、余計な Hash が生成されるのを回避できます。

    def initialize(name = "", *value)
      {{*if name.kind_of?(String)*}}
        {{*@name = name*}}
        {{*@value = value*}}
        {{*%r|^(.*/)|.match(ENV["SCRIPT_NAME"])*}}
        {{*@path = ($1 or "")*}}
        {{*@secure = false*}}
        {{*return super(@value)*}}
      {{*end*}}

      {{*options = name*}}
      unless options.has_key?("name")
        raise ArgumentError, "`name' required"
      end

      @name = options["name"]
      @value = Array(options["value"])
      # simple support for IE
      if options["path"]
        @path = options["path"]
      else
        %r|^(.*/)|.match(ENV["SCRIPT_NAME"])
        @path = ($1 or "")
      end
      @domain = options["domain"]
      @expires = options["expires"]
      @secure = options["secure"] == true ? true : false

      super(@value)
    end

こういった細かい改善を必要とする箇所が、cgir.b では随所に見られます。

任意のサイズの HTTP リクエストデータを受け取ってしまう

cgi.rb では、受信する HTTP リクエストデータのサイズを確認していません。 そのため、例えば 10GB の動画ファイルを送られてきた場合、それを正直に受け取ってしまうため、サーバ資源を食い荒らされてしまいます。

これを防ぐには、Content-Length の値を確認し、大きすぎるようであれば受信しないようにする必要があります。 以下がそのためのパッチです4

--- /usr/local/lib/ruby/1.8/cgi.rb	2008-02-05 17:10:03.000000000 +0900
+++ cgi.rb	2008-02-05 13:16:03.000000000 +0900
@@ -905,10 +905,18 @@
     end
 
     params
   end
 
+
+  # Maximum content length of post data
+  MAX_CONTENT_LENGTH  = 2 * 1024 * 1024
+
+  # Maximum content length of multipart data
+  MAX_MULTIPART_LENGTH  = 128 * 1024 * 1024
+
+
   # Mixin module. It provides the follow functionality groups:
   #
   # 1. Access to CGI environment variables as methods.  See 
   #    documentation to the CGI class for a list of these variables.
   #
@@ -1104,11 +1112,15 @@
     def initialize_query()
       if ("POST" == env_table['REQUEST_METHOD']) and
          %r|\Amultipart/form-data.*boundary=\"?([^\";,]+)\"?|n.match(env_table['CONTENT_TYPE'])
         boundary = $1.dup
         @multipart = true
-        @params = read_multipart(boundary, Integer(env_table['CONTENT_LENGTH']))
+        content_length = Integer(env_table['CONTENT_LENGTH'])
+        unless content_length <= MAX_MULTIPART_LENGTH
+          raise StandardError.new("too large multipart data.")
+        end
+        @params = read_multipart(boundary, content_length)
       else
         @multipart = false
         @params = CGI::parse(
                     case env_table['REQUEST_METHOD']
                     when "GET", "HEAD"
@@ -1117,11 +1129,15 @@
                       else
                         env_table['QUERY_STRING'] or ""
                       end
                     when "POST"
                       stdinput.binmode if defined? stdinput.binmode
-                      stdinput.read(Integer(env_table['CONTENT_LENGTH'])) or ''
+                      content_length = Integer(env_table['CONTENT_LENGTH'])
+                      unless content_length <= MAX_CONTENT_LENGTH
+                        raise StandardError.new("too large post data.")
+                      end
+                      stdinput.read(content_length) or ''
                     else
                       read_from_cmdline
                     end
                   )
       end

ここでは簡単のためにデータの制限値を定数で指定していますが、柔軟性を高めるためにクラス変数やインスタンス変数にしてもいいでしょう。

任意の数のパラメータを受け取ってしまう

cgi.rb では、HTTP リクエストにおいてパラメータの数をチェックしていません。 しかしこれだと、multipart 時に問題になります。 なぜなら、multipart 時には cgi.rb はパラメータの値を Tempfile オブジェクト (または StringIO オブジェクト) に格納するためです。 つまり、たとえば 1 万個のパラメータがあれば 1 万個のテンポラリファイルがサーバに作成されてしまいます。

これだと都合が悪いので、multipart 時にはパラメータの数をチェックすべきです。 以下がそのためのパッチです(5)。

--- /usr/local/lib/ruby/1.8/cgi.rb	2008-02-05 17:10:03.000000000 +0900
+++ cgi.rb	2008-02-05 13:16:03.000000000 +0900
@@ -905,10 +905,15 @@
     end
 
     params
   end
 
+
+  # Maximum number of request parameters when multipart
+  MAX_MULTIPART_COUNT = 128
+
+
   # Mixin module. It provides the follow functionality groups:
   #
   # 1. Access to CGI environment variables as methods.  See 
   #    documentation to the CGI class for a list of these variables.
   #
@@ -982,11 +987,15 @@
         raise EOFError, "no content body"
       elsif boundary + EOL != status
         raise EOFError, "bad content body"
       end
 
+      count = MAX_MULTIPART_COUNT
       loop do
+        unless (count -= 1) >= 0
+          raise StandardError.new("too many parameters.")
+        end
         head = nil
         if 10240 < content_length
           require "tempfile"
           body = Tempfile.new("CGI")
         else

multipart 形式のときにすべての値を Tempfile にしてしまう

cgi.rb では、HTTP リクエストが multipart 形式かどうかを自動的に判定します。 それ自体は問題ないのですが、multipart だった場合にはどのデータも Tempfile (または StirngIO) オブジェクトに入れてしまいます。

これが問題で、たとえば本来 multipart でないはずのフォームで悪意あるユーザが multipart 形式の HTTP リクエストを送ってきた場合、CGI#[] で取り出した値が文字列ではなく Tempfile になってしまいます。

これを防ぐには、次のように値を取り出すときにいちいち multipart かどうかを調べる必要があります。 しかしこれはあまりに面倒です。

value = cgi.multipart? ? cgi['name'].read() : cgi['name']

ここで、multipart 形式が実際にどのようなものかを見てみましょう。 たとえば次のようなフォームがあったとします。

<html>
 <body>
  <form action="/cgi-bin/example.cgi" enctype="multipart/form-data">
   <input type="file" name="upfile" />
   <input type="text" name="comment" />
   <input type="submit" />
  </form>
 </body>
</html>

このフォームでデータを送信すると、たとえば次のような multipart 形式の HTTP リクエストが送信されます。

POST /cgi-bin/example.cgi HTTP/1.1
Host: www.example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAC5j+Mcb61ZiI+mZ
Content-Length: 352

------WebKitFormBoundaryAC5j+Mcb61ZiI+mZ
Content-Disposition: form-data; name="upfile"; filename="hoge.txt"
Content-Type: text/plain

foo
bar
baz

------WebKitFormBoundaryAC5j+Mcb61ZiI+mZ
Content-Disposition: form-data; name="comment"

sample file
------WebKitFormBoundaryAC5j+Mcb61ZiI+mZ

これを見れば分かるように、<input type=”file”> で送信したデータには Content-Disposition ヘッダに「filename=”…“」が付くのに対し、<input type=”text”> で送信したデータには付きません。 つまり、「filename=”…“」が付いたデータは Tempfile オブジェクトを作成し、そうでないデータは (multipart でない場合と同様に) String として扱えばよいことが分かります。

またデータが Tempfile のときと String のときとで、データを格納する Hash オブジェクトを分けるべきです。 たとえば通常のデータは cgi[‘name’] で取り出し、ファイルの場合は cgi.files[‘name’] で取り出すようにすれば、悪意あるユーザが multipart 形式で送ってきても、cgi[‘name’] では必ず String が得られることが保証されます。

comment = cgi['comment']      ## データは必ず String である
upfile = cgi.files['upfile']  ## データは必ず Tempfile である

以上をまとめると次のようになります。

  • multipart 形式でかつ filename が指定されているデータだけ、Tempfile にする。filename がない場合は、たとえ multipart 形式でも String にする。
  • Tempfile と String とで、データを格納する先を分ける。

こうすることで、悪意あるユーザが悪意ある multipart データを送ってきても、サーバ側で安全に値を取り出すことができますし、いちいち multipart かどうか調べる必要がありません。

なお上記 2 番目のアイデアは、PHP から拝借したものです。 PHP では通常の値は $_REQUEST[‘name’] で取り出し、ファイルの場合は $_FILES[‘name’] で取り出します。 このおかげで、PHP では multipart かどうか気にせずプログラムすることができます。

次のコードは、上記を満たすように CGI::QueryExtension::read_multipart() を変更した場合の疑似コードです。

  def read_multipart(boundary, content_length)
    params = Hash.new('')   ## 通常の値を格納する Hash
    files  = Hash.new       ## Tempfile を格納する Hash
    while パラメータがある
      if ファイル名がある
        filename = ファイル名
        value = Tempfile.new
      else
        filename = nil
        value = ''
      end
      while データの終わりでない
        value << $stdin.read(bufsize)
      end
      if filename
        params[key] = value
      else
        files[key] = value
      end
    end
    return params, files
  end

なお Ruby 1.9 の cgi.rb では、データサイズによって Tempfile と StringIO とを使い分けるようになっていますが、本来このようなことは不要であり、上記の仕様を満たせば StringIO を使わずすべて Tempfile でよいと筆者は考えています。

パラメータが単一の値をとるのか複数の値をとるのかわからない

HTTP リクエストでは、同じパラメータ名を複数回指定することができます。 例えば「http://localhost/?a=1&b=2&b=3&b=4」というリクエストがあった場合、パラメータ「a」の値は「1」ですが、パラメータ「b」の値は「2」と「3」と「4」になります。 つまり HTTP リクエストの仕様では、パラメータ名を見ただけでは値が複数あるかどうかを判定することはできません。

そのため、cgi.rb ではすべてのパラメータにおいて配列を用意しています。 具体的には、CGI::parse_query() が次のような定義になっています (コメントは筆者による追記)。

  def CGI::parse(query)
    params = Hash.new([].freeze)  ## 配列を値とする Hash オブジェクト
    ## QUERY_STRING を '&' または ';' で分割
    query.split(/[&;]/n).each do |pairs|
      key, value = pairs.split('=',2).collect{|v| CGI::unescape(v) }
      if params.has_key?(key)
        params[key].push(value)  ## 配列に追加
      else
        params[key] = [value]    ## 配列を作成
      end
    end
    ## Hash オブジェクトを返す
    params
  end

しかしこれだと、パラメータの値を取り出すのにいちいち「params[‘a’][0]」のようにしなければならず、面倒です。 またほとんどのパラメータは値を 1 つしかとらないのに、すべてのパラメータで配列を用意しなければならないのも無駄が大きいです。

この問題の根本的な原因は、パラメータ名だけでは値を複数とるのか否かがわからないことです。 つまり、パラメータ名を見ただけで値が複数かそうでないかを判定できればいいわけです。

これは HTTP リクエストの仕様だと無理のように思うかもしれませんが、そうではありません。 単に、複数の値をとるようなパラメータ名のルールをライブラリ側で決めればいいだけです。

たとえば PHP では、パラメータ名が「[]」で終わっていれば複数の値をとり、そうでなければ 1 つの値だけをとると決めています。 このようなルールを設定することで、上述のような問題を避けています。

cgi.rb と PHP の仕様を比べると、これは明らかに PHP のほうがよくできた仕様だといえます。 筆者としては、値が複数あることを表すなら「[]」よりも「*」のほうが好みなのですが、それはともかく、パラメータ名に何らかのルールを設定することで、値が複数かどうかにまつわる問題を回避できることがわかります。

以下に、「パラメータ名の末尾が「」なら複数の値をとり、そうでなければ値を 1 つだけとる」というルールにした場合の、CGI::parse() の定義を載せておきます。 こうすることで、params[‘a’] で単一の値が、params[‘b’] で複数の値が取り出せるようになります。

  def CGI::parse(query)
    params = Hash.new('')
    ## QUERY_STRING を '&' または ';' で分割
    query.split(/[&;]/n).each do |pairs|
      items = pairs.split('=', 2)
      key   = CGI::unescape(items.first)
      value = CGI::unescape(items.last)
      if key[-1] == ?*
        (params[key] ||= []) << value  ## 配列に追加
      else
        params[key] = value            ## 単一の値を設定
      end
    end
    ## Hash オブジェクトを返す
    params
  end

CGI 以外のプロトコルに対応できるだけの柔軟性がない

cgi.rb は CGI の仕様に強く依存しており、他のプロトコルである mod_ruby や FastCGI や SCGI には十分対応できていません。

これは cgi.rb に求めることがそもそも間違いとは思いますが、現在の Web プログラミング事情は複雑であり、いくつものプロトコルが乱立するのは避けられない以上、それらを抽象化して統一的に扱えるだけの柔軟性が cgi.rb にも求められます。

たとえば cgi.rb は標準で mod_ruby に対応していますが、コードを見ると「if defined?(MOD_RUBY)」による条件分岐が多数出現するなど、かなり「やっつけ仕事」感が漂っています。 やはりここはオブジェクト指向らしく、CGI と mod_ruby とで別クラスを用意し、継承やコンポジションを使うなどして柔軟性を高めてほしかったです。 そうすれば FastCGI や SCGI に対応させるときも、それ用のクラスを追加するだけで済んだことでしょう。

また、たとえば Ruby 用の FastCGI ライブラリである fcgi の中の fcgi.rb を見ると、CGI クラスを FastCGI でも使えるようにするため、かなりトリッキーなことをしています。 作者の苦労が偲ばれます6

# There is no C version of 'each_cgi'
# Note: for ruby-1.6.8 at least, the constants CGI_PARAMS/CGI_COOKIES
# are defined within module 'CGI', even if you have subclassed it

class FCGI
  def self::each_cgi(*args)
    require 'cgi'
    
    eval(<<-EOS,TOPLEVEL_BINDING)
    class CGI
      public :env_table
      def self::remove_params
        if (const_defined?(:CGI_PARAMS))
          remove_const(:CGI_PARAMS)
          remove_const(:CGI_COOKIES)
        end
      end
    end # ::CGI class

    class FCGI
      class CGI < ::CGI
        def initialize(request, *args)
          ::CGI.remove_params
          @request = request
          super(*args)
          @args = *args
        end
        def args
          @args
        end
        def env_table
          @request.env
        end
        def stdinput
          @request.in
        end
        def stdoutput
          @request.out
        end
      end # FCGI::CGI class
    end # FCGI class
    EOS
    
    if FCGI::is_cgi?
      yield ::CGI.new(*args)
    else
      exit_requested = false
      FCGI::each {|request|
        $stdout, $stderr = request.out, request.err

        yield CGI.new(request, *args)
        
        request.finish
      }
    end
  end
end

これを見ると、cgi.rb の設計に引きずられて他のライブラリの設計もまずくなるという悪循環を感じます7。 これが、たとえば CGI クラスが CGI#env_table() や CGI#stdinput() や CGI#stdoutput() ではなく、インスタンス変数を使うように作られていたら、fcgi.rb がこんなに悲惨なコードになることはなかったでしょう。

class CGI
  def initialize(type="query", opts={})
    ## @env, @stdin, @stdout, @stderr を用意して、
    ## メソッド env_table, stdinput, stdoutput を廃止
    @env    = opts[:env] || ENV
    @stdin  = opts[:stdin] || $stdin
    @stdout = opts[:stdout] || $stdout
    @stderr = opts[:stdout] || $stderr
    ...
  end
  ...

cgi.rb が作られたときに、CGI 以外のプロトコルを考慮していなかったのは仕方ないことです。 しかし今もなお対応できないのは問題だと思います。 cgi.rb でうまく対応できないというのであれば、やはり「次世代 cgi.rb」が求められます。

なお複数のプロトコルをサポートするためのライブラリとして、Rack が注目されています。Waves などのフレームワークでも採用されているので、興味のある人は Rack を調べてみてください。

テストスクリプトが用意されていない

cgi.rb には、テストスクリプトが用意されていません。 そのため、バグを修正するためにパッチを適用したとしても、別のバグを発生させていないかをチェックすることができません。

cgi.rb が開発された当時はまだ UnitTest が一般的ではなかったので仕方ないとはいえ、いまだに用意されていないのは大きな問題です。

筆者は cgi.rb で使えるテストスクリプトを用意したので、それを Ruby 本体にいれてくれるよう提案したのですが、何の反応もありませんでした。 残念です。

最後に

cgi.rb は、Ruby による Web プログラミングを促した、大変功績のあるライブラリです。 と同時に、数々の問題点を内包しているのも事実です。

本稿では、cgi.rb における問題点を具体的に挙げてみました。 またそれらに対する解決策を提案しました。 cgi.rb にとって代わる「次世代 cgi.rb」を作ろうという方は、参考にしてください。

なお筆者は cgi.rb を書き直した CGIAlt というライブラリを開発しています8。 本稿の内容は、この CGIAlt を開発したときの経験がもとになっています。 興味のある人は使ってみてください。


  1. [ruby-dev:33606] で提案済みですが、採用には至ってません。 

  2. [ruby-dev:34049] で採用されました。 

  3. [ruby-dev:34049] で採用されました。 

  4. [ruby-dev:33606] で提案済みですが、採用には至ってません。 

  5. [ruby-dev:33606] で提案済みですが、採用には至ってません。 

  6. ただし、FCGI::each_cgi() の中でクラス定義を毎回 eval() する必要はないように思います。 

  7. 必ずしも cgi.rb だけのせいとは言いませんが。 

  8. CGIAlt は、cgi.rb との互換性を保つという制限があるため、残念ながら次世代 cgi.rb にはなり得ません。