目次へ

不定期連載準特別テーマ

スレッド式掲示板を作ろう

のに子のぱそ日誌、覚え書き、とにかく気がついたことを書いてばんばん放り込む。あるトピックに関連するトピックが後日起こった場合、前のトピックに「返信」の形で入力する。トピックを表示するときはトピック間の関連をスレッド式で表示する。さらに、表示にキーワードによる抽出機能を付加する。
PerlとPostgreSQLで作ってみました。内容はかなーり長いので、ときどき思い出したようにページ先頭へのリンクをつけてます。なお、ページリンクはtarget_blankなので(っていうのか)見終わったらすみませんが閉じてください。

実際の表示結果を先にまとめてしまいました。

キーワード検索とその結果
記事閲覧
新規入力
関連トピック入力

根本的な考え方(何が必要か?スレッドを表すしくみは?検索のしくみは?)

/背景/ひとつだけ/必要な入力フォーム/必要なパラメータ/PostgreSQLの導入/シリアル番号が記述するスレッド関係/キーワードで表示ファイルを抽出する/複数のキーワードで絞り込む/

実際に作る手順(いきなり完全系は作れないから、まずヒナガタを作って、それを拡張して、機能を付け加えていく)

/実際の作り方 /HTMLタグが嫌いだ/

重要なコード(以下はかなり長くなってしまいました)

/新規トピックにシリアル番号をつける/特定のファイルに返信する/さて、スレッド表示させてみよう/最後に検索抽出の機能だ/というわけで/


背景

誰かの投稿に返信するという形のスレッド掲示板だが、自分で「これでトラブった」という話題を書いて置いて、後日それが解決したときにその話を自分の前の記事に返信する形で書けば、あとで見たときにわかりやすい。パソコンネタ(一番多い)でも料理ネタでも旦那様の仕事ネタでも・・・ということでスレッド式掲示板を作ろうと考えた。
 そしてその前に作っておいたUNIX USERの目次検索を利用して、1998年12月号と1999年1月号に渡って連載された、かなだ まさかつ氏著「CGIで拓くWebの新世界」の内容を大いに参考にさせていただいた。
 最初は、付属CDに公開されていた同記事のソースコードを、入力文字数やウィンドウの大きさなどをカスタマイズして使う方向で進めた。だが、記事の表示に検索機能を付加しようということになったとき、やはり最初から自分でコードを書くことに決めた。しかし、「スレッド」の概念、必要なパラメータとその入力フォームの種類、その処理法といった考え方や流れは同記事無くしては見当もつかなかったと思われる。ここに謝意を表するものである。

ひとつだけ

今回、複数のホストからの同時入力によるデータ更新の混乱を防ぐための「ロックファイル」は考えなかった。だって最大でも二人しか入力する人いないしー。 でもいずれは勉強します。

ページ先頭へ

必要な入力フォーム

まず、お手本にあるように、

flow1: 「記事リスト表示ページ」->「選択した記事を表示させるページ」->「その記事に関連(返信)の記事を入力するフォーム」

flow2: 「新規記事入力フォーム」

という二つの流れを作ることにした。これをある程度整えてから、flow1の「記事リスト表示ページ」をさらに

「検索キーワード入力フォーム」->「キーワードで抽出した記事だけをリスト表示するページ」に修正することにした。難しそうだから。

必要なパラメータ

ファイル名」はとにかくできた順に通し番号をつけることにした。
発生日」は、それが実際に起こった日で、単に目安である。
作成日」を、date関数を使って自動入力することにした。もし間違って「発生日」の年を一年間違ったとしてもこれで見当がつく。
タイトル」をつける。
内容」は入力字数無制限の文章だ。

これらの「実際に閲覧するためのパラメータ」の他に、ファイルのスレッド関係を記述するためのパラメータを必要とする。

親ファイル名」は、そのファイルが直接関わっているファイルである。ファイル1に返信をつける形でファイル2を作れば、ファイル2は「親ファイル名=1」という情報を持つ。ファイル1自身が親ファイルを持っているかどうかは、このパラメータには関係ない。
同階層ファイル番号」は、同じ「親ファイル名情報」を持つファイル間での、作成順通し番号である。ファイル1に返信をつける形で新しいファイルを作るとき、「親ファイル名=1」の情報を持つ他のファイルがいくつあるかを調べ、2つあったら、この新ファイルの同階層ファイル番号は3になる。

