一行掲示板を作ろう

 CGIについて最低限の知識を得たところですが、早速一行掲示板を作ってみましょう。
 プログラムは何よりも「習うより慣れろ」です。掲示板にはCGIの基本動作がほぼ全て盛り込まれているといっても過言ではないので、実用的にも教材としてうってつけです。
 また、これ以降例となるプログラムのサイズが大きくなってくるので、全ソースを一まとめにしてこのページ上に表示はしません。[ソース]という形でソースへのリンクを置きつつ、部分的にピックアップしながら解説します。

 ではまず一行掲示板として最低限の処理だけを組み込んだ、linebbs1.cgi[ソース]です。もっと単純にしてしまおうかとも思いましたが、サンプルを公開する上でのセキュリティを考慮して、デコード時の禁則処理と保存行数の制限だけは行っています。それでも非常にシンプルですが、いくつか手抜きがされているため、構造上に致命的な欠陥すらあるので、その辺りも踏まえて解説します。

禁止文字列の扱い

#====================#
# 入力内容のデコード #

if($ENV{'REQUEST_METHOD'} eq "POST"){
	read(STDIN,$in,$ENV{'CONTENT_LENGTH'});
}else{ $in = $ENV{'QUERY_STRING'}; }
@in = split/&/,$in;
foreach(@in){
	($key,$val) = split/=/;
	$val =~ tr/+/ /;
	$val =~ s/%([0-9a-fA-F][0-9a-fA-F])/pack("C",hex($1))/eg;
	$val =~ s/\r//g;
	$val =~ s/\n//g;
	$val =~ s/</&lt;/g;
	$val =~ s/>/&gt;/g;
	$in{$key} = $val;
}

 フォームからの入力内容を処理している部分です。前のページで説明したものに比べると、何か同じような書き方の行が何行か増えていますね。これは正規表現による文字列の置換処理を使用して、入力されては都合の悪い文字列などを変換したり省いたりしているのです。
 「$val =~ s/変換対象文字列/変換文字列/g;」で、変数$valに含まれる変換対象文字列を変換文字列に置換します。\r,\nはPerlの復帰、改行を表す記号で、これらが発言内容に含まれると、ログデータの形式が崩れてしまうため削除(空文字に置換)しています。
 「<」と「>」を置換しているのは、タグの入力を禁止するためです。タグの入力を自由に行えてしまうと、JavaScriptを埋め込んだりしていたずらが出来てしまうため、タグとして機能しないようにしているのです。ユーザの任意の入力がサーバで処理されるのですから、不都合な事をされないような処置を組み込んでおくのは非常に大事なことです。

ファイルの読み書き

#==========#
# 発言処理 #

