cgi.rb がイケてない 12 の理由
初稿:2008-03-31
著者: 桑田 誠
はじめに
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 されるようにするのがいいでしょう。
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() は、次のようなコードになっています。
一見して分かるように、洗練されているとはいい難いコードです。
具体的には次のような点が気になります。
- buf を空文字列で作成した直後に文字列を追加している
- String#<< ではなく += を使っている
- if 文の後置記法を使ってない
- 無駄に行が空いている
これを書き換えると、次のように大変簡潔になりました。
他にも、たとえば 989 行目では「10240」というマジックナンバーがでてきます。
実はその前の 973 行目で「bufsize = 10 * 1024」という変数を設定しているのですが、なぜかそれを使わず、マジックナンバーを直接使ってしまっています。
これら以外にも、格好悪いコードが目立ちます。
筆者は、標準添付されるライブラリは初心者が読んで勉強になるコードであってほしいと思っているので、cgi.rb のコードには残念な感が否めません。
cgi.rb もぜひ添削してほしいところです。
読者にひとつ問題を出しましょう。
次のコードは CGI::unescapeHTML() のコードです。
これを書き換えるとしたら、みなさんならどうしますか?
挑戦される方はご自分のブログに書いて、本記事に trackback してください。
解答された方から抽選で豪華賞品が! …… 当たるわけありませんのであしからず。
読み込みが遅い
cgi.rb の読み込みは結構遅いです。
どのくらい遅いか調べるために、次のようなスクリプト「bench.rb」を用意しました。
これを使って、
- Ruby の起動時間
- 「require “cgi”」を伴う Ruby の起動時間
- 「require “erb”」を伴う Ruby の起動時間
を調べてみました (erb は比較のためです)。
これを見れば分かるように、「require “cgi”」にかかる時間は Ruby プロセスの起動よりも時間がかかっています。
「require “erb”」にはそれほど時間がかかってませんが、これは cgi.rb が 2303 行あるのに対し、erb.rb が 826 行と少ないためです。
ということは、cgi.rb のサイズが大きい以上、require にかかる時間は早くできないのでしょうか。
実はそうでもありません。
cgi.rb の読み込みが遅いのは、ファイルサイズも原因ですが、最大の原因は CGI::Cookie クラスが親クラスとして DelegateClass(Array) を指定していることです。
CGI::Cookie クラスは、1 つのクッキー名に対して複数の値をとることができるようになっています。
そのため、CGI::Cookie クラスを Array クラスのように見せかけるために、DelegateClass(Array) を使っているのだと思われます。
しかし DelegateClass() を使うのはかなりコストのかかる処理であるため、毎回ライブラリを読み込む必要のある CGI プログラムにはうれしくありません。
そもそもクッキーの仕様 (RFC2965) では複数の値を取るようには書かれていませんし、かりに複数の値を取ることができたとしても Array と完全互換である必要はまったくないはずです。
そこで、DelegateClass(Array) を使わないように書き換えてみましょう。
以下がそのパッチです。
これを適用して再度計測してみると、筆者の環境で 4.904 秒かかってたのが 4.081 秒 になりました。
約 20 % の改善です。
これ以上の改善となると、コードサイズを減らす必要があります。
筆者が試した限りでは、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 を生成して渡しています。
しかし CGI::Cookie.new() は Hash に変換しなくても名前と値をそのまま受け取ることができるので、Hash に変換するのをやめます。
また CGI::Cookie#initialize() のほうも、せっかく名前と値を引数として受け取っても、内部でそれを Hash に変換して使っています。
これもやはり無駄なので、引数が Hash かどうかを調べ、Hash でないときはより単純で高速な処理となるようにしました。
これにより、余計な Hash が生成されるのを回避できます。
こういった細かい改善を必要とする箇所が、cgir.b では随所に見られます。
任意のサイズの HTTP リクエストデータを受け取ってしまう
cgi.rb では、受信する HTTP リクエストデータのサイズを確認していません。
そのため、例えば 10GB の動画ファイルを送られてきた場合、それを正直に受け取ってしまうため、サーバ資源を食い荒らされてしまいます。
これを防ぐには、Content-Length の値を確認し、大きすぎるようであれば受信しないようにする必要があります。
以下がそのためのパッチです。
ここでは簡単のためにデータの制限値を定数で指定していますが、柔軟性を高めるためにクラス変数やインスタンス変数にしてもいいでしょう。
任意の数のパラメータを受け取ってしまう
cgi.rb では、HTTP リクエストにおいてパラメータの数をチェックしていません。
しかしこれだと、multipart 時に問題になります。
なぜなら、multipart 時には cgi.rb はパラメータの値を Tempfile オブジェクト (または StringIO オブジェクト) に格納するためです。
つまり、たとえば 1 万個のパラメータがあれば 1 万個のテンポラリファイルがサーバに作成されてしまいます。
これだと都合が悪いので、multipart 時にはパラメータの数をチェックすべきです。
以下がそのためのパッチです()。
multipart 形式のときにすべての値を Tempfile にしてしまう
cgi.rb では、HTTP リクエストが multipart 形式かどうかを自動的に判定します。
それ自体は問題ないのですが、multipart だった場合にはどのデータも Tempfile (または StirngIO) オブジェクトに入れてしまいます。
これが問題で、たとえば本来 multipart でないはずのフォームで悪意あるユーザが multipart 形式の HTTP リクエストを送ってきた場合、CGI#[] で取り出した値が文字列ではなく Tempfile になってしまいます。
これを防ぐには、次のように値を取り出すときにいちいち multipart かどうかを調べる必要があります。
しかしこれはあまりに面倒です。
ここで、multipart 形式が実際にどのようなものかを見てみましょう。
たとえば次のようなフォームがあったとします。
このフォームでデータを送信すると、たとえば次のような multipart 形式の HTTP リクエストが送信されます。
これを見れば分かるように、<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 が得られることが保証されます。
以上をまとめると次のようになります。
- multipart 形式でかつ filename が指定されているデータだけ、Tempfile にする。filename がない場合は、たとえ multipart 形式でも String にする。
- Tempfile と String とで、データを格納する先を分ける。
こうすることで、悪意あるユーザが悪意ある multipart データを送ってきても、サーバ側で安全に値を取り出すことができますし、いちいち multipart かどうか調べる必要がありません。
なお上記 2 番目のアイデアは、PHP から拝借したものです。
PHP では通常の値は $_REQUEST[‘name’] で取り出し、ファイルの場合は $_FILES[‘name’] で取り出します。
このおかげで、PHP では multipart かどうか気にせずプログラムすることができます。
次のコードは、上記を満たすように CGI::QueryExtension::read_multipart() を変更した場合の疑似コードです。
なお 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() が次のような定義になっています (コメントは筆者による追記)。
しかしこれだと、パラメータの値を取り出すのにいちいち「params[‘a’][0]」のようにしなければならず、面倒です。
またほとんどのパラメータは値を 1 つしかとらないのに、すべてのパラメータで配列を用意しなければならないのも無駄が大きいです。
この問題の根本的な原因は、パラメータ名だけでは値を複数とるのか否かがわからないことです。
つまり、パラメータ名を見ただけで値が複数かそうでないかを判定できればいいわけです。
これは HTTP リクエストの仕様だと無理のように思うかもしれませんが、そうではありません。
単に、複数の値をとるようなパラメータ名のルールをライブラリ側で決めればいいだけです。
たとえば PHP では、パラメータ名が「[]」で終わっていれば複数の値をとり、そうでなければ 1 つの値だけをとると決めています。
このようなルールを設定することで、上述のような問題を避けています。
cgi.rb と PHP の仕様を比べると、これは明らかに PHP のほうがよくできた仕様だといえます。
筆者としては、値が複数あることを表すなら「[]」よりも「*」のほうが好みなのですが、それはともかく、パラメータ名に何らかのルールを設定することで、値が複数かどうかにまつわる問題を回避できることがわかります。
以下に、「パラメータ名の末尾が「」なら複数の値をとり、そうでなければ値を 1 つだけとる」というルールにした場合の、CGI::parse() の定義を載せておきます。
こうすることで、params[‘a’] で単一の値が、params[‘b’] で複数の値が取り出せるようになります。
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 でも使えるようにするため、かなりトリッキーなことをしています。
作者の苦労が偲ばれます。
これを見ると、cgi.rb の設計に引きずられて他のライブラリの設計もまずくなるという悪循環を感じます。
これが、たとえば CGI クラスが CGI#env_table() や CGI#stdinput() や CGI#stdoutput() ではなく、インスタンス変数を使うように作られていたら、fcgi.rb がこんなに悲惨なコードになることはなかったでしょう。
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 というライブラリを開発しています。
本稿の内容は、この CGIAlt を開発したときの経験がもとになっています。
興味のある人は使ってみてください。