掲示板を作ろう (2)

前へ << 掲示板を作ろう (1) CGI プログラムのはじめの一歩 >> 次へ

ファイルへの書き込み

一応掲示板らしき形にはなってきましたが、 書き込んだデータをどこにも保存していないので、 後から読むことができません。 CGI プログラムは、WWW 経由でアクセスがあったときだけ実行されます。 次回の起動時に書き込んだデータを読めるように、 書き込みをファイルに保存しておかなくてはいけません。

後に詳しく述べますが、環境によっては事前の準備は要りませんが、 bbs-save-to-file.dat というファイルを事前に作成し、 パーミッションを 606 にしておかなくてはいけない場合もあります。

bbs-save-to-file.cgi (実行結果)

    1: #!/usr/local/bin/perl
    2: 
    3: # 自分自身のファイル名を取得
    4: $script_name = $ENV{SCRIPT_NAME};
    5: $script_name =~ s|.*/([^/]+)$|$1|;
    6: 
    7: # データファイル名
    8: $data_file = $script_name;
    9: $data_file =~ s/\.cgi$/.dat/;
   10: 
   11: # jcode.pl をロード
   12: require 'jcode.pl';
   13: 
   14: # 引数解析
   15: foreach ( split('&',$ENV{QUERY_STRING}) ){
   16:     ($key,$value) = split('=',$_);
   17:     $value =~ tr/+/ /;
   18:     $value =~ s/%([0-9a-fA-F][0-9a-fA-F])/pack("C",hex($1))/eg;
   19:     &jcode::convert(\$value,'euc');
   20: 
   21:     $value =~ s/&/&amp;/g;
   22:     $value =~ s/</&lt;/g;
   23:     $value =~ s/>/&gt;/g;
   24: 
   25:     $value =~ s/\r\n|\r|\n/<BR>/g;
   26: 
   27:     if ( $key eq 'FROM' ){
   28:         $from = $value;
   29:     } elsif ( $key eq 'MESSAGE' ){
   30:         $message = $value;
   31:     }
   32: }
   33: 
   34: print "Content-type: text/html\n\n";
   35: 
   36: print <<END;
   37: <HTML>
   38: <HEAD><TITLE>掲示板</TITLE></HEAD>
   39: <BODY BGCOLOR="#DDDDDD">
   40: <H1>掲示板</H1><HR>
   41: <FORM METHOD=GET ACTION="$script_name">
   42: <TABLE BORDER=1>
   43: <TR><TD>ハンドルネ―ム:
   44:     <TD><INPUT TYPE=text NAME=FROM SIZE=54>
   45: <TR><TD COLSPAN=2><TEXTAREA ROWS=6 COLS=60 NAME=MESSAGE></TEXTAREA>
   46: </TABLE>
   47: <P><INPUT TYPE=submit VALUE="送信">
   48: </FORM><HR>
   49: END
   50: 
   51: # 発言ならデータファイルに追加
   52: if ( $from ne '' && $message ne '' ){
   53:     open(OUT,">> $data_file");
   54:     print OUT "発言者: $from<P>\n";
   55:     print OUT "$message<HR>\n";
   56:     close(OUT);
   57: }
   58: 
   59: # データファイル内容を表示
   60: open(IN,"$data_file");
   61: print <IN>;
   62: close(IN);
   63: 
   64: print <<END;
   65: </BODY></HTML>
   66: END
まず、
    8: $data_file = $script_name;
    9: $data_file =~ s/\.cgi$/.dat/;
で $data_file にデータファイル名を代入します。 $script_name は bbs-save-to-file.cgi という文字列が入っているので、 $data_file は bbs-save-to-file.dat となります。
   52: if ( $from ne '' && $message ne '' ){
   53:     open(OUT,">> $data_file");
   54:     print OUT "発言者: $from<P>\n";
   55:     print OUT "$message<HR>\n";
   56:     close(OUT);
   57: }
$from と $message に値が設定されていたら発言と見なし、 $data_file に追加書き込み (>>) します。
   60: open(IN,"$data_file");
   61: print <IN>;
   62: close(IN);
そして発言・閲覧に関わらず、毎回データファイルの 内容を出力します。つまり、 という流れになります。

POST メソッド

先のスクリプトでは、書き込んだ後リロードすると、 再度同じ発言が書き込まれてしまいます。 また、長い文字列を書き込もうとすると、
414 Request-URI Too Large

The requested URL's length exceeds the capacity limit for this server.
request failed: URI too long
となってしまいます。これは、GET メソッドではデータの長さが制限されているからです。 どれだけの長さまで許されるかは OS や WWW ブラウザなどの環境に依存します。
apache-1.3.9 では URL 部分も含めて 8190 バイトを越えると 414 Request-URI Too Large となります。 日本語1文字2バイト、URLエンコードして6バイトなので、 1300文字程度しか書けません。一行80文字でたった17行です。

