jakarta commons の FileUpload と Java の FileChannel と

  • 投稿日:
  • by
  • カテゴリ:

Java 1.4 という古い時代に実装された FileChannel という新しいストリーム処理クラスは、新しいといってもリリースされてかなり時間が経つわけですが、そのわりには応用例が Web であんまり見つかりません。

自分の探し方が甘いんだろうとは思うのですが、そんなに気合い入れて探さないと出てこない情報というのはないも同然なので、自分で覚えたことを忘れないうちにメモって公開しておくことにします。

tomcat で FORM からのファイルアップロードを受ける処理には jakarta commons の FileUpload パッケージを使うのが基本中の基本。
ところがそのサンプルコードを探すと、どこを見ても FileItem の write メソッドを使うものばかり。ちょっと気が利いてても InputStream で読み込んだりするような。

たとえばこういう。

DiskFileItemFactory factory = new DiskFileItemFactory(512000, "/tmp");
ServletFileUpload upload = new ServletFileUpload(factory);

// アップロードされるデータの最大サイズを設定
upload.setSizeMax(1024000);

// リクエストを個々のアイテムに分解して処理
for (FileItem item : upload.parseRequest(request)) {
    if (item.isFormField()) {
        // フォームフィールド
        // たとえば <input type="text" name="abc" value="12345"> というタグがあれば、
        String name = item.getFieldName();
        String value = item.getString();
        // name = "abc", value = "12345" となる
    }
    else {
        // <input type="file" name="uploadFile"> というタグがあった場合、
        String fieldName = item.getFieldName();     // fieldName = "uploadFile"
        // アップロードされたファイルのクライアント上でのファイル名
        String fileName = item.getName();

        // この場で保存先が決まっているなら
        File uploadedFile = new File("保存するファイル名");
        item.write(uploadedFile);   // アップロードされたファイルを保存

        // ストリームクラスで処理するなら
        InputStream in = null;
        FileOutputStream out = null;

        byte[] buf = new byte[5120];    // ストリーム読み書きのバッファ

        try {
            in = item.getInputStream();
            out = new FileOutputStream("保存するファイル名");

            // ストリームの最後まで読み込む
            while (in.read(buf) != -1) {
                out.write(buf);
            }
        } finally {
            if (in != null) { in.close(); }
            if (out != null) { out.close(); }
        }
        // 実際、ここに直接ストリーム処理とか書かない
    }
}

でも FileUpload の公式サイトにある User Guide を読むといきなり「このやり方は古くて遅いから、イカしたコードを書きたければ Streaming API を使いな」と書いてある。

上に例を挙げたような「従来の書き方」だと、クライアントから送られてきたデータをいったんメモリなりディスクなりに一時保存してからプログラムに渡されることになるから遅いんだと。
でも新しくて便利な Streaming API を使えば、クライアントからデータを受け取った直後からプログラムで扱えるようになるので一時保存にかかるコストが省けるし、ディスクに保存するにしてもリアルタイムに処理するにしても、ムダのない効率的なコードがかけますよと。

名前が Streaming API とかたいそうな感じになってますが、使い方は上の例と大差ないくらい簡単です。

import java.nio.*;
if (ServletFileUpload.isMultipartContent(request)) {
    ServletFileUpload upload = new ServletFileUpload();

    // リクエストデータをフィールド 1 個ずつに分けて処理
    FileItemIterator iter = upload.getItemIterator(request);
    while (iter.hasNext()) {
        FileItemStream item = iter.next();
        // INPUT タグ name で指定されたフィールド名
        String name = item.getFieldName;
        // フィールドの値が読み出せるストリーム
        InputStream in = item.openStream();

        if (item.isFormField()) {
            // フォームのフィールド
            String value = Streams.asString(in);
        }
        else {
            // アップロードされたファイル本体
            String fileName = item.getName();
            FileOutputStream out = null;

            byte[] buf = new byte[5120];

            try {
                out = new FileOutputStream("保存するファイル名");

                while (in.read(buf) != -1) {
                    out.write(buf);
                }
            } finally {
                if (out != null) { out.close(); }
            }
        }
        // in.close() が必要かも
    }
}

最初の例と大差ないように見えるけど、上の書き方だと「逐次受信、即ディスク書き込み」というルーチンになるらしいので、最初の「一括受信、ばらしたファイルを読み込んで別ファイルに書き出し」というルーチンに比べれば全然速い。ムダもなく効率的。

ここで、冒頭で触れた「新しいパッケージ」であるところの FileChannel を使えばもっと速くなるんじゃないの?という話に流れるわけですよ。これが本題。

import java.nio.*;

if (ServletFileUpload.isMultipartContent(request)) {
    ServletFileUpload upload = new ServletFileUpload();

    // リクエストデータをフィールド 1 個ずつに分けて処理
    FileItemIterator iter = upload.getItemIterator(request);
    while (iter.hasNext()) {
        FileItemStream item = iter.next();
        // INPUT タグ name で指定されたフィールド名
        String name = item.getFieldName;
        // フィールドの値が読み出せるストリーム
        InputStream in = item.openStream();

        if (item.isFormField()) {
            // フォームのフィールド
            String value = Streams.asString(in);
        }
        else {
            // アップロードされたファイル本体
            String fileName = item.getName();
            ReadableByteChannel inCh = null;
            FileChannel outCh = null;

            byte[] buf = new byte[5120];

            try {
                inCh = Channels.newChannel(in);
                outCh = (new FileOutputStream("保存するファイル名")).getChannel();

                ByteBuffer buf = ByteBuffer.allocateDirect(5120);

                while (inCh.read(buf) != -1) {
                    buf.flip();
                    outCh.write(buf);
                    buf.clear();
                }
            } finally {
                if (inCh != null) { inCh.close(); }
                if (outCh != null) { outCh.close(); }
            }
        }
    }
}

キモは OutputFileStream から getChannel() で FileChannel を取り出してるところと、FileItem が開いた InputStream を Channels.newChannel() で ReadableByteChannel に変えているところ。

FileChannel は「新しい」と公式ドキュメントでアピールされまくってるとおり、パフォーマンスを上げるためにハード依存な実装を増やしつつ JVM の汎用性を保ちました的な設計になってるらしく、ファイルやストリームからの読み書きに使うメモリへのアクセスが速くなっているそうで。
上の例でもバッファは 5KB ほど確保してますが、Java のヒープじゃなくてハードネイティブのメモリを直接確保する allocateDirect() を使ってるので、JVM の中間バッファをスルーできます。前ふたつの例だと Java の仮想マシン上のヒープを使ってるので、その中間バッファからマシンネイティブのメモリにアクセスするという潜在的なコストの高い処理になってしまうわけですね。

ハードネイティブなアプローチだから、Java 仮想マシンの「ハードに依存しない」という大きな特徴が若干損なわれてもするんですが、今回のような tomcat 上で動かす Servlet という用途では「ハード非依存」とか比較的どうでもいいことなので無視できますし。

ほかにもメモリマップドファイルを扱うクラスや、ソケットからのストリームも Channel として扱えるようにするクラスがあったりして、うまく使えば今までより効率的な I/O 処理が書けそうで素敵な感じ。

正直なところ、もっと早く知っておきたかったものです。