7つのクラスローダー 7つの世界

Playを触っていると謎のClassNotFoundExceptionが発生することがあります。 もしかしてそれはDEVモードだけで起きてはいないでしょうか?

先日、開発中のPlayアプリケーションで、ある依存ライブラリからClassNotFoundExceptionが発生するという現象にあたりました。 そのClassNotFoundExceptionはObjectInputStream.readObjectを呼び出した時に起きるのですが、なぜこのようなことが起きるのでしょう。

それではここでPlayのソースコードにある深イイコメントを見てみましょう。

/*
 * We need to do a bit of classloader magic to run the Play application.
 *
 * There are seven classloaders:
 *
 * 1. buildLoader, the classloader of sbt and the Play sbt plugin.
 * 2. commonLoader, a classloader that persists across calls to run.
 *    This classloader is stored inside the
 *    PlayInternalKeys.playCommonClassloader task. This classloader will
 *    load the classes for the H2 database if it finds them in the user's
 *    classpath. This allows H2's in-memory database state to survive across
 *    calls to run.
 * 3. delegatingLoader, a special classloader that overrides class loading
 *    to delegate shared classes for build link to the buildLoader, and accesses
 *    the reloader.currentApplicationClassLoader for resource loading to
 *    make user resources available to dependency classes.
 *    Has the commonLoader as its parent.
 * 4. applicationLoader, contains the application dependencies. Has the
 *    delegatingLoader as its parent. Classes from the commonLoader and
 *    the delegatingLoader are checked for loading first.
 * 5. docsLoader, the classloader for the special play-docs application
 *    that is used to serve documentation when running in development mode.
 *    Has the applicationLoader as its parent for Play dependencies and
 *    delegation to the shared sbt doc link classes.
 * 6. playAssetsClassLoader, serves assets from all projects, prefixed as
 *    configured.  It does no caching, and doesn't need to be reloaded each
 *    time the assets are rebuilt.
 * 7. reloader.currentApplicationClassLoader, contains the user classes
 *    and resources. Has applicationLoader as its parent, where the
 *    application dependencies are found, and which will delegate through
 *    to the buildLoader via the delegatingLoader for the shared link.
 *    Resources are actually loaded by the delegatingLoader, where they
 *    are available to both the reloader and the applicationLoader.
 *    This classloader is recreated on reload. See PlayReloader.
 *
 * Someone working on this code in the future might want to tidy things up
 * by splitting some of the custom logic out of the URLClassLoaders and into
 * their own simpler ClassLoader implementations. The curious cycle between
 * applicationLoader and reloader.currentApplicationClassLoader could also
 * use some attention.
 */

Playはいわゆるソースコードのホットリロード機能などを実現するために、DEVモードではなんと7つのクラスローダーが駆使されているんですね。わー、すごい。

さて、この中で重要なのは 4.applicationLoader と 7.reloader.currentApplicationClassLoader です。 applicationLoaderはライブラリをロードされているクラスローダー、一方reloader.currentApplicationClassLoaderはapplicationLoaderを親とするクラスローダーで、アプリケーションコードを読み込んでいます。

と、いうことは、Playアプリケーションのコードってreloader.currentApplicationClassLoaderにしか見えていないことになります。依存ライブラリ内のObjectInputStream.readObjectはapplicationLoaderを使っているからまあそりゃClassNotFoundExceptionが起きますね。

解決方法は、依存ライブラリがreloader.currentApplicationClassLoaderを使うようにすることです。依存ライブラリに修正入れなきゃいけないのがイケてないですがまあ仕方ない。この場合はただのObjectInputStreamの替わりに

class ObjectInputStreamWithCustomClassLoader(
  stream: InputStream,
  customClassloader: ClassLoader
) extends ObjectInputStream(stream) {
  override protected def resolveClass(cls: ObjectStreamClass) = {
    Class.forName(cls.getName, false, customClassloader)
  }
}

のように利用するクラスローダーを選べるObjectInputStreamを作って、reloader.currentApplicationClassLoaderにあたるplay.api.Environment#classLoaderを利用します。これでClassNotFoundExceptionは出なくなります。めでたし、めでたし。

まとめ

  • We need to do a bit of classloader magic to run the Play application.
  • play.api.Environment#classLoader を使おう