また、UNIX では環境変数に代入できるデータの長さも制限があります。

そこで POST メソッドを使いましょう。 POST メソッドには長さの制限はありません。

GET メソッドは環境変数 QUERY_STRING から取得できましたが、 POST メソッドは標準入力からデータが渡されます。 GET でも POST でもデータの形式は変わりませんし、 URL エンコードされているのも同じです。

bbs-post.cgi (実行結果)

    1: #!/usr/local/bin/perl
    2: 
    3: # 自分自身のファイル名を取得
    4: $script_name = $ENV{SCRIPT_NAME};
    5: $script_name =~ s|.*/([^/]+)$|$1|;
    6: 
    7: # データファイル名
    8: $data_file = $script_name;
    9: $data_file =~ s/\.cgi$/.dat/;
   10: 
   11: # jcode.pl をロード
   12: require 'jcode.pl';
   13: 
   14: # 標準入力からデータを読み込む
   15: read(STDIN,$buf,$ENV{CONTENT_LENGTH});
   16: 
   17: # 引数解析
   18: foreach ( split('&',$buf) ){
   19:     ($key,$value) = split('=',$_);
   20:     $value =~ tr/+/ /;
   21:     $value =~ s/%([0-9a-fA-F][0-9a-fA-F])/pack("C",hex($1))/eg;
   22:     &jcode::convert(\$value,'euc');
   23: 
   24:     $value =~ s/&/&amp;/g;
   25:     $value =~ s/</&lt;/g;
   26:     $value =~ s/>/&gt;/g;
   27: 
   28:     $value =~ s/\r\n|\r|\n/<BR>/g;
   29: 
   30:     if ( $key eq 'FROM' ){
   31:         $from = $value;
   32:     } elsif ( $key eq 'MESSAGE' ){
   33:         $message = $value;
   34:     }
   35: }
   36: 
   37: print "Content-type: text/html\n\n";
   38: 
   39: print <<END;
   40: <HTML>
   41: <HEAD><TITLE>掲示板</TITLE></HEAD>
   42: <BODY BGCOLOR="#DDDDDD">
   43: <H1>掲示板</H1><HR>
   44: <FORM METHOD=POST ACTION="$script_name">
   45: <TABLE BORDER=1>
   46: <TR><TD>ハンドルネ―ム:
   47:     <TD><INPUT TYPE=text NAME=FROM SIZE=54>
   48: <TR><TD COLSPAN=2><TEXTAREA ROWS=6 COLS=60 NAME=MESSAGE></TEXTAREA>
   49: </TABLE>
   50: <P><INPUT TYPE=submit VALUE="送信">
   51: </FORM><HR>
   52: END
   53: 
   54: # 発言ならデータファイルに追加
   55: if ( $from ne '' && $message ne '' ){
   56:     open(OUT,">> $data_file");
   57:     print OUT "発言者: $from<P>\n";
   58:     print OUT "$message<HR>\n";
   59:     close(OUT);
   60: }
   61: 
   62: # データファイル内容を表示
   63: open(IN,"$data_file");
   64: print <IN>;
   65: close(IN);
   66: 
   67: print <<END;
   68: </BODY></HTML>
   69: END

   15: read(STDIN,$buf,$ENV{CONTENT_LENGTH});
まず標準入力から $ENV{CONTENT_LENGTH} の分だけデータを読み込み、 $buf に代入します。
   18: foreach ( split('&',$buf) ){
GET メソッドの場合は $ENV{QUERY_STRING} を split していましたが、 今回は $buf を split します。
   44: <FORM METHOD=POST ACTION="$script_name">
忘れてはいけないのが、フォームの METHOD を POST に変えておくことです。

発言時刻、発言者のホスト名を表示

掲示板としての基本動作には直接関係ないのですが、 掲示板にありがちな発言時刻とホスト名を表示してみましょう。 以下は変更点のみです。
   55: if ( $from ne '' && $message ne '' ){
   56:     open(OUT,">> $data_file");
   57:     print OUT "発言者: $from<P>\n";
   58:     print OUT "$message<HR>\n";
   59:     close(OUT);
   60: }
   61: 
   62: # データファイル内容を表示
   63: open(IN,"$data_file");
   64: print <IN>;
   65: close(IN);
   66: 
   67: print <<END;
   68: </BODY></HTML>
   69: END
簡単に言うと $now_date に現在時刻を、$host に発言者のホスト名を代入し、 それをデータファイルに書き出しているだけです。

まずは発言時間から。time 関数を呼ぶと、 その時点での 1970年1月1日からの経過秒数を返します。 2000年1月1日午前9時16分で 946685762 くらいです。 localtime は 1970年1月1日からの経過秒数を 秒/分/時/日/月/年 として返します。 つまり、localtime(time()) は現在時刻を 秒/分/時/日/月/年 の形式で返すわけです。

ただし、年 ($year) は西暦から 1900 を引いた値になります。西暦 2001 年なら 101 という値が得られるので、1900 を足します ($year += 1900)。

以下は想像ですが、初期の UNIX の localtime 関数は西暦の下 2桁を返していました。 1980 なら 80 を返していたわけです。 しかしこれでは 2000 年になると 00 を返してしまいます。だからと言って 西暦をそのまま返すようにすると、これまでのプログラムが正しく動作しなくなってしまいます。 互換性と2000年問題を両方解決したいため、結果として「西暦から 1900 を引いた値」 を返すという変な動作をすることになったわけです。
また、月は 1〜12 でなく 0〜11 という値が返されますので、 1を加算します ($mon++)。

それらの値を

   59:     close(OUT);
   60: }
