おまけ:開発後記

この文書は、僕の初めてのCGI制作において、いろいろと経験したことをテキトーに書き綴った開発後記です。間違って始めにこのファイルを開いてしまった方は、何はともあれ説明書の方を先にお読みください。

注意:読まなくてもMyWebSearchの利用にはまったく支障ありません。また、MyWebSearch設置前に読んでも全然意味が分からないと思います。さらに、設置後でもPerlの知識がないとあんまり分からないと思います。さらに困ったことには……分かったからといって、別に何かの役に立ったり、おもしろかったりするわけでもないということです(おい)。え〜と、あの、すみません……(^_^;)。

日記風ですが、だいたいできあがったあとに書いてます。

2001/01/24(Wed) 制作開始

……らしい(^_^;)。なんせ一カ月前のことなんで覚えてないけど、mwsearch.cgiの更新履歴によればこの日から作りはじめたようです。仕事の合間、会社の行き帰りの電車などの時間を利用して、少しずつ開発するしかないという身なれど、一カ月かかるとは思わなかったなぁ……。

もともと、去年の8月末にさくらインターネットに移転した際に、とほほのWWW入門にある「検索フォーム」を設置したのだけれど、TITLE要素に属性が付いている場合("lang=ja"など)に対応させるとか、結果表示が<B>タグで括られているのを<EM>に変えるとか、検索ヒット数を表示させるとかいった下手な改造が徒となったのか、特定の文字での検索がバグったりするようになっちゃいました。かといって、「日本語全文検索エンジンソフトウェアのリスト」、「全文検索ソフト(Perl版)徹底比較」なども参考にいろいろ探してみても、やはり「検索フォーム」以上に好みのものは見付からないし。「WASearch」はかなり好みに近かったんですが、う〜ん、検索対象の文字コードはShift-JISだけか……残念。何となくJISへのこだわりを捨て切れないのです。

そこで、どうせならCGI、Perlの勉強も兼ねて自分で1から作っちゃおう、というのが開発動機です。

Namazu使えば?」という話もあるのですが、うちのサイトくらいの小規模な所だと、インデックス作成型というのは運用の手間がちょっと……という感じですね。正直言ってオーヴァースペックかな、と。導入に関しては、さくらはCのコンパイルもできるので、まあ、ちょっと苦労するけど何とかなるとは思うのですが……。所詮共用サーバーなので、インデクサを動かすのは酷だろうしなあ。ローカルでインデックス作成→サーバーに転送、という手法もあるようですが、やはり何よりも、細々した修正のたびにインデックスを再作成するのって、どうなんだろ……という感じでして(結局、ただのめんどくさがりじゃん(^_^;))。

というわけで、基本的には自分用だけど一般配布も考慮して、gerp型で、導入は比較的容易、だけどちょっぴり高機能なものを、というコンセプト(ってほどのものじゃないか)で制作を開始しました。

制作開始時の、だいたいの仕様は以下のとおり。

とまあ、だいたい完成品と変わりません。一つ実装されていないものがありますが、それについてはのちほど(笑)。

ちなみに、初めてPerlで(10行程度の小物以外の)プログラム組むにあたっての、裏コンセプト(?)というものも存在しました。

2001/01/2x(XXX) Cっぽく書く計画のために

まずは形から入るのです……。というわけで、「構造体を実現する」のと「BOOL型を作る」から始めることに。構造体は、連想配列を使って実現する方法をいくつか見かけたけど、どうもな〜。と思っていたら、そのまんまClass::Structなる標準モジュールがあるではないですか。解決。

BOOL型は、要は0をFALSE、1をTRUEとして#defineしちゃえばいいわけだけど、Perlに#defineはないみたい。どうしようかなあ、と思っていたら……なんだ、constantプラグマで同じことできるじゃん。よ〜し、両方解決!

でもこういう、独自のスタイルって、ホントはよくないんだよなぁ……。とくにその言語に精通していない者がやることじゃない。ま、いいや。趣味でやってるんだし(^_^;)

2001/01/2x(XXX) ファイルリストアップ処理できあがり

