2013年12月1日日曜日

Google App Engine + Jersey (JAX-RS)

Web サービスを Google App Engine + Jersey (JAX-RS) で作成するときのメモ。

  1. Eclipse の m2e プラグインを更新して Maven 3.1 以上が使えるようにする。guestbook-archetype (後述) が Maven 3.1 を要求するため。ただし、2013年11月末では m2e プラグインの stable (1.4) が Maven 3.1 を含んでいないため、Eclipse → Help → Install New Software... で http://download.eclipse.org/technology/m2e/milestones/1.5 を追加し、そちらから m2e プラグインの更新をかける。
  2. Eclipse の Google App Engine 関連のプラグインを更新する。
  3. Eclipse で Maven プロジェクトを作成する。ウィザードの Filter に guestbook と入力して com.google.appengine.archetypes:guestbook-archetype を表示し、選択する。 
  4. pom.xml 内の appengine.target.version を最新のものに更新する。現時点では 1.8.8。
  5. Jersey の maven モジュール群を pom.xml に追加する。Jersey サイト (http://jersey.java.net/) のドキュメントを参照し、Jersey の最新バージョンを確認する (現在 2.4.1)。そして、GAE がサポートする Servlet API (現在 2.5) の場合、どのモジュールを選べばよいのか確認する。Chapter 2. Modules and dependencies / 2.3.2 Servlet based server-side application (https://jersey.java.net/documentation/latest/modules-and-dependencies.html#servlet-app-general) によると、Servlet API 3.0 より前のバージョンの場合は jersey-container-servlet ではなく jersey-container-servlet-core のほうを使えと書いてある。結果、次の dependency を pom.xml に追加する。
    <dependency>
        <groupId>org.glassfish.jersey.containers</groupId>
        <artifactId>jersey-container-servlet-core</artifactId>
        <version>2.4.1</version>
    </dependency>
    
  6. **.core パッケージを作る。(アプリケーション全体に関係するものは、このパッケージに置く。これは自分ルール。)
  7. javax.ws.rs.Application クラスを拡張して App クラスを作り、core パッケージ下に置く。とりあえず、ほぼ空実装。
    package com.example.myapp.server.core;
    
    import java.util.HashSet;
    import java.util.Set;
    import javax.ws.rs.core.Application;
    
    public class App extends Application
    {
        private final Set<Object> singletons = new HashSet<Object>();
        private final Set<Class<?>> classes = new HashSet<Class<?>>();
    
        @Override
        public Set<Class<?>> getClasses()
        {
            return classes;
        }
    
        @Override
        public Set<Object> getSingletons()
        {
            return singletons;
        }
    }
  8. src/main/webapp/WEB-INF/web.xml に Jersey サーブレットを登録する。ルートパス以下を全て Jersey サーブレットに処理させる。
    <servlet>
      <servlet-name>JAX-RS Servlet</servlet-name>
        <servlet-class>org.glassfish.jersey.servlet.ServletContainer
        </servlet-class>
        <init-param>
          <param-name>javax.ws.rs.Application</param-name>
          <param-value>com.example.myapp.server.core.App
          </param-value>
        </init-param>
    </servlet>
    
    <servlet-mapping>
      <servlet-name>JAX-RS Servlet</servlet-name>
      <url-pattern>/</url-pattern>
    </servlet-mapping>
    
  9. とりあえず、ここで一回アプリを実行してみる。プロジェクトのコンテキストメニューから、「Run As」→「Maven build」→「DevAppServer : appengine:devserver」を実行する。
  10. しかし、ここでエラーが出る。(2014/2/18 追記: Jersey version 2.5.1 で本不具合が修正されていることを確認。)
    java.lang.NoClassDefFoundError: sun.misc.Unsafe is a restricted class.
    Please see the Google  App Engine developer's guide for more details.
    
    ググってみると、jersey-common に含まれている org/glassfish/jersey/internal/util/JdkVersion.java の static イニシャライザの次のコードが原因とのこと (Answer to "NoClassDefFoundError when running Jersey in GAE")。既に 2013/10/10 に不具合報告 JERSEY-2136 があがっていることも判明。(というか、JERSEY-2248 で不具合報告したら duplicate にされたので、報告済みの問題だということを知った。)
       static {
    
           boolean isUnsafeFound;
    
           try {
               isUnsafeFound = Class.forName("sun.misc.Unsafe") != null;
           } catch (Throwable t) {
               isUnsafeFound = false;
           }
    
           IS_UNSAFE_SUPPORTED = isUnsafeFound;
       }
    
    上記のコードは、Java 実行環境に sun.misc.Unsafe クラスが存在すれば IS_UNSAFE_SUPPORTED フラグを true にし、そうでなければ false にするという意図がある。

    なぜこのコードが GAE 上で問題を起こすかというと、GAE の Java 実行環境に sun.misc.Unsafe は存在するものの、GAE の都合によりそのクラスが利用不可となっているせいである (利用不可の理由は知らない)。

    上記のコードを GAE でも機能させるには、次のようなパッチをあてればよい。
    --- JdkVersion.java.old Thu Nov 28 04:55:11 2013
    +++ JdkVersion.java     Sun Dec  1 02:27:06 2013
    @@ -50,16 +50,18 @@
         private static final boolean IS_UNSAFE_SUPPORTED;
    
         static {
    +        IS_UNSAFE_SUPPORTED =
    +             classExists("sun.misc.Unsafe") &&
    +            !classExists("com.google.appengine.api.LifecycleManager");
    +    }
    
    -        boolean isUnsafeFound;
    -
    +    private static boolean classExists(String className) {
             try {
    -            isUnsafeFound = Class.forName("sun.misc.Unsafe") != null;
    +            Class.forName(className);
    +            return true;
             } catch (Throwable t) {
    -            isUnsafeFound = false;
    +            return false;
             }
    -
    -        IS_UNSAFE_SUPPORTED = isUnsafeFound;
         }
    
         private static final JdkVersion UNKNOWN_VERSION = new JdkVersion(-1, -1, -1, -1);
    
    このパッチだと分かりにくいが、結果はこのようなコードになる。
        static {
            IS_UNSAFE_SUPPORTED =
                 classExists("sun.misc.Unsafe") &&
                !classExists("com.google.appengine.api.LifecycleManager");
        }
    
        private static boolean classExists(String className) {
            try {
                Class.forName(className);
                return true;
            } catch (Throwable t) {
                return false;
            }
        }
    
    このパッチをあてた JdkVersion.java を自分のアプリケーションのソースコードツリーに取り込んでビルドし、再度アプリを実行してみたが、どうも変更した JdkVersion が参照されない。そこで、WEB-INF/lib/jersey-common-2.4.1.jar 内の JdkVersion.class よりも先に自分のアプリ内に取り込んだ JdkVersion.class が先にクラスロードされるよう、pom.xml を次のように編集して WEB-INF/classes 以下にパッチをあてた JdkVersion.class が配置されるようにした。 (WEB-INF/lib/*.jar よりも WEB-INF/classes 以下のクラス群のほうがクラスローディングの順番が先との仕様があるとのことなので。Servlet API 2.4 から?)。
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-war-plugin</artifactId>
      <version>2.3</version>
      <configuration>
        <archiveClasses>true</archiveClasses>
        <webResources>
          <!-- in order to interpolate version from pom into appengine-web.xml -->
          <resource>
            <directory>${basedir}/src/main/webapp/WEB-INF</directory>
            <filtering>true</filtering>
            <targetPath>WEB-INF</targetPath>
          </resource>
          <resource>
            <directory>${project.build.outputDirectory}</directory>
            <targetPath>WEB-INF/classes</targetPath>
            <filtering>true</filtering>
            <includes>
              <include>org/glassfish/jersey/internal/util/JdkVersion.class</include>
            </includes>
          </resource>
        </webResources>
      </configuration>
    </plugin>
    
    しかし今度は別のエラーが出る。
    Unable to instrument org.glassfish.jersey.internal.util.JdkVersion.
    Security restrictions may not be entirely emulated.
    java.lang.ArrayIndexOutOfBoundsException: 8669
    at com.google.appengine.repackaged.org.objectweb.asm.ClassReader.<init>(ClassReader.java:181)
    
    ググってみると、このエラーは、バージョンの異なる複数のクラスが存在するときに起こるらしい。しかしながら、バージョンが異なるクラスが複数あっても、クラスローディングの過程で先に見つかったほうを利用するという仕様のはずだから、問題が起こるのはおかしくないか? と、思いながらスタックトレースを見ると、com.google.appengine.repackaged というパッケージ名に気が付いた。ここからは推測だが、おそらく、GAE 用にクラス群を再パッケージング (repackage) する仕組みがあって、そいつが悪さしているのだろう。Java の仕様に敬意を払わない Google なら普通にやりかねない。

    そういうわけで、美しくない方法だがやむをえない。既にローカルの MAVEN リポジトリ (~/.m2) にダウンロード済みの jersey-common-2.4.1 を一回削除し、JdkVersion.class を置き換えたバージョンの jersey-common-2.4.1 をローカルインストールする。
    # 作業用のディレクトリを作成し、そこに移動する。
    $ mkdir -p ~/tmp/jersey-common-2.4.1-fixed
    $ cd ~/tmp/jersey-common-2.4.1-fixed
    
    # 既存の jersey-common-2.4.1 の jar と pom を、ローカルの
    # Maven ディレクトリから作業ディレクトリにコピーする。
    $ cp ~/.m2/repository/org/glassfish/jersey/core/jersey-common/2.4.1/\
    jersey-common-2.4.1.jar .
    $ cp ~/.m2/repository/org/glassfish/jersey/core/jersey-common/2.4.1/\
    jersey-common-2.4.1.pom .
    
    # パッチを当ててコンパイルした JdkVersion.class を
    # 作業ディレクトリにコピーする。
    $ mkdir -p org/glassfish/jersey/internal/util
    $ cp ${プロジェクトディレクトリ}/target/classes/org/glassfish/jersey/\
    internal/util/JdkVersion.class org/glassfish/jersey/internal/util/
    
    # jar ファイル内の JdkVersion.class を置き換える。
    $ jar -uf jersey-common-2.4.1.jar \
          org/glassfish/jersey/internal/util/JdkVersion.class
    
    # ローカルの Maven ディレクトリから jersey-common を削除する。
    $ rm -rf ~/.m2/repository/org/glassfish/jersey/core/jersey-common
    
    # 手を加えた jersey-common-2.4.1 をローカルの Maven
    # リポジトリにインストールする。
    # 参考: Installing an artifact to a specific local repository path
    $ mvn install:install-file -Dfile=jersey-common-2.4.1.jar \
          -DgroupId=org.glassfish.jersey.core \
          -DartifactId=jersey-common \
          -Dversion=2.4.1 \
          -Dpackaging=jar \
          -DpomFile=jersey-common-2.4.1.pom
    
    ここまでやってから、プロジェクトを一度 Maven clean し、Web アプリを再起動すれば、エラーなく Jersey サーブレットが起動する。
  11. Jersey が動くことが確認できたので、JAX-RS でいうところのリソースを格納するためのパッケージ **.resource を作成する。
  12. リソースクラス群のためのベースクラスを作る。とりあえず空実装。
    package com.example.myapp.server.resource;
    
    public class ResourceBase
    {
    
    }
    
  13. 次はルートリソースを表現するクラスを作る。GET リクエストがきたときに hello と返すだけの実装をしてみる。
    package com.example.myapp.server.resource;
    
    import javax.ws.rs.GET;
    import javax.ws.rs.Path;
    
    @Path("/")
    public class RootResource extends ResourceBase
    {
        @GET
        public String get()
        {
            return "hello";
        }
    }
    
  14. ルートクラスを前述の App クラスで登録する。
        @Override
        public Set<Class<?>> getClasses()
        {
            classes.add(RootResource.class);
    
            return classes;
        }
    
  15. ここまでやったら、Web アプリケーションを再起動する。http://localhost:8080/ にアクセスして hello と表示されたら成功。
  16. Jersey 2.x 系を Google App Engine で動かすのがこんなに手間だとは思わなかった。疲れたので、ユーザー管理等のアプリ寄りの話は、書くとしても別ブログ。
  17. おしまい。