# 名前と本文が入力されていたら書き込み
if($in{'name'} and $in{'body'}){
	# 追加するログデータの作成
	$newlog = "<hr><b>$in{'name'}</b> > $in{'body'}\n";

	# ログファイル読込
	open FILE,$logfile;
	@log = <FILE>;
	close FILE;

	# 先頭に新規ログデータを追加
	unshift @log,$newlog;

	# 保存行数を超える分末尾を削除
	while(@log > $maxlog){ pop @log; }

	# 更新されたデータでログファイルに上書き
	open FILE,">$logfile";
	print FILE @log;
	close FILE;
}

 発言内容をログファイルに保存している箇所です。
 フォームから名前と本文が送信された場合に限り、ログファイルに発言内容を保存していますが、まずはこのif文による分岐が手抜き箇所です。
 この方法では、名前と本文のいずれかが空の状態で送信すると、何事も無かったかのように無視してリロードしただけのような動作をしてしまいます。未記入の項目があったら、エラー画面を出してあげるのが適切でしょう。
 尚、「if(変数){処理}」は、「変数の中身が真(TRUE)であれば処理を実行する」という意味です。(正確には「条件式が真を返したら」であって、変数でなく、真(TRUE)か偽(FALSE)を返す関数などでも構いません) 「変数の中身が真(TRUE)」と言っていますが、Perlの場合、0や空文字以外の文字列が返されてもTRUEと判定されるので、「変数に何かしら文字が入っていたら」という様に解釈してしまって問題ありません。但し、「000」の様な数値として0と等しい文字列が無視されてしまうので、この場合「if($in{'name'} ne '' and $in{'body'} ne ''){〜}」として、「名前が空でなくかつ本文も空でない」とするのが正確です。

 更にこのソースは「ロック処理」というものを行っていないため、複数の人がほぼ同時に発言した場合に発言が反映されなかったり、最悪の場合ログファイルが壊れてしまうという致命的な欠陥を持っています。
 「ロック処理」というのは、複数のアクセスによりサーバ上の同じファイルが読み書きされる場合に発生しうる問題からファイルを保護する、このようなプログラムにとって必須といえる非常に重要な処理です。
 これについては概念から正しく理解しておく必要があるので、コラムで詳しく解説することにして、ひとまずここで行われている注目すべき処理についてのみ解説します。

 「追加するログデータの作成」では、発言内容をログファイルに保存する形式に成型しています。
 ここでは画面に表示するHTMLそのままの形式で、一回の発言を1行のソースにまとめています。これは一見手抜きのようにも見えますが、頻度の高いログの表示処理の際に、ファイルを開いてそのまま表示するだけでよいためサーバへの負荷が少ない、という利点があります。
 そして、次の「ログファイルの読込」の時にも、この書き方で自動的にログファイルの1行分の内容が配列の1要素になるなど、様々な面で都合がよいので、掲示板に限らず1つ分のデータを1行にまとめるのが一般的です。また、入力内容のデコード時に改行を削除していたのも、ここで1行1データという形を崩さないためです。

 「先頭に新規ログデータを追加」では、unshift関数を使用しています。「unshift 配列,変数;」で、配列の先頭に変数を追加します。この逆で配列の先頭の1要素を取り除く関数としてshiftがあり、「変数 = shift 配列;」で取り除いた要素を変数に入れることができます。
 ちなみにこの処理は、unshift関数を使わずに、「@log = ($newlog,@log);」としても構いません。

 「保存行数を超える分末尾を削除」は、よく使われる内容です。
 while文は条件式を利用したループで、「while(条件式){ 処理 }」で、条件式の内容を満たす限り処理を繰り返します。この場合、ログの行数が指定行数より大きい限り(@log > $maxlog)、配列の末尾を削除する(pop @log;)、となります。pop関数は配列の末尾の要素を取り除く関数で、「変数 = pop 配列;」とすると取り除いた要素を変数に入れることができます。また、やはり逆に配列の末尾に変数を追加するpush関数(push 配列,変数;)があります。

ファイルの読み書き?

#==========#
# 出力処理 #

# ログファイル読込
open FILE,$logfile;
@log = <FILE>;
close FILE;

# 出力
print <<"EOT";
Content-Type: text/html;

<html>
<head>
<title>一行掲示板</title>
</head>
<body bgcolor=white text=black>

<form method="POST" action="$mycgi">
<table>
<tr><td>お名前</td><td><input name=name size=16></td></tr>
<tr><td>本文</td><td><input name=body size=64></td></tr>
<tr><td></td><td><input type=submit value="送信"></td></tr>
</table>
</form>

@log

</body>
</html>

exit;
EOT

 発言フォームとログを画面に出力する処理です。ログを読み込んで出力しているわけですが、実はここにも無駄があります。
 いったいどこが?と思うかもしてませんが、この内容だと、発言を行った場合には、既に読み込んだはずのログファイルをもう一度読み直すことになってしまいます。
 ファイルの読み書きは、人間にとっては瞬きする間の出来事かもしれませんが、コンピューターにとっては、四則演算などに比べればかなり重い処理になります。1回分は大したことがなくとも、例えばチャットのように頻繁に発言が行われる場合や、レンタルサーバのように複数のユーザによって様々なプログラムが動作している環境を考えれば、サーバのマシンスペックが高く、事実上問題の無いレベルであっても軽視すべきではありません。可能な限りサーバへの負荷が少ない処理を心がけましょう。

 これらを踏まえ、先ほどの「ロック処理」も含めた問題を解決したものが、linebbs2.cgi[ソース]です。