シリアル番号」このシステムのキモになるのが、ファイルが隠し持つシリアル番号である。「隠し持つ」というのはこの番号そのものをファイル名にしてしまうと長すぎて取り扱いが不便になるからだ。
今ファイル名1から6までのファイルを作ったとしよう。

filename
linkfile
linkno.

1

new
0
2
1
1
3
1
2
4
new
0
5
2
1
6
4
1

ファイル名は全く持って作成順の通し番号だ。このうちファイル1とファイル4が新規トピックで、ファイル2と3はそれぞれ1に対する別々の関連トピックで、2の次に3ができたわけだから同階層ファイル番号として1と2が割り振られている。ファイル5は、ファイル1の関連ファイルであるファイル2にさらに関連する、階層のひとつ深いトピックなので、ファイル2やファイル3とは別の次元で同階層ファイル番号1だ。ファイル6はファイル4の関連ファイルである。
そこでこれにシリアル番号をつける。番号は将来の桁数増加を見越して十分な桁数を0で用意してやる。

filename
linkfile
linkno.
serialno.

1

new
0
0000000001
2
1
1
0000000001-001
3
1
2
0000000001-002
4
new
0
0000000004
5
2
1
0000000001-001-001
6
4
1
0000000004-001

さすがにファイル数が10億を越えることはないだろうというわけだ。あと「関連」といっても、15年前のあの事件が今明らかに、ということもないだろう・・・「この記事ダヨこの記事!」と言いたくなる深い関係がなければ全部「新規」に思い切ろう、という考えでせいぜい100個(絶対ねえ)。

「シリアル番号」の与え方は、あるファイルに対して返信がなされたときに、返信先の親ファイルの「シリアル番号」に、自分の「同階層ファイル番号」が-で区切られたあとに与えられるというしくみだ。ここにきてようやくファイル5の階層が一番深いことが明確になる。

ページ先頭へ

PostgreSQLの導入

かなだ まさかつ氏著「CGIで拓くWebの新世界」でのお手本スクリプトは、Perlだけで記述できるものだ。だがのに子風情にそのまねは難しい。そこでPostgreSQLの助けを借りることにした。
といっても、無制限に長い内容文章まではPostgreSQLのデータには入れないことにした。あくまでファイルをピックアップしたり並べたりするための情報だけあればよい。ということでPostgreSQLに作った
files
というテーブルには、ファイル名(filename, int)親ファイル名(linkfile, int)同階層ファイル番号(linkno,int)シリアル番号(serial, text primary key),タイトル(title, text)だけを与えることにした。

シリアル番号が記述するスレッド関係

これら6つのファイルをスレッド関係で表すには、このようにしたいことになる。

--1
----2
------5
----3

--4
----6

ファイル1系とファイル4系の、2本の木だ。そして、対等の枝である2と3の間に、2の分岐である5が、一階層下に字下げされて現れてほしい。

それには、まずファイルを、シリアル番号順に並べればよい。

filename
linkno.
serialno.

1

0
0000000001
2
1
0000000001-001
5
2
0000000001-001-001
3
1
0000000001-002
4
0
0000000004
6
1
0000000004-001

シリアル番号はテキスト扱いなのでソートするとこのように並んでくれる。
それから、linkno=0のファイルがくれば新規ツリーと考えて、改行なりで区別してやればいい。
字下げの幅は、-で区切られた要素の個数で表せばよい。

キーワードで表示ファイルを抽出する

このシステムの特徴は、データが入力されたときそれをテキストファイルとして保存するのとSQLのテーブルにデータの一部を保存する2系統同時処理をやっていることである。これが実は若干ヘボいかもしれない。クソ長い内容も含めて全てSQLに放り込んでもよかったかも知れない。だがそうなるとたぶん、pg_dumpしたときにむちゃくちゃ時間かかりそうだし、psqlで全部データ表示などというのもかなり難しそうなのでやめたのだ・・・
だがキーワードで検索する場合は当然クソ長い内容を探さなければならないのだから、これはテキストファイルを開いてその内容を一行ずつ読み込んでは正規表現/$keyword/にマッチするかどうか判断することになる。
マッチしたら、そのファイル名をSQLから探し出し、タイトルなど必要事項とともに表示させるというしくみだ。
この場合、もとのfilesテーブルから1個1個セレクトするより、まず検索に引っかかったファイル名の全ての情報を

複数のキーワードで絞り込む