たぶん制作開始の2、3日後だと思いますが……。

まずは検索を行なうファイルをリストアップする処理から、ということで、モジュールないかな〜と探してみたら、そのものずばり、「File::Find」なるものがありました。うん、こりゃらくちんだ。WindowsのFindFirstFile系APIのノリで、いやもっと簡単に、サブディレクトリまで含めたファイルを再帰的に処理できます。あとは、スキップ対象のディレクトリや、検索対象でない拡張子の場合はコールバック関数でreturn FALSEすればOK、と……。

ここまでできた段階で、初めてさくらインターネットのサーバーに転送して試してみたり。うん、ちゃんとリストアップしとるなあ。やった!

……このころはよかったなぁ、何も苦労を知らなくて……。

2001/01/30(Tue) プロトタイプ完成。だけど……重い!

最適化などは後回し、何はともあれ動くものを、ということで、プロトタイプが完成しました。まだスコアリングも何もしてないけど、「検索処理を行ない、ヒットした場合は最初にヒットした段落を抜き出して、タイトル、フルパス名(このころはまだURLに置換してなかった)を表示」という基本的な動作はできあがり。

ローカルに構築した実験環境(AN HTTPDActivePerl)でも無事動いたし、いざ、サーバーでテスト!

……出力が途中で止まる……なんでや…………(泣)。

もともとgrep型というのはサーバーへの負荷が高いわけで、さらにMyWebSearchでは、(たいていは1行ずつ読み込んでヒットした時点で検索をやめるものを)ヒット件数を調べたいがためにファイルを全部読み込んでお尻まで検索してたり、(たいていは、少なくとも出力に関しては特定の文字コードに依存することによって処理を減らし、負荷を低減するものを)入力、出力ともに文字コードはJIS、Shift-JIS、EUCどれでもよいようにしたりと、それなりに「重い」処理をしているのは確かなんですが、それでも「初めてをサーバーで動かしてみたら、落ちた」というのはなかなかアレでした。ここでやっと、CGIってのはサーバーの負荷も考えて作んなきゃならんよなぁ、というあたりまえのことに気が付いてみたり。昨日までの僕は何も知らないボンボンだったさ……。

この時点で「HTMLの論理構造を加味したスコアリング」計画は当然のごとく中止となります(^_^;)。

2001/01/31(Wed) 暴走の果てに

負荷低減のためにあがいてみた日。改行コードの統一処理を外してみたり(この時点で、CRのみの文書は使用不可に)、タグの除去などをして本格的な検索を行なう前に、index関数を使って


#負荷低減処理 AND検索または検索語が一つで、indexに失敗したら即座にRETURN FALSE
if(($szOption eq 'and') || (@szSearchWords == 1)){
    if(index($szDocument,$szSearchWords[0]) == -1){
        return FALSE;
    }
}

なんて処理を書いてみたり(結局その後、AND検索では大文字小文字の同一視をするようになったので廃止)、ファイルの内容を一気に読んでいたを、1行ずつにしてみたら、かえって負荷が上がって泣きそうになってみたり、ヒット数のカウント、それによるソート処理を外してみようかと考えて、次の瞬間「そこまで機能落としたら自分で作る意味がねぇ!」と却下してみたり。

こういった試行錯誤の末、それなりに軽くなった……はずなのですが、それでもまだ、検索内容によっては途中で止まってしまう。今でこそ、「ある程度は仕方ない」と思えますが、当時は「初テストで途中終了」のショックから抜け出せず、ありもしない完璧を求めていたのですね(「あ or い or う or え or お」なんて条件でテストして、「途中で止まる〜」って、そりゃ当たり前だっての)。そしてたどり着いた結論が……。

「こーなったらC言語で作ってやる!」
……ってオイ、「導入は比較的容易」って話はどこ行ったよ(^_^;)

で、資料を求めてネットを漁りはじめましたが、やはりPerlに比べてCで作るCGIというのは圧倒的に扱っているページが少ない。一応、CGIモジュールに相当するものや、文字列処理用のライブラリなんてのもありましたが、Perlと違ってバッファオーバーフローとかにも気を付けなきゃいけないし、やっぱり、素人が手を出すもんじゃないなあ、というのが結論で、C言語計画は3時間ほどで頓挫しました(めでたしめでたし)。

