【Java】InputStream#readしたバイトデータを出力するときの注意点(自分用備忘録)


InputStream#readしたバイトデータをByteArrayOutputStreamにwriteして、後でFileOutputStreamでファイルに出力する、というのは、割とよく見る作りだけれども、InputStream#readの結果データを格納したbyte配列をそのままFileOutputStreamに渡すと出力結果に余分なデータが含まれる可能性がある。
これ、昔から割とありがちで、そのたびに毎回実装に若干悩むのだが、ググってみた感じあんまり「これ」といった回避策が載ってるサイトがなかったので、自分用の備忘録として残す。
(まあ、Java1.9以降では、全データ読み込み専用のメソッドも用意されてるらしいので、そっち使えばこんなこと考えなくても済みそうだが…)



ネットでよく見かける実装例

入力ファイルを全部読み込んで出力する(要するにコピー)コードとしてよく見かけるのは、こういうの↓

		FileInputStream fis = null;  
		FileOutputStream fos = null;  
		int readSize = 1024;  
		try {  
			fis = new FileInputStream(new File("test_in.txt"));  
			ByteArrayOutputStream baos = new ByteArrayOutputStream();  
			int size = 0;  
			byte[] data = new byte[readSize];  
			while(  (size = fis.read(data , 0 , data.length)) != -1 ) {  
				baos.write(data , 0 , data.length);  
				data = new byte[readSize];  
			}  
		fos = new FileOutputStream(new File("test_out.txt"));  
		baos.writeTo(fos);  
		  
	} catch(Exception e) {  
		throw e;  
	} finally {  
		if (fis != null) {  
			fis.close();  
		}  
		if (fos != null) {  
			fos.close();  
		}  
	}  


ただ、これだと、読み込んだ結果データを格納するbyte配列が毎回必ず1024の要素数を持つ配列になるので、出力ファイルの容量は必ず1024の倍数になる。
言い換えれば、入力ファイルの容量が1024バイトの等数倍ならともかく、そうでない場合は、出力結果が1024の倍数になるように「補正」される。
これは入力時、最後に読み込まれたポイント以降の要素が、変数定義時の初期値のまま(= new byte[1024])ほったらかしになってるためで、とはいえ1024の要素を持つ配列であることに変わりはないから、最後に読み込まれたポイント以降の要素は初期値、つまり実質ダミーの余分なデータのまま書き出されるためである。(実際には、UnicodeでいうところのU+0000のバイトデータが書き込まれる。これは初期値未指定でbyte型の変数を定義したときのデフォルトが0だからである)

例えば3000バイトのファイルがあったとする。
これを、上記の実装に従って出力すると、出力結果は3072(=1024×3)バイトになる。
入力ファイルが3000バイトしかないんだから、3000バイトで止まってくれれば問題ないんだが、上の理屈に従い、なぜか余分に72バイトがくっついてくる。

目的が「ファイルのコピー」ならそもそもこんな面倒くさい実装は不要で、もっと簡単なやり方があるし、テキストファイルを扱う前提ならいちいちFileInputStream等使わなくとも、もっと適したクラスがある。
ただ、CDN等のインターネット上のリソースにアクセスして、それをローカルにダウンロードしてくるようなケースでは、入力側からのデータの取り出しにInputStreamを使わざるを得ないケースもあって、そのときには上述したような考慮が必要になる。
(もはやそんなこと考えるくらいならcurlぶっ放して取り出したほうがはるかに手っ取り早いし簡単だ…という見方もあるが…)

しかしまあ、よくよく考えてみると、この問題は至極当然で、入力側の都合では、「渡されたbyteの配列に、読み込めた分だけ結果を書き写します」なので、渡されるbyteの配列が事前にどうなっているのかなど知る由もないし、出力側の都合だけ見れば、「渡されたbyteの配列をそのまま書き出します」というだけなので、渡された中身がどうなってるかなど知ったこっちゃない。
そこを人間がうまい具合に手を加えて回避してあげないといけないのである。


2021/01/06追記
通りすがりさんから情報頂いた内容を追記。

回避策★