絞り込みはせいぜい3語だろうと考えた。世界に開かれたポータルでの検索でも、この単純主婦は3語くらいしか使わない。まして家庭内の話題なら・・・
そこでキーワードを3語まで受け付ける。そして検索条件はANDのみにした。
この絞り込みにSQLは使えない。非常に残念だが。なぜなら主な検索対象になる内容はSQLには入力されないからだ。
そこでこのようにした。送信された内容をテキストファイルとして保存してあるフォルダはたとえば./dataである。
キーワードが一つの時はここから直接探せばよい。だがキーワードが3つ与えられた場合。まず3番目のキーワードで./dataフォルダ内のファイルを検索する。マッチしなければそれでサヨナラ(ANDだから)。
マッチしたら、マッチしたファイルだけを、別に与えられた./data3フォルダの中に、そのシンボリックリンクを作る。
そして、2番目のキーワードをこの./data3フォルダにあるファイル名を順次呼び出しては検索し、同様に検索条件にマッチしたファイル名のシンボリックリンクを./data2フォルダに作成。
最後に1番目のキーワードを./data2フォルダに与えられたファイルから検索して、マッチしたら初めてこのファイル名をPostgreSQLに参照するのだ。
キーワードが2つだけのときは3番目のキーワードの過程を省略することは言うまでもない。

これが根本的な考え方である。

ページ先頭へ

実際の作り方

1)まず、他のページとほとんどリンク関係のない「新規トピック入力フォーム」を作る。
2)次に、その入力フォームで送信されてきたデータを、以下の2系統に分けて処理する。
------->全てのデータをテキストとして記述したテキストファイルを作成し、通し番号をつけて保存する。
------->filename, linkfile, linkno, title, serialnoに該当するパラメータを、PostgreSQLのテーブル「files」に入力する。

3)新規トピックを実際にWebブラウザから入力し、通し番号自動作成でテキストファイルとして保存され、かつPgSQLのテーブルに値が挿入されているかどうか確認する。

4)作成されたテキストファイルからの内容を表示する「選択した記事を表示させるページ」を作る。本当は記事リストページで任意に選択された記事を表示するようにするのだが、ここではまず適当なファイルをスクリプト内で決めて、それをちゃんと表示できるか確認する。
5)それから、その記事に対して返信するフォームを作り、返信してみて、正しいlinkfile, linkno, serialnoが発生するかどうか確認する。

6)いろいろなスレッド関係を持つ記事を3,4個作成してみてから、まずはそれらを全て表示させるリストページを作ってみる。上記の考えでスレッド関係が正確に反映されるか確認する。

HTMLタグが嫌いだ

ていうかめんどくさい。そこでperlにCGI.pmにより導入される関数に全部まかしちゃう。肝心なのはなが〜い文章になるであろう内容の入力エリアだ。

print "Content";
print br;
print textarea (
-name => "message",
-rows => 50,
-columns => 80,
-wrap => "virtual",
);

で、入力フォーム上でちゃんと改行してくれる複数行テキストエリアが作れる。

新規トピックにシリアル番号をつける

新規トピックファイルを作るときは、まずファイル名の通し番号をこのようにして決める。

my $idselectstr = "select max(filename) from files";
$result = $conn->exec($idselectstr);
$n = $result->getvalue(0,0);
my $id=$n+1;

ファイル名そのものは単純な通し番号にしたのできわめてわかりやすい。一方この単純なファイル名からシリアル番号も作成しておく。

my $lid = length($id);
my $serial = "0000000000";
substr ($serial, -$lid, $lid) = $id;

最初に0000000000という文字列を用意しておいて、それにファイル名の長さぶんだけ左から文字列置換を行うというしくみだ。

特定のファイルに返信する

ファイルリストを表示させるためのcgiコード(っていうのかな)はこんなだ。もっともこれは特定のファイル「1」を示している場合だが。

print ( a ( { href=>"thread.cgi?linkfile=1"} , "FileNo:1 Title: 最初のファイル"));

ブラウザ上では、

FileNo:1 Title:最初のファイル