制限の強化

# 入力内容のデコード(POST限定) #
read(STDIN,$in,$ENV{'CONTENT_LENGTH'});
@in = split/&/,$in;
foreach(@in){
	($key,$val) = split/=/;
	$val =~ tr/+/ /;
	$val =~ s/%([0-9a-fA-F][0-9a-fA-F])/pack("C", hex($1))/eg;
	$val =~ s/\r//g;
	$val =~ s/\n//g;
	$val =~ s/</&lt;/g;
	$val =~ s/>/&gt;/g;
	$in{$key} = $val;
}

 このサンプルは、パラメータを渡してアクセスするのは発言時だけです。従って、入力内容を処理するのはPOSTメソッドでアクセスした時だけでいいので、GETの場合の処理を省きました。
 実際パラメータが無ければ@inが空になってループなども行われないので、ほとんど処理軽減にはならないのですが、少しでもソースの量を減らしたり、GETメソッドでパラメータを入れたいたずらが出来ないようにといったことを気にするなら、こういうことも無駄ではないはずです。

サブルーチン

# 処理モード判定 #
if($in{'submit'}){ &insert(); }
else{ &view(); }
exit;

 大きく違うのがこの部分です。
 入力内容を判定して&insert();か&view();という処理を行い、それで終了してしまっています。
 この&xxxx()というのはサブルーチンと呼ばれるもので、簡単に言えば、一塊の処理に名前を付けて括り、名前を呼ぶだけで同じ処理を繰り返すことが出来るというものです。

保安対策と転送処理

# 発言処理 #
sub insert{

$in{'name'} eq '' and &error('お名前が入力されていません。');
$in{'body'} eq '' and &error('本文が入力されていません。');

# 追加するログデータの作成
$newlog = "<hr><b>$in{'name'}</b> > $in{'body'}\n";

# ロック開始
&lock() or &error('只今混雑しているようです。');

# ログファイル読込
open FILE,$logfile;
@log = <FILE>;
close FILE;

# 先頭に新規ログデータを追加
unshift @log,$newlog;

# 保存行数を超える分末尾を削除
while(@log > $maxlog){ pop @log; }

# 更新されたデータでログファイルに上書き
open FILE,">$logfile";
print FILE @log;
close FILE;

# ロック終了
&unlock();

# 出力処理へ飛ばす
print "Location:$mycgi\n\n";

}

 発言処理をまとめたサブルーチンです。
 先ほどの入力内容の判定時に、$in{'submit'}という入力があった場合に、&insert()としてこのサブルーチンが実行されます。
 サブルーチンは「sub サブルーチン名{内容}」のようにして作成(定義)します。作っただけでは何も起こらず、他の箇所で呼ばれて初めて実行されるので、ソースのどこにあっても構いません。呼び出した箇所より後にあっても、Perlは一度ソースを全て読み込んでから実行するので問題ないのです。
 サブルーチンには、1つのプログラム内で重複する内容をまとめて、ソースの量を節約したり、独立した部品としてローカライズすることによって、他のプログラムへの使い回しを容易にするといった目的の他、単にソースを見易くするために使用することも可能です。
 なお、サブルーチン名には、基本的に変数名と同様の、半角アルファベットで始まる任意の半角英数文字列を使用することが出来ます。やはりそのサブルーチンが行っている内容を示すような単語にするのがよいでしょう。

 では内容についてですが、まず最初に入力内容のチェックとして、名前や本文が空だった場合、エラー処理のサブルーチンerrorを実行しています。変数の値を比較する場合、普通はifを使用しますが、「条件 and 実行内容」のような書き方で、「if(条件){ 実行内容 }」と同様の処理を行うことが出来ます。どちらを利用するかはお好みでかまいません。
 続いて保存用に入力内容を成型してからロックを開始します。ロックの開始は必ずファイル読み込み前に行うのを忘れてはいけません。
 そしてログを追加して行数を整え保存後ロックを終了。これでアクセスが集中していても発言が反映されずに処理が完了する、といった不具合は起こりません。

 そして出力、のはずなのですが、またも見慣れない書き方です。これまで出力時にはContent-typeという記述をしていましたが、Locationというものを出力してそれだけで終わってしまっています。
 これらCGIの出力などのHTTP通信時に、必ず最初に出力しなければならない行のことを「HTTPヘッダ」といって、通信処理に必要な基礎情報が記述されます。HTMLにおけるMETAタグに似ていますが、HTTPヘッダは必須の項目であるということが大きな違いです。通常のHTMLなどは、サーバが自動的にHTTPヘッダを付けて出力しているため気にする必要はありませんが、CGIの場合このような事も最低限は自分で記述しなければなりません。逆に、このおかげでHTMLだけでなく、画像やアーカイブなどを出力することが出来るのです。尚、HTTPヘッダとデータの本体との間には、必ず1行の空行を入れなければなりません。
 「Location:移動先のURL」というのは、サーバ内で別のURLを実行することが出来る方法の1つです。リクエストの送信元には、最終的に実行されたURLの結果が返ることになります。HTTPヘッダなので、必ず改行を2つ入れるのを忘れないようにしましょう。Locationヘッダで別のURLへ転送を行うと同時にHTML等の出力を行うことは出来ないため、Locationの後に空行を出力したらそのままexitで処理を終了します。
 こうするとCGIは一旦そこで処理を終了し、改めて指定されたURLで1から処理が再実行されます。1からやり直すのですから2度手間な様に思えますが、こうすると送信完了後にリロードしても、フォームの内容が再送信されずに済むという特徴があります。
 また、この時の処理は、指定したURLにGETメソッドでアクセスしたのと同じになり、その前に入力されたパラメータは残りません。リロードしてもパラメータが再送信されなくなるのはこのためです。POSTメソッドでこの処理を行うことは出来ないので、どうしてもパラメータを送ってこの処理を行いたい場合、URLエンコーディングされたパラメータの文字列を作ってGETメソッドで送ってやる必要があります。当然GETメソッドでは送ることの出来る量に限界があるので、ある程度制限がかかります。

