2014年3月5日水曜日

Google App Engine Tips

  1. Eclipse で com.google.appengine.archetypes:guestbook-archetype を指定して GAE のテンプレートプロジェクトを作ると、.classpath の中に <classpathentry> が大量に挿入されるが、絶対パスで書かれるので、GAE SDK をアップデートするときに都合が悪い。 .classpath を手作業で編集して次の内容で置き換えたほうがよい。
    <classpathentry kind="con"
      path="com.google.appengine.eclipse.core.GAE_CONTAINER"/>
    
  2. .classpath の中で、GAE_CONTAINER を MAVEN2_CLASSPATH_CONTAINER より前に置いておかないと、.m2 の中にある GAE ライブラリが先に検索で見つかってしまい、変なエラーが出ることがある。あと、Java Build Path の Order and Export でも GAE のほうを上にしておかないとダメみたい。
  3. guestbook-archetype で GAE テンプレートプロジェクトを作ると eclipse-launch-profiles というサブディレクトリができ、その中に *.launch ファイルができる。 これらのファイルの中の M2_RUNTIME の値は EMBEDDED になっているが、これは DEFAULT に書き換えたほうがよい。Eclipse の Windows -> Preferences -> Maven -> Installations の内容に EMBEDDED が無い場合、エラーとなってしまう。
    <stringAttribute key="M2_RUNTIME" value="DEFAULT"/>
    
    ちなみに、eclipse-launch-profiles の中にあるファイルは、プロジェクトを右クリックして Properties -> Run As -> Maven build とすると出てくる。
  4. logging.properties で formatter を設定しても、うまくいかない。 開発サーバーなら、次のようなことをすれば独自のフォーマッターを使うようにすることもできるが、実サーバーだとこのやり方もうまくいかない。
    Logger logger = Logger.getLogger(...);
    
    for (Logger l = logger; l != null; l = l.getParent())
    {
        Handler[] handlers = l.getHandlers();
    
        // If the logger does not have any handler.
        if (handlers == null || handlers.length == 0)
        {
            continue;
        }
    
        // For each handler of the logger.
        for (Handler handler : handlers)
        {
            // If the handler's formatter is not ours.
            if (handler.getFormatter() != formatter)
            {
                // Set (or replace) the formatter.
                handler.setFormatter(formatter);
            }
        }
    }
    
    Stack Overflow にも "How can I change the logging format in Google AppEngine" という質問が出ているが、結局解決策は無いようだ。
  5. GAE SDK 1.9.0 を設定し、プロジェクトを実行すると、Maven が appengine-java-sdk-1.9.0.zip のダウンロードに失敗して次のようなエラーを吐く。
    Return code is: 503 , ReasonPhrase:backend read error.
    
    たぶんファイルが大き過ぎるのだろう。こうなった場合は、appengine-java-sdk-1.9.0.zip ファイルと appengine-java-sdk.1.9.0.pom を Maven Central Repository からダウンロードし、次のようにして手作業でローカル Maven リポジトリにインストールするほかない。
    mvn install:install-file \
      -Dfile=appengine-java-sdk-1.9.0.zip \
      -DpomFile=appengine-java-sdk-1.9.0.pom
    
  6. プロジェクトを右クリックして Properties -> Run As -> Maven build とし、 UpdateApplication でアプリケーションを GAE 実環境にはじめてアップロードしようとすると、 デフォルトブラウザが立ち上がり、Google App Engine サイトへのログイン後にキーが表示される。 このキーを Eclipse に入力すると、以後、ログイン無しでもアップロードできるようになる。 ただし、デフォルトブラウザで使っている Google アカウントと、GAE アプリケーション開発で使っている Google アカウントが別々だと、面倒なことになる。表示されたキーを Eclipse に入力しても、 "This application does not exist (app_id=u'xxx')" というエラーが出てしまう。 エラーが出るくらいならばいいが、再度試みると、前回入力したキーが有効なため、 キーを再入力するチャンスも与えられず、アップロードに失敗してしまう。 こうなった場合は、次の手順をおこなう。

    1. デフォルトブラウザから Google アカウントを一度ログアウトするか、他のブラウザを一時的にデフォルトブラウザに設定する。
    2. ~/.appcfg_oauth2_tokens_java (または ~/.appcfg_cookies) を削除する。
    3. 再度 UpdateApplication を実行する。
    4. 表示されたブラウザに GAE アプリケーション開発用の Google アカウントでログインしてキーを入手する。
    5. キーを Eclipse に入力する。
  7. SystemProperty.environment.value() の値を調べると、実行環境が開発サーバーなのか実サーバーなのかを識別することができる。こんなクラスを用意しておくと便利。
    public class Env
    {
        public static boolean isProduction()
        {
            return SystemProperty.environment.value() ==
                    SystemProperty.Environment.Value.Production;
        }
    
    
        public static boolean isDevelopment()
        {
            return SystemProperty.environment.value() ==
                    SystemProperty.Environment.Value.Development;
        }
    }
    
  8. クライアントからのリクエストに含まれる Content-Encoding ヘッダーは GAE により取り除かれるので、GAE アプリケーションからは参照できない。かわりに、GAE が Content-Encoding ヘッダーの内容に合わせて勝手に適宜 gzip などしてくれる。(詳しくは Java Runtime Environment, Content-Encoding を参照のこと)
  9. アプリを実行すると、ClassNotFoundException が出て困った。かなり調べた結果、WEB-INF/lib 内の幾つかの JAR ファイルが壊れていることが分かった。なぜ壊れるのか。かなり調べた結果、maven-war-plugin がファイルコピー時に .jar ファイルに対してフィルタリング処理を実行していることが原因のようだと分かった。maven-war-plugin がフィルタリング処理を行わない拡張子のデフォルトリストは jpg, jpeg, gif, bmp, png とのこと。.jar が含まれていない。maven-war-plugin の Filtering の説明を参考に、.jar をフィルタリング処理から外さなければならない。結果、pom.xml の該当部分は次のようになる。
    <build>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-war-plugin</artifactId>
        <version>2.4</version>
        <configuration>
          <nonFilteredFileExtensions>
            <nonFilteredFileExtension>jar</nonFilteredFileExtension>
          </nonFilteredFileExtensions>
          <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>
          </webResources>
        </configuration>
      </plugin>
    
    ちなみに、JAR ファイルが壊れているかどうかは、WEB-INF/lib ディレクトリに移動して次のようなコマンドを実行すると分かる。
    for i in *.jar; do echo === $i ===; (jar tf $i > /dev/null); done
    
    JAR ファイルが壊れていると、次のような表示になる。
    === datanucleus-appengine-1.0.10.final.jar ===
    java.util.zip.ZipException: error in opening zip file
            at java.util.zip.ZipFile.open(Native Method)
            at java.util.zip.ZipFile.<init>(ZipFile.java:215)
            at java.util.zip.ZipFile.<init>(ZipFile.java:145)
            at java.util.zip.ZipFile.<init>(ZipFile.java:116)
            at sun.tools.jar.Main.list(Main.java:1004)
            at sun.tools.jar.Main.run(Main.java:245)
            at sun.tools.jar.Main.main(Main.java:1177)
    
  10. ClassNotFoundException でも、次のものは別問題。
    java.lang.ClassNotFoundException:
      org.datanucleus.api.jdo.JDOPersistenceManagerFactory
    
    これは、JDO 3.0 を使おうと思って jdoconfig.xml 内で PersistenceManagerFactoryClass に org.datanuleus.api.jdo.JDOPersistenceManagerFactory を指定したのに、それが見つからないということ。JDO 2.3 から JDO 3.0 への変更時、 PersistenceManagerFactoryClass は org.datanucleus.store.appengine.jdo.DatastoreJDOPersistenceManagerFactory から org.datanuleus.api.jdo.JDOPersistenceManagerFactory へと変更されたものの(参考: Changes to Configuration File)、 その org.datanuleus.api.jdo.JDOPersistenceManagerFactory を含むライブラリを取り込んでいないということ。 UpgradingToVersionTwo を参考にしつつも、最終的には pom.xml に次のエントリーを追加することで ClassNotFoundException は無くなった。
    <dependency>
      <groupId>javax.jdo</groupId>
      <artifactId>jdo-api</artifactId>
      <version>3.0.1</version> <!-- 09-Feb-2012 -->
    </dependency> 
    <dependency>
      <groupId>javax.transaction</groupId>
      <artifactId>transaction-api</artifactId>
      <version>1.1</version> <!-- 22-Jan-2010 -->
    </dependency> 
    <dependency>
      <groupId>com.google.appengine.orm</groupId>
      <artifactId>datanucleus-appengine</artifactId>
      <version>2.1.2</version> <!-- 15-Feb-2013 -->
    </dependency> 
    <dependency>
      <groupId>org.datanucleus</groupId>
      <artifactId>datanucleus-core</artifactId>
      <version>3.1.3</version> <!-- 12-Nov-2012 -->
    </dependency>
    <dependency>
      <groupId>org.datanucleus</groupId>
      <artifactId>datanucleus-api-jdo</artifactId>
      <version>3.1.3</version> <!-- 12-Nov-2012 -->
    </dependency>
    
  11. Google Cloud SQL と一緒に使っていて、実行時に 「There is no available StoreManager of type "jdbc". Make sure that you have put the relevant DataNucleus store plugin in your CLASSPATH and if defining a connection via JNDI or DataSource you also need to provide persistence property "datanucleus.storeManagerType"」 というエラーが出たら、pom.xml に datanucleus-rdbms を追加してみる。
    <dependency>
      <groupId>org.datanucleus</groupId>
      <artifactId>datanucleus-rdbms</artifactId>
      <version>3.1.3</version> <!-- 12-Nov-2012 -->
      <scope>runtime</scope>
    </dependency>
    
  12. 開発中、Google Cloud SQL の代わりにローカル環境の MySQL を使う設定にしていて、「Caused by: org.datanucleus.store.rdbms.datasource.DatastoreDriverNotFoundException: The specified datastore driver ("com.mysql.jdbc.Driver") was not found in the CLASSPATH. Please check your CLASSPATH specification, and the name of the driver.」 という感じで MySQL 用のドライバーが見つからない、というエラーが出たら、pom.xml に mysql-connector-java を追加してみる。
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>5.1.29</version>
      <scope>runtime</scope>
    </dependency>
    
  13. 開発中に MySQL を使っていて、AccessControlException が発生し、その原因が Thread を生成したせいであるならば、DatastoreReadTimeoutMillis や DatastoreWriteTimeoutMillis の設定を jdoconfig.xml からコメントアウトすることで問題を回避できる場合がある。詳細はこちら → AccessControlException when using local MySQL for Google Cloud SQL
  14. Enhance フェーズで「コマンドラインが長すぎます。」 (The command line is too long.) というエラーが出た場合、一般的には
    <plugin>
      <groupId>org.datanucleus</groupId>
      <artifactId>datanucleus-maven-plugin</artifactId>
      <configuration>
        <fork>false</fork>
      </configuration>
    </plugin>
    
    を追加することで解決するが、GAE 開発のときは解決しない。appengine-maven-plugin が絡んでいるから。そこで、
    <plugin>
      <groupId>com.google.appengine</groupId>
      <artifactId>appengine-maven-plugin</artifactId>
      <version>${appengine.target.version}</version>
      <configuration>
        <fork>false</fork>
      </configuration>
    </plugin>
    
    としても、こちらでも解決しない。appengine-maven-plugin が <configuration> 内の <fork>false</fork> を認識しないから。

    しかし、datanucleus のソースコードを読んで解決策を見つけた人がいた(appengine-maven-plugin Issue 49)。<properties> 内に <fork>false</fork> を追加すればよいらしい。
    <properties>
      <fork>false</fork>
    </properties>
    
  15. JDO をやっていて、「Query for candidates of クラス名 and subclasses resulted in no possible candidates」 というようなエラーが出た場合、直接的な原因はおそらく、enhance がうまくいっていないこと。で、間接的な原因はおそらく、Eclipse の 「properties -> Google -> App Engine -> ORM」 の設定で何かミスをしていること。あるフォルダー以下のファイルを全部 enhance の対象とするとの意図で 「.../folder」 と書いても、どうもうまくいかないようだ ("specify file, folder or pattern" と書いてあるのに・・・)。かわりに 「.../folder/*.java」 という具合に指定すれば、フォルダー以下の *.java ファイルが確実に enhance の対象になる。 ただし、enhance がうまく動かない理由はほかにもあると思われる。

    次のコマンドにより手作業で enhance してみるという方法もある。
    $ mvn appengine:enhance
    
    datanucleus.log というログファイルができるので、その中身を確認する。
  16. 開発サーバーのポート番号を変更したいときは、appengine-maven-plugin の configuration で port を指定する。
    <plugin>
      <groupId>com.google.appengine</groupId>
      <artifactId>appengine-maven-plugin</artifactId>
      <version>${appengine.target.version}</version>
      <configuration>
        <port>8880</port>
      </configuration>
    </plugin>
    
  17. 認可された App Engine アプリから Cloud SQL にアクセスする場合、DB パスワードを指定すると、逆にエラーになってしまう。 「App Engine Java Servlet does not connect to Cloud SQL」に対する回答を参照のこと。
  18. Jersey (JAX-RS 実装) を使い、クライアントエラー時 (= HTTP ステータスコードが 400 番台の時) も JSON を返すエンドポイントを実装したが、「GAE 開発サーバーでは期待通りの動作をするが実際の GAE では HTML が返される」、という問題にぶちあたった。最初は GAE の問題だと思っていたが、JAX-RS ではなく素の Servlet では問題が起きないことから、Jersey のせいではないかと思い、Jersey のソースコードを読みはじめたところ、すぐに直感が働いた。Jersey の サーバー設定項目の一つである "jersey.config.server.response.setStatusOverSendError" を true に設定したところ、問題が解決した。下記は web.xml からの抜粋。
    <servlet>
      <servlet-name>API</servlet-name>
      <servlet-class>
        org.glassfish.jersey.servlet.ServletContainer
      </servlet-class>
      ......
      </init-param>
      <init-param>
        <param-name>
          jersey.config.server.response.setStatusOverSendError
        </param-name>
        <param-value>true</param-value>
      </init-param>
    </servlet>
    
    詳細は StackOverflow の質問「GAE/J changes Content-Type from JSON to HTML when the status code is 4xx」に対する回答 (自己解決) を参照のこと。
  19. Google Cloud SQL に JDO を使ってアクセスしていたが、admin テーブルを参照すると、「org.datanucleus.store.rdbms.exceptions.MissingTableException: Required table missing : "`ADMIN`" in Catalog "" Schema "". DataNucleus requires this table to perform its persistence operations. Either your MetaData is incorrect, or you need to enable "datanucleus.autoCreateTables"」という例外が出た。しかし、当該テーブルは存在するし、他のテーブルには同じ方法でアクセスできていた。当該テーブル用のエンティティークラスが enhance されていることは javap プログラムで確認していた。なぜそのテーブルが存在していないとみなされるのかが分からない。

    時間を使っていろいろ試行錯誤したがうまくいかないので、エラーメッセージに素直に従って、jdoconfig.xml で datanucleus.autoCreateTables を true に設定したら、
    <property name="datanucleus.autoCreateTables" value="true"/>
    
    MissingTableException は出なくなったが、JDO 経由で当該テーブルにアクセスすると、データが存在しないと言われる。様子がおかしいので Google Cloud SQL に mysql コマンドでログインして show tables をすると、admin テーブルに加えて、ADMIN テーブルが作成されていた。

    これは、識別子の case sensitivity の問題だ。"JDO : Datastore Identifiers" によると、JDO はデフォルトで識別子を大文字にしてしまうそうで、もしも識別子に小文字を使っていて、かつ、MySQL を動かしている環境のファイルシステムが大文字・小文字を区別する場合、JDO からはテーブルが発見できない。このようなときは、識別子の case sensitivity を制御する "datanucleus.identifier.case" 変数を適切にセットする必要がある。私の場合は識別子に全て小文字を使っていたので、次のような設定を jdoconfig.xml に追加した。
    <property name="datanucleus.identifier.case" value="LowerCase"/>
    
    詳細は "MissingTableException on Google Cloud SQL" を参照のこと。
  20. ローカルマシンで動く GAE 開発サーバーに ASCII 以外の文字 (日本語など) をフォーム送信すると、正しくパースされず、エラーになるようだ。開発サーバーのログには「no viable alternative at character」云々のエラーメッセージが出る。ちなみに、「no viable alternative at character」というメッセージは、org.antlr.runtime.Lexer.java というソースコードから来ていると思われる。GAE 開発サーバーは Jetty 6 というかなり古い Jetty をベースに作られているようなので、いろいろ不具合が残っているのだろう。なお、本番環境の GAE ではこの問題は起こらない。
  21. GAE 関係なく一般的な話。JDO の PersistenceManagerFactory は生成にコストがかかるので、一度インスタンスを作ってそれを使いまわすことが推奨されている。そういうわけで、コード上ではシングルトンインスタンスにする。でも、アプリのインスタンスが複数存在すると、同時に PersistenceManagerFactory が複数存在することになる。ここで問題になるのが JDO の Level 2 Cache。デフォルトで有効になっているこのキャッシュ機構のせいで、ある PersistenceManagerFactory インスタンスから作成した PersistenceManager を用いて DB 上のデータを書き換えても、他の PersistenceManagerFactory インスタンスから作成した PersistenceManager からはその変更が見えない(=古いデータを参照し続ける)ということが起こる。この問題は、"datanucleus.cache.level2.type" 変数に none をセットすると回避できる。jdoconfig.xml に次のように追加することになる。
    <property name="datanucleus.cache.level2.type" value="none"/>
    
    コード上で変更する場合は次のとおり。
    pm.setProperty("datanucleus.cache.level2.type", "none");
    
  22. つづく