Perlで実装するCGIデコード
CGIとは、Webサーバでプログラムを動作させる仕組みです。CGIプログラムは、Perl製のものが多いですが、PythonやRubyなども動作させることが出来ます。このページでは、CGIでの処理の前提となるURLエンコード(パーセントエンコーディング)を、Perlでデコードする方法を説明します。
目次
CGIのデコードとは
ブラウザはURLを通じてWebサイトと送受信を行っていますが、URLとして使うことの出来る文字は決まっていて、日本語などの文字は使うことが出来ません。そのため使えない文字を使える文字内での表現に変換して送受信を行なうことになります。具体的には、文字コードの16進数表現に%を足したものになり、URLエンコードと呼ばれます。元の文字に戻す処理がURLデコードになります。
またブラウザからの送信データはURLだけではなく、POST送信によるファイルアップロードなども行われます。サーバ側でファイルを受け取るには、データを取り出す処理が必要になり、CGIでデコードすることになります。
Webプログラミング言語であるPHPでは、デコード処理が自動化されているため、通常は気にする必要はありませんが、CGIではライブラリなどを利用してデコードすることになります。今回は学習のために、必要最小限のデコードのみを行なう処理を実装します。また開発効率を上げるために、取り出しやすいかたちで保持する仕様にします。
# URLエンコードされた文字
%E6%97%A5%E6%9C%AC%E8%AA%9E
# utf-8で日本語
# utf-8は2-4バイトの文字
# E6 97 A5 の部分が日
# E6 9C AC の部分が本
# E8 AA 9E の部分が語
HTTPリクエストの種類
Webサーバとの通信の仕方は規格で決まっていて、主なリクエスト方法はGETとPOSTがあります。他にPUTやDELETE、HEADなどがありますが、プログラムとの通信を行なうWeb APIなどで利用されています。今回はブラウザを対象としたCGIプログラムになるため、GETとPOSTに対応します。
GET通信は、サイト内のリンクなどが該当します。URL内にエンコードした文字を含めて、データをやり取りすることになります。POST送信は、HTMLタグで構築されるフォームから行なうことになります。お問い合わせなどのコンタクトフォームが該当します。
# GET通信はURL上の受け渡し
index.cgi?search=word&lang=ja
# POST送信はURLからは見えない
index.cgi
Perlでユーザーからの入力を受け取る方法
PerlでGET送信を受け取るには、環境変数ENVのQUERY_STRINGから取得します。環境変数には、Webサーバが設定した情報が入っています。GET送信なのか、POST送信なのかの情報も環境変数から取得できます。環境変数は、連想配列(ハッシュ)になっていて、キーで一意に値を取得することができます。
POST送信されたデータについては、標準入力から受け取ることが出来ます。Perlでは、標準入力はSTDINなので、<STDIN>で一行文を取得できます。
# GET送信のクエリーを取得
my $q = $ENV{'QUERY_STRING'};
# POST送信されたデータを一行文取得
my $d = <STDIN>;
Perlで正規表現を使う方法
受け取った文字列はエンコードされていますが、決まったルールで変換されている状態なので、一定の法則で一括処理できると効率が良いです。文字の出現法則で変換が行えるコーディング方法に、正規表現があります。他に変換テーブルを作成し、通す方法がありますが、今回は正規表現でエンコードを行っていくことにします。
また変換リストで変換する方法もあり、より高速なので、一部の変換はこちらも利用します。変換リストによる変換は、tr で行いますが、同じ長さの文字列を指定し、1文字ずつの位置の対応で変換します。下記の例ではaに1が対応するので、すべてのaが1になります。
# Perlの正規表現で置換する
# 数値を除去する
$str =~ s/[\d]//g;
# 英数字_を除去する
$str =~ s/[\w]//g;
# 変換リストで変換する
my $str = 'aabbcc';
$str =~ tr/abc/123/; #112233
# 文字で判定
if ($str =~ m/a(\w+)/) {
# ()で適合した文字の取り出し $&が全体 $1から()内
$m = $1;
}
Perlのグローバル変数とローカル変数
Perlには、ブロック内で利用できるローカル変数と、どのブロック内でも利用できるグローバル変数があります。グローバル変数は、同名の変数での書き換えの危険があるため避けられますが、Webプログラムでは必ず行なう処理と必ず使うデータがあるため、今回その変数となるデコードデータは、グローバル変数として保持する事にします。
またある程度の一般化のため、PHPで利用されているスーパーグローバル変数に似た名前にします。アップロードファイルは、複数ファイルに対応するためファイル数の配列に、ファイル情報の連想配列を代入します。
Perlには、変数の扱いを厳格にチェックする方法があり、use strict; や use warnings; が使われます。今回は、チェック下でも動作するように開発を行います。
# 厳格モード
use strict;
use warnings;
# グローバル変数の宣言
our(%_GET,%_POST,%_COOKIE,@_FILES);
連想配列(ハッシュ)の%_GETにGET送信データ、%_POSTにPOST送信データ、%_COOKIEにクッキーデータ、@_FILESにファイル情報が代入される想定。
PerlでGETリクエストを解析する
リクエストの種類は、環境変数ENVのREQUEST_METHODで確認することが出来ます。ですが、GET送信はURL情報なので、POST送信時も情報が含まれており、処理上必要になることもあるので、常に取得する構成にします。
またクッキーの処理もついでに実装します。GETのURLでは、?以降にkey1=value1&key2=value2の状態でデータが並んでいます。クッキーではkey1=value1; key2=value2; となります。「&」繋ぎと「; 」繋ぎの差があります。
# リクエストのメソッド判定
if ($ENV{'REQUEST_METHOD'} eq 'POST') {
# ここでPOST用処理
}
# GET用処理
%_GET = _decode(\$ENV{'QUERY_STRING'});
# COOKIE用処理
%_COOKIE = _decode(\$ENV{'HTTP_COOKIE'},'; ');
変数の$文字の前に\が付いていますが、これを付けると関数への参照渡しになります。参照(リファレンス)とは変数へのアドレスで、\を付けない場合は値渡しとなり、変数の内容が複製された状態で渡される事になります。
# URLデコード処理
sub _decode {
# 解析対象の変数のリファレンス
my $buf = shift;
# 繋ぎ文字
my $de = shift;
# 保存するハッシュ
my %h = ();
return %h unless defined $$buf;
# 繋ぎ文字指定がない場合、&で分離
$de = '&' unless defined $de;
foreach (split(/$de/, $$buf)) {
# キーと値を=で分離
my($key, $val) = split /=/;
$val = '' unless defined $val;
# +を半角スペースに復元
$key =~ tr/+/ /;
# %文字を元の文字に復元
$key =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack('H2',$1)/eg;
$val =~ tr/+/ /;
$val =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack('H2',$1)/eg;
# ハッシュに保存する処理
_setHash(\$key,\$val,\%h);
}
return %h;
}
Perlでは、関数の引数を取得するには、shiftが利用されます。これは引数の配列@_の先頭から削除して取得していることになります。リファレンスから元の変数に戻すには、$や@、%の文字を先頭に付けてデリファレンスします。
置換の正規表現で、置換対象とする対象文字は()で指定できます。()内で適合した文字列は、$1や$2などの連番で指定できます。今回は%と16進数の2文字から元の文字列に変換しています。
# ハッシュに保存する処理
sub _setHash {
# 引数の受取
my($key,$val,$h) = @_;
# すでにキーがある場合、値を配列に
if (exists $h->{$$key}) {
# 値が配列の場合、最後に追加
if (ref $h->{$$key} eq 'ARRAY') {
push @{$h->{$$key}}, $$val;
# 値が文字列の場合、配列に
} else {
my @a = ($h->{$$key}, $$val);
$h->{$$key} = \@a;
}
} else {
$h->{$$key} = $$val;
}
}
URLやフォームからの送信データは、基本的にkey=valueの構成ですが、例えばチェックボックスのようにkeyが重複して渡ってくる事が有ります。PHPでは、タグのname属性の末尾に[]を付けることで配列化が行えますが、今回は既にデータがある場合に配列化する構成にします。
ハッシュのリファレンスからキーで値を取り出すには、->を使用します。またデリファレンスは、対象が複雑となる場合は、${},@{},%{}のかたちで表現します。また重要な点として、Perlでは、配列やハッシュの要素や値には、数値、文字列、リファレンスしか入れることが出来ません。多次元配列や多次元ハッシュは、リファレンスで実現することになります。
またリファレンスから変数にアクセスし内容を書き換えた場合、元の変数の内容も書き換わります。
PerlでPOSTリクエストを解析する
POST送信は、一般的に2つのエンコード方法が存在します。formタグのデフォルトであるapplication/x-www-form-urlencodedと、ファイルをアップロードする場合のmultipart/form-dataです。前者はGET送信と同じエンコード方法で、後者はboundaryと呼ばれる区切り文字で、フォームの各データを区切った状態で送信します。
# リクエストのメソッド判定
if ($ENV{'REQUEST_METHOD'} eq 'POST') {
# multipart/form-data を判定
if ($ENV{'CONTENT_TYPE'} =~ m/multipart\/form-data; boundary=(.+)/) {
# 区切り文字列のboundaryを取得
my $b = $1;
%_POST = _multipart(\$b);
} else {
# 標準入力から取り出し
my $buf = <STDIN>;
%_POST = _decode(\$buf);
}
}
Perlでmultipart/form-dataを解析する
ファイルのアップロード時は、フォームタグにenctype="multipart/form-data"を指定して、他のフォーム要素と一緒に、ファイルデータも受け取ることになります。各フォーム要素を区切る文字列として、boundaryが利用されるので、これをフォームデータの終端として利用します。
フォームデータの開始行は、改行のみの行になっています。ただし、フォームデータ内に改行のみの行が存在し得るので、データが空の時のみ開始行と判定します。
# マルチパートデータを処理
sub _multipart {
# 区切り文字列の受取
my $bound = shift;
# 変数の初期化
my($key,$val,$file,$type) = ('','','','');
my %h = ();
my @f = ();
my $rec = 0;
# 標準入力を一行ずつ処理
while (<STDIN>) {
# 区切り文字があった場合
if (m/$$bound/) {
# キーがあった場合
if ($key ne '') {
# 末尾の改行を除去
$val =~ s/\r\n$//;
# フォームデータがファイルの場合
if ($file ne '') {
# 通常のフォームデータ
} else {
_setHash(\$key,\$val,\%h);
}
$key = '';
$val = '';
}
$rec = 0;
# データの結合
} elsif($rec==1) {
$val .= $_;
# フォームのname属性とアップロードされたファイル名
} elsif(m/Content-Disposition: form-data; name="([^"]+)"; filename="([^"]+)"/) {
$key = $1;
$file = $2;
# 通常のフォームデータ
} elsif(m/Content-Disposition: form-data; name="([^"]+)"/) {
$key = $1;
# アップロードされたファイルタイプ
} elsif(m/Content-Type: (.+)/) {
$type = $1;
# データ内容の開始判定
} elsif($rec==0 && m/^\r\n$/) {
$rec = 1;
}
}
# ファイル情報を代入
@_FILES = @f;
return %h;
}
Perlで一時ファイルを作成する
Perlで一時ファイルを作成するには、標準モジュールを利用できます。標準モジュールなのでインストール作業は必要ありません。use で読み込み、qw でサブルーチンを指定できます。一時ファイルは、サーバの/tmp/ディレクトリに作成されるので自動的に削除されますが、必要に応じて削除処理が必要です。
ファイル情報として保持するのは、フォームのname属性、アップロードされたファイル名、ファイルタイプ、一時ファイルのファイル名とします。CGIのプログラム側で、ファイルをチェックして、移動する処理を行なうことになります。
# モジュールの読み込み
use File::Temp qw/ tempfile /;
# フォームデータがファイルの場合
if ($file ne '') {
# 一時データを作成
my($fp, $fname) = tempfile( UNLINK => 0 );
# 一時データにファイルを保存
print $fp $val;
# 保持用のファイル情報を登録
my %f = ('name'=>$key,'up_name'=>$file,'type'=>$type,'tmp_name'=>$fname);
push @f, \%f;
$file = '';
$type = '';
}
受け取った入力データの利用方法
以上でデコード面は完成しました。ファイルはモジュール化して再利用できるようにします。またデコードしたファイルデータは、独自の仕様で保持されているため、保存を簡単に行えるようにメソッド化しておきます。
# 一時ファイルから移動する
sub move {
# モジュールのメソッドなので第二引数以降が引数に
my $self = shift;
# 引数
my $f = shift;
my $name = shift;
# ファイル指定がおかしいか、ファイル名指定がない時
if (!exists $f->{'tmp_name'} || $name eq '') {
return 0;
}
# 一時ファイルを読み込んで新ファイルを作成
if (open TMP, '<'.$f->{'tmp_name'}) {
if (open DAT, '>'.$name) {
print DAT do { local $/; };
close DAT;
close TMP;
unlink $f->{'tmp_name'};
return $name;
}
}
return 0;
}
完成したモジュールファイルはこちらで公開しています。正規表現をコンパイル、デストラクタで一時ファイルを削除などの変更を行っています。
アップロードされたファイルをチェックして、利用できる位置へ移動、保存する処理は、Webアプリごとに実装されるものになります。サンプルとなるコードを掲載します。
# モジュールの読み込み
require 'CgiDecode.pm';
my $q = CgiDecode->new;
# 厳格モード下で使う場合、宣言が必要
our(%_GET,%_POST,%_COOKIE,@_FILES);
# ファイル名用のid番号
my $id = 1;
# アップロードされたファイルを順番に処理
foreach(@_FILES){
# アップロードされたファイル名から判定
my $up = $_->{'up_name'};
# 拡張子がcsvの場合のみ一時ファイルから移動
if ($up =~ m/\.csv$/) {
# ファイル名をid番号で保存
my $f = $q->move($_, ($id++).'.csv');
# 保存されたファイル名が戻る
}
}
最後にCGIでのデコードが出来ているか確認できるコードを掲載します。%_GETや%_POST、%_COOKIEは連想配列(ハッシュ)なので、name属性をキーとしてvalueを取得できます。
#!/usr/bin/perl
# 厳格モード
use strict;
use warnings;
# モジュールの読み込み
require 'CgiDecode.pm';
# 厳格モード下で使う場合、宣言が必要
our(%_GET,%_POST,%_COOKIE,@_FILES);
# 変数の内容を確認できるモジュール
use Data::Dumper;
print "Content-Type: text/html; charset=utf-8\n\n";
print '<div style="white-space: pre;">';
#print "=環境変数\n";
#print Dumper \%ENV;
print "=GET\n";
print Dumper \%_GET;
print "=POST\n";
print Dumper \%_POST;
print "=COOKIE\n";
print Dumper \%_COOKIE;
print "=FILES\n";
print Dumper \@_FILES;
print '</div>';
print <<'HTML';
<a href="?key1=日本語&キー=値&key1=複数データ">GET確認</a>
<form method="post">
<input type="text" name="email" value="form1@example.com">
<label>
<input type="radio" name="radio1" value="ラジオ">ラジオ
</label>
<label>
<input type="radio" name="radio1" value="ボタン">ボタン
</label>
<label>
<input type="checkbox" name="check1" value="チェック">チェック
</label>
<label>
<input type="checkbox" name="check1" value="ボックス">ボックス
</label>
<textarea name="text">複数行
テキスト</textarea>
<input type="submit" value="送信">
</form>
<form method="post" enctype="multipart/form-data">
<input type="text" name="email" value="form2@example.com">
<label>
<input type="radio" name="radio1" value="ラジオ">ラジオ
</label>
<label>
<input type="radio" name="radio1" value="ボタン">ボタン
</label>
<label>
<input type="checkbox" name="check1" value="チェック">チェック
</label>
<label>
<input type="checkbox" name="check1" value="ボックス">ボックス
</label>
<textarea name="text">複数行
テキスト</textarea>
<input type="file" name="f1">
<input type="file" name="f1">
<input type="file" name="f2">
<input type="submit" value="送信">
</form>
<style>
form {
margin: 50px auto;
width: 400px;
background: #eee;
}
input[type="text"],input[type="submit"],label,textarea {
display: block;
padding: 5px;
margin: 5px 0;
width: 100%;
}
</style>
HTML