アプレットからWebサーバへファイルアップロード(高速化・大容量対応)

前に書いた、

の続き。

問題点

あのコードでは2つの問題があった。

  • アップロード速度が遅い
  • 大容量のファイルをアップロードすると OutOfMemory の例外になる

つまり、ダメだということ。

アップロード速度の改善

前回のコードでは、実際にファイルを送信しているところで、

int buff = 0;
while((buff = in.read()) != -1){
    out.write(buff);
}

と書いていた (inがファイルからの読み込みで、outがhttp出力への書き込み)。これは1バイトずつ処理しているので、遅かった。

この部分を、こう直した。

byte[] bytes = new byte[1024];
while(true){
    int ret = in.read(bytes);
    if(ret == 0) break;
    
    out.write(bytes, 0, ret);
    out.flush();
}

ファイルから1024バイトずつ読み込んで、http出力に書き込む。読み込めなかった場合はループを抜ける。これでだいぶ速くなった。

大容量ファイルへの対応

前回のコードでは、URLConnectionのデフォルトの接続方法を使っていた。

どうやらこのやり方では、outにデータを書き込んでも、すぐにWebサーバへは送らないようだ。全ての書き込みが終了した時点で総データ量を測定し、それをContent-Lengthヘッダとして渡した後で一気にデータを送るっぽい。

だから、大容量のデータをアップロードしようとすると、すべてメモリに溜め込んでしまって結局OutOfMemoryの例外になるわけだ。

この件で人力検索に質問した。

もらった回答を検証してるうちに期限が来てしまって質問は自動終了してしまったけど、その後も頑張って調べて、なんとか解決した。

HttpURLConnection#setFixedLengthStreamingMode

HttpURLConnection#setFixedLengthStreamingModeというのがあった。

このメソッドは、コンテンツ長が事前にわかっている場合に、内部バッファ処理を行わずに HTTP 要求本体のストリーミングを有効にするために使用します。

HttpURLConnection (Java 2 Platform SE 5.0) #setFixedLengthStreamingMode

人力検索で教えていただいたsetChunkedStreamingModeの方は、Webサーバ側が送信が完了したことに気付いてくれない場合があって残念ながらダメだったけど、このsetFixedLengthStreamingModeの方は、正しいContent-Lengthを事前に調べてそれを教えてあげることで、その後のデータ送信はメモリに溜め込まずにどんどん送ることができる。

総データ量を事前に調べてから送信

setFixedLengthStreamingModeのおかげで問題は解決しそうだ。あとは総データ量を事前に調べることができればOK。そこで以下のようなことを考えた。

  • OutputStreamを継承したクラスを作る。
  • このクラスは、writeメソッドでデータが書き込まれても、何もしない (というものにオーバーライドする)。
  • アップロード処理と全く同じ処理をこのクラスに対して行い、データ量を測定する。

というわけで、DummyOutputStreamクラスを作った。

public class DummyOutputStream extends OutputStream {

    private int size = 0;
    
    @Override
    public void write(int b) throws IOException {
        size += 1;
    }
    @Override
    public void write(byte[] bytes) throws IOException {
        size += bytes.length;
    }
    @Override
    public void write(byte[] b, int off, int len) throws IOException {
        size += len;
    }
    
    public int getSize(){
        return this.size;
    }
}
  • writeメソッド(3つのオーバーロード)が呼ばれたら、その渡されたデータ量をsize変数に加算していく
  • size変数を取得するgetter

これだけ。とっても簡単なクラス。

あとは、このクラスへの書き込みと、実際のアップロードのための書き込みを共通化する。

String boundary = generateBoundary();
String text = "テキスト";
File file = new File("c:\\files\\file.zip");

// ダミーに書き込んで、データ量を調べる
DummyOutputStream dummy = new DummyOutputStream();
doOutput(boundary, dummy, text, file);
int contentLength = dummy.getSize();

// 接続
URL url = new URL("http://example.com/upload.do"); // 送信先
HttpURLConnection conn = (HttpURLConnection)url.openConnection();
conn.setFixedLengthStreamingMode(size);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
conn.connect();

// 実際にデータを送信する
OutputStream out = conn.getOutputStream();
doOutput(boundary, out, text, file);
out.flush();
out.close();

// レスポンスを受信 (これをやらないと通信が完了しない)
InputStream stream = conn.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
String responseData = null;
while((responseData = reader.readLine()) != null){
    System.out.print(responseData);
}
stream.close();
conn.disconnect();
private void doOutput(String boundary, OutputStream out, String text, File file) throws IOException{
    String charset = "Shift_JIS";
    
    // テキストフィールド送信
    out.write(("--" + boundary + "\r\n").getBytes(charset));
    out.write(("Content-Disposition: form-data; name=\"text\"\r\n").getBytes(charset));
    out.write(("Content-Type: text/plain; charset=Shift_JIS\r\n\r\n").getBytes(charset));
    out.write((text).getBytes(charset));
    out.write(("\r\n").getBytes(charset));
    
    // ファイルフィールド送信
    out.write(("--" + boundary + "\r\n").getBytes(charset));
    out.write(("Content-Disposition: form-data; name=\"file\"; filename=\"").getBytes(charset));
    out.write((file.getName()).getBytes(charset));
    out.write(("\"\r\n").getBytes(charset));
    out.write(("Content-Type: application/octet-stream\r\n\r\n").getBytes(charset));
    InputStream in = new FileInputStream(file);
    byte[] bytes = new byte[1024];
    while(true){
        int ret = in.read(bytes);
        if(ret == 0) break;
        
        out.write(bytes, 0, ret);
        out.flush();
    }
    out.write(("\r\n").getBytes(charset));
    in.close();
    
    // 送信終わり
    out.write(("--" + boundary + "--").getBytes(charset));
}

こんな感じ。データを書き込んでいる部分を別メソッド(doOutput)に切り出して、ダミーの書き込みと、本当のアップロードのための書き込みの両方から呼び出すわけだ。

この例ではテキストフィールド1つ、ファイルフィールド1つだったけど、ここが変わるならdoOutputの引数が変わることになる。

これで良いのかな

テストした感じではうまく行ってる。速度も遅くはないし、大容量のデータも例外にならずに送れる。送ったデータが欠損してるとかもなさそうだ。

ただ、こういう風にやるという情報がWeb上に全く見あたらないのが不安。みんなはどうやってるんだろう。