判定対策

# 出力処理 #
sub view{

# ログファイル読込
open FILE,$logfile;
@log = <FILE>;
close FILE;

# 出力
print &header();
print <<"EOT";
<form method="POST" action="$mycgi">
<table>
<tr><td>お名前</td><td><input name=name size=16></td></tr>
<tr><td>本文</td><td><input name=body size=64></td></tr>
<tr><td></td><td><input type=submit name=submit value="送信"></td></tr>
</table>
</form>

@log

</body>
</html>
EOT

}

 出力処理をまとめたサブルーチンです。
 大きな変更はありませんが、エラー処理と共通になるHTMLのヘッダ部分をheaderというサブルーチンにまとめています。こうしておくと、ページのデザインを共通にする場合、ここさえ修正すれば全てのページのヘッダが変更されるので便利です。このように共通部分を1箇所に集約することを「一元化」といいます。プログラムのメンテナンス性が格段に向上するので、上手に一元化して無駄を省きましょう。
 また、フォームの送信ボタンにもsubmitというname属性が付き、「送信」という文字列がパラメータとして送られるようになっています。こうしておくと、フォームに何も入力せずに送信ボタンを押しても、このパラメータだけは送られるので、送信ボタンを押したかどうかのチェックがやり易くなるのです。一つのフォームにnameの違う複数の送信ボタンを作って処理を振り分けるなど、結構便利な使い方なので覚えておくとよいでしょう。

サブルーチンへの入出力

# HTMLヘッダ #
sub header{

return <<"EOT";
Content-Type: text/html; charset:SHIFT_JIS;

<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=SHIFT_JIS">
<title>一行掲示板</title>
</head>
<body bgcolor=white text=black>
EOT

}

# ロック開始 #
sub lock{

# 古いロックは削除(3分)
if(-e "$logfile.lock"){
	my $t = (stat("$logfile.lock"))[9];
	if($t < time - 180){ &unlock("$logfile.lock"); }
}
local $retry = 5;
while(!mkdir("$logfile.lock",0755)){
	if(--$retry <= 0){ return 0; }
	sleep(1);
}
return 1;

}