そのかわり、最大ヒット数を設定し、これを超えたらメッセージを出して以降は検索しない、という処理を加えました。

今にして思えば、どうも単にこの二日間くらい、さくらのサーバーが調子悪かったって話もあります。その後結局いろいろ機能を追加しても、だいたい順調に動いてたからなぁ……まあ、負荷に対して鈍感だった昨日までの自分を捨てれたから(って何それ)、結果オーライかな。

2001/02/03(Sat) nkf使用開始

というわけで、文書を読みこんで検索を行ない、結果を表示する、という最低限の処理は実装。ここまでは、影響を受けたくないのでほかの検索スクリプトのソースを覗いたりはしなかったのですが、ちょっと休憩がてら、ということで、有名どころを2、3覗いてみました。参考にするってよりはほとんどひやかしだったけど、一つだけ、某スクリプトが使っていた、(読み込んだ文書をjcode.plで処理するのではなく)文書を読み込む際に、パイプ処理を利用して、nkfでコード変換を行なうというテクニックをいただきました。おお、CPU処理時間にして2倍速い!

2001/02/08(Thu) テンプレート処理を実装

そろそろ表示部分に手を入れようかな、と思って、一応このスクリプトの肝であるテンプレート処理をどうするか考えてみたり。

HTML::Templateというそのものずばりなモジュールがあるんで使いたいなと思ったのですが……ううん、残念。標準モジュールではないのか。変数の代入とか条件分岐とかループ処理とか、欲しい機能は全部あるんだけどな。

結局、テンプレートファイルにマクロ文字列を埋めこみ、出力時に変数で置換、という感じに落ち着きました。原始的……。

2001/02/09(Fri)〜2001/02/15(Thu) ゆっくりと機能追加

このころから仕事が忙しくなって、ほとんど時間が取れなくなっちゃいましたが、会社の行き帰りなんかを利用して開発を続行。ログ出力処理の実装、正規表現検索に対応、ついでにできそうだったので完全一致にも対応、エラー処理……などなど、ちょっとずつ機能を追加して行きました。

2001/02/16(Fri) 各地で動作テスト開始

だいたい形になってきたので、いろんなサーバーで試してみたいな、ということで、

での動作テストを開始。え〜と、これ、無料のWeb開設サービスはもちろんのこと、プロバイダも全部自分で入りました(爆)。クレジットカードがあれば、オンライン入会して、その場でもうWeb公開スペースが使えるようになるプロバイダって多いですね。便利便利(っておい……)。

そのほか、

にも入会しましたが、Web公開用スペースについては、まだ処理中でした。

で、動作テストでは早速@niftyでつまづいてみたり。ちょっと前に調べて知ってはいたけど、Perl 5の標準モジュールが一切入ってないってどういうことよ? それで「Perlが使えます」ってのはどうかと思う。何はともあれ、ユーザーが多い@niftyを見捨てるわけにもいかないなぁ。手動でモジュールを組み込んでどうにかする方法をドキュメントに書かなきゃなあ。むう。

ほかは、Hi-HOとBEKKOAMEでは動きませんでしたが、それ以外はだいたい順調かな、と。

2001/02/18(Sun) ドキュメント執筆

まる一日かけて、やっと80%ってところでしょうか……うっわ、もう原稿用紙50枚分書いてる。
そうそう、プロバイダの退会もさっさと済まさないと、たいへんなことになるぞ(^_^;)。

2001/02/20(The)〜2001/02/23(Fri) 「Effective Perl」読んでいろいろリファイン

NetLaputa、ミルトクラブでの動作確認も終わって、あとはドキュメントを仕上げるだけ……というつもりだったのですが、ふと、CGIメーリングリストでちょっと話題になっていた「Effective Perl」(Joseph N. Hall/Randal L. Schwartz著/吉川邦夫訳/アスキー)が気になったので買ってきました。何か使える部分があればいいな、と思ったのですが……ありまくり(^_^;)。3日ほどかけてMyWebSearchに反映しました。以下は反映内容です。[xx]内の数字は項目番号なり。

