MissingResourceExceptionの解決法 | Java | プログラミング

JavaベースのWebアプリケーション実行環境Tomcatをターゲットとしたアプリケーションを開発していたときのこと。一部のロジックをmainメソッドから実行できるように変更した。そして、いざ実行してみるとこれまで読み込めていたプロパティファイルが読めなくなってしまった!

なぜこんなことになってしまったのか?原因はよくある単純ミスだったけど、よくよく調べてみると衝撃的な事実を知ることに・・・。

間違ったのはメソッドの引数

エラーが発生したのは次のようにプロパティファイルapplication.propertiesを読み込んでいる部分。

  private ApplicationConfig() {
    this.properties = ResourceBundle.getBundle("/application");
  }

このコードをServletから呼び出すと /WEB-INF/classes 内の application.properties が正常に読み込める。が、mainメソッドからこのコードを呼び出すと、MissingResourceExceptionが発生してしまう。

java.util.MissingResourceException: Can't find bundle for base name /application, locale ja_JP
	at java.util.ResourceBundle.throwMissingResourceException(ResourceBundle.java:838)
	at java.util.ResourceBundle.getBundleImpl(ResourceBundle.java:807)
	at java.util.ResourceBundle.getBundle(ResourceBundle.java:551)
	at ApplicationConfig.<init>(ApplicationConfig.java:76)
	at ApplicationConfig.getConfig(ApplicationConfig.java:67)
	at Test.main(Test.java:15)
Exception in thread "main"

このコードは、ResourceBundle#getBundleメソッドへの引数に問題がある。一般に、リソースバンドル名の先頭には”/”を記述しない。”/application” を “application”に直せば、mainメソッド起動でもTomcat上でも正常に動作するようになる。

でも本題はココから先。

Servlet経由の動作とmainメソッド経由の動作に違いが!?

どうしても気になる。

それは、Tomcat上のServlet経由で呼び出した場合と、直接mainメソッドから呼び出した場合とで、動作に違いがあったことだ。

直接起動する場合でも、Tomcatが使用するjarファイルやWebアプリケーションの使用するclassファイル・jarファイルにクラスパスを設定さえしておけば、挙動は同じハズじゃないのか?同じJVMを使っているのに、こんなことが起きるって考えられない・・・。

クラスローダの違いが鍵

ResourceBundleはクラスローダを使ってプロパティファイルとかのリソースを読みこむ。そこでまずクラスローダとして何が使われているかを次のコードで調べてみた。

ClassLoader classLoader = this.getClass().getClassLoader();
System.out.println("ClassLoader: " +
  classLoader.getClass().getName());

結果は次のようになった。

起動方法 クラスローダ
Tomcat org.apache.catalina.loader.WebappClassLoader
直接起動 sun.misc.Launcher$AppClassLoader

TomcatはWebアプリケーション専用のクラスローダが使われ、直接起動ではSun JVMのアプリケーション起動用クラスローダが使われている。クラスローダに違いがあることが分かったが、どちらもURLClassLoaderのサブクラスという点では同じ。

次にそれぞれのクラスローダがリソースを読み込む際、リソース名とファイルパスがどのようにマッピングされるかを調べてみた。

System.out.println("「/」なし: " +
  classLoader.getResource("application.properties"));
System.out.println("「/」あり: " +
  classLoader.getResource("/application.properties"));

結果は次の通り。

■Tomcatの場合
「/」なし: file:/C:/webapp/WEB-INF/classes/application.properties
「/」あり: file:/C:/webapp/WEB-INF/classes/application.properties

■直接起動の場合
「/」なし: file:/C:/webapp/WEB-INF/classes/application.properties
「/」あり: null

なるほど、getResouceの引数に与える値によって戻り値が異なるのか。クラスローダの微妙な実装の違いが動作に影響したようだ。

単体テストが通っても信用できない!?

Servlet・JSPなどから呼び出されたコードと、mainメソッドから呼び出されたコードとの間には、動作に微妙な違いがあることが分かった。この違いは些細な問題に見えるかもしれないけど、Webアプリケーション開発者にとってはまさに衝撃的!深刻な問題が懸念される。

なぜなら、これまで大丈夫と思っていた理屈が通らなくなるからだ。例えば、Webアプリケーションのコードとバッチのコードを共有するケースはあると思うけど、コードを共有していたとしてもWebアプリケーションとバッチとで全く同じように動作すること限らない。WebアプリケーションのコードがJUnitでのユニットテストに合格していたとしても、Webアプリケーションから呼び出されたときは正しく動作しない可能性がある。今回のように、正しく動作しているWebアプリケーションコードの一部をmainメソッドから単体実行すると正常に動作しないこともある。

果たして注意が必要な箇所はクラスローダだけなのか?それ以外にもありうるのか?現時点では分かっていない。影響範囲が分からないということは、リスクを考慮する必要があることを意味する。

高い可搬性を持つJavaとは言え、アプリケーションロジックの動作確認は必ず実際の実行環境で行う必要がありそうだ。

(参考)

  • http://wikilog.namikare.net/ amano

    >微妙な実装の違い

    JavaアプリケーションのClassLoaderは/をつけたら
    ファイルシステムのルートから、Servletコンテナの
    ClassLoaderは/をつけたらそのWebアプリのルートから、
    という当然の仕様だと思いますが。

  • koreadays

    あの後も少し調べてみましたが、
    SunのJavaアプリケーションクラスローダーは
    「/」から始まるパスを指定すると
    ファイルの有無に関係なく必ずnullを返すようです。
    一方、TomcatのWebアプリケーションクラスローダーは
    「/」から始まるパスを指定した場合は
    最初の「/」を除いたパスを指定した場合と同じ動きをするようです。

    API仕様を見ても / で区切られた名前を指定する、
    となっているだけなので、仕様として
    少々あいまいな部分なのかもしれませんね。