ファイルアップロード => PostgreSQLにラージオブジェクトとして保存



フォームからアップロードされたファイルを受取り、サーバマシン上にテンポラリファイルを作成することなく、そのままデータベースにラージオブジェクトとして保存する方法を解説します。

  • 使用する perl モジュール: CGI、DBI、DBD::Pg、File::Basename
  • サンプルファイル: 送信元フォーム pg_lo_load.html
  • サンプルファイル: CGIスクリプト pg_lo_load.cgi

※ 普通にリンクを選択するとブラウザウィンドウ内にHTMLとして表示されてしまうので、ブラウザの「名前を付けて保存」等の機能を使用してファイルを保存してから、テキストエディタ等で参照して下さい。

処理のポイント

  • むやみやたらとファイルを受け付けると、大量/大容量ファイルの処理でサーバ機能が停止してしまう可能性があるので、受付できるファイルの容量制限をします。
  • サーバにウィルス対策を施していない場合、ウィルスの付いている可能性のあるようなファイルタイプは、可能であれば受付を拒否します。(特に、サーバマシンのOSがウィンドウズであるような場合は、注意する必要があります。)
  • DBI(基本的には接続先のデータベースの種類に依存しない、perl のデータベースインターフェース)を使用しますが、ファイルをデータベースに保存する場合は、データベース特有の機能を処理する func 関数を使用(経由)して、PostgreSQL のラージオブジェクトをハンドリングします。
    DBI と DBD::Pg についての詳細は、search.cpan.org などからモジュールのドキュメントを参照して下さい。

動作確認環境(2004-08-09追記)

2004-08-09 現在の最新バージョンである DBD::Pg 1.32 だとうまく動作しません(DBD::Pg側の仕様変更またはバグ(?)により)。

DBIモジュール DBD::Pgモジュール PostgreSQL 結果
1.15 1.22 7.3.2、7.4.3
1.43 1.31 7.3.2、7.4.3
1.43 1.32 <=これがネック! 7.3.2、7.4.3 ×

PostgreSQL のテーブル設定

ファイル(ラージオブジェクト)を格納する PostgreSQL のテーブルを作成するSQLコマンドの一例は以下の通りです。
ファイル(ラージオブジェクト)を格納するフィールドのタイプは、oid です。


CREATE TABLE FILE_TABLE (
data_id serial NOT NULL PRIMARY KEY,
file_name text NULL,
file_type text NULL,
body oid NULL
);

サンプルと解説 – 送信元フォーム

以下が、送信元フォームのHTMLファイル全体です。

普通のフォームですが、フォームのエンコーディングタイプとして、「ENCTYPE=”multipart/form-data”」を指定しています。ファイルを転送する場合はこれにしなければなりません。

アップロードするファイルをクライアントのローカルマシンから選んでもらうための INPUT 要素は、TYPE=”file” です。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=EUC-JP">
<title>ファイルアップロード + PostgreSQL へインサート</title>
</head>

<body bgcolor=”#FFFFFF”>
<form method=”post” action=”/cgi/pg_lo_load.cgi” ENCTYPE=”multipart/form-data”>

ファイル選択: <input type=”file” name=”upload_file” size=”60″>
<hr>
<input type=”submit” value=”アップロード & インサート”>

</form>

</body>
</html>

ちょっと雑学


フォームに ENCTYPE 指定していない場合、デフォルトは ENCTYPE=”application/x-www-form-urlencoded” なのですが、MIME タイプのお約束として、この 「x-」は、(誰かが)自由に作った MIME タイプである、という意味合いがあるそうです。「application/x-mytype」なんてことを宣言できる、ということらしいです。そんなのでも、標準になっちゃったりするんですね~。

サンプルと解説 – CGIスクリプト

ダウンロードできるファイル内にコメントとして大体のことは書いてあるので、面倒な人は読み飛ばして下さい。少しずつ分割しながら簡単に解説を加えます。

perl のパスの設定とモジュールのロード、変数宣言を行います。


#!/usr/bin/perl -w

#使用するモジュールをロード
use File::Basename;
use DBI;
use CGI;

#変数宣言
my ($form, @type_ok, @ext_ok, $connstr, $filename, $parsename,
@filename, $error, $ok, $type, $dbh,
$lobj_id, $lobj_fd, $buffer, $bufsize, $sql, $rc);

環境設定をします。
@type_ok と @ext_ok は、受付可能な ファイルの MIMEタイプと拡張子を設定します。送信できるファイルの制限は、許可するものを指定する方法と拒否するものを指定する方法とが考えられますが、このサンプルでは、「許可するものを指定する」方法を採用しています。送信されたファイルのMIMEタイプか拡張子のいずれかが指定した中に含まれていれば、送信が許可されます(実際は、保存が許可される。MIMEタイプやファイル名を取得する時には既に、web サーバがファイルのデータを受取切って、そのデータを内部的に保持している。CGIスクリプトがそれを保存しない場合、そのデータは破棄される。)。
$connstr は、DBI で使用するデータベース接続用文字列です。詳細は、CPANのDBD::Pg のドキュメントを参照して下さい。


