1. 概要

このチュートリアルでは、ファイルのダウンロードに使用できるいくつかの方法を紹介します。

Java IOの基本的な使用法からNIOパッケージ、およびAsyncHttpClientやApacheCommonsIOなどのいくつかの一般的なライブラリに至るまでの例を取り上げます。

最後に、ファイル全体が読み取られる前に接続が失敗した場合にダウンロードを再開する方法について説明します。

2. JavaIOの使用

ファイルのダウンロードに使用できる最も基本的なAPIは、 JavaIOです。 URL クラスを使用して、ダウンロードするファイルへの接続を開くことができます。

ファイルを効果的に読み取るために、 openStream()メソッドを使用してInputStreamを取得します。

BufferedInputStream in = new BufferedInputStream(new URL(FILE_URL).openStream())

InputStream から読み取る場合は、パフォーマンスを向上させるためにBufferedInputStreamでラップすることをお勧めします。

パフォーマンスの向上は、バッファリングによるものです。 read()メソッドを使用して一度に1バイトを読み取る場合、各メソッド呼び出しは、基になるファイルシステムへのシステムコールを意味します。 JVMがread()システムコールを呼び出すと、プログラム実行コンテキストがユーザーモードからカーネルモードに切り替わり、また元に戻ります。

このコンテキストスイッチは、パフォーマンスの観点から高価です。 多数のバイトを読み取ると、関連するコンテキストスイッチの数が多いため、アプリケーションのパフォーマンスが低下します。

URLから読み取ったバイトをローカルファイルに書き込むには、 FileOutputStreamクラスのwrite()メソッドを使用します。

try (BufferedInputStream in = new BufferedInputStream(new URL(FILE_URL).openStream());
  FileOutputStream fileOutputStream = new FileOutputStream(FILE_NAME)) {
    byte dataBuffer[] = new byte[1024];
    int bytesRead;
    while ((bytesRead = in.read(dataBuffer, 0, 1024)) != -1) {
        fileOutputStream.write(dataBuffer, 0, bytesRead);
    }
} catch (IOException e) {
    // handle exception
}

BufferedInputStream を使用する場合、 read()メソッドは、バッファーサイズに設定したバイト数を読み取ります。 この例では、一度に1024バイトのブロックを読み取ることでこれをすでに実行しているため、BufferedInputStreamは必要ありません。

上記の例は非常に冗長ですが、幸いなことに、Java 7の時点で、IO操作を処理するためのヘルパーメソッドを含むFilesクラスがあります。

Files.copy()メソッドを使用して、 InputStream からすべてのバイトを読み取り、ローカルファイルにコピーできます。

InputStream in = new URL(FILE_URL).openStream();
Files.copy(in, Paths.get(FILE_NAME), StandardCopyOption.REPLACE_EXISTING);

私たちのコードはうまく機能しますが、改善することができます。 その主な欠点は、バイトがメモリにバッファリングされるという事実です。

幸い、Javaは、バッファリングなしで2つのチャネル間でバイトを直接転送するメソッドを備えたNIOパッケージを提供しています。

次のセクションで詳しく説明します。

3. NIOの使用

Java NIO パッケージは、2つのチャネル間でバイトをアプリケーションメモリにバッファリングせずに転送する可能性を提供します。

URLからファイルを読み取るには、URLストリームから新しいReadableByteChannelを作成します。

ReadableByteChannel readableByteChannel = Channels.newChannel(url.openStream());

ReadableByteChannel から読み取られたバイトは、ダウンロードされるファイルに対応するFileChannelに転送されます。

FileOutputStream fileOutputStream = new FileOutputStream(FILE_NAME);
FileChannel fileChannel = fileOutputStream.getChannel();

ReadableByteChannelクラスのtransferFrom()メソッドを使用して、指定されたURLからFileChannelにバイトをダウンロードします。

fileOutputStream.getChannel()
  .transferFrom(readableByteChannel, 0, Long.MAX_VALUE);

transferTo()および transferFrom()メソッドは、バッファーを使用してストリームから単に読み取るよりも効率的です。 基盤となるオペレーティングシステムによっては、データをファイルシステムキャッシュからアプリケーションメモリにバイトをコピーせずにファイルに直接転送できます。

LinuxおよびUNIXシステムでは、これらの方法はゼロコピー手法を使用して、カーネルモードとユーザーモードの間のコンテキストスイッチの数を減らします。

4. ライブラリの使用

上記の例では、Javaコア機能を使用するだけでURLからコンテンツをダウンロードする方法を見てきました。

また、パフォーマンスの微調整が必要ない場合は、既存のライブラリの機能を活用して作業を容易にすることもできます。

たとえば、実際のシナリオでは、ダウンロードコードを非同期にする必要があります。

すべてのロジックをCallableにラップすることも、既存のライブラリを使用することもできます。

4.1. AsyncHttpClient

AsyncHttpClient は、Nettyフレームワークを使用して非同期HTTPリクエストを実行するための一般的なライブラリです。 これを使用して、ファイルURLへのGETリクエストを実行し、ファイルコンテンツを取得できます。