[11]入力セパレータをなしにして、ファイルを一度に読み込むように

これまでは、もっとうまい方法はあるんだろうなぁと思いつつ、


open(TEMPLATE,"< $MWS_Config::szListFile");
while($szTemp = <IN>){
    $szDocument .= $szTemp;
}
close(IN);

なんてやってたんですが、特殊変数の$/で、入力セパレータを指定できることを知りました。というわけで


open(TEMPLATE,"< $MWS_Config::szListFile");
$szTemplate = <TEMPLETE>;
close(TEMPLATE);

てなかんじで、うん、すっきり。パフォーマンスも多少上がるらしい……。メモリはこっちのほうが食うらしいけど(^_^;)。

[54]設定ファイルの読み込みはdoで行なうように

これまではrequireでやってたのですが……うわあ、呼ばれた側は最後に1を返さなきゃいけなかったのか。今まではたまたまうまく動いてたけど、設置者が「使わないからいいや」って空文字でも設定した日にゃあ……くわばらくわばら。というわけでめんどうのないdoに変更。

[36][42]strictプラグマを使用、設定ファイルにpackegeで名前空間を設定

……って、ほぼ完成してからやるもんじゃないですね、use strictなんて。本にも「最初から使うべきである」ってあるし。存在は知ってたんですが、この本を読むまではその有用性に気が付かなかったという……もっと早く読んどきゃよかった。

ちょっと問題になったのはdoで読み込んだ設定ファイル側の変数の数々。一瞬

use vars qw($szTargetPath $szHomeUrl $szFindExt $szSkipDir $nMaxHit $szLogFile $szNkfPath $szOutputCharCode $szHeaderFile $szListFile $szFooterFile $szHitWord_Pre $szHitWord_Post $szMsg_Hit_Pre $szMsg_Hit_Post $szMsg_Over $szMsg_Nohit $szNoTitle);

なんて大馬鹿なことをしかけましたが、設定ファイル側でpackage宣言すりゃいいんだってのを気が付いて一件落着。

[36]フォーム入力のエラーチェックを強化(汚染チェック)

汚染チェック(perl -T)は公開前にいつかはやろうと思っていたのですが、ちょうどよい機会なのでstrict化と同時にやることに。引っかかったのは、jcode.plを読み込む部分と、フォームから受け取った値(config)を使って設定ファイル(config.plなど)を読み込む部分、設定ファイルから読み込んだ変数を利用して、nkfをパイプで呼び出す部分、あとはFile::Findの内部処理で、chdir()するところ……といったところでしょうか。jcode.plについてはちゃんと同梱しているもの(か一次配布元から持ってきたもの)を使えば問題ない&それは設置者に任せることだし、nkfは外部から取り込んだ値とはいえ、ユーザー入力ではなく、設置者が設定ファイルに記述した値だからこれも信頼に足るものだし、標準モジュールの内部処理については、こっちでどうこうできるものでもないし。

問題はフォーム入力を利用してdoで実行するファイル名を決めてる所だな……。うん、これも以前から、ファイルチェック(-f)を行なって、ファイルが存在しなければエラーを出すようにしてるから、問題ないでしょう。

と、そこまで終えて朝の9時。一段落付いたから寝よう、ということで寝て、夕方起きて……はたと気が付いた。それじゃ全然ダメじゃん! 同じサーバーにたとえばattackerってユーザー名の攻撃者(そのまんまやね……ってまあ例だから)が居て、configに「/home/attacker/worm」なんて値を入れてフォームを送信してきたら……そいつが自分のディレクトリに仕掛けた/home/attacker/worm.plが、nobodyや、CGI設置ユーザーの権限で実行されちまう! 結果として破壊工作の手助けをする羽目になるわけです。攻撃者も同じサーバーの住人だから自爆っちゃあ自爆だけど、もともと攻撃目的なら知ったこっちゃないだろうし。