# ロック解除 #
sub unlock{
	rmdir("$logfile.lock");
}

# エラー画面 #
sub error{

my ($body,$title) = @_;
$title or $title = 'ERROR!!';
print &header();
print <<"EOT";
<h3>$title</h3>
$body
</body>
</html>
EOT
exit;

}

 各種サブルーチン群です。ソースの順序とは異なりますが、まずはロック開始、ロック解除から説明します。
 ロック処理は、コラムで紹介しているものとは違い、ソースの最初に定義したログファイル名に".lock"を付けたディレクトリを固定で使用しています。(※Perlで扱うファイルやディレクトリ名に拡張子という概念は存在しません。例え"index.html"という名前でも、ディレクトリであればディレクトリとして動作します。従って、同じ名前のファイルとディレクトリを同時に作ることも出来ません)その為、このlockサブルーチンを呼ぶと必ず同じロック処理を行います。

 次にエラー処理の説明です。最初にエラー画面で表示するための文字列をパラメータとして受け取り、変数に代入しています。@_という配列は特殊変数で、サブルーチン名(配列)の様に書くと、サブルーチン内で@_に格納されます。このまま$_[0],$_[1],…という風に扱ってもいいのですが、パラメータ数の少ない非常に単純な処理ならともかく、複雑になってくると、どういう内容が送られているのかわかり辛くなるので、一旦変数に代入する方が何かと便利だと思います。
 また、ここで最初にmyという指定がされていますが、これはローカライズという作業です。このようにして宣言された変数は、そのブロックの外の同じ名前の変数に影響を与えません。どういうことかというと、もしこのサブルーチンを呼ぶ前に、$titleという変数が使われていても、サブルーチン内では使われていなかったのと同様に扱われますし、サブルーチンが終わった後、$titleという変数はサブルーチン内で行われた処理が無かったかのように扱われます。このようにサブルーチン内の変数を完全にローカライズし、サブルーチンの外に全く影響を与えないようにした場合、どのようなプログラムへもそのままコピーして使って構わなくなります。ロック処理など関数並によく使われる処理は、このようにローカライズして使いまわすと便利です。尚、変数のローカライズにはlocalという関数もありますが、この使い分けが結構ややこしいので、コラムで紹介したいと思います。
 それから、このサンプルでは、exitを処理モード判定のところでだけ実行して、サブルーチン内では基本的に実行していません。ですが、エラーが起こった場合には、エラー画面を出力したら確実にそこで処理を終了する必要があるのでexitを実行しています。

 最後にHTMLヘッダです。ヒアドキュメントが使われていますが、最初に書いてあるのは変数でもprintでもなくreturnです。これはサブルーチンの外へ値を返す関数で、「変数 = サブルーチン」の様にしてサブルーチンの返す値(戻り値)を変数に代入したり、「print サブルーチン」として、戻り値を直接出力するといったことが可能です。return(配列)とすることで、複数の値を返すこともできます。また、パラメータの入出力によって、元々用意されている関数と同じような動作を行うサブルーチンを作ることも出来ます(関数化)。最近のPerlでは、サブルーチンを&を付けずに呼ぶことが可能になっており、サブルーチンの関数化は一般的なものになりつつあります。

 このように、サブルーチンは処理を一つのまとまりとして区切るだけでなく、共通処理の一元化、ローカライズやパラメータの入出力による関数化といった事を可能にする、Perlを使いこなす上で欠かせないものです。サブルーチンを上手に利用することは、効率のよいプログラムを作る近道となることでしょう。

機能の追加

 ここまででとりあえず実用しても問題の無い内容の一行掲示板にはなりましたが、一般的に使用されているものに比べると見劣りしてしまうのは否めません。そんなわけで、発言文字色の選択機能とページ切り替え処理を入れてみましょう。linebbs3.cgi[ソース]です。