まず、HTTPクライアントを作成する必要があります。

AsyncHttpClient client = Dsl.asyncHttpClient();

ダウンロードしたコンテンツは、FileOutputStreamに配置されます。

FileOutputStream stream = new FileOutputStream(FILE_NAME);

次に、HTTP GETリクエストを作成し、 AsyncCompletionHandler ハンドラーを登録して、ダウンロードしたコンテンツを処理します。

client.prepareGet(FILE_URL).execute(new AsyncCompletionHandler<FileOutputStream>() {

    @Override
    public State onBodyPartReceived(HttpResponseBodyPart bodyPart) 
      throws Exception {
        stream.getChannel().write(bodyPart.getBodyByteBuffer());
        return State.CONTINUE;
    }

    @Override
    public FileOutputStream onCompleted(Response response) 
      throws Exception {
        return stream;
    }
})

onBodyPartReceived()メソッドをオーバーライドしたことに注意してください。 デフォルトの実装では、受信したHTTPチャンクがArrayListに蓄積されます。これにより、メモリ消費量が多くなるか、大きなファイルをダウンロードしようとするとOutOfMemory例外が発生する可能性があります。

HttpResponseBodyPartをメモリに蓄積する代わりに、 FileChannelを使用して、バイトをローカルファイルに直接書き込みます。 getBodyByteBuffer()メソッドを使用してByteBufferを介してボディパーツのコンテンツにアクセスします。

ByteBuffer には、メモリがJVMヒープの外部に割り当てられるという利点があるため、アプリケーションのメモリには影響しません。

4.2. Apache Commons IO

IO操作によく使用されるもう1つのライブラリは、 Apache CommonsIOです。 Javadocから、一般的なファイル操作タスクに使用するFileUtilsという名前のユーティリティクラスがあることがわかります。

URLからファイルをダウンロードするには、次のワンライナーを使用できます。

FileUtils.copyURLToFile(
  new URL(FILE_URL), 
  new File(FILE_NAME), 
  CONNECT_TIMEOUT, 
  READ_TIMEOUT);

パフォーマンスの観点から、このコードはセクション2のコードと同じです。

基礎となるコードは、 InputStream から数バイトをループで読み取り、OutputStreamに書き込むという同じ概念を使用します。

1つの違いは、ここでは URLConnection クラスを使用して接続のタイムアウトを制御し、ダウンロードが長時間ブロックされないようにすることです。

URLConnection connection = source.openConnection();
connection.setConnectTimeout(connectionTimeout);
connection.setReadTimeout(readTimeout);

5. 再開可能なダウンロード

インターネット接続が時々失敗することを考えると、バイトゼロからファイルを再度ダウンロードする代わりに、ダウンロードを再開できると便利です。

この機能を追加するために、前の最初の例を書き直してみましょう。

最初に知っておくべきことは、 HTTP HEADメソッドを使用して、実際にダウンロードしなくても、指定されたURLからファイルのサイズを読み取ることができるということです。

URL url = new URL(FILE_URL);
HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
httpConnection.setRequestMethod("HEAD");
long removeFileSize = httpConnection.getContentLengthLong();

ファイルの合計コンテンツサイズがわかったので、ファイルが部分的にダウンロードされているかどうかを確認できます。

その場合、ディスクに記録された最後のバイトからダウンロードを再開します。

long existingFileSize = outputFile.length();
if (existingFileSize < fileLength) {
    httpFileConnection.setRequestProperty(
      "Range", 
      "bytes=" + existingFileSize + "-" + fileLength
    );
}

ここで特定の範囲のファイルバイトを要求するようにURLConnectionを構成しました。範囲は最後にダウンロードされたバイトから始まり、リモートファイルのサイズに対応するバイトで終わります。

Range ヘッダーを使用する別の一般的な方法は、さまざまなバイト範囲を設定してファイルをチャンクでダウンロードすることです。 たとえば、2 KBのファイルをダウンロードするには、0〜1024および1024〜2048の範囲を使用できます。

セクション2のコードとのもう1つの微妙な違いは、FileOutputStreamがappendパラメーターをtrueに設定して開かれることです。

OutputStream os = new FileOutputStream(FILE_NAME, true);

この変更を行った後、残りのコードはセクション2のコードと同じになります。

6. 結論

この記事では、JavaのURLからファイルをダウンロードするいくつかの方法を見てきました。

最も一般的な実装は、読み取り/書き込み操作を実行するときにバイトをバッファリングすることです。 この実装は、ファイル全体をメモリにロードしないため、大きなファイルでも安全に使用できます。

また、JavaNIOチャネルを使用してゼロコピーダウンロードを実装する方法も確認しました。 これは、バイトの読み取りおよび書き込み時に実行されるコンテキストスイッチの数を最小限に抑え、直接バッファーを使用することにより、バイトがアプリケーションメモリにロードされないため便利です。

また、ファイルのダウンロードは通常HTTPを介して行われるため、AsyncHttpClientライブラリを使用してこれを実現する方法を示しました。

この記事のソースコードは、GitHubから入手できます。