ISAPI フィルタで URL 変換をするための調査
SPECIAL
?ID=... というような引数だと検索エンジンが拾ってくれないようなので、URL に直接 ID を埋め込むための ISAPI を作ってみようと思います。
ISAPI フィルタ
ISAPI とは Internet Server API ( Application Programming Interface ) の略で、これを使うことで Microsoft 社の IIS ( Internet Information Server ) を拡張することが出来ます。
そして ISAPI フィルタとは、その ISAPI を利用して、サーバへの要求、または応答を、途中で処理して都合のいい形にデータを処理するものです。
どうやら検索エンジンは GET メソッドの引数ごとは拾ってくれないようなので、データベースを利用していると、/detail.asp?id=xxx みたいな URL が多くなって、検索に引っかかる割合が減ってしまいます。
なので、/xxx.detail みたいな感じの URL をフィルタ処理することで、目的のページまでたどり着けないかなぁ…、というのが今回のテーマです。
フィルタ案
さて、今回どのようなフィルタを作ろうとしているかを書いてみます。
サーバへ、たとえば http://program.ez-net.jp/notes/program/details/0001.memo という URL を要求します。
このとき、ISAPI フィルタが要求をキャッチして、解析すべき URL であるか判断します。あらかじめ /notes/ が解析対象であることがフィルタに登録されていて、今回の要求がそれに一致するので解析を行います。
解析が行われ、次のような環境変数に値が設定されます。
EZ_URLPARAM_TYPE | memo | 指定された拡張子です。 |
---|---|---|
EZ_URLPARAM_TARGET | 0001 | 指定されたファイル名です。これを ID として使用します。 |
EZ_URLPARAM_OPTIONS | program/details | 途中の URL です。"/" 区切りで引数として使用します。 |
あとは、あらかじめ設定されたファイルへ物理パスがマップされる仕組みです。
値の保存に環境変数を使用するのは、下調べによるとどうやら Request オブジェクトがすぐに取り扱えそうな状況ではなさそうだったということと、OnPreprocHeaders() を利用すればヘッダの編集が容易そうであったということからです。
実現のための実験
個人的に ISAPI フィルタを作るのは初めてだったので、いろいろと実験してみました。
URL から物理パスを取得
とりあえず OnUrlMap() 関数をいじって、全ての URL リクエストがひとつのファイルに終結するようにしてみました。
DWORD CURLParamFilterFilter::OnUrlMap(CHttpFilterContext* pCtxt, PHTTP_FILTER_URL_MAP pMapInfo)
{
strcpy(pMapInfo->pszPhysicalPath, "D:\\WorkArea\\URLParamFilter\\test.asp");
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
Visual C++ 6.0 が作ってくれた雛形に、ほんの一行追加するだけで、求められた URL にかかわらず、一定のファイルを返す IIS が完成しました。
ASP スクリプトは処理されるのか
さて、飛び先が ASP ですので、ちゃんと ASP が処理されるかを調べてみました。
http://localhost/workarea/URLParamFilter/default.asp という要求を出してみたところ、ちゃんと ASP は処理されました。では、http://localhost/workarea/URLParamFilter/default.html とすると…?
あらら…、ファイルのダウンロード要求になってしまいました。
ちょっとダウンロードしてみると、なんと ASP スクリプトが混ざった形でダウンロードできました。どうやらオリジナルの拡張子によって、ASP などの処理がなされるかが決定されるようです。
ちなみに、ASP 独自の拡張タグを取り除いてみたところ、ダウンロード要求にならずに、ちゃんと HTML ドキュメントが表示されました。
じゃあ、.data という拡張子のファイルを ISAPI の中で指定するとどうなるか…。
プログラムを変更して http://localhost/workarea/URLParamFilter/default.asp へ要求を出してみると…。ちゃんと ASP スクリプトが処理されました。
URL の拡張子さえ正しければ、物理パスへのマップ時の拡張子はどうでもいいようですね。
HTTP ヘッダを追加してみる
今度は HTTP ヘッダを追加してみることにします。
ウィザードが用意してくれた OnPreprocHeaders() を編集して、ひとつだけヘッダを登録するようなプログラムを作ってみることにします。
DWORD CURLParamFilterFilter::OnPreprocHeaders(CHttpFilterContext* pCtxt, PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo)
{
pHeaderInfo->AddHeader(pCtxt->m_pFC, "EZ_URLPARAM_TYPE", "test");
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
このようにプログラムを組んでみましたけど、どうやらその先の ASP では、ServerVariable コレクションをつかってこのヘッダを見つけることは出来ないようでした。
調べてみると、ヘッダ名には最後に ":" をつけないといけないとのこと。
逆に、"method", "url", "version" という、":" を最後につけない特別な文字によって、簡単に要求処理が出来るように工夫されているそうです。
さっそく、ヘッダ名に "EZ_URLPARAM_TYPE:" というように、最後にコロンを付け加えてみると、とんだ先の ASP で、ちゃんと見ることが出来ました。最終的には自動的に "HTTP_" が付加されて、"HTTP_EZ_URLPARAM_TYPE" という名前になっていました。
けれど、なぜかセットしたはずの値が表示されません。
いろいろとやってみましたけどうまくいかないようです。けれど、ALL_HTTP と ALL_RAW ヘッダにはちゃんと値も含んだヘッダ情報が表示されていました。
なので何とかすれば大丈夫なのでしょう。ということで、とりあえず文末に ":" をつけてみたら例外発生/爆。そのほか "\r\n" とか、" " とか "\0" とかやってみましたけどうまくいきません。2つほど登録すれば何かわかるかも、と思いましたが結果は同じでした。
ヘッダの取得実験
SetHeader のお話が半端ですけど、とりあえず GetHeader 関数をいじってみました。
ここに ":" つきのヘッダ名をわたしてヘッダ情報を取得しては見るのですけど…。なぜだかこちらもうまく行きませんでした。"PATH_INFO:" とか "HTTP_USER_AGENT:" とか "USER_AGENT:" とか試してみましたけどダメです。幸い、特殊な扱いである "url" ならば正常に取得することが出来たのですけど。
そのほか "Cookie:" なら読み込めることがわかりました。大文字小文字は関係ないようです。
"Host:" も大丈夫、"Connection:" も大丈夫、"Accept:" も大丈夫。どうやら HTTP_ ではじまってアンダーバーを含まないものなら大丈夫そう…。
…、ということは??
ためしに "USER-AGENT:" で取得してみたら、見事値を取得できました。
ASP にわたるころにはすっかり形が変わってしまっていて気づきませんでしたけど、この段階で扱えるヘッダ名はまったくもって、サーバに渡されたそのままの形のヘッダ名だということがわかりました。
DWORD CURLParamFilterFilter::OnPreprocHeaders(CHttpFilterContext* pCtxt, PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo)
{
char buffer[1000];
ULONG len = 1000;
pHeaderInfo->GetHeader(pCtxt->m_pFC, "USER-AGENT:", buffer, &len);
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
ヘッダの設定実験
読み込みの方法がわかったので、書き込みのほうもなんとなく察しがつきました。
とりあえず、"USER-AGENT:" の値を SetHeader 関数で書き換えてみることにしました。するとみごと、ASP 側の HTTP_USER_AGENT の値が書き換わりました。
調子に乗って、今度は "EZ-URLPARAM-TYPE:" の値追加です。
DWORD CURLParamFilterFilter::OnPreprocHeaders(CHttpFilterContext* pCtxt, PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo)
{
pHeaderInfo->AddHeader(pCtxt->m_pFC, "EZ-URLPARAM-TYPE:", "test string");
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
このようなフィルタを組んで ASP から ServerVariables で参照してみると、みごと "HTTP_EZ_URLPARAM_TYPE" という環境変数に "test string" という値が設定されました。
SetHeader と AddHeader
SetHeader を使うと、同一名称のヘッダがあった場合は、新しい値で上書きされます。また、AddHeader を使った場合、同一名称のヘッダがあった場合は , 区切りで追加されるようです。
GetHeader を行うさいの注意
GetHeader 関数を使用する際に、存在しないヘッダを取得しようとすると、バッファに何もコピーされないようです。
C++ の場合、確保されたばかりのメモリは初期化されていませんので、そのままだと思わぬエラーを招く恐れがあります。なので、確保したメモリの先頭には 0 を代入しておきましょう。これで何もロードされなくても、空文字として利用することが出来ます。
また、必要なバッファサイズはヘッダに格納されている文字数よりもひとつ多いサイズです。
バッファのサイズがヘッダの値以下の場合は、途中までとかではなくまったくコピーされなくなるようです。一番最後には文字の終わりを示す '\0' が入りますので、実際の文字数よりもひとつ大きなバッファが必要です。
GetHeader 関数のバッファサイズに指定した値には、その余分なサイズを含めます。なので、new char[100] でとったバッファの場合、ちょうど &ULONG -> 100 を渡して大丈夫です。
ハンドラの処理順番は…
いまいちよくわからないので、ハンドラの処理順番を調べてみました。すると OnReadRawData 以外を実装した場合、次のようになりました。
- GetFilterVersion
- OnPreprocHeaders
- OnUrlMap
- OnAuthentication
これらは、それぞれのハンドラからファイル出力をすることによって順番を見定めたものです。なぜか、OnSendRawData と OnLog がでてきませんでした。
また、OnReadRawData をハンドルすると、GetFilterVersion 以降はなにも現れなくなります。
ハンドラの発生タイミングはいろいろとあるようで、Web Folder の追加を行ってみると、上記の OnAuthentication に続いて、
- OnLog
- OnSendRawData
というようにもなりました。
OnUrlMap で物理パスを変更すると…
OnUrlMap 関数内で物理パスの情報 (pszPhysicalPath) の値を変更すると、環境変数 PATH_TRANSLATED にもちゃんと変更が反映されていました。
なので、後に ASP から PATH_TRANSLATED を参照すると、プログラム中で設定した pszPhysicalPath の方を取得することが出来ます。
OnPreprocHeaders で URL を処理するとどうなるか
さて、OnPreprocHeaders 内で URL の値を変更すると、その後の環境変数にどのような影響がでるのかを簡単に調べてみました。
DWORD CURLParamFilterFilter::OnPreprocHeaders(CHttpFilterContext* pCtxt, PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo)
{
pHeaderInfo->SetHeader(pCtxt->m_pFC, "url", "/workarea/URLParamFilter/test.asp");
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
このような変換を行ってみると、どんな URL を入力しても必ず、上記で設定しなおした /workarea/URLParamFilter/test.asp へ行くようになりました。
しかも、OnUrlMap のときとは違って、変更先の拡張子によって処理が決まるようです。これなら、最初に想定していたフィルタ案が OnPreprocHeaders での処理だけで簡単に実現できそうですね。
ちなみに、URL を変更したときに、その周りのヘッダの変化は環境変数にしっかりと表れています。
PATH_INFO, SCRIPT_NAME, URL, PATH_TRANSLATED 、これら全てが変更後に対応した値を示すので、もともとの URL を確保したい場合には、別途、HTTP ヘッダに SetHeader で登録しなす必要がありそうです。
FrontPage / Web Folder での注意
ISAPI フィルタは、FrontPage や Web Folder を用いてアクセスするときにも有効なので注意が必要です。
通常のブラウザによるアクセスか、FrontPage Server Extensions での編集目的のアクセスかの違いは、サーバへの要求に ETag: というヘッダがついているかどうかで判断できるそうです。
FrontPage などでアクセスした場合に、ETag: ヘッダが付加されるそうです。ちょっと確認できませんでしたけど…。
簡易フィルタを作ってみる
さて、実現性が見えてきたので、簡単なフィルタを作って、このレポートを終わりにしようと思います。
フィルタ仕様
非常に単純なフィルタを作ります。
URL の最初の部分が /filter/ ではじまる場合、要求ファイルのファイル名を EZ-URLPARAM-TARGET ヘッダーへ、要求ファイルの拡張子を EZ-URLPARAM-TYPE ヘッダーへ、それ以外の部分を EZ-URLPARAM-OPTIONS ヘッダーへ格納します。
また、要求 URL をそのまま、EZ-URLPARAM-URL ヘッダーへコピーします。そして、参照先の URL を /filter.asp に変更します。あと、変更処理を通ったことが判定できるように、EZ-URLPARAM ヘッダーに "1" を設定しておくことにします。
最初の部分が /filter/ でない場合は、何もしません。ただ、まったく無視はちょっと気持ち悪いので、EZ-URLPARAM ヘッダに "0" を設定するようにしてみます。
コーディング
Visual C++ 6.0 の ISAPI Extensions Wizard を利用して、フィルタオブジェクトを作成します。
その際、OnPreprocHeaders をオーバーライドするように、「要求ヘッダーのポスト処理」 を処理するように設定します。今回はそのほかのフィルタは使用しません。
そして、OnPreprocHeaders に次のようなコードを追加します。
DWORD CURLParamFilterFilter::OnPreprocHeaders(CHttpFilterContext* pCtxt, PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo)
{
char *srcURL;
char *dstURL = "/filter.asp";
char *maskURL = "/filter/";
unsigned long len, offset;
unsigned long buffer_len = 500;
srcURL = new char[buffer_len * sizeof(char)];
srcURL[0] = 0;
// URL の取得
pHeaderInfo->GetHeader(pCtxt->m_pFC, "url", srcURL, &buffer_len);
// URL 判定
if (!strncmp(srcURL, maskURL, strlen(maskURL)))
{
// URL に maskURL が含まれていた場合の処理 -----
char *pS, *pP;
char *target = new char[strlen(srcURL) + 1];
char *type = new char[strlen(srcURL) + 1];
char *options = new char[strlen(srcURL) + 1];
// srcURL を一番最後の "/" で分離
pS = strrchr(srcURL, '/');
if (pS != NULL)
{
// 最初の "/" の先から、最後の "/" の前までを取得
offset = strlen(maskURL);
len = (unsigned long)pS - (unsigned long)srcURL - offset;
strncpy(options, srcURL + offset, len);
options[len] = '\0';
// 最後の部分を、さらに最後の "." で分離
pP = strrchr(pS, '.');
if (pP != NULL)
{
// "/" 以降 "." 以前を target へ、"." 以降を type へ複製
len = (unsigned long)pP - (unsigned long)pS - 1;
strncpy(target, pS + 1, len);
strcpy(type, pP + 1);
target[len] = '\0';
}
else
{
// "/" 以降を target へ複製
strcpy(target, pS + 1);
type[0] = '\0';
}
}
else
{
// なぜか "/" がない場合…。
*target = '\0';
*type = '\0';
*options = '\0';
}
// HTTP ヘッダの追加
pHeaderInfo->SetHeader(pCtxt->m_pFC, "EZ-URLPARAM:", "1");
pHeaderInfo->SetHeader(pCtxt->m_pFC, "EZ-URLPARAM-TARGET:", target);
pHeaderInfo->SetHeader(pCtxt->m_pFC, "EZ-URLPARAM-TYPE:", type);
pHeaderInfo->SetHeader(pCtxt->m_pFC, "EZ-URLPARAM-OPTIONS:", options);
pHeaderInfo->SetHeader(pCtxt->m_pFC, "url", dstURL);
// 後始末
delete [] target;
delete [] type;
delete [] options;
}
else
{
pHeaderInfo->SetHeader(pCtxt->m_pFC, "EZ-URLPARAM:", "0");
}
delete [] srcURL;
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
なにやら長々と醜いプログラムになってしまいましたけど、まぁ、サンプルということで…。
本来なら関数とかに事細かに分けるところなのですけど、そうするとここでは逆に見にくくなってしまうのであえて長々と書いてみました。
このソースはまだチェックが甘いですので気をつけてください。たとえば、/filter/.asp のように ファイル名がなかったりすると例外エラーが発生します。境界チェックがあまいので、正式な URL を入れないとサーバが落ちます(爆)
IIS に登録する
出来上がったフィルタを IIS に登録します。今回は、URLFilter.dll というファイルで出来上がっているものとします。
インターネットサービスマネージャを開いて、フィルタを設定したい Web サイトのプロパティを表示します。そして 【ISAPI フィルタ】 の項目の中の 「追加」 ボタンを押します。
フィルタ名 | URLFilter |
---|---|
実行ファイル | C:\URLFilter.dll |
フィルタ名はとくにはなんでもよくて、実行ファイルには作った ISAPI フィルタをフルパスで指定します。これで登録完了です。
アクセスしてみる
さて、http://localhost/filter/workarea/URLParamFilter/notes/0001.memo にアクセスしてみます。
/filter.asp の内容が表示されました。その ASP 内で環境変数を表示してみると、意図的に追加した次のヘッダを取得できました。
HTTP_EZ_URLPARAM | 1 |
---|---|
HTTP_EZ_URLPARAM_TARGET | 0001 |
HTTP_EZ_URLPARAM_TYPE | memo |
HTTP_EZ_URLPARAM_OPTIONS | workarea/URLParamFilter/notes |
また、http://localhost/filter.asp というように、/filter/ を通さずに直接アクセスしてみると、次のようになります。
HTTP_EZ_URLPARAM | 0 |
---|
おわりに
個人的には今後、ちゃんとした URL フィルタを作って、このサイト内で使ってみようと思っているところです。ただ、いまのレベルだとサーバが落ちること必死^^;
というわけで、もうちょっとがんばらなくては…。
なにはともあれ、なかなかいい感じですね☆
製作時のポイント?
Windows 2000 または Windows XP Professional についている IIS (Internet Information Service) を使用すれば、管理ツールの中の 「インターネットサービスマネージャ」 を使って、IIS を根本的に再起動できるので便利です。
IIS の Web サイトに ISAPI Filter として、Visual C++ の Debug が吐き出す DLL をそのまま登録します。
そしてコンパイルして動作を確認して修正を加える際には、いったん Web サイトを 「停止」 状態にしてから IIS 自体を再起動します。
IIS の再起動は表示されているコンピュータ名のところで右クリックをして、すべてのタスクの中から選択することが出来ます。
こうすることで、使用中の DLL が解放されるようです。そして再びビルドしたら、Web サイトを 「開始」 状態にしてチェック、という繰り返しです。
なにやら、致命的なエラーを連発していると、そのうち IIS が正常に Filter を読み込めなくなってしまうようです。なので、いろいろと修正してもうまく動作しなくなった感じがした場合、Windows 自体を再起動してみるといいかもしれません。