コメントいただいた内容を追記しておく。(通りすがりさんありがとうございます)

		FileInputStream fis = null;  
		FileOutputStream fos = null;  
		int readSize = 1024;  
		try {  
			fis = new FileInputStream(new File("test_in.txt"));  
			ByteArrayOutputStream baos = new ByteArrayOutputStream();  
			int size = 0;  
			byte[] data = new byte[readSize];  
			while(  (size = fis.read(data , 0 , data.length)) != -1 ) {  
				baos.write(data , 0 , size);  
				data = new byte[readSize];  
			}  
		fos = new FileOutputStream(new File("test_out.txt"));  
		baos.writeTo(fos);  
		  
	} catch(Exception e) {  
		throw e;  
	} finally {  
		if (fis != null) {  
			fis.close();  
		}  
		if (fos != null) {  
			fos.close();  
		}  
	}  


下記に本記事執筆時点の筆者の対応を記載するが、どうみてもこれが一番シンプルである。。
こんなやり方があったとは…

回避策1

InputStream#readは戻り値として「読み込めたバイト数」が返却されるので、それをもとに出力用のbyteの配列を作り直す。

		FileInputStream fis = null;  
		FileOutputStream fos = null;  
		int readSize = 1024;  
		try {  
			fis = new FileInputStream(new File("test_in.txt"));  
			ByteArrayOutputStream baos = new ByteArrayOutputStream();  
			int size = 0;  
			byte[] data = new byte[readSize];  
			byte[] writeData = new byte[readSize];  
			while(  (size = fis.read(data , 0 , data.length)) != -1 ) {  
				writeData = data;  
				if (size < readSize) {  
					writeData = new byte[size];  
					System.arraycopy(data , 0 , writeData , 0 , size);  
				}  
				baos.write(writeData , 0 , writeData.length);  
				data = new byte[readSize];  
			}  
		fos = new FileOutputStream(new File("test_out.txt"));  
		baos.writeTo(fos);  
		  
	} catch(Exception e) {  
		throw e;  
	} finally {  
		if (fis != null) {  
			fis.close();  
		}  
		if (fos != null) {  
			fos.close();  
		}  
	}  


もうちょいスマートな書き方があるんだろうけど、実際に回避したやり方はこれ。
ByteArrayOutputStreamに書き出すbyte配列を、読み込むのとは別に用意し、読み込んだ内容を書き写す。
ただし、読み込んだ量がbyteの配列より小さかった場合は、読み込んだ量でbyte配列を再生成し、読み込んだ分だけSystem#arraycopyでコピーする。

回避策2

ちなみにもっと原始的(?)な回避策がある。
この問題の焦点は「読み込んだ結果を格納するbyteの配列の要素数」(A)と「入力対象のファイルの容量」(B)が等数倍になっていないことにあるので、逆に言えば(B)がどんな値であろうとも、(A)の等数倍になるように(A)を調整すればいい。
要するに(A)を「要素1の配列にする」のである。

		FileInputStream fis = null;  
		FileOutputStream fos = null;  
		int readSize = 1;  
		try {  
			fis = new FileInputStream(new File("test_in.txt"));  
			ByteArrayOutputStream baos = new ByteArrayOutputStream();  
			int size = 0;  
			byte[] data = new byte[readSize];  
			while(  (size = fis.read(data , 0 , data.length)) != -1 ) {  
				baos.write(data , 0 , data.length);  
				data = new byte[readSize];  
			}  
		fos = new FileOutputStream(new File("test_out.txt"));  
		baos.writeTo(fos);  
		  
	} catch(Exception e) {  
		throw e;  
	} finally {  
		if (fis != null) {  
			fis.close();  
		}  
		if (fos != null) {  
			fos.close();  
		}  
	}  


こんだけ。
これだけでこの問題は回避できる。

回避できるのだが、3000バイトのファイルを読み込むのに、1024だと3回のループで済んだものが、1だと当然、3000回もループが必要になる。
よってめちゃ遅い。
小さいファイルしか相手にしないならそれほど気にはならないはずだが、この問題を回避する目的だけのために、この回避策を選択するのは実にイケてない。
昔、「回避策1」が思いつかなかった時には、無理やりこれで逃げていたことがあったのでw、その苦労と恥ずかしい日々の記録として残しておくが、まあ、実際に使うことはないだろう。


COMMENT:
通りすがりさん>
ありがとうございます。
出来ました。
完全に盲点でした。。。
(ブログ内容に反映させていただきました)


COMMENT:
AUTHOR: 通りすがり
baos.write(data , 0 , data.length);

baos.write(data , 0 , size);
でいかがでしょうか