Google App Engine上にFileOutputStreamを実装する

Google App Engine for Java(以下GAE/J)で、FileOutputStreamのような感覚でデータストアに書き込むためのOutputStreamを実装しました。

背景

GAE上ではFileOutputStreamを始めとしたファイル書き込みのためのクラスを利用することができません。代わりに、永続化するデータはGAEの提供するデータストアに書き込むことになっています。

GAE/Jでデータストアにデータを書き込む手段として、公式に提供されているJDOやJPA、Low-Level API(以下LLAPI)、ひがやすをさんのslim3などがあります。先日このブログに書いたように、僕もデータストアをHashMapのように扱えるDatastoreMapを実装してみました。また、最近ではBlobstoreという、ファイルを格納するための仕組みも公式に提供されています。

このようにデータストアに書き込む手段はたくさん提供されているのですが、OutputStreamを継承したクラスを用いてFileOutputStreamのようにデータを書き込みたいというケースもあるのではないかと思い、データストアに書き込むためのOutputStreamを実装してみました。タイトルの「FileOutputStreamを実装する」は少し言い過ぎかもしれません。正確には、「FileOutputStreamのような感覚で使用し、データを永続化できるOutputStreamを実装する」です。データストアのエンティティのサイズの制限である1MBを超えるデータでも書き込むことができます

サンプルコード

下記のように、FileOutputStreamを用いてファイル書き込みを行うのと同じようにデータストアに書き込めます*1。書き込みにはDatastoreOutputStream、読み込みにはDatastoreInputStreamを使います。

書き込み

BufferedWriter writer = null;
try {
	// DatastoreOutputStreamを用いてBufferedWriterを作成
	// FileOutputStreamと同じように利用することが可能
	writer = new BufferedWriter(new OutputStreamWriter(
			new DatastoreOutputStream("sample")));

	// データの書き込み
	writer.write("Hello");
	writer.newLine();
	writer.write("World");
	writer.newLine();

	writer.flush();
} catch (IOException e) {
	e.printStackTrace();
} finally {
	if (writer != null) {
		writer.close();
	}
}

読み込み

BufferedReader reader = null;
try {
	// DatastoreInputStreamを用いてBufferedReaderを作成
	// FileInputStreamと同じように利用することが可能
	reader = new BufferedReader(new InputStreamReader(
			new DatastoreInputStream("sample")));

	// データの読み込み
	String line;
	while ((line = reader.readLine()) != null) {
		System.out.println(line);
	}
} catch (IOException e) {
	e.printStackTrace();
} finally {
	if (reader != null) {
		reader.close();
	}
}

上記サンプルコード中にはありませんが、DatastoreOutputStreamの第2引数appendにtrueを渡すことで、データの追記が可能になります。

ダウンロードと使い方

JARファイルとソースを下記からダウンロードできます。Apache License 2.0にて公開します。

GitHub - koher/Koherent-App-Engine-Library-for-Java: Koherent App Engine Library is a library for Google App Engine for Java. It includes "DatastoreMap" which enable to operate Datastores like java.util.HashMap, "DatastoreOutputStream" which enable to write data to Datastores like java.util.FileOutputStream and so on.
(画面右上あたりのDownload Sourcesをクリック→ZipかTarの形式を選択)

ダウンロードした圧縮ファイルを解凍し、中にあるJARファイルを/WEB-INF/libに放り込んでパスを通せばOKです(Eclipseなら、プロジェクトを右クリック→Properties→Java Build Path→Libraries→Add JARs→/WEB/INF/libの中に入れたJARファイルを選択)。

FileOutputStream、FileInputStreamとの相違点

DatastoreOutputStream、DatastoreInputStreamはできるだけFileOutputStream、FileInputStreamと同じような挙動をするように作っていますが、実装上バッファリングを行う必要がありました。そのため、下記の三点を留意して下さい。ただ、FileOutputStream、FileInputStreamを使用する場合も、通常はBufferedOutputStream、BufferedInputStream等を用いてバッファリングを行うので、差異はほぼないと考えられます。

  • DatastoreOutputStream、DatastoreInputStreamではバッファリングが行われるため、BufferedOutputStream、BufferedInputStream等でラップする必要がない。
  • データ読み込み時に同時に書き込みが行われた場合、すでにInputStreamのバッファに読み込まれたデータに関しては変更が反映されない。
  • バッファが溢れるかflushされるまではデータが書き込まれない。

注意点として、BufferedWriterを利用した際に、BufferedWriterで指定したバッファサイズを超える書き込みを行っても、DatastoreOutputStreamのバッファサイズ(およそ1MBです)を超えない限り書き込みが行われないことが挙げられます(もちろんflushすれば書き込みが行われます)。

同時アクセス時の挙動

FileOutputStream、FileInputStream同様に、読み込み時にはロックを行わず、書き込み時にはWrite Lockを行うようにできています。FileOutputStream、FileInputStream同様、書き込み途中の読み込みには、書き込まれたデータが反映されるので注意が必要です。

Write Lockによってデータ書き込みが拒否された場合には、WriteLockExceptionがスローされます

WEBでの利用という前提を考えればSerializableに扱えた方が便利かとも思いましたが、とりあえずFileOutputStream、FileInputStreamに合わせて実装しました。

なお、データ書き込み途中にエラーが発生してロックが残ってしまうと問題があるので、ロックは取得から30秒*2で自動的に解除されるように実装しています。

Datastoreへの格納の仕組み

データストアの各エンティティには1MBまでしかデータを格納できないため、DatastoreOutputStream、DatastoreInputStreamでは約1MBごとにデータを分割して複数のエンティティにデータを保持しています。エンティティのnameには連番が振られており、順番に書き込み/読み込みを行います。

エンティティごとに1MBぎりぎりまでデータを格納しているのは、データストアへの読み書きの回数を減らすためです。ランダムアクセスを行う場合や一部だけを読み書きする場合はこれが効率的であるとは限りませんが、OutputStream、InputStreamを用いてそのような操作を行うケースは少ないと思うので。

上書きを行った場合、古いデータのエンティティは上書きされますが削除はされません。つまり、古いデータよりも小さいデータで上書きを行った場合、データストアにゴミが残る可能性があります。(close時にゴミエンティティを削除する実装も考えましたが、同期的に削除を行うと巨大データを小さなデータで上書いたときのオーバーヘッドが大きいですし、Task Queueに入れて非同期的に削除するか、cronで定期的にゴミデータを削除するかが現実的でしょうか。)

雑感

DatastoreMapと比べると簡単だろうと思い、また思いついてから勢いで実装しましたが、並列処理を考えると結構面倒でした。FileOutputStreamが排他ロックを、FileInputStreamが共有ロックを取得しないという実装は意外でしたが、何か理由があるのでしょうか?

バグや改善点などあればご連絡いただけると幸いです。

*1:このプログラムのDatastoreOutputStreamをFileOutputStreamに、DatastoreInputStreamをFileInputStreamに書き換えるだけで動作します。

*2:GAEでのプログラムの最大実行時間です。誤差を考慮して、実際には最大ロック時間を31秒に設定しています。