TL;DR
初回呼び出し時に初期化が走ってその後の挙動が固定されてしまうクラスがマルチバンドル環境下でどんな問題を引き起こすのかを解説します。
まえがき
前にもやりましたが、主にOSGiでシングルVMマルチバンドルの環境で起こるできごとについて書きます。SecurityManagerが(あっさりと)なくなってしまうことが決定した今になってなんやねん、という感じですが、そうは言ってもまだ生き残っているシステムもありますので、実感するもよし、他山の石とするもよし、でお願いします。
初回呼び出し時に初期化って
コンストラクタが呼ばれるとインスタンスが生成され、そのインスタンスに対して初期化が走るというのが一般的なクラス実装ですが、一部に、システム内で共通の設定を保持する(つまり、あるクラスの設定を決めると、それ以降生成されるインスタンスが全部その設定を引き継ぐ)ようなものがあります。
極端な例としてはコマンドラインでシステムプロパティをセットしてやるとその設定で振舞が固定されてしまうものなどがあります。
それって
まあいろいろと例はあるのですが、ここでは分かりやすい?例としてImageIOの話を書きます。
ImageIOの話
ImageIOって
ImageIOはJDK1.4で正式に標準APIになりました。細かい経緯はそちらを読んでいただくとして、様々な画像形式に対応しており非常に便利なライブラリです。
このクラスの大きな特徴として、ImageIOというクラスがほぼ全部staticメソッドで処理を実装しているということがあります。ImageIOクラスのJava SE 8のpublicなメソッド一覧は以下のようになっています。
修飾子 | 戻り型 | シグネチャ |
---|---|---|
static | ImageInputStream | createImageInputStream(Object input) |
static | ImageOutputStream | createImageOutputStream(Object output) |
static | File | getCacheDirectory() |
static | ImageReader | getImageReader(ImageWriter writer) |
static | Iterator | getImageReaders(Object input) |
static | Iterator | getImageReadersByFormatName(String formatName) |
static | Iterator | getImageReadersByMIMEType(String MIMEType) |
static | Iterator | getImageReadersBySuffix(String fileSuffix) |
static | ImageWriter | getImageWriter(ImageReader reader) |
めんどいので以下略。(笑)まあ要するにリストアップされているpublicなメソッド全部staticです。(Objectクラスから継承したインスタンスメソッドがあるにはありますが、コンストラクタがないのでまあ無意味。)インスタンス実装の本体が隠蔽されているわけでもなく、ほぼこのままクラスが本体な実装だと言えるでしょう。(あんまりお行儀が良くない、というのがこの辺からにおってくる。)
ちなみにソースはこの辺から読めます。
ファイルキャッシュのメカニズム
さて、ではどこに問題があるのか、具体的に見ていきます。ソースはこの部分です。
/**
* A class to hold information about caching. Each
* <code>ThreadGroup</code> will have its own copy
* via the <code>AppContext</code> mechanism.
*/
static class CacheInfo {
boolean useCache = true;
File cacheDirectory = null;
Boolean hasPermission = null;
public CacheInfo() {}
public boolean getUseCache() {
return useCache;
}
public void setUseCache(boolean useCache) {
this.useCache = useCache;
}
public File getCacheDirectory() {
return cacheDirectory;
}
public void setCacheDirectory(File cacheDirectory) {
this.cacheDirectory = cacheDirectory;
}
public Boolean getHasPermission() {
return hasPermission;
}
public void setHasPermission(Boolean hasPermission) {
this.hasPermission = hasPermission;
}
}
この内部クラスは文字通りキャッシュの情報を保持するためのクラスです。キャッシュを使用しているか、使用しているとしたらどこのディレクトリか、パーミッションを持っているか、などが保持されています。またディレクトリや、パーミッションを持っているかどうかをあとからセットすることもできます。
続いてこのクラスを呼び出しているところ。
private static synchronized CacheInfo getCacheInfo() {
AppContext context = AppContext.getAppContext();
CacheInfo info = (CacheInfo)context.get(CacheInfo.class);
if (info == null) {
info = new CacheInfo();
context.put(CacheInfo.class, info);
}
return info;
}
で、このgetCacheInfo()があちこちで呼ばれています。何らかの処理を開始する際に必ず呼ばれていて、最初に呼んだ人がこのCacheInfoのインスタンスを初期化します。
ImageIO.getHasPermission()を見てみましょう。
private static boolean hasCachePermission() {
Boolean hasPermission = getCacheInfo().getHasPermission();
if (hasPermission != null) {
return hasPermission.booleanValue();
} else {
try {
SecurityManager security = System.getSecurityManager();
if (security != null) {
File cachedir = getCacheDirectory();
String cachepath;
if (cachedir != null) {
cachepath = cachedir.getPath();
} else {
cachepath = getTempDir();
if (cachepath == null || cachepath.isEmpty()) {
getCacheInfo().setHasPermission(Boolean.FALSE);
return false;
}
}
// we have to check whether we can read, write,
// and delete cache files.
// So, compose cache file path and check it.
String filepath = cachepath;
if (!filepath.endsWith(File.separator)) {
filepath += File.separator;
}
filepath += "*";
security.checkPermission(new FilePermission(filepath, "read, write, delete"));
}
} catch (SecurityException e) {
getCacheInfo().setHasPermission(Boolean.FALSE);
return false;
}
getCacheInfo().setHasPermission(Boolean.TRUE);
return true;
}
}
どのように動いているか
getCacheDirectory()は何も設定されていなければgetTempDir()を呼び出してSecurityManagerがセットされていれば、それを通じてそのディレクトリへの書き込み権限を確認します。
つまり、システムプロパティjava.io.tmpdirの値を取って、そこをキャッシュディレクトリにします。
通常Linuxシステムだとこれは「/tmp」になります。このコードを通った時点で/tmpに書き込める権限があれば、cacheInfoフィールドにはhasPermissionがtrueにセットされたCacheInfoインスタンスがセットされることになります。
ここで例によってOSGiの話になるのですが(類似のメカニズムを持っているプラットフォームなら同様ですが)、ある種のシステムでは、一つのVMの上で複数のバンドルが動作する、という構成になっています。そして、それらのバンドルのうち一部バンドルは/tmpに書き込む権利を持っていない、という可能性がありえます。
それは、端的に言うとシステム全体の都合で/tmpの容量が制限されているなどの理由があって、書きたい放題書き込まれてしまうとシステムが安定しないことなどがありうるからです。あるいはファイル名が衝突したらどうするか、みたいな心配事もあるので、独立したバンドル同士でテンポラリディレクトリを共有するというのはそれ自体が危険なのですね。
実際には何が起こるか
そういう状況で、/tmpに書けないバンドルがシステムの中で初めてImageIOを呼び出して初期化が走った場合、/tmpには書けないのでCacheInfoにはパーミッションがありません、という記録が残ります。
このとき、後から別のバンドルがImageIOを呼び出したとしても、たとえそのバンドルが/tmpに書き込む権利を持っていたとしても、CacheInfoには/tmpは使えないという情報が残っているためメモリ上にキャッシュを取って動作を継続します。まあこれだけだとメモリ不足が発生する可能性はあるとしても、通常は問題なく動作するでしょう。
しかしImageIOを最初に呼び出したのがたまたま一般バンドルではなく特権バンドル、つまり/tmpに書き込む権限をもっているバンドルだったということが有りえます。すると、この場合はImageIOは/tmpに書き込む権限を持ったコンテキストで動作しますので、checkPermission()を通過してしまい、cacheInfoにはパーミッションがあり/tmpがキャッシュ用に使えます、という記録が残ります。
この状態であとから一般バンドルがImageIOを呼び出してしまうとどうなるかというと、ImageIOはキャッシュを/tmpに作ろうとします。しかしImageIOを呼び出したバンドルに権限がないのでImageIO自体も書き込むことができません。そこで例外が飛ぶのでした。
つまり、誰が先に呼ぶかによってシステムの挙動が変わります。そして、誰が先に呼ぶかが一定であるとは限りません。
要するに、いつ爆発するか分からない爆弾ということです。テスト環境では起こらない、みたいなことがあるかも知れません。テスト環境で起こるとしても確率が低ければ見逃されるでしょう。
じゃあどうしろと
本来は、ImageIOを捨てて同じような機能をもったクリーンな設計のパッケージを採用しましょう、という話になると思うのですが、さすがにAPIまるごと捨てるのはJavaとしては難しいことと思います。(いや、最近の風潮ではそうでもないか?)
なので、特権バンドルが率先してディスクキャッシュを使わないと宣言してしまうしかないと思います。今時のシステムはメモリが潤沢だから大丈夫です。たぶん。
いろいろありますが、こういうライブラリの設計は難しいなあと思います。決してImageIOを書いた人(達)がアレだったとかいうことではなく…。