と青文字で表示され、これを選択してクリックすると(注意:ここではただ色を青くしただけで実際にリンクはついてません)
thread.cgiという、特定のファイルに返信するためのcgiページに行く。このとき、パラメータとして、linkfile=1を持っていくのだ。
この状態のthread.cgi上で、内容を入力して送信すると、これがテキストファイルとして保存される。
このファイル名は、全ファイルを通した番号をつけられる。すなわちSQLでfilenameの最大値を探してきてそれに1加えた数字がファイル名となる。今、 これに3というファイル名が与えられたとする。
SQLのほうにはさらに、このファイル3のファイル情報として、linkfile, linknoが与えられ、それをもとにserialnoがつけられる。
linkfile=1をパラメータに持っているので、シリアル番号の第1項はまず
0000000001
となる。次に第1項が同じで、かつlinknoが有限値である他のファイルがないかどうかさがす。あったらその中で最大のものを探して1を加え、このファイルのlinknoとする。linknoはこのようにして同じ親ファイルに関連するファイルの数を数えるためのパラメータなのだ。今linknoが2に決まったとする。そうするとシリアル番号は
0000000001-002
となる。
こうして返信型のファイルの諸パラメータが決まる。

ページ先頭へ

さて、スレッド表示させてみよう

もう一度さっきの表を出します。本当はこれにtitleフィールドが加わるのだが、今回は便宜上省略。

filename
linkfile
linkno.
serialno.

1

new
0
0000000001
2
1
1
0000000001-001
5
1
2
0000000001-001-001
3
new
1
0000000001-002
4
2
0
0000000004
6
4
1
0000000004-001

PerlでSQLコマンドをかましてシリアル番号順に並べたところだ。今はまだ検索抜きで、これを全部表示させてみる。
まずはセレクトコマンドをかましておく。それから以下のCode Aを実行する。これは、

"group:0000000001:0000000001-001:0000000001-001-001:0000000001-002"

"group:0000000004:0000000004-001"
に、グループわけするための措置である。