で、「2000/01/01 09:32:08」という形にして $now_date に代入します。もし sprintf を使わず
$now_date = "$year/$mon/$day $hour:$min:$sec";
とすると、2000/1/1 9:32:8 と、「01 月」でなく「1月」となってしまいます。 もちろん「1月」の方がよければこれでも構わないのですが、個人的には桁数が常に 同じ方が好みなので、sprintf を使っています。

次に発言者のホスト名です。基本的には

が WWW サーバにより代入されることになっています。

ただし、最近の apache の初期設定では REMOTE_ADDR には IP アドレスが入っていますが、 REMOTE_HOST には何も値が入っていません。 これはあくまでも初期設定の話なので、管理者が設定変更していたら REMOTE_HOST にホスト名が入っている場合もあります。

この理由を説明すると細かい話になるのですが、ブラウザを使って web を見るということは、 ブラウザの動いているマシンから WWW サーバのマシンへ向かって TCP/IP コネクションを張るということです。 このとき WWW サーバからは、相手側のマシン (ブラウザの動いているマシン) の IP アドレスを必ず知ることができます。なぜなら TCP/IP パケットのヘッダには 送信元の IP アドレスが記録されているからです。 その IP アドレスを環境変数 REMOTE_ADDR に設定してから CGI プログラムを実行するわけです。

しかし相手側のホスト名は TCP/IP パケットには記録されていません。 そのため、ホスト名を知りたければ いちいち DNS サーバに問い合わせて、IP アドレスから ホスト名に変換しないといけないのです (これを「逆引き」と言います)。 つまり、web を見るたびに WWW サーバは毎回 DNS サーバと通信して逆引きを 依頼しなくてはいけません。結果的にネットワークに負荷をかけ、 レスポンスの低下を招きます。それを嫌って apache のデフォルトは 「逆引きをしない」設定になっています。 これはあくまでもデフォルト設定なので、httpd.conf の HostnameLookups Off を HostnameLookups On に変えれば逆引きしてくれるようになります。

WWW サーバの設定により

発言を逆順に

掲示板を読むときは、やはり新しい発言から順に表示された方が便利です。 しかし現在の掲示板は、ファイルに追加書き込みしているため、 最新の発言が一番下に表示されてしまいます。 そこで、最新の発言をファイルの先頭に記録するようにしましょう。
   69: END
まずデータファイルから全データを @buf に代入します。 これまではデータファイルに追加 (>>) していましたが、 今度は先頭に (>) 最新情報を書き込みます。 そしてその後に @buf を再度書き込みます。

ただし、この書き方ではファイルの内容が全て @buf に代入されます。 もしデータファイルが 1MB あれば、メモリも 1MB (+α) 必要になり、 マシンに負荷がかかります。 これが気になる場合は

open(TMP_OUT,"> tmp_file");
print TMP_OUT "発言者: $from<BR>\n";
print TMP_OUT "$now_date $host<BR>\n";
print TMP_OUT "$message<HR>\n";

open(IN,"$data_file");
while (<IN>){
    print TMP_OUT $_;
}
close(IN);
close(TMP_OUT);

# tmp_file の内容を $data_file にコピーするだけ
open(TMP_IN,"tmp_file");
open(OUT,"> $data_file");
while (<TMP_IN>){
    print OUT $_;
}
close(TMP_IN);
close(OUT);
と、まずテンポラリファイル (tmp_file) に書き込んで、 それを元のファイル ($data_file) にコピーすればよいでしょう。

前へ << 掲示板を作ろう (1) CGI プログラムのはじめの一歩 >> 次へ

$Id: bbs-perl-2.html,v 1.4 2004/06/28 15:51:45 68user Exp $