というわけで、Cwdモジュールを使ってカレントディレクトリを取得し、

$szConfig = getcwd() . "/$szConfig.pl";

って感じでOK……かな?

……と思ったら、まだまだOKじゃなかった。相対パス使われたらどうすんじゃぁ〜。これについては、某掲示板で覚えたおまじない「$szConfig =‾ s/¥W//g;」を追加して、ふぅ、やっとこれで大丈夫……だよ……ね……。

それにしても、つ、疲れた……。MyWebSearchの制作で、いちばん神経すり減らしたところかな、ここは。設定ファイル名決め打ちにしちゃえば、こんな苦労もないんですが(^_^;)。

[54]ログファイルのロックはevalで実行するようにした

これまでは、「flockに対応してない環境ではエラーになるけどあきらめれ」なんてひどいことをドキュメントに書いていたんですが、evalでやりゃあいいよな、っということで。ドキュメントは「flockに対応してない環境ではエラーは出ないけどな〜んもロックかからんから気を付けれ」という記述に変更。ってどっちもひどいな、おい……。

[26]サブルーチンへの引数渡しをリファレンスで行なうようにした

今まではグローバル変数にしてたんですが、やっぱ気持ち悪いなぁ、ということで。めんどくさくてやらなかったけど、本読んだらやりたくなりました。

でもこの項目に載ってる、リファレンスを型グロブにして別名変数を作る、というのはやりたくないなあ。localを使わなきゃいけないのがいやだし、それ以前にuse strictしてるからlocal変数を宣言するにもuse varsしなきゃいけないし(間違ってるかも)、それじゃグローバル変数にするのといっしょだし。でも、10%早くなるって書いてあるなぁ……。

う〜ん、悩んだときにはuse Benchmarkだっ! といわけでやってみました。normalが、普通に、使うごとにデリファレンスする方法、effectiveが型グロブに代入して別名を付ける方法です。それ以外の部分をすべて同じにしたコード(DeleteTagサブルーチンをそのまま利用)を、10万回(<やりすぎ(笑))ほど回してみました。もちろん、ローカル環境で、ですよ。


Benchmark: timing 100000 iterations of effective, normal...
 effective: 67 wallclock secs (67.34 usr +  0.00 sys = 67.34 CPU) @ 1485.00/s (n=100000)
    normal: 65 wallclock secs (65.08 usr +  0.00 sys = 65.08 CPU) @ 1536.57/s (n=100000)

なんだ、誤差の範囲じゃん。てゆうか普通にやった方が微妙ながら速いし(10万回で2秒ってのは、気にする必要がある数値とは思いませんけどね)。というわけで心置きなく$$hogehogeを連発することに。

[15][54]正規表現検索時、不正なキーワードでないかevalでチェックするようにした

おー、ユーザーからの間違った正規表現により、ランタイムエラーが発生するというのは盲点でした。当然と言えば当然だけど、「ユーザーが悪いんじゃ、わしゃ知らん」と言って放っておいてよいものではもちろんないですね。というわけで、正規表現が指定された場合は、検索に入る前にダミーのパターンマッチをevalで行ない、失敗したらエラーメッセージを出して強制終了、という処理を組み込みました。コードは以下のような感じ。


#正規表現検索の場合:不正な正規表現(エラーを引き起こすようなもの)ではないかどうか調べる
if($szOption eq 'regular'){
    if(!defined eval{'test' =‾ /$szInput/}){ #evalでパターンマッチを行ない、未定義値が返ってきたら
        &jcode::convert(¥$MWS_Config::szErrorMsg_Regular,$MWS_Config::szOutputCharCode);
        print("<STRONG>$MWS_Config::szErrorMsg_Regular<STRONG>"); #エラーメッセージを出力
        exit; #強制終了
    }
}

2001/02/24(Sat) ドキュメント完成、あとは……

やっとこさINTERLINKからWebページ関係の設定情報が届いたので、早速FTPサーバーに繋いでみたり。え〜っと、いつものようにNextFTPの「ホームページ転送機能」で転送して……あれ、転送したファイルがサーバー上で見えないぞ。書き込み自体にエラーは出てないけど、う〜む、バグかしらん?