#受付可能なファイルタイプ(正規表現)
@type_ok = qw (
text\/[\w]+
application\/zip
application\/pdf
application\/x-gzip
application\/x-tar
application\/mac-binhex40
application\/postscript
application\/x-csh
application\/x-director
application\/x-dvi
application\/x-javascript
application\/x-latex
application\/x-sh
application\/x-shar
application\/x-shockwave-flash
application\/x-stuffit
application\/x-tex
application\/x-texinfo
application\/xml
audio\/[\w]+
image\/[\w]+
video\/[\w]+
);

#受付可能な拡張子(正規表現)
@ext_ok = qw (
hqx
zip
pdf
ai
eps
ps
csh
dvi
js
latex
sh
swf
shar
sit
tar
tcl
tex
lzh
gz
);

#データベース接続設定
$connstr = ‘”dbi:Pg:dbname=dbname”, “”, “”, { RaiseError => 1, AutoCommit => 0 }’; #DBI接続文字列

web サーバとクライアントのセッションが切断された時に実行する関数を定義します。このサンプルの場合は、データベース接続が残っていたらそれを切断する処理を定義しています。


#接続切断時に実行する関数
sub db_disconnect {
$dbh->disconnect if ($dbh);
}

送信可能なサイズの上限を設定し、CGIオブジェクトを作成します。これらは モジュール CGI の機能です。詳細は、search.cpan.org などからモジュールのドキュメントを参照して下さい。

このように制限した場合でも、ファイルサイズの判断は、送信されてきたデータを全て受取り切った後です。送信そのものを打ち切りたい場合は、(WebサーバがApacheなら)Apacheの LimitRequestBody ディレクティブで制限できます。
[>> Apache のドキュメント(LimitRequestBody ディレクティブ)]


#転送できるファイルの最大サイズを設定
#(実際は、post送信されるコンテンツ合計の最大サイズ)
#この値は、CGIオブジェクトを作成する時には既に
#設定されていなければならない
$CGI::POST_MAX = 1024 * 1000; #max = 1MB

#CGIオブジェクトを作成
$form = new CGI;

クライアントにヘッダを出力します。


#クライアントにヘッダを送信
#これは、結果メッセージ表示のため
print $form->header("text/plain");

まず、ファイルが送信されているかチェックします。


#アップロードされたファイルの情報を変数に格納
#この変数は、コンテクストによって色々な値を返してくれる
$filename = $parsename = $form->param('upload_file');

unless (defined($filename)){
#ファイルが転送されていなかったら、$filename は 未定義値となっている
#(ファイルサイズエラーである場合が多いと思う)
#フォーム上でファイルを選択しないままフォームがサブミットされた場合は、
#この変数 $filename は空文字列として定義されている。

$error = $form->cgi_error;
print “ファイルが転送できませんでした:$error\n”;
exit;
}

ファイルが送信されている場合は、送信拒否すべきファイルでないかをチェックします。

「ベース名のチェック(アスキー文字列であること)」を行うと、ファイル名に日本語を含むファイルは送信できません。一般に、ファイル名に日本語等を使用すると何かと問題が出ることが多いのでこの例では制限を付けていますが、CGI(送信フォーム)とファイル名の使用文字コードが一致していれば日本語ファイルの送信自体は問題なく行えます。
例えば、フォームとCGIをShift_JISで作成し、WindowsやMacからファイルをアップロード、PostreSQLで使用する文字コードもShift_JISに設定してある場合等は全てShift_JISの環境なので、日本語ファイル名を受け付けても特に問題が出ません。
フォームとCGIをEUC-JPやその他の文字コードで作成している場合、WindowsやMacなど、Shift_JISコードを採用しているOSから日本語ファイル名のファイルを送信してしまうと、送信側のブラウザによっては、CGI側でコンテンツをうまく取得できないことがあります。うまくいかないことが明らかなブラウザは Netscape 4.x(Windows) です。IE 4.x 以上(Windows)、IE5.5(Mac)、Opera6.x以上(Windows)等では、特に問題が出ないようです。