Code A(詳しい解説はこちら

$n = $result->ntuples;;

my $initialorg = 0;.......................................(1)
my @array;
my $groupname;

for ($i=0; $i<$n; $i++)
{
$serialnext = $result->getvalue($i,3); ...................................(2)

my @initialnext = split(/-/, $serialnext);..................................(3)

if ($initialnext[0] ne $initialorg).................................(4)
{ @array = ($groupname, @array);.................................(5)
$groupname = "group";...............................(6)
$initialorg = $initialnext[0];...................................(7)
}
$groupname .= ":$serialnext";.................................(8)

}
@array = ($groupname, @array);..............................(9)

この結果、

@array=(
"group:0000000001:0000000001-001:0000000001-001-001:0000000001-002" ,
"group:0000000004:0000000004-001"
)
となる。最初のgroupは、接頭辞のようなもので、あとは切り捨てる予定だ!ともあれ、これでスレッドごとに、ひとつの文字列にまとまったことになる。このあと、配列@arrayの要素に対して以下の CodeBを実行する。これは、

スレッドをまとめた文字列をバラバラにして並べる
改行
次のスレッドをまとめた文字列をバラバラにして並べる
改行

操作を繰り返すためのものだ。改行1個のために大騒ぎという話もあるが、いちどこうやってまとめないとわけわかんなくなっちゃうんだもん。

Code B (詳しい解説はこちら

foreach $group (@array)
{

my @tree = split(/:/, $group);
shift (@tree);.................................(1)
my $point;

foreach $record (@tree)
{

my @arrayagain = split(/-/,$record);
my $point = @arrayagain;....................................(2)
my (@profile);

my $identstr = "select * from $tablename where serial ='$record'";
$result = $conn->exec($identstr);

for ($i=0; $i< 5; $i++)
{
@profile = (@profile, $result->getvalue(0,$i));...................(3)
}

print "--" x $point;...............(4)
print (a({href=>"thread.cgi?linkfile=$profile[0]"},
"FileNo: $profile[0] Title: $profile[4]"));..............................(4)
print br;

}
print br;...............................(5)
}

こうして、ようやくスレッド表示をすることができた。それもこれもPostgreSQLの力を借りてようやくのことである。これを全部Perl一本で書いてるんだからお手本はすごいかも。

ページ先頭へ

最後に検索抽出の機能だ

いくつか新規ファイルやそれに対する返信を作って、ちゃんとファイルが作成されそれがスレッド関係とともに表示されることを確認したら、いよいよこれに検索機能をつける。簡単のためにキーワード2つを受け付ける場合とする。本体は以下のようだ。
データ抽出用のダミーフォルダとともに、あとから表示をしやすくするために、最終的に抽出されたファイルの情報だけを一時的に保管するダミーテーブルも必要となった。ここではオリジナルのファイル情報のテーブルを$originaltable, 一時的に抽出したファイルのテーブルを$extractedtableで区別する。

CodeC(詳しい説明はこちら

my $nextfolder = "data";..........................(1)

if ($key2)
{
my $orig2;
while ($orig2 = <./data/*>)
{
open (FILE, $orig2);
while (<FILE>) {
if ($_ =~ /$key2/i) {
my @ref =split(/\//, $orig2);...................(2)
my $full = "../data/$ref[2]";
my $ext = "./data3/".$ref[2];
symlink ($full, $ext);...................................(3)
last;........................................................................(4)

}
}
close FILE;
}
unless (<./data2/*>) {
print h1("検索結果が見つかりませんネ");
print "キーワードを変えて再検索してみてください";
exit;
} .........................................................................(5)

$nextfolder = "data2";..................................(6)

}


my $orig;
my $nn=0;........................................................(7)

while ($orig = <./$nextfolder/*>)..............(8)
{
open (FILE, $orig);
while (<FILE>) {
if ($_ =~ /$key1/i) {
my @ref =split(/\//, $orig);
my $findstr = " select * from files where $originaltable= $ref[2] ";
$result = $conn->exec($findstr);...............................(9)
my @found;

for ($i=0; $i<5; $i++)
{ my $gv =$result -> getvalue(0, $i);
@found = (@found, $gv);}
my $collectstr = "insert into $extractedtable values (";
$collectstr .= "$found[0], $found[1], $found[2], ";
$collectstr .= "'$found[3]', '$found[4]')";

$result = $conn->exec($collectstr);

$nn=$nn+1;.................................(10)
last;
}
}
close FILE;
}

さて、2番目のキーワードのときに一致するファイルが見つかったかどうかは、抽出ファイルである./data2に値があるかどうかを見ればよい。だが1番目のキーワードでは?
やっぱり抽出用ダミーファイルを作るという手もあるが、キーワードは1個というケースは実は案外多いのではないか。そのときにいちいちダミーファイルをつくるのも手間がかかるということで、それで$nnを作った。つまり、一致するファイルが1個でも見つかったら$nn>0だから、以下のselect文を実行し、なければ「見つかりません」と言って抜ければいいというわけだ。未定義値に対してselect文を実行することはさすがにできないみたいだもんな・・・あとは、扱うテーブルが違うというだけでCodeBと全く同じだ。

if ($nn > 0)
{

my $fileselectstr = "select * from $extractedtable order by serial";

$result = $conn->exec($fileselectstr);
$n = $result->ntuples;;

my $initialorg = 0;
my @array;
my $groupname;

for ($i=0; $i<$n; $i++)
{
$serialnext = $result->getvalue($i,3);

my @initialnext = split(/-/, $serialnext);

if ($initialnext[0] ne $initialorg)
{ @array = ($groupname, @array);
$groupname = "group".$serialnext;
$initialorg = $initialnext[0];
}
$groupname .= ":$serialnext";

}
@array = ($groupname, @array);

foreach $group (@array)
{

my @tree = split(/:/, $group);
shift (@tree);
my $point;
foreach $record (@tree)
{
my $identstr = "select * from $tablename where serial ='$record'";
$result = $conn->exec($identstr);
my @arrayagain = split(/-/,$record);
my $point = @arrayagain;
my (@profile);

for ($i=0; $i< 5; $i++)
{
@profile = (@profile, $result->getvalue(0,$i));
}

print "--" x $point;
print (a({href=>"thread.cgi?linkfile=$profile[0]"},
"FileNo: $profile[0] Title: $profile[4]"));
print br;

}
print br;
}
}

一致するものがなければ、これで抜ける。

else {

print h1("検索結果が見つかりませんネ");
print "キーワードを変えて再検索してみてください";

}

}

これが本体だ。これに加えて、検索をし直すたびにダミーフォルダやダミーテーブルを初期化する。検索キーワードが送信されるたびにその処理を行うことにした。
PostgreSQLには、テーブルの名前を特定すればそれを自動的に一時ファイルとして初期化してくれる便利な機能があるようだが、「いつ初期化するのか」が不安だったので、せっせとテーブルを消してはまた作ることにした。


というわけで

三日考え抜いて、お手本とはちと違う「のに子流方式」でスレッド掲示板を作ってみた。
ひとつ足りないと思うのは、キーワード検索だけでなく、「特定の期間の記事」を抽出するようなシステムである。作って見ようと思っている。

おわり

2001年12月09日

ページ先頭へ