とりあえず、接続先である/home/[ユーザー名]からディレクトリを一つ上がってみたら、一覧の読み込みに数分かかかったので、何が起ったかと思いましたが、要はこれ、一つのサーバーに全ユーザーのデータが入ってるんですな。21,940個のディレクトリがありました(笑)。中にはユーザーディレクトリ以外のものもあるみたいで、「自動引き落とし」、「銀行振り込み」なんて日本語のディレクトリがあったりして。んな大事そうなもん、Web公開用サーバーと同じところに置いといて大丈夫すか? って、当然所有者はroot、所有者以外は読み込み不可ですが。

っと、横道はこれくらいにして、自分のディレクトリの設定を確認…‥して呆然とす。なんだこの、341(-wxr----x)なんちゅう無茶苦茶なパーミッションは! オーナーが読み込み不可だと、何考えてんだ!

僕以外のディレクトリでも、たまに見かけるけど、メンテナンスのミス? なんだかなぁ〜、と思いつつ、パーミッションを変更し、無事転送に完了。動作チェックも基本的に問題ありませんでした。ちょっと重いかな……。

ドキュメントの方も、校正(といっても自分でやってるだけだけど)が無事終了し、一段落。あとは……開発後記を書かねば(笑)。こんなもん付けるっていつ決めたんだっけなぁ。まあ、いいや。

2001/02/26(Mon) 今度こそ最後の仕上げ、かな?

開発後記を書きながら、もう一度コードを眺めてみたり、Perl・CGI関係のWebサイトなどをめぐってみたり。あ、「CGI Program Security Advisories」に「CGI and Security」なんてページができてる。早速読んでみよう……。

汚染関係はもう、さんざんチェックしたのでよいとして(と言いつつもう一回見直しちゃったけど)、「不正な入力」という部分が非常に参考になりました。

外部から与えられるデータをそのまま扱うことで、無限ループを引き起こす・大量の資源 (CPU・メモリ) を消費するなどの問題がある。

さて、MyWebSearchに該当しそうなところは……おお、あるある(苦笑)。AND、OR検索の場合は、keywordsで与えられたデータをスペースで分割して配列に放り込み、foreachでキーワードの数だけパターンマッチを行なっているから、たとえば「a a a a a a a a a a a a a a a a a a(.....以下500回繰り返し)」なんてやられたらイチコロね(^_^;)。そこで、配列に放り込んだあとに要素数を数えて、指定した数以上ならエラーで強制終了することに。ついでに重複する値はまとめちゃいましょう。配列内の重複する要素の削除ってどうすればいいかな〜。冗長な方法は思い付くけど、うまい手は無いか、調べてみよう……ということで、Googleで「perl 重複 配列 削除」てな感じで検索してみたら、いちばん始めに大崎さんの「Perlメモ」がヒット。タグの削除とパターンマッチの部分で大いに参考(ってゆうかほとんど流用)させてもらっているのに、ほかをちゃんと読んでないのバレバレ(^_^;)。

てな感じで、こんなコードになりました。


my %haTemp;
@szKeyWords = grep(!$haTemp{$_}++,@szKeyWords); #重複する値を削除
if($MWS_Config::nMaxWord < @szKeyWords){ #キーワードの数が10個を超えたら
    OutputTemplate('Error',$MWS_Config::szErrMsg_WordNum); #エラー出力
    exit; #強制終了
}

また、これを機に、従来ただのテキストを1行出すだけの手抜きだったエラー出力も、ちゃんとしたテンプレートを用意して行なうことにしました。

ついでに、しつこくコード全体の見直し。相変わらずちょこちょこと気になるところが出てくるのはいかなることか。もう、この辺でやめておいたほうがいいかもしれない。もう眠いし、完成ってことにしちゃって、いいかな? うん、そうしよう。いつまでやってもキリないし……。

[EOF]オチなし


Copyright©2001 Yujiro Nakamura(yujiro@finalbeta.jp) All rights reserved.