# 出力処理 #
sub view{

# ログファイル読込
open FILE,$logfile;
@logdat = <FILE>;
close FILE;

# 総ページ数
$maxpage = int((@logdat - 1)/$pageline) + 1;

# ページの指定が無ければ1ページ目
($in{'page'} >= 1 and $in{'page'} <= $maxpage) or $in{'page'} = 1;

# 表示するページのログ取得
foreach$l(0..$#logdat){
	$l >= ($in{'page'} - 1) * $pageline and $l < $in{'page'} * $pageline and $log .= $logdat[$l];
}

# ページ切り替えリンク作成
foreach$p(1..$maxpage){
	if($p == $in{'page'}){
		$pagelink .= <<"EOT";
<b>$p</b>
EOT
	}else{
		$pagelink .= <<"EOT";
[<a href="$mycgi?page=$p">$p</a>]
EOT
	}
}

# 色選択フォーム作成
foreach(@msgcol){
	$colform .= <<"EOT";
<input type=radio name=color value="$_"><font color="$_">●</font> 
EOT
}

# 出力
print &header();
print <<"EOT";
<form method="POST" action="$mycgi">
<table>
<tr><td>お名前</td><td><input name=name size=16></td></tr>
<tr><td>本文</td><td><input name=body size=64></td></tr>
<tr><td>文字色</td><td>$colform</td></tr>
<tr><td></td><td><input type=submit name=submit value="送信"></td></tr>
</table>
</form>

<center>$pagelink</center>

$log

</body>
</html>
EOT

}

 機能の追加に当たり、1ページ当たりの表示行数や発言文字色のリストといった設定が追加されていたり、発言処理での文字色選択チェック、ログデータへの文字色の反映、といった処理が追加されていますが、最も大幅な変更がされたのは表示処理です。

 まず、ページ切り替えのために、現在全部で何ページ分のログがあるかを計算しています。1行が1発言になっているのがこういう場面で役に立ちます。配列を計算式などに入れると、配列の要素数を表す数字として扱われるのですが、気持ちが悪いという人はscalar(配列)の様に書くと、明示的に配列の要素数を取得することが出来ます。

 続いて指定ページチェックです。最初にページを内部的に0ページ目とする方法もあるのですが、結局見た目で1ページ目としなければなりませんし、何より紛らわしいので、ページの指定が無ければ1ページ目ということで、パラメータとして1ページ目が指定されたことにして処理を進めます。また、総ページ数以上のページを指定した場合も1ページ目になるようにしています。

 それから指定ページの分のログだけを取り分けています。「foreach変数名(配列){処理}」は、配列の要素を先頭から順に変数に入れながら処理を繰り返すループ処理です。「0..$#logdat」は「0から$#logdatまでの連番」を表します。例えば「0..5」は「0,1,2,3,4,5」という配列と同じ意味です。「$#配列名」で、配列の序数の最大値が取得できます。この値は「配列の要素数-1」と等しいと思って問題ありません。つまりこの書き方で、配列@logdatの要素数分、$l に配列番号を入れながらループする、という処理になります。配列をループさせる時、この書き方はよく使うので、ちょっと難しいかもしれませんが覚えておきましょう。それとここで注意したいのが、配列は0番から始まっているということです。ログデータファイルの1行目は、配列として受け取ると0番目ですので、そこに注意して処理しなければなりません。あと、さりげなくandを複数連ねていますが、「A and B and C;」で、「if(A and B){ C }」とほぼ同じ意味になります。

 最後に各ページへのリンクを作成しています。現在のページの場合はリンクを行わないようにする処理も行っています。「変数 .= 文字列」は、変数の末尾に文字列を追加する場合の書き方です。なお、ここで指定ページのパラメータをGETメソッドで渡す必要が出てきたので、linebbs2.cgiで省いたGETメソッドの処理を復活させています。
 ページ切り替え処理のやり方は様々で、今回は全ページへのリンクを作りましたが、「次のページ」「前のページ」というリンクにする事もよくあります。その場合はどう処理すべきかを考えてみるのもよいでしょう。

 後は色選択フォームをリストから作成して出力です。foreachの時に配列の要素を入れる変数を指定しないと、$_という特殊変数が使用されます。


≪Index  ≪Back  Next≫