if ($filename) { #ファイルが転送されていれば、値は真

#ファイルパス内の「\」を「/」に変換
# $parsename には、送信元クライアントマシン内での
#ファイルパスが格納されている。
$parsename =~ s#\\#/#g;

#ファイル名を(ベース名, ディレクトリ名, 拡張子)に分解
@filename = fileparse($parsename, “\.[^\.]+”);

#ファイル名のチェック(アスキー文字列であること)
$filename[0] =~ /^[\.\w~-]+$/ and $filename[2] =~ /^[\.\w-]+$/ and $ok = 1;

unless ($ok) {
$error = ‘ファイル名は、半角英数字にして下さい。’;
print “ファイル転送ができませんでした。: $error\n”;
exit;
}

$ok = 0; #フラグのリセット

#ファイルタイプのチェック
$type = $form->uploadInfo($filename)->{‘Content-Type’};
foreach (@type_ok){
$type =~ /^$_$/ and $ok = 1 and last;
}

unless ($ok){
#ファイルタイプのチェックでNGだった場合

#拡張子のチェック
foreach (@ext_ok){
$filename[2] =~ /^\.$_$/ and $ok = 1 and last;
}
}

unless ($ok){
#ファイルタイプ、拡張子どちらのチェックもNGの場合

$error = “許可されていないファイルタイプ($type)・拡張子($filename[2])です。”;
print “ファイル転送ができませんでした。: $error\n”;
exit;
}

保存してよいファイルである場合、データベースに格納する処理を行います。

まず、$SIG{‘***’} に最初に定義した関数を設定して、web サーバとクライアントのセッション切断に備えます。%SIG は perl の特殊変数で、スクリプトがシグナルを受け取った時の処理関数を指定できます。詳細は perl のドキュメントを参照して下さい。

#転送を許可されたファイルの場合、データベースにインサートする

#セッションが切れたときのDBの切断処理を設定
$SIG{‘TERM’} = $SIG{‘CHLD’} = $SIG{‘KILL’} = $SIG{‘INT’} = \&db_disconnect;

データベースに接続します。

#データベースに接続
eval "\$dbh = DBI->connect($connstr)";
($dbh) or do {print 'データベースへの接続に失敗しました'; exit;};

PostgreSQL の「ラージオブジェクト」を作成し、アップロードされたファイルの内容を書き込みます。
ラージオブジェクトの扱いの詳細に関しては、DBD::Pg や PostgreSQL のドキュメントを参照して下さい。

#ラージオブジェクトを作成
#(失敗したら、DB接続を切断して終了)
$lobj_id = $dbh->func($dbh->{pg_INV_WRITE}, 'lo_creat');
(defined $lobj_id) or do {
print 'ラージオブジェクトの作成に失敗しました。';
$dbh->disconnect if $dbh;
exit;
};

#ラージオブジェクトを書込み用にオープン
#(失敗したら、DB接続を切断して終了)
$lobj_fd = $dbh->func($lobj_id, $dbh->{pg_INV_WRITE}, ‘lo_open’);
(defined $lobj_fd) or do {
print ‘ラージオブジェクトのオープンに失敗しました。’;
$dbh->disconnect if $dbh;
exit;
};

# $filename から内容を読み出して
#作成したラージオブジェクトに書き出す
#この場合、変数 $filename はファイルハンドルとして
#機能する
while (read($filename,$buffer,1024)) {
$bufsize = length $buffer;
$dbh->func($lobj_fd, $buffer, $bufsize, ‘lo_write’);
}

補足注:
上記コードで単純に以下のようにしてしまうと、ファイルの末尾に余計なデータが追加されてしまいます。
while (read($filename,$buffer,1024)) {
$dbh->func($lobj_fd, $buffer, 1024, ‘lo_write’);
}
(DBD:Pgモジュール、PostgreSQLのバージョンによると思いますが、)固定で「1024」と書き出しサイズを指定してしまうと、1024バイトを上限とするのではなく、1024バイトまできっちり、$buffer に格納されているデータ長が足りなければ、直前に書き込んだデータで補完してしまうようです。


#ラージオブジェクトをクローズ
#(失敗したら、DB接続を切断して終了)
$lobj_fd = $dbh->func($lobj_fd, 'lo_close');
($lobj_fd) or do {
print 'ラージオブジェクトのクローズに失敗しました。';
$dbh->disconnect if $dbh;
exit;
};

生成されたオブジェクトID($lobj_id)をデータベースに格納します。

#インサート用SQLを設定
$sql = "insert into FILE_TABLE (file_name, file_type, body) ";
$sql .= "values ('$filename[0]$filename[2]', '$type', $lobj_id);";

#sql実行
eval { $rc = $dbh->do($sql);}
or do {
print ‘インサートに失敗しました’.”\n\n”;
print $dbh->errstr.'(‘.__LINE__.’)’;
$dbh->disconnect if $dbh;
exit;
};

#エラーが無ければコミット
if ($rc == 1){
$rc = $dbh->commit;
} else {
$rc = $dbh->rollback;
print ‘インサートに失敗しました’.”\n\n”;
print $dbh->errstr.'(‘.__LINE__.’)’;
}

#データベースとの接続切断
$dbh->disconnect if $dbh;

ファイルが送信されていない場合は、メッセージだけ表示して終了です。

} else {
# ファイルが転送されていない場合
# $filename は 偽
print 'ファイルはアップロードされていません。';
}

インサートされたデータ

インサートされたデータを psql で参照すると、以下の様に表示されます。body のフィールドに格納されている番号は、perl スクリプト内で生成した $lobj_id(オブジェクトID)の値と同じです。

dbname=# select * from FILE_TABLE;
data_id | file_name  |    file_type    |  body
---------+------------+-----------------+----------
1 | myfile.pdf | application/pdf | 1234567
(1 row)