1. InputStream使えてる?
最近データ入力のInputStreamを使うにあたり、以下のような使い方についてWebを検索してもOracleのJavaDocの説明は不十分、Qiitaを含む個人が発信する情報でも十分な情報を得られなかったので、今更(2020年12月)ですがこの記事でまとめました。
- mark/resetメソッドで途中まで読み込んだInputStreamを始めから読み込み直す
- パイプ入出力を使ってInputStreamを作る
JavaのInputStreamは、連続するデータを順次に必要な分だけ読み込むJavaの標準ライブラリのクラスです。対となるOutputStreamはデータを書き込むためのクラスです。
InputStreamクラスとOutputStreamクラスが導入されたバージョンはJDK1.0と古く、InputStreamの利用事例についてもWeb上に多く紹介されており、Javaを学習する上では基本文法やjava.utilの集合を学習した後に学習することが多いでしょう。
ファイルの入出力やネットワーク通信の受信送信で実際に使うことも多いと思いますが、そこではあまり紹介されない事例となります。
この記事では、InputStreamの上記の使い方を紹介します。
2. mark/resetメソッドで途中まで読み込んだInputStreamを始めから読み込み直す
ファイル内容を読み込んでいて、先頭から途中まで読み込んだところで最初から読み込み直しをしたい場合があります。
例えば、以下に例を挙げるUTF8のテキストファイルを読み込む場合で、ファイルがBOM付か否かを判別する必要がある場合です。
BOM付か否かは先頭の3バイトの内容を比較すれば判定できます。判定の結果、BOMコードである場合はその続きからUTF8テキストとして読み込むことになりますが、BOMコードと異なる場合は先頭からUTF8テキストとして読み込み直す必要があります。
このような場合、InputStreamクラスで定義されているmark/resetメソッドを使用することで実現できます。
2.1. mark/resetメソッドでInputStreamの読み込み直しをする実装例
ここでは実装例として、UTF8テキストファイルを開き、ファイル先頭のバイナリについてBOM付の判定をします。そして、BOM付の場合はBOMを読み飛ばし、BOM無しの場合は先頭からテキストファイルを読む実装例を示します。
// 1. 各InputStreamとReaderを定義
try (FileInputStream file = new FileInputStream("./utf8text.txt");
BufferedInputStream input = new BufferedInputStream(file, 8192);
InputStreamReader reader = new InputStreamReader(buffer, "UTF8");
BufferedReader bufferedReader = new BufferedReader(reader)) {
// 2. InputStreamの現在の読込位置をマーク
input.mark(3);
// 3. 先頭3バイトがBOMであるかを検査
byte[] bytes = new byte[3];
input.read(bytes);
if ((-17 == bytes[0]) && (-69 == bytes[1]) && (-65 == bytes[2])) {
System.out.println("with BOM");
} else {
System.out.println("with not BOM");
// 4. BOMである場合はInputStreamを先頭から読みなおすようにInputStreamをリセット
input.reset();
}
// 5. InputStreamの内容を読み込み
String line = null;
while (null != (line = bufferedReader.readLine())) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
処理の内容は以下の様になります。
1. 各InputStreamとReaderを定義
try-with-resource文で必要なリソースを作成します。ここでは、読み込むファイルのFileInputStream、読み込むファイルの読み直しをするためのBufferedInputStreamを作成します。InputStreamReaderとBufferedReaderはファイルの内容をテキストで読み込むためのものです。
2. InputStreamの現在の読込位置をマーク
UTF8のBOMはデータの先頭にあるため、BOMチェックが完了してBOM付ではない場合は先頭からUTF8テキストデータとして読み込みなおす必要があります。そのため、データの先頭にマークをします。
3. 先頭3バイトがBOMであるかを検査
inputから3バイトだけ読み込んでBOMコードであるかをチェックします。BOMコードの場合とそうではない場合で処理を分岐します。
先頭3バイトがBOMコードの場合は、BOMコードの後からデータをテキストと読み込めばよいため、そのままInputStream(をラップしているBufferedReader)の読み込みを継続します。
4. BOMである場合はInputStreamを先頭から読みなおすようにInputStreamをリセット
先頭3バイトがBOMコードではない場合は、BOMチェックのために読み込んだ3バイトもテキストデータとして読み込む必要があります。そのため、あらかじめマークした先頭から再度読み込むようにInputStreamをリセットします。
5. InputStreamの内容を読み込み
あとは、通常通りデータを読み込みます。この実装ではUTF8テキストデータとして1行ずつテキストを読み込んで標準出力に出力します。
2.2 InputStreamのresetメソッドはどのオブジェクトでも使えるわけでない
上記の例では、FileInputStreamをラップしたBufferedInputStreamに対してmarkメソッドとresetメソッドを呼び出しています。しかし、BufferedInputStreamの元であるFileInputStreamもInputStreamのサブクラスなので、FileInputStreamにもmarkメソッドとresetメソッドがあります。しかし、FileInputStreamのmarkメソッドは問題なく呼び出せますが、resetメソッドの呼出すとIOExceptionがthrowされます。
① InputStreamオブジェクトがmark/resetメソッドをサポートするかは、markSupportedメソッドを使う
InputStreamのサブクラスはmark/resetメソッドをサポートするか否かを選択することができます。ファイルの読み込みやネットワークの受信のようなデータでは一度読み込んだデータを、戻って再度読み込むことができないためです。
InputStreamオブジェクトのresetを呼ぶ前mark/resetのサポートを知る方法として、InputStreamにはmarkSupportedメソッドがあります。markSupportedメソッドは、そのオブジェクトがmarkメソッドでマークをした位置までresetメソッドで戻る実装が備わっている場合にtrueを返却します。
② mark/resetメソッドがサポートされない場合はBufferedInputStreamでラップする。
mark/resetメソッドをサポートしていないオブジェクトであっても、BufferedInputStreamでラップをすればBufferedInputStreamサイズの範囲でresetできるようにようになります。
markした位置のデータが読み込みによってバッファから消された場合、resetメソッドはIOExceptionをthrowします。markの引数で指定するreadlimitの値がBufferedInputStreamサイズより大きければ、バッファサイズを超えてデータを読み込んでもBufferedInputStreamはreadlimitの値までバッファサイズを拡張します。
2.3 実装例の改善 ~不要なBufferedInputStreamのラップを抑止する~
BufferedInputStreamはどのようなInputStreamオブジェクトでもラップできますが、mark/resetをサポートしているオブジェクトであればBufferedInputStreamでラップする必要はありません。そこで、上記の例のBufferedInputStreamを作成する処理から、markSupportedの結果がfalseの場合のみBufferedInputStreamをラップするようにすれば、不要なBufferedInputStreamの作成を防ぐことができます。
下記の例は、markSupportedがfalseの場合はfileオブジェクトをBufferedInputStreamでラップし、trueの場合はそのまま使う実装例です。fileはFileOutputStreamなのでmarkSupportedは必ずfalseを返すので意味のない条件分岐ですが、fileが抽象的なInputStreamであればこのような分岐を入れることに意味をもつようになります。
// 1. 各InputStreamとReaderを定義
try (FileInputStream file = new FileInputStream("./UTF8withNotBOM.txt");
InputStream input = (file.markSupported() ? file : new BufferedInputStream(file, 8192));
InputStreamReader reader = new InputStreamReader(input, "UTF8");
BufferedReader bufferedReader = new BufferedReader(reader)) {
2.4. mark/resetメソッドによる読み込み直しの代替案
(あまり考えにくいですが)InputStreamはmark/resetをサポートしておらず、BufferedInputStreamによるラップもできない状況があり得るかもしれません。その場合に、InputStreamのmark/resetメソッドを使用せずにデータの読み直しの代替案を考えます。
① 一から読み直す
読み込み直しが必要となったら一度InputStreamを閉じて、再度InputStreamを開きなおして先頭から読みなおす方法です。
この方法はファイルの様に何度でもInputStreamを開ける場合は可能ですが、ServletInputStreamのように通信の受信結果の読み込みは、一度クローズしたものを再度読み込むことができません。
② バイト配列に全データを格納する
InputStreamからあらかじめ全てのデータをバイト配列で読み込んでしまい、読み込んだバイト配列でByteArrayInputStreamを作成することです。バイト配列からByteArrayInputStreamを作成することは何度でもできますし、今回の様に先頭3バイトのみを読み込むのであれば、直接バイト配列の先頭3バイトにアクセスできます。
この方法は、全てのデータをメモリ上に展開するため、実行環境で使用可能なメモリ量を超過したデータは扱えなくなります。
3. パイプ入出力を使ってInputStreamを作る
InputStreamオブジェクトは通常、FileInputStreamのようにデータを読み込むリソースを指定して作成するか、ServletInputStreamのようにフレームワークが作成したオブジェクトを使用します。
一方で、自分で作成した連続したデータをInputStreamオブジェクトを使って渡したい場合があります。例えば、DBにSELECT文のクエリを発行して取得したレコードの内容をテキストデータに変換した結果をInputStreamとして読み込みたい場合があるかもしれません。
このような場合、仮にOutputStreamに書き込んだ内容をInputStreamのとして読み込めれば、この問題は解決します。
しかし、OutputStreamは書き込み専用であり、InputStreamは読み込み専用であるため、OutputStreamに書き込みとInputStreamとして読み込みを一つのStreamオブジェクトで実現はできず、PipedOutputStreamとPipedInputStreamの「パイプ入出力」を使う必要があります。
3.1. パイプ入出力の実装例
パイプ入出力の実装例を以下に示します。
今回はOutputStreamにint型の数値をインクリメントしながら書き込みます。InputStreamはデータが終了するまでバイトを読み込んで標準出力に出力します。
下記の実装を実行すると、0から99までの数値が標準出力に出力されます。
// 1. PipedOutputStreamを作成
PipedOutputStream out = new PipedOutputStream();
Runnable runnable = () -> {
try {
// 2. PipedOutputStreamにデータを書き込み(ここでは例で0~99の数値を書き込み
for (int i = 0; i < 10000; i++) {
out.write(i);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
// 3. PipedOutputStreamをフラッシュしてクローズ
try {
out.flush();
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
};
PipedInputStream input = null;
try {
// 4. PipedOutputStreamの出力を受取るPipedInputStreamを作成
input = new PipedInputStream(out);
// 5. PipedOutputStreamに出力するスレッドを開始
Thread thread = new Thread(runnable);
thread.start();
// 6. PipedInputStreamの内容を読み込み
int value = Integer.MAX_VALUE;
while (0 <= (value = input.read())) {
// ここではPipedInputStreamから読み取った内容を標準出力に出力
System.out.println(value);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
// 7. PipedInputStreamをクローズ
try {
input.close();
} catch (IOException e) {
// クローズ時の例外は無視
}
}
処理の内容は以下の様になります。
1. PipedOutputStreamを作成
パイプ入出力はPipedOutputStreamオブジェクトとPipedInputStreamオブジェクトをセットで使用する必要がありますが、まずはPipedOutputStreamオブジェクトを作成してPipedOutputStreamに書き込む処理を定義します。
2. PipedOutputStreamに書き込むRunnrableオブジェクトを定義
PipedOutputStreamに書き込む処理を、別スレッドの処理とするためRunnableオブジェクトのrunメソッドの処理として定義します。
この実装例では、単純にint型の数値をインクリメントして書き込みます。
3. PipedOutputStreamをフラッシュしてクローズ
PipedOutputStreamはcloseメソッドの内部でフラッシュを実行しないので、クローズ前には必ずフラッシュをする必要があります。
PipedOutputStreamのフラッシュでは、PipedInputStreamの待機しているreadメソッドを呼び出します。
書き込みと読み込みを別スレッドで実行する処理でフラッシュをしないと、PipedInputStreamはPipedOutputStreamで書き込んだデータを最後まで読み込まずにデータを終了と判断する可能性があります。
データの書き込みが完了したらPipedOutputStreamのcloseメソッドを呼び出してクローズします。これにより、PipedInputStreamはデータの完了を検知することができます。具体的にはPipedInputStreamががそれまでのデータを全て読み込むとreadメソッドが-1を返します。
4. PipedOutputStreamの出力を受取るPipedInputStreamを作成
PipedOutputStreamを作成します。
この実装例では直後にPipedOutputStreamの読み込みをしていますが、実際のパイプ入出力の実装では作成したPipedInputStreamをreturnで返し呼出し元がInputStreamを読み込むことが多いはずです。
5. PipedOutputStreamに出力するスレッドを開始
PipedOutputStreamに書き込むRunnrableオブジェクトについて、スレッドを起動して処理を開始します。
6. PipedInputStreamの内容を読み込み
この実装例ではパイプ入出力の処理確認のため、PipedInputStreamの読み込みをこの場所で行うようにしています。
7. PipedInputStreamをクローズ
この実装例では、PipedInputStreamの読み込みが既に完了しているためここでPipedInputStreamをクローズします。しかし、実際はPipedInputStreamはこの処理のメソッドでreturnされて別の場所で読み込まれる事の方が多いと思われます。その場合は、PipedInputStreamの読み込みが完了してからクローズをするようにします。
3.2. なぜPipedOutputStreamの書き込みは別スレッドなのか?
上記の実装例では、PipedOutputStreamの書き込みをメインスレッドとは別にスレッドを立ち上げて実行しました。このような実装が必要なのは、Javaのパイプ入出力の仕様によるものです。
PipedOutputStreamのJavaDocには、以下の様な記述があります。
Attempting to use both objects from a single thread is not recommended as it may deadlock the thread.
(単一のスレッドから両方のオブジェクトを使用することは、スレッドがデッドロックする可能性があるため推奨されていません。)
① シングルスレッドでパイプ入出力を動かしてみる
PipedOutputStreamの正式なコメントでも、PipedOutputStreamの書き込みとPipedInputStreamからの読み込みを同一スレッドで実行することは推奨されていないようです。試しに、上記のコードを以下の様に変更して、シングルスレッドで書き込みと読み込みを実行するようにしてみます。
// 6. PipedOutputStreamに出力するスレッドを開始しないで、runnableを同スレッド実行する。
// Thread thread = new Thread(runnable);
// thread.start();
runnable.run();
上記の様に修正しても、処理結果は変わらないはずです。
シングルスレッドでも無事に処理を完了したのは、PipedOutputStreamのパイプサイズがデフォルトの1024バイトに設定されており、PipedOutputStreamの書き込みがパイプサイズを超える前に終了したからです。
② パイプ入出力がデッドロックを起こすのは?
パイプ入出力がデッドロックを起こすのは、パイプサイズ上限までデータが書き込まれた状態でPipedOutputStreamがさらに書き込みをしようとしてパイプからデータが読み込まれるのを待っているのに、PipedInputStreamが読み込みをできない場合です。
下記の様にPipedOutputStreamに書き込むサイズを1025以上で実行すると、処理が完了せずにプロセスは立ち上がったまま処理が完了しない状態なります。つまり、「デッドロック」が起きます。
// 3. PipedOutputStreamにデータを書き込み(ここでは例で0~99の数値を書き込み
for (int i = 0; i < 1025; i++) {
out.write(i);
}
シングルスレッドでPipedOutputStreamの書き込みとPipedInputStreamからの読み込みを行うようにすると、PipedOutputStreamの書き込みが全て完了しない限りPipedInputStreamからの読み込みは始まらないので、この処理は永久に終わらないことになります。
ちなみに、Web上にはシングルスレッドによるパイプ入出力実装例が何例かあります。(私が見つけた例はPipedInputStreamとPipedOutputStreamではなくPipedWiterとPipedReaderですが同じです)。
具体的なURLやサイト名はここでは示しませんが、その実装例では、出力に書き込むデータ量が少ないため問題なく動くはずですが、上記と同じようにデータ量を増やせばデッドロックが起きるため、パイプ入出力の実装例として適切ではありません。