ラベル Java の投稿を表示しています。 すべての投稿を表示
ラベル Java の投稿を表示しています。 すべての投稿を表示

2011年4月3日日曜日

#appengine 1.4.3の #slim3 単体テスト環境でQueueへのAddで例外が発生する場合

追記

このエントリを書いた数時間後に、この問題に対する対策が実施されたslim3-1.0.10がリリースされています。このエントリは無視してそれを使いましょう!

Slim3の単体テスト環境で、先週リリースされた Google App Engine SDK 1.4.3を適用するとQueueへTaskをaddする処理で次のような例外が発生します。

java.lang.IllegalArgumentException: Task name does not match expression [a-zA-Z\d-]{1,500}; given taskname: 'null'
 at com.google.appengine.api.taskqueue.TaskHandle.validateTaskName(TaskHandle.java:103)
 at com.google.appengine.api.taskqueue.TaskHandle.(TaskHandle.java:30)
 at com.google.appengine.api.taskqueue.QueueImpl.add(QueueImpl.java:489)

あれこれ見てみた結果、この原因は、1.4.3 からSDK内でtaskqueue#BulkAddのresponseをチェックするようになった、という事かなと思います。

対処方法

Slim3内部でなんとかするしか無いですが、急いでいる方は次のようにすると応急処置になります。

  1. Slim3を使ったプロジェクトに、org.slim3.tester.AppEngineTesterを作成する
  2. Slim3のorg.slim3.tester.AppEngineTesterをそのクラスにまるごとコピーする
  3. 次の箇所を探す。
    } else if (service.equals(TASKQUEUE_SERVICE) && method.equals(BULK_ADD_METHOD)) {
      TaskQueueBulkAddRequest taskPb = new TaskQueueBulkAddRequest();
      taskPb.mergeFrom(requestBuf);
      TaskQueueBulkAddResponse responsePb = new TaskQueueBulkAddResponse();
      for (int i = 0; i < taskPb.addRequestSize(); i++) {
        tasks.add(taskPb.getAddRequest(i));
        responsePb.addTaskResult();
      }
      return responsePb.toByteArray();
    
  4. その箇所を次のように変更する。
    } else if (service.equals(TASKQUEUE_SERVICE) && method.equals(BULK_ADD_METHOD)) {
      TaskQueueBulkAddRequest taskPb = new TaskQueueBulkAddRequest();
      taskPb.mergeFrom(requestBuf);
      TaskQueueBulkAddResponse responsePb = new TaskQueueBulkAddResponse();
      for (int i = 0; i < taskPb.addRequestSize(); i++) {
        tasks.add(taskPb.getAddRequest(i));
        TaskResult taskResult = new TaskResult();
        taskResult.setChosenTaskName("task" + String.valueOf(System.nanoTime()));
        responsePb.addTaskResult(taskResult);
        // responsePb.addTaskResult();
      }
      return responsePb.toByteArray();

ついでに

どうせSlim3のクラスにパッチをあてるなら…という事で、テスト時の初期ファイルをSlim3の単体テストで使用してテストの処理時間を短縮したい、等考えている人は次の対処をしておくと便利かもしれません。

AppEngineTester#tearDown()内のApiProxy.setDelegate(originalDelegate);となっている箇所の後ろに次の処理を追加する。

if (!AppEngineUtil.isProduction()) {
  ClassLoader loader = loadLibraries();
  Class apiProxyLocalImplClass = loader.loadClass(API_PROXY_LOCAL_IMPL_CLASS_NAME);
  Method stopMethod = apiProxyLocalImplClass.getMethod("stop");
  stopMethod.setAccessible(true);
  stopMethod.invoke(apiProxyLocalImpl);
  ApiProxy.setEnvironmentForCurrentThread(originalEnvironment);
  new File("build/test-classes/WEB-INF/appengine-generated/local_db.bin").delete();
}   

単体テスト用の初期データファイルをSlim3の単体テスト実行フォルダ(build/test-classes/WEB-INF/)にコピーしてテストする際は、データストアを正しく終了する処理をしておかないとデータファイルが正しく読めなくなってくる(タイミングによっては問題なく読める時もあるw)ための対処です。念には念を入れてlocal_db.binの削除も行っています。この対策を行って、AppEngineTester#setUp()の前に初期データファイルを"build/test-classes/WEB-INF/appengine-generated/local_db.binへコピーしておけばテストのための初期データ作成が高速になります。

2011年3月12日土曜日

MacOS Xのアップデート後 #appengine javaで開発サーバが動作しない件

2011-03-11にあったMacOS Xのアップデート実施すると、ローカルで開発サーバが起動できなくなる問題が出るようです。現象としては、開発サーバ(eclipseから起動しても、結局はSDK内のdev_appserverが起動されます)を起動した後、数秒で開発サーバが終了してしまう、という現象です。私が試したときはたまーに少しだけ動作してから落ちる、という場合もありました。下記でも報告されていますし、対策も説明されています。

対策を簡潔に説明しておきます

  1. http://openjdk-osx-build.googlecode.com/files/OpenJDK-1.7-x86_64-20110221.dmg からOpenJDK1.7のイメージを入手する
  2. dmgを開いて、DukeアイコンのInstallation.pkgを実行してOpenJDK1.7をインストールする

これでopenjdk-1.7がインストールされます。此処から先は開発サーバの起動毎に手順があります。

SDK内のdev_appserver.shを直接起動している人

dev_appserver.shをエディタで開いてJAR_FILE=...などの次の行あたりにJAVA_HOME=/Library/Java/JavaVirtualMachines/openjdk-1.7-x86_64/Contents/Homeを追加する

eclipseから起動している人

  1. eclipseの設定画面を開く
  2. 左のツリーからJava > Installed JREsを選択する
  3. 右側のペインでAddをクリックする
  4. Add JREダイアログでMacOS X VMを選択してNextをクリックする
  5. JRE definitionの画面に移るので、JRE HomeDirectoryボタンをクリックして/Library/Java/JavaVirtualMachines/openjdk-1.7-x86_64/Contents/Homeを選択する
  6. JRE nameopenjdk-1.7-x86_64を入力する

ここまでの手順で、eclipseがOpenJDK1.7を認識出来るようになります。次に、appengineプロジェクトに対して次の設定を行う必要があります。

  1. プロジェクトのプロパティを開き、左のツリーからJava Build Pathを選択する
  2. 右側のペインでLibrariesタブを開き、リストの下の方にあるJRE System Library[JVM 1.6.0 (MacOS X Default)]を選択し、Removeをクリックして削除する。
  3. Add Libraryボタンをクリックする
  4. Add LibraryダイアログでJRE System Libraryを選択してNextをクリックする
  5. どのJREを使うか選択する画面になるのでAlternate JREを選択し、先の手順でEclipseに認識させたopenjdk-1.7-x86_64を選択する
  6. プロジェクトのLibraryにopenjdkが追加される。

これらの手順で開発サーバが起動できるようになります。

追記

上記手順で開発サーバを起動したときにjavax.net.ssl.SSLExceptionが発生するのが気になる場合は、次の手順で解消できます。

  1. cd /Library/Java/JavaVirtualMachines/openjdk-1.7-x86_64/Contents/Home/jre/lib/security
  2. cp /System/Library/Java/Support/CoreDeploy.bundle/Contents/Home/lib/security/cacerts .

2011-03-15追記

AppleのJDKを入れ直すという、もっと簡単そうな手順も紹介されています。

2010年10月15日金曜日

#slim3 のテスト環境で投入したデータを削除せずファイルに保存する #appengine

AppEngine/Javaで、開発サーバで実行するときのテストデータの初期状態を作っておきたい時、私はよくAppEngine SDKのLocalServiceTestHelperを使ってテスト環境を作成してデータ投入し、そこで作成したファイル(local_db.bin)をwar/WEB-INF/appengine-generatedフォルダにコピーしていました。これと同じことをSlim3のAppEngineTesterを使ってできないかなー?と思って試してみました。

Slim3のAppEngineTester#tearDown()はテスト中に投入されたデータは全部消してしまう(それが便利なわけですが)仕様です。これはtearDown()をオーバーライドして削除処理をしないようにすれば回避できます。
それだけだと良いんですが、環境終了時にApiProxyLocalImpl#stop()を呼んでいません。これが無いとデータがlocal_db.binに永続化されないので、ファイルが作成できません。そこで次のようなAppEngineTesterのサブクラスを作って使うことにしました。テスト中に投入したデータのクリア処理を外し、各LocalServiceクラスを停止するためのApiProxyLocalImpl#stop()を実行するようにしただけです。

以下のように使うことになります。

  1. テストデータ投入前に、上記のAppEngineTesterのサブクラスをインスタンス化し、setUp()を実行する
  2. 開発サーバで実行するときのアプリケーションIDを返すApiProxy.EnvironmentのインスタンスをApiProxy#setEnvironmentForCurrentThread()で設定する
  3. テストデータを投入する
  4. 上記のAppEngineTesterのサブクラスのオブジェクトのtearDown()を実行する
  5. build/test-classes/WEB-INF/appengine-generated/local_db.binwar/WEB-INF/appengine-generated/local_db.binにコピーする

データファイルに保存されるエンティティにはアプリケーションIDも合わせて永続化されるので、データ投入時と開発サーバ実行時で同じになる必要がある点に注意が必要です。

2010年9月22日水曜日

#appengine の開発環境でfederatedLoginなアプリを動作させる

appengineのfederatedLoginの機能を使ったアプリケーションの場合、開発環境のログイン機能ではcom.google.appengine.api.users.UserからfederatedIdentityの値などが取得できずに不便です。実行時のUserService#isLoggedIn(), UserService#isAdmin(), UserService#getAuthDomain()等はApiProxy.Environmentを自身の実装に置き換えるだけだったので簡単でしたが、federatedLoginの場合はこれらの値を触るだけでは不十分です。
ではいつものようにApiProxy#setDelegate(ApiProxy.Delegte)でいじってやるか!というワケにもいきません。UserService#getCurrentUser()等はRPCされないためです(以前、本家MLで「UserServiceはコストが低い」という話がありましたね。その理由はRPCしない=コストが低い、ということです。このブログでもいつだったかに書いた記憶があります。)。

んじゃどーするのか?といいますと、ApiProxy#getCurrentEnvironment()で取得できるApiProxy#Environment<ApiProxy.Delegate>Map<String, Object>#getAttributes()メソッドが返す値を操作してやるのです。

ApiProxy#Environment<ApiProxy.Delegate>#Map<String, Object>#getAttributes()に以下の要素を設定してやることで、federatedLoginの機能が開発環境でも有効になります。

  1. com.google.appengine.api.users.UserService.is_federated_user
  2. com.google.appengine.api.users.UserService.user_id_key
  3. com.google.appengine.api.users.UserService.federated_identity
  4. com.google.appengine.api.users.UserService.federated_authority

com.google.appengine.api.users.UserService.is_federated_userについてはBoolean.TRUEを設定しておく必要があります。他はすべて文字列を設定すればよく、それぞれの値はfederatedLogin機能を使うときのアレです。

単体テスト環境では、ApiProxy#Environment<ApiProxy.Delegate>Map<String, Object>#getAttributes()が返す値に上記を追加し、ApiProxy#setEnvironmentForCurrentThred()してやれば良いです。
開発サーバ環境でも同じことをすれば良いのですが、私が使っている「専用のFilterとして実装する」という方法のサンプルコードを掲載しておきます。
「独自のApiProxy#Environment<ApiProxy.Delegate>を常に使用する」「createLoginURL()すると、専用のフォームを表示し、そこからのPostの内容に従ってfederatedLoginUser情報を設定する」「web.xml内でinit-paramにfederatedLoginUser情報があれば、それを読み込んで起動時からログイン状態にしておく」等をやっています。
単体テスト環境では、下記のサンプルコードの内部staticクラスとして定義されているFederatedLoginEnvironmentをインスタンス化して、loginメソッドなどでfederatedLogin状態を作り出し、ApiProxy#setEnvironmentForCurrentThred()すれば良いですね。

2010年7月23日金曜日

PubSubHubBubを経由してBuzzのFirehoseを #appengine で使ってみる

ちょっと前に BuzzでFirehoseが使えるようになりました。

今の空うさぎでは、FriendFeedはリアルタイム(FriendsがPostした瞬間にクライアントに通知される)になっていますし、TwitterもUserStreamを使うことでFirendFeed同様にリアルタイムになっています(Twitter側の制限のために、リリース版ではTwitterのリアルタイムはオフになってますが)。さらに開発版ではGoogle Buzzも利用可能になっています。であればBuzzもリアルタイムに…!と思うんですが、これがTwitterやFriendFeedとはちょっと仕組みが違う。Webアプリでしか使えないようです。なので、AppEngineで受けてXMPP経由で空うさぎに送ってしまえないか?という事でPubSubHubBub を使ってAppEngine/JavaでBuzzのFirehoseを使ってみようかと、そんな流れです。
ここではまずはリアルタイムに取得されるBuzzのActivityをデータストアにどんどん保存するアプリを作ってみます。

まずAppEngine/Javaのプロジェクトを作る

slim3のarchetype-pluginを使って簡単にプロジェクトを構築します。

  1. $ mvn archetype:generate -DarchetypeCatalog=http://slim3.googlecode.com/svn/trunk/repository
  2. Projectの生成が対話形式で進むので、質問に従ってプロジェクト名などを入力
  3. $ cd ${プロジェクト名}
  4. $ mvn eclipse:eclipse
  5. Eclipseでインポートします。

BuzzのActivityを保存するモデルクラスを作成

今回はお試し程度なので、Atomの解析はせずにそのまま格納するようにします。

PubSubHubBub用のコントローラを作成

http://myapp.appspot.com/subscriberというURLでPubSubHubBubをSubscribeすると想定して、それをハンドルするようにします。ここではSlim3のControllerを使います。

PubSubHubBubでSubscriberの登録をする

  1. 先の手順で作成したアプリケーションをデプロイします。
  2. http://pubsubhubbub.appspot.com/subscribeを開きます。
  3. Callback: (the subscriber URL)に、先に作成したSubscriberのURLを入力します。例えばhttp://myapp.appspot.com/subscriberのようなURLになるはずです。
  4. Topic: (the feed URL)に、https://www.googleapis.com/buzz/v1/activities/@all/@publicを入力します。
  5. Do itをクリックします。

登録処理が問題なくAppEngineの管理コンソールのINFOレベルのログを見ると、subscribe: hub.challenge=...のようなログが出力されるはずです。

どんどん流れてきます

AppEngineの管理コンソールでall requestログを見るとどんどんリクエストが飛んできている事が確認できます。DatastoreViewerの"buzz"カインドにはどんどんエンティティが増えていきます。こんなカンジでfirehoseが使えます。が、恐ろしい勢いでデータがたまっていくのでちょっと怖いですね。

PubSubHubBubからSubscriberの登録を解除する

登録した時の手順と同様に、Callback: (the subscriber URL)Topic: (the feed URL)を入力して、Mode:Unsubscribeを設定して、Do itをクリックします。問題なくUnsubscribeができればAppEngineの管理コンソールのINFOレベルのログを見ると、unsubscribe: hub.challenge=...のようなログが出力されるはずです。

2010年3月6日土曜日

#appengine javaでcapability serviceを利用する

capabilityサービスを使用すること自体は、今回作ったモジュール(ProtocolBufferの定義それを使用するサンプル)をそのままコピーするなどして使えば簡単にできます。このエントリは、普段JavaでAppEngineを使う人も、Pythonコードを読むと色々役に立つよ、というような内容になってますので、そのあたりに興味が無い人は読んでもあんまり面白くないと思います。Java, Pythonの関係だとその逆も言えるんですけどね。AppEngineは今のところJavaとPythonがありますが、どちらかだけじゃなくて両方触っておくのが一番です。AppEngineJavaを深く触っている人にはSDKのpythonコードもよく参照している人が多いです。

本題

App Engineのサービスがメンテナンスモードの時に、例えばデータストアが読み込み専用になったりします。この時、データストアに対して書き込み操作を行うとCapabilityDisabledExceptionという例外が発生します。これについて、python版ではCapabilitySet(サービス名).is_enabled()のような方法で特定のサービスの稼働状況を調査することができるという事がNick Johnson さんのブログでHandling downtime: The capabilities API and testingというタイトルで紹介されています。失敗させて判断するよりはこういった方法で確認できた方がキレイですね。しかし、JavaにはこのAPIが存在しません。これは残念。しかしPython版のSDKのソースを見ると、capability_serviceというリモートサービスのIsEnabledという機能を呼び出しているだけだという事がわかりますので、これであればJavaであれPythonであれ、言語に関係なくこの機能を使用できるという事がわかります。必要な事はリクエストするバイト配列とレスポンスを受けるバイト配列それぞれについて、どのようにオブジェクトを組み立てれば良いのか?という事だけです。これさえできればJavaからも使用出来そうです。

AppEngineではリモートサービスとのデータのやり取りは全てProtocolBufferでシリアライズされたバイナリを使って行われるので、今回の「capabilityサービスをJavaからも使う」という目的に必要な作業は以下のようになります。ProtocolBufferについては、公式ドキュメントが一番参考になります。

  1. python版のsdkのソースコードから、リクエスト・レスポンスそれぞれのデータ構造を推測する
  2. リクエスト・レスポンスそれぞれのProtocolBuffer定義ファイルを作成する
  3. protocで定義ファイルをコンパイルし、Javaで使用できるクラスを生成する
  4. capabilityサービスを使ってみる!

データ構造を推測する

pythonコードを参考にすると、IsEnabledRequest, IsEnabledResponseというオブジェクトを使う事がわかり、IsEnabledResponseで使用されているCapabilityConfigというオブジェクトも必要そうです。

protocolbufferの定義ファイルを作成する

上記のpythonコードから推測した定義は以下のようなカンジです。完全に正しいかどうかはわかりませんので、もっと厳密に作りたい人はもっと正確な定義を書いてしまえばイイと思います。

message CapabilityConfig {
  enum Status{
    ENABLED = 1;
    SCHEDULED = 2;
    DISABLED = 3;
    UNKNOWN = 4;
  }
  optional string package = 1;
  optional string capability = 2;
  optional Status status = 3;
  optional string internal_message = 4;
  optional string admin_message = 5;
  optional string error_message = 6;
  optional string scheduled_time = 7;
}

message IsEnabledRequest {
  required string pacakge = 1;
  repeated string capability = 2;
  repeated string call = 3;
}

message IsEnabledResponse {
  enum SummaryStatus {
    ENABLED = 1;
    SCHEDULED_FUTURE = 2;
    SCHEDULED_NOW = 3;
    DISABLED = 4;
    UNKNOWN = 5;
  }
  required SummaryStatus summary_status = 1;
  optional int64 time_until_scheduled = 2;
  repeated CapabilityConfig config = 3;
}

Javaのクラスを生成し、使ってみる

protocコマンドでJavaソースを生成して取り込んだ後、私の場合はローカル環境から直接Production環境に接続する方法で試してみました。ソースの該当箇所へのリンクをふたつほど置いておきます。

実行結果は標準出力に出力しましたが、以下のようになりました。

datastore_v3
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "datastore_v3"
  capability: "*"
  status: ENABLED
}
config {
  package: "datastore_v3"
  capability: "write"
  status: ENABLED
}
--end dump of protolbuffer--
memcache
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "memcache"
  capability: "*"
  status: ENABLED
}
--end dump of protolbuffer--
taskqueue
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "taskqueue"
  capability: "*"
  status: ENABLED
}
--end dump of protolbuffer--
user
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "user"
  capability: "*"
  status: ENABLED
}
--end dump of protolbuffer--
blobstore
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "blobstore"
  capability: "*"
  status: ENABLED
}
--end dump of protolbuffer--
mail
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "mail"
  capability: "*"
  status: ENABLED
}
--end dump of protolbuffer--
urlfetch
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "urlfetch"
  capability: "*"
  status: ENABLED
}
--end dump of protolbuffer--
xmpp
--start dump of protolbuffer--
summary_status: ENABLED
config {
  package: "xmpp"
  capability: "*"
  status: ENABLED
}
--end dump of protolbuffer--

使ってみた感想

今試した段階では常にENABLEDが帰って来てるので試せませんが、capabilityサービスから返された状態が「SCHEDULED_FUTURE」「SCHEDULED_NOW」の時の「time_until_scheduled」の値などが気になります。
またcapabilityサービスの実行はおよそ15ms-25ms程度のようです。軽いので、フレームワーク側に組み込んでも良さそうですね。

ついでに書いておく

ついでにAppEngineではPython/Javaどっちかいいんだろう?について。これは色々意見があるし自分もよく聞かれるけど、まだブログでそれについて書いたことが無いのでそろそろ書いておこうかと。
自分の結論としては、どっちでもいい、どっちでも触れるならそんなに違いはない、て事ですね。言語自体に好きなものがあるならそっちを使うのがやっぱり楽しい、と思いますし。
ちなみに、比較対象を深く触った・試した事も無いのに「こっちがイイ、あっちはダメ!」とか言う人がよくいるけど、そういうのは恥ずかしい人だなーと感じる。そういう人はそうやって「イイ」と勧める対象をむしろ貶めているかもしれません。せめて、「こっちはイイけど、あっちは知らないからわからない」ならわかるんだけど、深く触ってもいない試してもいない事に対して「ダメ」といっちゃうのは、典型的な「老害」の行動ですね。年とってからそうなるorもうその立場になってしまってる人なんでしょうね。
また、どんなユーザを対象にしたどんなアプリケーションをどれくらいの規模でどういったメンバで作りたいのか?などの具体的な背景がないとどっちとも言えないのですが、それも考えずに断言しちゃう人もどーにかしてるんでしょかね。

2010年2月15日月曜日

#appengine コードラボを開催します

ちょっと前のエントリでSeasarファンデーションよりJava Cloud Meeting Fukuoka 2010が開催されますというのを書きましたが、その翌日に、福岡のヌーラボさんのオフィスをお借りしてGoogle App Engine Javaのコードラボを開催させていただくことになりました。

私が説明した内容を参加者の方が実際にコードを書いて試す、といった形式で進めていく形式の入門コースとなっています。

上記リンク先がコードラボの申し込みページになっています。また、Java Cloud Meeting Fukuoka 2010合わせて参加申し込み(学割もあります)していただくと、このイベントで概要を学び、その翌日コードラーボで実践できる、と充実した週末を過ごしていただけるんじゃないかなーと思います!

2010年1月22日金曜日

非同期でクエリを実行してORを実現する #appengine

SDK1.2.8-prereleaseの頃にmakeAsyncCall()の導入に気づいて思いついた、クエリを複数に分けて非同期実行して結果をマージすることで、条件をOR結合するのと同じ事ができる!非同期ならそこそこ速いんじゃね?というアイデアの検証がようやく完了しました。

テストデータと仕様

1-1000までのIDを持つキーを作成し、そのキーのIDの数値が2で割り切れるならmod2という属性の値がtrue、割り切れないならfalseというカンジでmod3,mod5とか用意した。で、クエリは「2または3または5で割り切れる」というものを抽出するという簡単なもの。条件的にはmod2 EQUAL true OR mod3 EQUAL true OR mod5 EQUAL trueとなり、分けるとmod2 EQUAL truemod3 EQUAL truemod5 EQUAL trueの3本を走らせて結果をマージするというもの。うん、でも本当はそんな細かいとこはどうでも良くて、重要なのはクエリを分割実行してマージする、しかもクエリは非同期で一気にみっつ実行するという点です。

ソースコード

まずは普通にクエリをみっつ走らせてマージする方法。普通なカンジです。

void sync2or3or5(PrintWriter w) {
  EntityQuery q2 =
      Datastore.query(KIND).filter("mod2", FilterOperator.EQUAL, true).offset(0).limit(
          1000).prefetchSize(1000);
  EntityQuery q3 =
      Datastore.query(KIND).filter("mod3", FilterOperator.EQUAL, true).offset(0).limit(
          1000).prefetchSize(1000);
  EntityQuery q5 =
      Datastore.query(KIND).filter("mod5", FilterOperator.EQUAL, true).offset(0).limit(
          1000).prefetchSize(1000);

  long start = System.currentTimeMillis();
  List<Entity> r2 = q2.asList();
  List<Entity> r3 = q3.asList();
  List<Entity> r5 = q5.asList();
  List<Entity> merged = merge(Arrays.asList(r2, r3, r5));
  w.println("count=" + merged.size() + ", " + (System.currentTimeMillis() - start) + "[ms]");
}

List<Entity> merge(List<List<Entity>> lists) {
  Map<Key, Entity> map = new HashMap<Key, Entity>();
  for (List<Entity> list : lists) {
    for (Entity entity : list) {
      if (map.containsKey(entity.getKey()) == false) {
        map.put(entity.getKey(), entity);
      }
    }
  }
  return new ArrayList<Entity>(map.values());
}

次に、クエリを非同期に実行する方法。makeAsyncCall()を使っています。また、QueryDatastorePb.QueryというProtocolBufferオブジェクトに変換するのにPbUtilというクラスを使ってますが、それについては後述。

void async2or3or5a(PrintWriter w) throws InterruptedException, ExecutionException {
  Query q2 = new Query(KIND).addFilter("mod2", FilterOperator.EQUAL, true);
  Query q3 = new Query(KIND).addFilter("mod3", FilterOperator.EQUAL, true);
  Query q5 = new Query(KIND).addFilter("mod5", FilterOperator.EQUAL, true);
  FetchOptions fetchOptions = FetchOptions.Builder.withOffset(0).limit(1000).prefetchSize(1000);

  long start = System.currentTimeMillis();

  DatastorePb.Query qPB2 = PbUtil.toQueryRequestPb(q2, fetchOptions);
  DatastorePb.Query qPB3 = PbUtil.toQueryRequestPb(q3, fetchOptions);
  DatastorePb.Query qPB5 = PbUtil.toQueryRequestPb(q5, fetchOptions);

  Delegate<Environment> delegate = ApiProxy.getDelegate();
  Environment env = ApiProxy.getCurrentEnvironment();
  ApiConfig config = new ApiProxy.ApiConfig();
  config.setDeadlineInSeconds(5.0);
  Future<byte[]>[] futures = new Future[3];

  futures[0] = delegate.makeAsyncCall(env, "datastore_v3", "RunQuery", qPB2.toByteArray(), config);
  futures[1] = delegate.makeAsyncCall(env, "datastore_v3", "RunQuery", qPB3.toByteArray(), config);
  futures[2] = delegate.makeAsyncCall(env, "datastore_v3", "RunQuery", qPB5.toByteArray(), config);

  List<List<Entity>> lists = new ArrayList<List<Entity>>();
  for (int i = 0; i < futures.length; i++) {
    byte[] bytes = futures[i].get();
    DatastorePb.QueryResult rPb = new DatastorePb.QueryResult();
    rPb.mergeFrom(bytes);
    Iterator<EntityProto> it = rPb.resultIterator();
    List<Entity> entities = new ArrayList<Entity>();
    while (it.hasNext()) {
      entities.add(EntityTranslator.createFromPb(it.next()));
    }
    lists.add(entities);
  }
  w.println("count=" + merge(lists).size() + ", " + (System.currentTimeMillis() - start) + "[ms]");
}

実行結果

それぞれ22回ずつ実行して、一番大きい値と一番小さい値を省いた結果は以下のようになりました。

通常実行非同期実行
623294
710229
596286
760275
539217
521294
572297
1054287
569251
534305
830233
494285
442379
731388
731218
510268
467199
547593
483225

ぱっと見ただけで、非同期実行の方がかなり速いことがわかります。結果件数はどちらも734件で、消費した時間の平均は通常実行が622.1ms、非同期実行が296.6msとなりました。倍くらいの差が出ています。非同期なんだから当然、と思われるかもしれませんが、色々と苦労したのでこの結果が出てくれて嬉しいのですw

疑惑のPbUtil

コレについては何も突っ込まないでください、自分もあまり気持ちよく無いのです。…まぁ、クエリの非同期実行を優先したと言うことで…。

package com.google.appengine.api.datastore;

import com.google.apphosting.api.DatastorePb;

public class PbUtil {
  public static DatastorePb.Query toQueryRequestPb(Query q, FetchOptions fetchOptions) {
    return QueryTranslator.convertToPb(q, fetchOptions);
  }
}

追記:おまけ

上記のPbUtilが存在している前提だけど、ユーティリティ化してみた

2010年1月19日火曜日

Kindless Ancestor Queryをローカル環境で使用する #appengine

AppEngineで実装されているQueryで、Kind指定なしで親キーのみ指定して、親キーに属するEntityGroupをKindをまたいでゴッソリ持ってくるという便利なクエリがあるのですが、開発環境で動かないという問題を抱えています。開発環境で動いてくれないって事はデプロイ前にテストできないって事で、結局プロダクトコードに含めることもできずまだ使えないカンジで勿体無いです。
makeSyncCallという仕組みで開発環境とProduction環境のDatastoreを直結するとテストできない事も無いですが、無理矢理過ぎるのでそれは考慮してません

ApiProxy.Delegateの出番

このブログを読んでくれている方はもう聞き飽きておられる事でしょうから詳しく説明しませんが、アレです、フックします。んでProtocolBufferをゴリゴリと触ります。

今回は、以下のようにいじってやる作戦です。もちろん、結局は環境を弄っているワケなので、必ずProduction環境と同じというワケでもないですし、多少の違いが出てしまうのは確かですが、それを言い始めると開発環境はProduction環境とはそもそも違うという割り切りはできて触っているワケで、今回のこのエントリの内容に関してもその割り切りができればそこそこ便利かもしれません。

  1. フックして、datastore_v3#RunQueryと関係がないものは保存しておいたApiProxy#getDelegate()に処理を委譲する。
  2. サービス側へリクエストされるbyte配列を奪って、ProtocolBufferオブジェクトとして組立て直す。それにより、AncestorKeyが指定されているか?Kindが指定されていないか?というチェックを行うことができる。これらの条件を満たさない場合はkindless ancestor queryでは無いので、これまた保存しておいたApiProxy#getDelegate()に処理を委譲する。
  3. ここまですり抜けて来た処理は、kindless ancestor queryであるとわかるので、違うクエリに組み立てる。

違うクエリと書いていますが、実際に何をするかと言うとKind指定アリのAncestorクエリをKindの数だけ実行するという処理を行います。毎回クエリ結果のProtocolBufferオブジェクトを組み立てなおして、最後に全てのクエリの結果をマージして返します。かなり昔に書いた、開発環境でのみ実行可能なgetSchemaというデータストア内の全てのKindを取得できる機能を利用しています。

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.Future;

import com.google.appengine.api.datastore.dev.LocalDatastoreService;
import com.google.appengine.tools.development.ApiProxyLocalImpl;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.DatastorePb;
import com.google.apphosting.api.ApiProxy.ApiConfig;
import com.google.apphosting.api.ApiProxy.ApiProxyException;
import com.google.apphosting.api.ApiProxy.Environment;
import com.google.apphosting.api.ApiProxy.LogRecord;
import com.google.apphosting.api.DatastorePb.GetSchemaRequest;
import com.google.apphosting.api.DatastorePb.QueryResult;
import com.google.apphosting.api.DatastorePb.Schema;
import com.google.storage.onestore.v3.OnestoreEntity.EntityProto;
import com.google.storage.onestore.v3.OnestoreEntity.Reference;
import com.google.storage.onestore.v3.OnestoreEntity.Path.Element;

/**
 * kindless ancestor queryをローカルでも動作させる{@link ApiProxy.Delegate}の実装.
 * @author shin1ogawa
 */
public class EnableAncestorQueryDelegate implements ApiProxy.Delegate<Environment> {

  @SuppressWarnings("unchecked")
  ApiProxy.Delegate<Environment> before = ApiProxy.getDelegate();

  public byte[] makeSyncCall(Environment env, String service, String method, byte[] request)
      throws ApiProxyException {
    if (service.equals("datastore_v3") == false || method.equals("RunQuery") == false) {
      return before.makeSyncCall(env, service, method, request);
    }
    // kindless ancestor queryかどうかをチェックするために、pbを組立て直す。
    DatastorePb.Query requestPb = new DatastorePb.Query();
    requestPb.mergeFrom(request);
    if (requestPb.getAncestor() == null || requestPb.getKind().isEmpty() == false) {
      return before.makeSyncCall(env, service, method, request);
    }
    
    // kindless ancestor queryの時は全てのKindに対してancestor queryを実行する。
    String[] kinds = getKinds();
    // ancestor keyのpbからancestorのkindを取得し、最初はancestor kindから実行する。
    Reference ancestor = requestPb.getAncestor();
    String ancestorKind = ancestor.getPath().getElement(0).getType();
    QueryResult resultOfAncestor =
        runAncestorQuery(env, service, method, ancestorKind, requestPb);
    resultOfAncestor.setMoreResults(true);
    for (String kind : kinds) {
      if (kind.equals(ancestorKind) == false) {
        // ancestor kind以外の全てのkindに対してancestorクエリを実行する
        QueryResult result = runAncestorQuery(env, service, method, kind, requestPb);
        Iterator<EntityProto> i = result.resultIterator();
        while (i.hasNext()) {
          EntityProto next = i.next();
          resultOfAncestor.addResult(next);
        }
      }
    }
    return resultOfAncestor.toByteArray();
  }

  DatastorePb.QueryResult runAncestorQuery(Environment env, String service, String method,
      String kind, DatastorePb.Query requestPb) {
    requestPb.setKind(kind);
    byte[] requestbytes = requestPb.toByteArray();
    DatastorePb.QueryResult result = new DatastorePb.QueryResult();
    result.mergeFrom(before.makeSyncCall(env, service, method, requestbytes));
    return result;
  }

  String[] getKinds() {
    LocalDatastoreService datastoreService =
        (LocalDatastoreService) ((ApiProxyLocalImpl) before).getService("datastore_v3");
    Schema schema =
        datastoreService.getSchema(null, new GetSchemaRequest().setApp(ApiProxy
          .getCurrentEnvironment().getAppId()));
    List<EntityProto> entityProtoList = schema.kinds();
    List<String> kindList = new ArrayList<String>(entityProtoList.size());
    for (EntityProto entityProto : entityProtoList) {
      List<?> path = entityProto.getKey().getPath().elements();
      Element element = (Element) path.get(path.size() - 1);
      kindList.add(element.getType());
    }
    return kindList.toArray(new String[0]);
  }

  public void log(Environment env, LogRecord logRecord) {
    before.log(env, logRecord);
  }

  public Future<byte[]> makeAsyncCall(Environment env, String service, String method,
      byte[] request, ApiConfig config) {
    return before.makeAsyncCall(env, service, method, request, config);
  }
}

やっつけで書いたので、getSchemaを実行するためにLocalDatastoreServiceが必要になり、ApiProxy#getDelegate()が必ずApiProxyLocalImplじゃないとダメ(つまり、他のDelegateが設定されているとダメ)という問題がありますが、そのあたりは外部からLocalDatastoreServiceを渡すなどの工夫をすればもっと便利になりますね。なかなか便利そうな気がします。

2010年1月9日土曜日

#AppEngine 用のアプリケーションの自動テストについて(5) - URLFetchに関するテスト

下記のAppEngineアプリケーションの自動テストシリーズに続く、第五回目です。

URLFetchサービスのテスト?シミュレート?

URLFetchのテストと言っても、目的は2種類分かれると思います。 ひとつは「想定通りのリクエストが組み立てられているか」という事です。 こちらはこれまで説明してきた通りの 「サービスをフックしてサービスへリクエストとして送信されるバイト配列をJavaオブジェクトとして組立て直す」という方法でテストができます。

もうひとつの目的は「実際のホストへ通信せずにローカルのテストデータをレスポンスとして使用する」という事です。 自動テスト環境が常に外部に接続された状態とも限りませんし、外部のサービスの影響で自動テストが失敗するといった事態も避けるためには必要な仕組みです。
この仕組を実現するにはこれまでに説明していきたサービスへの通信部分をフックする仕組みを使うことには変わりませんが、それに加えて ApiProxyLocalの実装に処理を委譲するのではなくサービスからのレスポンスも自前で組み立てる という仕組みが必要になります。

java.net.URLを使用したURLFetchを行っている場合の問題

さらに、URLFetchサービスのシミュレートにはもうひとつ問題があります。
例えばDatastoreサービスへのインターフェースとして「JDO/JPA」「低レベルAPI」の複数種類が提供されているのと同様に、 URLFetchサービスへのインターフェースとしては「低レベルAPI」「java.net.URL」のふたつが用意されています。 Datastoreサービスの「JDO/JPA」についてはそれらのAPIの裏側では結局低レベルAPIが使用されているのですが、 自分で起動したAppEngine環境では、URLFetchサービスのjava.net.URLを使った場合に低レベルAPIが使用されません。
つまり、これまで説明してきた「ApiProxy.Delegateを作成し、それをApiProxy#setDelegate()で設定して各種サービスの実行をフックする」 といった手法が簡単には使えないのです。低レベルAPIが使用された時のみApiProxy.Delegate#makeSyncCall()を通るのです。

絶望したっ…となりそうですが、実はプロダクション環境ではjava.net.URLを使った場合でも低レベルAPIが使用される という動作をします。試さなくても「URLFetchの動作に制限がある」というAppEngineの仕様からして、 「プロダクション環境では各種サービスのノード群経由で通信している=ApiProxy.Delegate#makeSyncCall()を通る」はず、という事がわかっているのです。
この振る舞いの差はURL#openConnection()で生成され、実際に通信処理を行うjava.net.URLConnectionの実装クラスの違いにあります。 通常はURL#openConnection()するとsun.net.www.protocol.http.HttpURLConnectionが取得されますが、 AppEngineのプロダクション環境や開発環境で提供されているWebコンテナから起動した環境ではcom.google.apphosting.utils.security.urlfetch.URLFetchServiceStreamHandler$Connection が取得されます。つまりURL#openConnection()した時に生成されるjava.net.URLConnectionをプロダクション環境と同じになるように設定すれば良いのです。 これは、java.net.URL#setURLStreamHandlerFactory()でファクトリを設定することで可能になりますが、 AppEngineのSDK内でもこの設定を行っている箇所があるのでそれを利用した方がラクです。
com.google.appengine.tools.development.StreamHandlerFactoryクラスにinstall()というメソッドがあり、 これを実行すれば目的どおりプロダクション環境と同じ振る舞いをするようになります。例えば以下のコードを実行してみるとこれらの事がよく見えます。

System.out.println(new URL("http://localhost/").openConnection().getClass());
com.google.appengine.tools.development.StreamHandlerFactory.install();
System.out.println(new URL("http://localhost/").openConnection().getClass());

この処理の実行結果は以下のようになります。 一度StreamHandlerFactory.install()を実行するとそれ移行のURL#openConnection() は常にsun.net.www.protocol.http.HttpURLConnectionを返すようになります。

class sun.net.www.protocol.http.HttpURLConnection class com.google.apphosting.utils.security.urlfetch.URLFetchServiceStreamHandler$Connection

URLFetchのリクエスをテストし、レスポンスをカスタマイズするApiProxy.Delegateの実装

今回も前回までの説明と同じように、サービスへのリクエストをフックする ApiProxy.Delegateを作成してApiProxy#setDelegate()でそれを適用する手法でテストします。 前回まではサービスへリクエストされるバイト配列を組み立ててそれをリストに保持してから本来のサービスを実行していましたが、 今回は実際のサービスを実行しないため、ApiProxy#setDelegate()にハンドラとなるクラスをコンストラクタで受け取って、 それをコールバックする事でハンドラにレスポンスの組み立てをさせます。ハンドラ側では、リクエストの評価とレスポンスの組み立てを行うことになります。

import java.io.IOException;
import java.util.concurrent.Future;
import com.google.appengine.api.urlfetch.URLFetchServicePb.*;
import com.google.appengine.repackaged.com.google.protobuf.ByteString;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

public class URLFetchDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();

  public interface Handler {
    void handle(URLFetchRequest request) throws IOException;
    byte[] getContent(URLFetchRequest request) throws IOException;
    int getStatusCode(URLFetchRequest request) throws IOException;
  }

  final Handler handler;

  public URLFetchDelegate(Handler handler) {
    this.handler = handler;
  }

  public byte[] makeSyncCall(Environment env, String service, String method,
      byte[] request) throws ApiProxyException {
    if (service.equals("urlfetch") && method.equals("Fetch")) {
      try {
        URLFetchRequest requestPb = URLFetchRequest.parseFrom(request);
        handler.handle(requestPb);
        return URLFetchResponse.newBuilder()
          .setContent(ByteString.copyFrom(handler.getContent(requestPb)))
          .setStatusCode(handler.getStatusCode(requestPb))
          .build().toByteArray();
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    } else {
      return apiProxyLocal.makeSyncCall(env, service, method, request);
    }
  }

  @Override public Future<byte[]> makeAsyncCall(Environment env, String service,
      String method, byte[] request, ApiConfig config) {
    return apiProxyLocal.makeAsyncCall(env, service, method, request, config);
  }

  @Override public void log(Environment env, LogRecord logRecord) {
    apiProxyLocal.log(env, logRecord);
  }
}

ハンドラのインターフェースには、リクエストを評価するための「handle()」、 レスポンスのbody部分を組み立てる「byte[] getContent()」、レスポンスコードを決定する「int getStatusCode()」を定義しました。

Delegateからコールバックされるハンドラの例

先のURLFetchDelegate.Handlerインターフェースの実装となります。

public void handle(URLFetchRequest request) throws IOException {
  assertEquals(REQUEST_URL, request.getUrl());
  assertEquals(RequestMethod.POST, request.getMethod());
  Map<String, Header> headerMap = getHeaderMapFromRequests(request);
  assertEquals(1, headerMap.size());
  assertEquals("HeaderValue", headerMap.get("X-Header").getValue());
  String payload = URLDecoder.decode(request.getPayload().toString("utf-8"), "utf-8");
  assertEquals("param=自動テスト", payload);
}

public byte[] getContent(URLFetchRequest request) throws IOException {
  return IOUtils.toByteArray(new FileReader("testdata/urlfetch/response1.txt"), "utf-8");
}

public int getStatusCode(URLFetchRequest request) throws IOException {
  return 200;
}

後で例として紹介するテストケースで、低レベルAPIとjava.net.URLを使ってそれぞれ全く同じリクエストをテストする例を上げているため、 上記の例では一種類のリクエストを評価・レスポンスする実装になっています。実際にはもっと複雑で、 リクエストのURLやパラメータを判断してgetContent()getStatusCode()で返す値を変えたりすることになると思います。
上記の例ではレスポンスする内容に"testdata/urlfetch/response1.txt"という外部のファイルを使用しています。 ファイルにそのまま記述しておくことでテストデータの管理もしやすいと思います。またファイルやバイト配列の処理をラクにするためにcommons-ioを使用しています。

テストケースの例

このApiProxy.Delegateの実装を使ったテストケースの例は以下のようになります。 先のエントリで説明したとおりにUnitTestEnvironment#getAttribute()メソッドの修正が行われていることが前提です。

また、ファイルのコピーを簡単にするために、commons-ioを利用しています。

import java.io.*;
import java.net.*;
import java.util.*;
import java.util.Map.Entry;
import org.apache.commons.io.IOUtils;
import org.junit.*;
import com.google.appengine.api.urlfetch.*;
import com.google.appengine.api.urlfetch.URLFetchServicePb.URLFetchRequest;
import com.google.appengine.api.urlfetch.URLFetchServicePb.URLFetchRequest.*;
import com.google.appengine.repackaged.com.google.protobuf.Descriptors.FieldDescriptor;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

import static org.junit.Assert.assertEquals;

public class URLFetchTest {

  static final String REQUEST_URL = "http://uso800.co.jp/detarame";

  URLFetchDelegate delegate = new URLFetchDelegate(new URLFetchDelegate.Handler() {
    ...省略...
  });

  @Test public void lowLevel() throws MalformedURLException, IOException {
    ApiProxy.setDelegate(delegate);

    URLFetchService service = URLFetchServiceFactory.getURLFetchService();
    HTTPRequest httpRequest = new HTTPRequest(new URL(REQUEST_URL), HTTPMethod.POST);
    httpRequest.addHeader(new HTTPHeader("X-Header", "HeaderValue"));
    String queryString = "param=" + URLEncoder.encode("自動テスト", "utf-8");
    httpRequest.setPayload(queryString.getBytes("utf-8"));
    HTTPResponse response = service.fetch(httpRequest);
    assertEquals(200, response.getResponseCode());
    assertEquals("hogehoge", new String(response.getContent()));
  }

  @Test public void standardAPI() throws MalformedURLException, IOException {
    ApiProxy.setDelegate(delegate);

    URL url = new URL(REQUEST_URL);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setRequestMethod("POST");
    conn.setRequestProperty("X-Header", "HeaderValue");
    conn.setDoOutput(true);
    IOUtils.write("param=" + URLEncoder.encode("自動テスト", "utf-8"), conn.getOutputStream());
    IOUtils.closeQuietly(conn.getOutputStream());
    assertEquals(200, conn.getResponseCode());
    assertEquals("hogehoge", new String(IOUtils.toByteArray(conn.getInputStream())));
  }

  @Before public void setUp() throws IOException {
    setUpAppEngine(new File("bin/urlfetchtest"));
  }

  @After public void tearDown() {
    @SuppressWarnings("unchecked")
    Delegate<Environment> delegate = ApiProxy.getDelegate();
    if (delegate instanceof URLFetchDelegate) {
      ApiProxy.setDelegate(((URLFetchDelegate) delegate).apiProxyLocal);
    }
    tearDownAppEngine();
  }

  @BeforeClass public static void setUpBeforeClass() {
    com.google.appengine.tools.development.StreamHandlerFactory.install();
  }

  static Map<String,Header> getHeaderMapFromRequests(URLFetchRequest request) {
    Map<String,Header> headerMap = new HashMap<String, Header>();
    Iterator<Entry<FieldDescriptor, Object>> i = request.getAllFields().entrySet().iterator();
    while (i.hasNext()) {
      Entry<FieldDescriptor, Object> next = i.next();
      FieldDescriptor key = next.getKey();
      if (key.getFullName().equals("apphosting.URLFetchRequest.header")) {
        @SuppressWarnings("unchecked")
        Collection<Header> headers = (Collection<Header>) next.getValue();
        for (Header header: headers) {
          headerMap.put(header.getKey(), header);
        }
      }
    }
    return headerMap;
  }

  static void setUpAppEngine(File testFolder) {
    com.google.appengine.tools.development.StreamHandlerFactory.install();
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(testFolder) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

  static void tearDownAppEngine() {
    ApiProxyLocal apiProxyLocal = (ApiProxyLocal) ApiProxy.getDelegate();
    if (apiProxyLocal != null) {
      apiProxyLocal.stop();
    }
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

「省略」となっている箇所は、先に説明したURLFetchDelegate.Handlerインターフェースの実装となります。
ここでは説明をわかりやすくするためにテストメソッド内でURLFetch処理を行っていますが、 実際はテスト対象であるサービス層の機能が、内部的にURLFetchサービスの実行を行う事が多いんじゃないかと思います。

追記

公開した直後は、テストケースの例のsetUp()メソッド内にHTTPURLConnectionの実装クラスを調べるためのコードが残っていました。が、これは必要ないので削除しました。ついでに com.google.appengine.tools.development.StreamHandlerFactory.install(); は一回だけ実行すれば良いのでsetUp()からsetUpBeforeClass()に移動しました。@bufferingsさんが指摘してくれました、Thx!

2010年1月6日水曜日

#AppEngine 用のアプリケーションの自動テストについて(4) - TaskQueueに関するテスト

下記のAppEngineアプリケーションの自動テストシリーズに続く、第四回目です。

Taskの「投入」をテストする

TaskQueueのテストといっても「Taskの投入」をテストするのか「Taskの実行」をテストするのか、といった2種類のテスト対象が考えられます。 今回は「Taskの投入」をテストする説明をします。「WebHandlerによるTask実行」については、例えば私はテストしやすいように以下のような手法で実装しています。

  • 何らかの機能で、Taskを投入する
  • 投入されたTaskはWebHandler内で実行することになるが、WebHandler内にはほとんど処理を書かず、 int HogeTask.execute(Map<String, String> parameters)という、リクエストパラメータを渡してHttpStatusを返すようなメソッドに処理を委譲する

このようにしておく事で、「Taskの実行」は通常の処理と同じように簡単にテストできる事が多いです。

今回も前回と同じようにサービスへのリクエストをフックする ApiProxy.Delegateを作成し、ApiProxy#setDelegate()でそれを適用する手法でテストします。

Taskの投入をテストするためのApiProxy.Delegateの実装

例えばこのようなApiProxy.Delegateの実装を見てみましょう。 前回とほぼ同じ実装で、サービスへリクエストされたバイト配列を組み立てるオブジェクトが com.google.appengine.api.labs.taskqueue.TaskQueuePb.TaskQueueAddRequestに変わっている程度です。

import java.util.*;
import java.util.concurrent.Future;
import com.google.appengine.api.labs.taskqueue.TaskQueuePb.TaskQueueAddRequest;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

public class TaskQueueDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();
  
  public final List<TaskQueueAddRequest> tasks = new ArrayList<TaskQueueAddRequest>();
  
  public byte[] makeSyncCall(Environment env, String service, String method,
      byte[] request) throws ApiProxyException {
    if (service.equals("taskqueue") && method.equals("Add")) {
      try {
        TaskQueueAddRequest taskPb = new TaskQueueAddRequest();
        taskPb.mergeFrom(request);
        tasks.add(taskPb);
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }
    return apiProxyLocal.makeSyncCall(env, service, method, request);
  }

  @Override
  public Future<byte[]> makeAsyncCall(Environment env, String service,
      String method, byte[] request, ApiConfig config) {
    return apiProxyLocal.makeAsyncCall(env, service, method, request, config);
  }

  @Override
  public void log(Environment env, LogRecord logRecord) {
    apiProxyLocal.log(env, logRecord);
  }
}

前回と同様にサービスへリクエストするバイト配列から ProtocolBufferオブジェクトを組み立てて、テストケースから評価できるようにそれを保持しているだけです。

TaskQueueサービスを起動するための準備

以下の二つの手順が必要になります。

  1. ApiProxy#setCurrentEnvironment()に設定するApiProxy.Environmentで実装するメソッドに Map<String, String> ApiProxy.Environment#getAttribute()があるが、 これが返すMapオブジェクトは"com.google.appengine.server_url_key"というキーに何か値が設定されている必要がある。
  2. デフォルトのキュー以外のキューを使用するのであれば、war/WEB-INF/queue.xmlをテスト用のフォルダにコピーしておく必要がある。

最初の手順については、第一回目の説明で UnitTestEnvironmentというクラス名で説明していたApiProxy.EnvironmentMap<String, String> ApiProxy.Environment#getAttribute()メソッドの実装を修正する必要があります。

public java.util.Map<String, Object> getAttributes() {
  java.util.Map<String, Object> map = new java.util.HashMap<String, Object>();
  map.put("com.google.appengine.server_url_key", "dummy");
  return map;
}

テストケースの例

このApiProxy.Delegateの実装を使ったテストケースの例は以下のようになります。 デフォルトキューにTaskを追加する処理と、名前付きのキューにTaskを追加する処理のふたつをテストしています。 先に説明したとおりにUnitTestEnvironment#getAttribute()メソッドの修正が行われていることが前提です。

また、ファイルのコピーを簡単にするために、commons-ioを利用しています。

import java.io.*;
import java.net.URLEncoder;
import org.apache.commons.io.FileUtils;
import org.junit.*;
import com.google.appengine.api.labs.taskqueue.*;
import com.google.appengine.api.labs.taskqueue.TaskQueuePb.*;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;
import static org.junit.Assert.assertEquals;

public class TaskQueueTest {

  @Test public void addTaskToDefaultQueue() throws UnsupportedEncodingException {
    TaskQueueDelegate delegate = new TaskQueueDelegate();
    ApiProxy.setDelegate(delegate);
    
    Queue queue = QueueFactory.getDefaultQueue();
    queue.add(TaskOptions.Builder.url("/tqHandler").param("key", "あいうえお"));

    assertEquals(1, delegate.tasks.size());
    TaskQueueAddRequest task = delegate.tasks.get(0);
    assertEquals("/tqHandler", task.getUrl());
    assertEquals("key="+URLEncoder.encode("あいうえお", "utf-8"), task.getBody());
  }

  @Test public void addTaskToNamedQueue() throws UnsupportedEncodingException {
    TaskQueueDelegate delegate = new TaskQueueDelegate();
    ApiProxy.setDelegate(delegate);
    
    Queue queue = QueueFactory.getQueue("background-processing");
    queue.add(TaskOptions.Builder.url("/tqHandler").param("key", "あいうえお"));
    
    assertEquals(1, delegate.tasks.size());
    TaskQueueAddRequest task = delegate.tasks.get(0);
    assertEquals("/tqHandler", task.getUrl());
    assertEquals("key="+URLEncoder.encode("あいうえお", "utf-8"), task.getBody());
  }

  @Before public void setUp() throws IOException {
    File testFolder = new File("bin/tqtest");
    File queueXml = new File("war/WEB-INF/queue.xml");
    if (queueXml.exists()) {
      FileUtils.copyFile(queueXml, new File("bin/tqtest/WEB-INF/queue.xml"));
    }
    setUpAppEngine(testFolder);
  }

  @After public void tearDown() {
    @SuppressWarnings("unchecked")
    Delegate<Environment> delegate = ApiProxy.getDelegate();
    if (delegate instanceof TaskQueueDelegate) {
      ApiProxy.setDelegate(((TaskQueueDelegate) delegate).apiProxyLocal);
    }
    tearDownAppEngine();
  }

  static void setUpAppEngine(File testFolder) {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(testFolder) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

  static void tearDownAppEngine() {
    ApiProxyLocal apiProxyLocal = (ApiProxyLocal) ApiProxy.getDelegate();
    if (apiProxyLocal != null) {
      apiProxyLocal.stop();
    }
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

ここでは説明をわかりやすくするためにテストメソッド内でTask投入処理を行っていますが、 実際はテスト対象であるサービス層の機能が、内部的にTaskの投入やデータストアサービスを行う事が多いんじゃないかと思います。

2010年1月5日火曜日

#AppEngine 用のアプリケーションの自動テストについて(3) - メール送信に関するテスト

第一回目第二回目に続く、 テストシリーズ第三回です。

開発環境ではメールは送信されない

見出しに書いた通り、AppEngineの開発環境ではメール送信サービスを使ってメールを送信しても実際にはメールは送信されません。 メールが送信されたところで自動テストでそれを判断するのは面倒そうですが、 AppEngine環境のサービス実行の仕組みを利用する事でメール送信の成否を自動テストできるようにする方法を説明します。

前回のDatastoreのExceptionをシミュレートする仕組みと同様に、 ApiProxy#setDelegate()を使ってサービスの実行をフックする方法を使います。 前回と比べるとフックを行う対象となるサービスとメソッドがデータストアサービスからメール送信サービスになるという当たり前の違いがあるのですが、 それ以上に違う点として「前回はサービスとそのメソッド名を判断するだけ」だったのに加えて「サービスへ送信されるリクエストを解析する」という処理が加わります。

今回は「メールが送信された」という事の確認に加えて「どんな内容で送信されたか」も確認しようと思いますので、 サービス側へ送られるリクエストを、バイト配列からJavaで扱えるオブジェクトに変換して、それをJUnitでassertします。 第二回すべてのサービスの実行時に経由するbyte[] makeSyncCall(Environment env, String service, String method, byte[] request) というメソッドの第三引数byte[] request実行しようとしているサービスのメソッドへ渡す引数をProtocolBufferでシリアライズしたバイト配列 だという事を説明しました。 第二回で作成したApiProxy.Delegateではこのパラメータには触れませんでしたが、 今回はこれを利用することになります。

例えばこのようなApiProxy.Delegateの実装を見てみましょう。

import java.util.*;
import java.util.concurrent.Future;
import com.google.appengine.api.mail.MailServicePb.MailMessage;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

public class SendMailDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();
  
  public final List<MailMessage> messages = new ArrayList<MailMessage>();
  
  public byte[] makeSyncCall(Environment env, String service, String method,
      byte[] request) throws ApiProxyException {
    if (service.equals("mail") && method.startsWith("Send")) { // Send[ToAdmins]
      try {
        MailMessage messagePb = new MailMessage();
        messagePb.mergeFrom(request);
        messages.add(messagePb);
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }
    return apiProxyLocal.makeSyncCall(env, service, method, request);
  }

  @Override
  public Future<byte[]> makeAsyncCall(Environment env, String service,
      String method, byte[] request, ApiConfig config) {
    return apiProxyLocal.makeAsyncCall(env, service, method, request, config);
  }

  @Override
  public void log(Environment env, LogRecord logRecord) {
    apiProxyLocal.log(env, logRecord);
  }
}

今回もmakeAsyncCall()メソッドとlog()メソッドは気にする必要はありません。 インスタンス変数で既存のApiProxy#getDelegate()で取得できるApiProxy.Delegateを保持している点もこれまでと同様です。

一番重要なmakeSyncCall()メソッド内では"mail"サービスで、"Send"で始まるメソッドを対象にフックを行っています。 "mail"サービスにはメールを送信するメソッドとして"Send""SendToAdmin"というメソッドがあり、その両方をフックスするためです。 フックする対象の通信が行われようとした時には以下のような処理を行っています。

  1. com.google.appengine.api.mail.MailServicePb.MailMessageクラスのインスタンスを作成し、 第三引数で渡されたバイト配列を作成したインスタンスのmergeFromメソッドに渡す。
  2. メンバ変数として保持しているリストに作成したインスタンスを追加する。

最初の処理は、典型的なProtocolBufferオブジェクトを組み立てる処理です。 mergeFromメソッドはcom.google.appengine.repackaged.com.google.io.protocol.ProtocolMessage<T>クラスで定義されています。 AppEngineの各サービスへリクエストする/レスポンスを受けるためにバイト配列がやりとりされますが、 これを組み立てるために使用されるProtocolBufferオブジェクトは殆どがこのクラスのサブクラスとして実装されています。

二番目の処理は、テストケースから送信したメールの内容を評価できるように保持するための処理です。

このApiProxy.Delegateを使ったテストケースの例は以下のようになります。

import java.io.*;
import org.junit.*;
import com.google.appengine.api.mail.MailServiceFactory;
import com.google.appengine.api.mail.MailService.Message;
import com.google.appengine.api.mail.MailServicePb.MailMessage;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;
import static org.junit.Assert.assertEquals;

public class SendMailTest {

  @Test public void sendmail() throws IOException {
    SendMailDelegate sendMailDelegate = new SendMailDelegate();
    ApiProxy.setDelegate(sendMailDelegate);

    Message message1 = new Message();
    message1.setTo("foo@bar.com");
    message1.setSender("hoge@fuga.com");
    message1.setSubject("一通目のタイトルです!");
    message1.setTextBody("こんにちは、一通目の本文です。");
    MailServiceFactory.getMailService().sendToAdmins(message1);
    
    Message message2 = new Message();
    message2.setSender("hoge@fuga.com");
    message2.setSubject("二通目のタイトルです!");
    message2.setTextBody("こんにちは、二通目の本文です。");
    MailServiceFactory.getMailService().sendToAdmins(message2);

    assertEquals(2, sendMailDelegate.messages.size());
    MailMessage messagePb1 = sendMailDelegate.messages.get(0);
    assertEquals(messagePb1.getTo(0), "foo@bar.com");
    assertEquals(messagePb1.getSender(), "hoge@fuga.com");
    assertEquals(messagePb1.getSubject(), "一通目のタイトルです!");
    assertEquals(messagePb1.getTextBody(), "こんにちは、一通目の本文です。");
    MailMessage messagePb2 = sendMailDelegate.messages.get(1);
    assertEquals(messagePb2.getSender(), "hoge@fuga.com");
    assertEquals(messagePb2.getSubject(), "二通目のタイトルです!");
    assertEquals(messagePb2.getTextBody(), "こんにちは、二通目の本文です。");
  }

  @Before
  public void setUp() {
    setUpAppEngine(new File("bin/mailtest"));
  }

  @After
  public void tearDown() {
    @SuppressWarnings("unchecked")
    Delegate<Environment> delegate = ApiProxy.getDelegate();
    if (delegate instanceof SendMailDelegate) {
      ApiProxy.setDelegate(((SendMailDelegate) delegate).apiProxyLocal);
    }
    tearDownAppEngine();
  }

  static void setUpAppEngine(File testFolder) {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(testFolder) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

  static void tearDownAppEngine() {
    ApiProxyLocal apiProxyLocal = (ApiProxyLocal) ApiProxy.getDelegate();
    if (apiProxyLocal != null) { apiProxyLocal.stop(); }
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

ここでは説明をわかりやすくするためにテストメソッド内でメール送信処理を行っていますが、 実際はテスト対象であるサービス層の機能が、内部的にメール送信サービスやデータストアサービスを行う事が多いんじゃないかと思います。

このようにmakeSyncCallメソッド内で、各サービスにリクエストされるバイト配列を組み立てなおすような方法を使うことでテストができるようになる事もあります。 どのcom.google.appengine.repackaged.com.google.io.protocol.ProtocolMessage<T>クラスの実装を使えば良いのか、 については調査してみなければ分からないのですが、@marblejenkaさんが これに関する調査を行い易くするためのプロダクトを公開 してくれていますので、興味があれば参考にすると良いと思います。

#AppEngine 用のアプリケーションの自動テストについて(2) - Datastoreに関するテスト

前回に続く、テストシリーズ第二回です。

テストに必要な初期テストデータ

Datastoreの操作をテストする際に初期データを必要としないものは特に準備は必要ありませんが、初期データを投入済みの状態でテストを開始したい場合もよくあります。 そういった時にsetUp()setUpBeforeClass()メソッドで初期データを投入する方法がありますが、 その方法だと初期データが大きい時にテストに時間がかかってしまうという問題があり、スローテストと呼ばれる問題を引き起こしてしまいます。

そこでAppEngineの開発環境でのDatastoreの特徴を確認すると、メモリ上で実行するオプションを指定しない限りは テストフォルダ配下の"WEB-INF/appengine-generated/local_db.bin" というファイルにデータが書き込まれます。 一度local_db.binに書き込まれたデータは、次回以降に同じテストフォルダから起動したときに、 起動直後から以前に書き込んだデータが存在している状態として再利用する事ができます。 そこで、以下のような手順でテストケースの仕組みを作っておくと便利です。

  1. テストデータ専用のフォルダを用意する。何種類かの初期テストデータを用意することも多いので、 例えば"testdata/${初期データセット名}"のようにテストデータのルートフォルダ配下にさらにフォルダを用意すると良いかもしれません。
  2. テストの直前に、上記フォルダにすでに初期テストデータが存在するかをチェックし、存在していない場合は初期データを作成する
    1. 初期テストデータを作成する処理もテスト時と同様にAppEngine環境を起動した後で通常のDatastore操作と同じ処理を行えば良いが、 AppEngine環境を起動する際のApiProxyLocalImplクラスのコンストラクタには "testdata/dataset1"のように初期テストデータ用のフォルダを指定する。
    2. 初期テストデータを作成した後は一旦AppEngine環境を終了する。
  3. すでに初期テストデータが存在する状態であれば、テスト用フォルダに初期テストデータフォルダをコピーする。
  4. "war/WEB-INF/datastore-indexes.xml"が存在していれば、それをテスト用フォルダ配下のWEB-INFフォルダにコピーする。
  5. 初期テストデータが投入された状態でテストを行う

このように"testdata/${初期データセット名}/WEB-INF"をテスト用のフォルダにコピーするだけで初期データの準備ができるため、 テストの準備にかかる時間も大幅に節約することができるようになります。 また、初期テストデータもSCMで管理すれば初期テストデータの内容を変えた場合以外はその作成処理にかかる時間も節約できます。

以下のソースはこれらの処理を行う例です。

import java.io.*;
import java.util.*;

import org.apache.commons.io.FileUtils;
import org.junit.*;
import com.google.appengine.api.datastore.*;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.Environment;

import static org.junit.Assert.assertEquals;

public class DatastoreTest2 {

  @Test public void datastoreTest01() {
    // 何も操作していないが、初期データが読み込まれている。
    int size = DatastoreServiceFactory.getDatastoreService().prepare(
        new Query("child")).asList(FetchOptions.Builder.withOffset(0)).size();
    assertEquals(2, size);
  }

  @Test public void datastoreTest02() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Entity entity = new Entity("child");
    service.put(entity);
    int size = service.prepare(new Query("child")).asList(
        FetchOptions.Builder.withOffset(0)).size();
    assertEquals(3, size);
  }

  @Before public void setUp() throws IOException {
    File initialDataFolder = new File("testdata/2/");
    if (initialDataFolder.exists() == false) {
      // 初期テストデータが存在しなければ作成する
      createinitialData(initialDataFolder);
    }
    // 初期テストデータをテストフォルダにコピーする
    File testFolder = new File("bin/2/");
    FileUtils.copyDirectory(initialDataFolder, testFolder);
    // インデクス定義ファイルが存在すれば、それもテストフォルダにコピーする
    File datastoreIndexes = new File("war/WEB-INF/datastore-indexes.xml");
    if (datastoreIndexes.exists()) {
      FileUtils.copyFile(datastoreIndexes, new File("bin/2/WEB-INF/datastore-indexes.xml"));
    }
    setUpAppEngine(testFolder);
  }

  @After public void tearDown() {
    tearDownAppEngine();
  }

  static void createinitialData(File initialDataFolder) {
    try {
      setUpAppEngine(initialDataFolder);
      DatastoreService service = DatastoreServiceFactory.getDatastoreService();
      Key parentKey = service.allocateIds("parent", 1).getStart();
      Iterator<Key> childKeys = service.allocateIds(parentKey, "child", 2).iterator();
      Key childKey1 = childKeys.next();
      Key childKey2 = childKeys.next();
      Entity parent = new Entity(parentKey);
      parent.setProperty("prop1", "hoge");
      Entity child1 = new Entity(childKey1);
      child1.setProperty("prop1", "foo");
      Entity child2 = new Entity(childKey2);
      child2.setProperty("prop1", "bar");
      service.put(Arrays.asList(parent, child1, child2));
    } finally {
      tearDownAppEngine();
    }
  }

  static void setUpAppEngine(File testFolder) {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(testFolder) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

  static void tearDownAppEngine() {
    ApiProxyLocal apiProxyLocal = (ApiProxyLocal) ApiProxy.getDelegate();
    if (apiProxyLocal != null) {
      apiProxyLocal.stop();
    }
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

Exceptionのシミュレート

例えばDatastore操作を行う際、すべての操作についてApiProxy.DatastoreTimeoutException例外が投げられる可能性を考慮する必要があります。 こういったAppEngine特有の例外への対応が実装されているか?もできるだけテストしておいた方が良いと思われます。

そういった際に使用できるのがApiProxy#setDelegate()を使ったApiProxy.Delegateの入れ替えです。 サービスの実行をフックするような処理を行うことになります。

ApiProxy.Delegate

byte[] makeSyncCall(Environment env, String service, String method, byte[] request)
もっとも重要なメソッドで、基本的にすべてのサービスの実行時にこのメソッドを経由します。
Environment env
アプリケーションが実行されているスレッドで使用されているEnvironementです。
String service
実行しようとしているサービス名です。"datastore_v3"や"memcache"等の文字列です。
String method
実行しようとしているサービスのメソッド名です。Datastoreサービスなら"Put"や"Get"等の文字列です。
byte[] request
実行しようとしているサービスのメソッドへ渡す引数をProtocolBufferでシリアライズしたバイト配列です。
Future<byte[]> makeAsyncCall(Environment env, String service, String method, byte[] request, ApiConfig config)
1.3.0が最新リリースの時点ではあまり気にする必要がありません(気になる人は"makeAsyncCall"でぐぐってみましょう)。
void log(Environment env, LogRecord logRecord)
ログ出力用のメソッドですが、重要ではありません。

以下に、Datasotreへの一回目のリクエスト時に必ずDatastoreTimeoutExceptionが発生するApiProxy.Delegateの例をしめします。 makeSyncCallメソッド以外は、最初に保存したApiProxyLocalに丸投げをしています。

class TimeoutExceptionDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();
  boolean first = true;
  
  @Override
  public byte[] makeSyncCall(Environment env, String service, String method, byte[] request)
      throws ApiProxyException {
    if (first && service.equals("datastore_v3")) {
      first = false;
      throw new DatastoreTimeoutException("TimeoutExceptionDelegate");
    }
    return apiProxyLocal.makeSyncCall(env, service, method, request);
  }

  @Override
  public Future<byte[]> makeAsyncCall(Environment env, String service,
      String method, byte[] request, ApiConfig config) {
    return apiProxyLocal.makeAsyncCall(env, service, method, request, config);
  }

  @Override
  public void log(Environment env, LogRecord logRecord) {
    apiProxyLocal.log(env, logRecord);
  }
};

通常のAppEngine環境の起動後にこのクラスを適用し、DatastoreTimeoutExceptionに対応しているかどうかを確認するためのテストケースの例が以下のようになります。

  1. setUp時に通常どおりAppEngine環境の起動を行い、その後でApiProxy#setDelegate()TimeoutExceptionDelegateを設定しています。
  2. その後テストをおこなっていますが、TimeoutExceptionへ未対応のテストは当然TimeoutExceptionDelegateが発生し、エンティティの保存が行われません。 TimeoutExceptionへ対応したテストはエンティティが正常に保存されます。
  3. tearDown時に、まずApiProxy#getDelegate()TimeoutExceptionDelegateかどうかをチェックし、 TimeoutExceptionDelegateだった場合はそのインスタンスに保存されたApiProxyLocalを取得し、ApiProxy#setDelegate()で設定することで元に戻しています。
  4. その後通常通りAppEngine環境の終了処理を行っています。

リトライ対応したメソッドで成功時にbreakが抜けていたので追加しました、指摘してくれた@bluerabit777jpさんThx!

import java.io.*;

import org.junit.*;
import com.google.appengine.api.datastore.*;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

import static org.junit.Assert.assertNotNull;

public class DatastoreTest3 {

  @Test(expected=DatastoreTimeoutException.class)
  public void datastoreTest01() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Entity entity = new Entity("child");
    service.put(entity);
  }

  @Test public void datastoreTest02() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Entity entity = new Entity("child");
    Key key = null;
    for (int i = 0; i < 5; i++) { // 最大5回リトライする
      try {
        key = service.put(entity);
        break;
      } catch (DatastoreTimeoutException e) {
        continue;
      }
    }
    assertNotNull(key);
  }

  @Before public void setUp() throws IOException {
    setUpAppEngine(new File("bin/3"));
    ApiProxy.setDelegate(new TimeoutExceptionDelegate());
  }

  @After public void tearDown() {
    @SuppressWarnings("unchecked")
    Delegate<Environment> delegate = ApiProxy.getDelegate();
    if (delegate instanceof TimeoutExceptionDelegate) {
      ApiProxy.setDelegate(((TimeoutExceptionDelegate)delegate).apiProxyLocal);
    }
    tearDownAppEngine();
  }

  static void setUpAppEngine(File testFolder) {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(testFolder) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

  static void tearDownAppEngine() {
    ApiProxyLocal apiProxyLocal = (ApiProxyLocal) ApiProxy.getDelegate();
    if (apiProxyLocal != null) {
      apiProxyLocal.stop();
    }
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

このようにApiProxy#setDelegate()を使ったサービス実行をフックする事ができます。DatastoreTimeoutException以外にも、データストアのメンテナンス中の時に発生するCapabilityDisabledException等もシミュレートすることができます。

2010年1月2日土曜日

AppEngine用のアプリケーションの自動テストについて(1)

AppEngine用のアプリケーションのテストの手法について、公式ドキュメントがあまりにも貧弱なためそれを補足する資料を作ろうと思います。 DatastoreはもちろんMail送信やQueueへのTask投入等のテストを行う説明まで何回かに分けて徐々に書いていき、 最終的にはそれらのエントリを清書してWikiにまとめたいと思います。これはその第一回目で、テストのための仕組みの説明と基本的なテストの手順について説明します。

文中でApiProxyと書かれているクラスはcom.google.apphosting.api.ApiProxyの事です。

この説明での「AppEngine環境」とは、データストアサービスなどのAppEngineで提供されている各種サービス群を利用するための環境のことをさします。 テストのためにAppEngine環境を起動するには、大きくわけると

  • ApiProxy.setEnvironmentForCurrentThread()
  • ApiProxy.setDelegate()
のふたつの処理が必要になります。

ApiProxy.setEnvironmentForCurrentThread(ApiProxy.Environment)

AppEngineの実行環境ではスレッドごとにApiProxy.Environmentのインスタンスが必要となるので、 AppEngineの実行環境がApiProxy#getCurrentEnvironment()を経由してApiProxy.Environmentのインスタンスを取得できるように設定する必要があります。 プロダクション環境や開発用のWebコンテナ経由で起動した場合はこれが自動的に設定されますが、それらを経由せず起動する場合は独自にインスタンスを作成・設定してやる必要があります。

ApiProxy.setDelegate(ApiProxy.Delegate<ApiProxy.Environment>)

AppEngineの実行環境では色々なサービスが存在しており、それらのサービスへのAPIが用意されています。 例えばデータストアサービスであればDatastoreServiceFactory#getDatastoreService()を使うことでそれらのサービスへのAPIを使用することができます。
これら各サービスへのAPIは内部的にApiProxy#getDelegate()で取得したApiProxy.DelegateのインスタンスのmakeSyncCall()というメソッドを通してサービスの実装へ処理を委譲します。 ApiProxy.Delegateのインスタンスの中で各サービスの実装の解決や、それらとの通信が行われます。 この仕組みはプロダクション環境も開発環境でも同じ動作を行うように実装されており、それぞれの環境ごとにApiProxy#getDelegate()が返すApiProxy.Delegateの実装が違うだけ、となっています。

例えば開発用のWebコンテナ経由で起動された環境ではApiProxy#getDelegate()com.google.appengine.tools.development.ApiProxyLocalImplというクラスのインスタンスが返されます。

ApiProxy.Environment

例えば以下のようなクラスを用意する事になります。

public class UnitTestEnvironment implements com.google.apphosting.api.ApiProxy.Environment {
  public String getAppId() { return "myApplicationId"; }
  public String getVersionId() { return "unittest"; }
  public String getRequestNamespace() { return ""; }
  public String getAuthDomain() { return "gmail.com"; }
  public boolean isLoggedIn() { return true; }
  public String getEmail() { return "unittest@gmail.com"; }
  public boolean isAdmin() { return true; }
  public java.util.Map<String, Object> getAttributes() {
    java.util.Map<String, Object> map = new java.util.HashMap<String, Object>();
    return map;
  }
}

この中で重要なメソッドについてピックアップして説明しておきます。

String getAppId()
Webコンテナ経由で起動した場合にappengine-web.xmlから読み込まれるアプリケーションのIDの事ですが、 Datastoreサービスを使用した際に重要となります。
というのもDatastoreサービスで読み込み・書き込みで使用する時には必ずアプリケーションIDが使用されるからです。 Datastoreサービスでエンティティを保存すると必ずKeyがエンティティに保存されますが、Keyには必ずアプリケーション名が含まれます。 ですので開発環境でひとつのlocal_db.binを使っていたとしても、 環境を起動したときのApiProxy.Environment#getAppId()で書き込んだ時とは別のアプリケーションIDを使ったりすると、 local_db.binに書き込んまれているはずのエンティティが読み込めなかったりします。
boolean isLoggedIn(), String getEmail(), boolean isAdmin()
UserServiceを使用した場合に、ログイン済み・ログインユーザのEmailアドレス・ログインユーザが管理者権限をもっているか、を制御する事ができます。 実行時に制御したいのであればApiProxy.Environmentの実装ないで状態に応じた値を返すようにするか、 ApiProxy.Environmentの実装を複数用意してApiProxy#setCurrentEnvironment()で切り替えるなどすれば良いです。

ApiProxyLocalImpl

ApiProxy.Environmentとは違い、 ApiProxy.Delegate<ApiProxy.Environment>の実装は既存のcom.google.appengine.tools.development.ApiProxyLocalImplを利用することになります。

このクラスはコンストラクタにjava.io.FileのインスタンスでAppEngine環境を起動するフォルダを指定します。 ここで指定したフォルダ配下にWEB-INF/appengine-generated等のフォルダが生成されます。

ここで指定したフォルダ配下が上書きされる…という事は、Webコンテナ経由で起動した時のWEB-INF/appengine-generated/local_db.binファイルも上書きされるという事です。 そのため単体テストで使用するときには、このフォルダはWebコンテナ経由で起動する時のwarフォルダを指定したくない場合が多々あります。 その時に少し問題になるのがWEB-INF/datastore-indexes.xmlWEB-INF/queue.xmlです。 ApiProxyLocalImplはコンストラクタで指定されたAppEngine環境を起動するフォルダ配下に何かを書き込むだけではなく、 そのフォルダ配下のWEB-INF/datastore-indexes.xmlWEB-INF/queue.xmlを読み込もうとします。 ですので、テスト環境でもQueueを使ったりindexについて厳密に処理したい場合はApiProxyLocalImplのコンストラクタに渡すフォルダに、 それらのファイルをコピーしておく必要があります。

実際にテストを行う手順

ここでの説明は公式ドキュメントとほぼ同じです。

テスト用のライブラリを配置するフォルダを準備する

プロジェクト直下にlib.testというtestライブラリ用のフォルダを作成します。フォルダ名は例ですので自由に決めると良いです。
プロダクト用のライブラリはwar/WEB-INF/libに配置してclasspathに追加しますが、war/WEB-INF/libはプロダクト環境にデプロイされてしまいます。 そのため、テスト用のライブラリはデプロイする必要が無いため別のフォルダを用意します。

テストに必要なライブラリを配置する

AppEngineのSDKを展開したフォルダを「${appengine-sdk}」として記述します。 SDK本体ではなくEclipseのPluginだけをインストールしている場合は、${appengine-sdk}は 「eclipseをインストールしたフォルダ/plugins/com.google.appengine.eclipse.sdkbundle.../appengine-java-sdk-xxx」 となります。「...」や「xxx」はSDKのバージョンやEclipsePluginのバージョンによって異なります。

以下のふたつのjarがテストに必要ですので、testライブラリ用のフォルダにコピーしclasspathに追加します。

  • ${appengine-sdk}/lib/impl/appengine-api-stubs.jar
  • ${appengine-sdk}/lib/impl/appengine-local-runtime.jar

また上記のappengine用のjarだけではなくJUnitのjarもtestライブラリ用のフォルダに配置してclasspathに追加しておきましょう。

ApiProxy.Environmentの実装を用意する

今回は先のApiProxy.Environmentに記述したUnitTestEnvironmentをそのまま使用することにします。 これは複数必要であれば複数の実装を用意しておき適宜切り替えを行えば良いです。

AppEngine環境を起動する

各テストの実行前に、かならずAppEngine環境を起動する処理が必要となります。 JUnit4では@Before@BeforeClassで修飾されたメソッド内に実装すると良いです。

ApiProxy.Environement environment = new UnitTestEnvironment();
ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(new File(testFolderName)) {
};
ApiProxy.setEnvironmentForCurrentThread(environment);
ApiProxy.setDelegate(apiProxyLocal);

サンプルコード中のtestFolderNameはテスト環境用のフォルダです。 bintarget等の、出力用フォルダを指定しておくのが良いです。

またApiProxyLocal#setProperty(String 設定キー, String 設定値)を使って各サービスの実装の動作を設定することもできます。 例えばファイルを使わずにメモリ上だけでDatastoreを操作したい場合は以下のような処理になります。

ApiProxy.Environement environment = new UnitTestEnvironment();
ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(new File(testFolderName)) {
};
apiProxyLocal.setProperty(LocalDatastoreService.NO_STORAGE_PROPERTY, Boolean.TRUE.toString());
ApiProxy.setEnvironmentForCurrentThread(environment);
ApiProxy.setDelegate(apiProxyLocal);

ファイルを使って処理するが、データを全て削除したい場合には以下の処理を実行します。

LocalDatastoreService datastoreService = (LocalDatastoreService) ((ApiProxyLocal) ApiProxy.getDelegate()).getService(LocalDatastoreService.PACKAGE);
datastoreService.clearProfiles();

QueueにTaskを投入したりdatastore-indexes.xmlの定義を必要とする場合はそれらをテスト環境用のフォルダにコピーしておく必要があります。 詳しくは先のApiProxy.Delegateの説明に書いた内容を参考にしてください。

AppEngine環境を終了する

各テストやテストケースの実行後に、起動した環境を終了する処理が必要となります。 JUnit4なら、起動処理を@Before@BeforeClassで行っているでしょうから、 それに対応する@After@AfterClassで修飾されたメソッド内に実装することになります。

ApiProxy.setDelegate(null);
ApiProxy.setEnvironmentForCurrentThread(null);

基本的には上記の処理だけで良いですが、local_db.binがクローズされるタイミング等の問題で 上記の処理だけではなく以下のようにしておいた方が良い場合もあるかもしれません。

if (ApiProxy.getDelegate() != null) {
  ((ApiProxyLocal) ApiProxy.getDelegate()).stop();
}
ApiProxy.setDelegate(null);
ApiProxy.setEnvironmentForCurrentThread(null);

目的のテストを記述し、実行する

上記の準備を整えるだけで単純なテストは既に実行できる状態になっています。以下はその全体の例です。

import java.io.File;
import java.util.*;
import org.junit.*;
import com.google.appengine.api.datastore.*;
import com.google.appengine.api.datastore.dev.LocalDatastoreService;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.Environment;

import static org.junit.Assert.assertEquals;

public class DatastoreTest {

  @Test public void datastoreTest01() {
    Entity parent = new Entity("child");
    parent.setProperty("prop1", "fuga");
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    service.put(parent);

    int size = service.prepare(new Query("child")).asList(FetchOptions.Builder.withOffset(0)).size();
    assertEquals(1, size);
  }
  
  @Test public void datastoreTest02() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Key parentKey = service.allocateIds("parent", 1).getStart();
    Iterator<Key> childKeys = service.allocateIds(parentKey, "child", 2).iterator();
    Key childKey1 = childKeys.next();
    Key childKey2 = childKeys.next();
    Entity parent = new Entity(parentKey);
    parent.setProperty("prop1", "hoge");
    Entity child1 = new Entity(childKey1);
    child1.setProperty("prop1", "foo");
    Entity child2 = new Entity(childKey2);
    child2.setProperty("prop1", "bar");
    service.put(Arrays.asList(parent, child1, child2));

    int size = service.prepare(new Query("child")).asList(FetchOptions.Builder.withOffset(0)).size();
    assertEquals(2, size);
  }

  @Before
  public void setUp() {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(new File("bin/datastoreTest")) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

  @After
  public void tearDown() {
    ApiProxyLocal apiProxyLocal = (ApiProxyLocal) ApiProxy.getDelegate();
    if (apiProxyLocal != null) {
      LocalDatastoreService datastoreService = (LocalDatastoreService) apiProxyLocal.getService(LocalDatastoreService.PACKAGE);
      datastoreService.clearProfiles();
      apiProxyLocal.stop();
    }
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

おまけ: 開発環境での各サービスの実装クラス

特に使用する機会は無いと思いますが、開発環境でサービスの実装の振る舞いを変更する設定値だとかを知りたいときなどに役に立つかもしれません。全てのクラスがcom.google.appengine.tools.development.LocalRpcServiceを実装しており、 ${appengine-sdk}/lib/impl/appengine-api-stubs.jarに含まれています。
一覧の各サービス名にくっついているカッコ内太字で表記されている文字列はApiProxy.Delegate#makeSyncCall()への引数として渡されるサービスの内部的な名称です。 これは開発環境ではcom.google.appengine.tools.development.LocalRpcServiceString getPackage()として定義されています。

BlobstoreService(blobstore)
com.google.appengine.api.blobstore.dev.LocalBlobstoreService
DatastoreService(datastore_v3)
com.google.appengine.api.datastore.dev.LocalDatastoreService
ImagesService(images)
com.google.appengine.api.images.dev.LocalImagesService
MailService(mail)
com.google.appengine.api.mail.dev.LocalMailService
MemcacheService(memcache)
com.google.appengine.api.memcache.dev.LocalMemcacheService
TaskQueue(taskqueue)
com.google.appengine.api.labs.taskqueue.dev.LocalTaskQueue
URLFetchService(urlfetch)
com.google.appengine.api.urlfetch.dev.LocalURLFetchService
UserService(user)
com.google.appengine.api.users.dev.LocalUserService
XmppService(xmpp)
com.google.appengine.api.xmpp.dev.LocalXmppService

追記

@mokkouyouさんが誤字を指摘してくれましたので、修正しました。

2009年12月5日土曜日

#appengine java night #3( #ajn3 )に参加した

今回の本編では、TaskQueueをメインテーマにして開催されました。

@bluerabbit777jpさん:実際に作ってわかったApp Engineの困ったところ

大量のメール送信を行うために、「Datastoreにメールをキューしておき、TQでメールを拾う→送信→Datastoreに送信完了フラグを書き込む」というパターン。これは最後の「送信完了フラグの書き込み」のフェーズに失敗する状況だとメールが多重送信されてしまう事がありうる。
fmfm、確かにそうだなぁ。こういったエラーをよしとするポイントが必要になるという事ですね。もちろん、アプリケーションの特性のよってそこへかけるコストと妥協できるボーダーは変わってくるわけですが、完璧を求めることは難しいですもんね。

時間がかかるバッチ処理を分割してTQで処理すると並列で走るので早くなる。でも、並列実行だと難しい設計だった場合は、TQ内のTaskのチェイン(Taskから次のTaskをQueueに投入)をする事でシーケンスに処理を行う事もできる。
並列実行がどうしても難しい場合はこういった手もアリですね。できるだけ並列処理できるように設計したいけど、難しい場合もありますもんねぇ。

途中で発生した「エンティティグループのルートエンティティを永続化しなかったらどーなるの?」という疑問は、疑問が出たその場ではTQに投入され、@bluerabbit777jpのプレゼン中に並列で検証が行われることとなった。勉強会の途中でこういう進行をするのも面白いですね!!勉強会に参加していなかったがリモートから@ashigeruが協力してくれた結果、検証も無事完了。結論は以下の通り。これは自分にとっては結構インパクトがある結果でした。

  • ルートエンティティが存在しなくてもエンティティグループに対するトランザクション処理が正常に行われる。つまり、トランザクション管理で使用されるエンティティグループのタイムスタンプは、Datastoreのエンティティとは関係が無いところで管理されている可能性がある

ここだけの話、バックグラウンドでこの検証をやっていたので、途中から20分ほどプレゼンをあまり聞けてなかったり…。

ぶいてく竹嵜さん:ぶいてく流 スケーラブルアプリの作り方

以前からPDFの大量処理で注目していた方のプレゼンなので、生でお話を聞くことができて良かった。

エンティティのバージョニングを行うために、「更新無し追加のみ・物理削除なし・論理削除のみ、ですべての履歴を保持、エンティティをアプリケーション的なキー+リビジョン番号でユニークに管理する」という戦略で設計されていたのは興味深かった。
このあたりはどこまで厳密に整合性を保証するのか、読み込み時の整合性をどこまでリアルタイムに保証する必要があるのか、などなどがアプリケーションによって求められるレベルが違うと思うので、正解は無いと思う。たぶん、いくつかのパターンが出てくるのは従来通り。…なんだけど、それをBigTableでやるには?という意味で、どんどん「俺はこうやってる!」という話が出てくるのは大変ありがたい。やってみなきゃワカラン問題がたくさんあるんですよねぇー。今後の竹崎さんの方針にも注目ですね!

「TQがスケールしない。色々タイミングなどの工夫をしたけど、インスタンスが4つ以上に増えてくれない」という話も面白かった。工夫の仕方も面白いし、なによりスケールしない理由がわからなさすぎるのが面白いw
このあたりはLT@WdWeaverのLTでも語られることだし、@WdWeaverの検証の話を知っているので結構想像できたり、逆に知っていることもあるからこそ想像できなかったりwこの竹崎さんの例は「スケールしないパターン」という意味で、とても貴重な例だと思いました。これもまた、やってみなきゃわからないし、やってみたから少し垣間見える世界もあるのですよね。@WdWeaverの世界だけだと、スケールしないパターンは見えませんから。

@WdWeaverさん:スケールアウトの真実?

個人的には、Appengine上に本格的なアプリケーションを展開する人全員が絶対に知っておかなきゃならない、ある意味基礎知識といえる話だと思ってます。もちろんアプリケーションの設計パターンと言う意味では今回のメインテーマのTaskQueueなども重要だけど、もっと低レイヤの前提条件として、みんな知っておくべきお話でした。

ずいぶん前にもこの件の資料が公開されていたので、ある程度アンテナを張っている人は知っていた話だと思うけど、リアルタイムにデモを見せてもらえたのは感激。ひがさんの号令のもと皆があれこれ試して推測した結果をうらづける結果がリアルタイムに、納得できる形で確認できたのが嬉しい。そして、今回はさらに「竹崎さんのスケールしないパターン」の話がどうなの?ってのも出てきたわけだし(warmアップしたらいいんじゃね?という話もあるが、それだけじゃ不足な気がする)、まだまだスケールする仕組みは検証する価値がありそうです。楽しいなぁ!

@tmatsuoさん:AppEngine/Py用のフレームワーク「Kay」

PythonでもJava同様にspin upに時間がかかる問題があるんだと判明したのは自分にとっては大きな発見!

管理機能としてDatastoreのデータをExportできる機能があるのは知っていたんだけど、そこで謎に感じていた「Kind一覧はどーやって取得してるんだろう?」という問題に対しては「モデルクラスのソースを読んでる」との事。なるほどね、確かにそれならできるなぁ、と謎が解けました。

@marblejenkaさん:makeSyncCallでいろいろ試してみた

LowLevelAPIよりもさらに低いレベルのAPIの話。このあたりは、みんなが詳しく触る必要がある話ではないのだけれども、@WdWeaverのスケールの仕組みの話と同様、「こういう世界もある」という事を知っておいた方がいいと思ってたので、こうやって紹介されるのは大変良いことだと思います。

1.2.8から追加されたmakeASyncCall()という機能については実はめっちゃくちゃ重要な話で、今後のAppEngine上のアプリケーションの設計方針が大きく変わる可能性がある機能。これの紹介もあったのはとても良かったと思います。ウチの同僚も興味津々だったようですし。

帰宅してからハッシュタグを追いかけていて気づいたんだけど、@marblejenkaはひとり違う視点でつぶやいているのが大変おもしろいです。イケナイbyte配列を投げすぎて垢BANされるとしたら、世界で最も早く垢BANされるのは@marblejenkaじゃないかな、と真面目に考えてます。

@kimteaがハッシュタグをまとめてくれてます

まとめありがとうございます!追いかけると色々と面白い発見がありました。

感想とか

今までで一番濃い会になったかもしれないな、と思いました。ひがさんがおられると、色々重要な議論を沸き起こせて大変助かります。特に今回は議論で発生した新たな疑問をその場で検証してみたりとか、新たな展開もあって良かったです。次回からその場検証の担当としてもっと武装(デプロイ環境でもすぐに検証できるアプリを用意しておくとかね)していこうかなぁとか思ったw

例のごとく、時間がおしまくったため、予定されていたLTが4本消化できずに終わりましたw でも、いいですね、ひがさんがおっしゃった内容で「時間通り終わるのが目的じゃない、密に時間をスゴスのが大事なんだ」というのがありましたが、それが実現できている会だなと思います。これからもこの調子でやっていけると良いな、と思います。

感謝

懇親会もできるひろーーい会場を提供してくださったグリー株式会社様、いちいさん、大変ありがとうございました。特に夜遅くまで面倒を見てくださったいちいさん、お疲れ様でした&ありがとうございました。今回も開催に関するしきりをしてくださったスティルハウス佐藤さん、有益な話を聴かせてくださったスピーカーの皆様、会場やTwitterで盛り上げてくださった参加者の皆様、リアルタイム検証をリモートから手伝ってくれた@ashigeru(赤木しげるではない)さん、ありがとうございました!

2009年11月24日火曜日

@bufferings さんによる #appengine #slim3 データストアドキュメントの日本語訳

bufferingsさんがやってくれました。このドキュメントはslim3についての説明というだけではなく、Google App Engineのデータストアの詳しい説明にもなっており、とてもわかりやすくなっています。オススメ。

2009年11月19日木曜日

#appengine hack-a-thon #1に参加した

もう二日も経ってしまったので、今更かよってカンジですけれども。

感想

六本木のアカデミーヒルズ40Fで開催されました。キレイ&眺めが良いフロアで、からあげ弁当・麻婆なす弁当がそれぞれ\600と\500、喫煙ルーム付き、というかなり良い環境でした。コミュニケーションは途中の昼食、終了後の懇親会がメインで、作業中はとにかくもくもくと作業するカンジ。
オンラインのコミュニケーションは、事前の予定通りTwitterで#ahack1 のハッシュタグを使おう!ってやってましたが、主に「富士山」「チョコレートの配給」「座った席の連絡(松尾さんを目印に)」「芋の配給」という話題ばかりが流れてましたw ついでに書いておくと、事前に用意したリポジトリは実に役に立ちませんでしたw でも、それなりの成果も上がっていましたし、色々と濃い話もできて楽しかったです。

私の成果

maven+eclipse(with Google Plugin for Eclipse)を使っている場合に、appengineのプロジェクトを簡単に作成するプラグインを作っていました。当日はslim3datastore+servletのシンプルなプラグインが完成し、slim3datastore+wicket用のプラグインの途中までで時間切れ。帰宅後にそれを完成させ、次の日にslim3datastore+cubby用のプラグインを完成させて、このエントリを書いています。

このプラグインを使うと以下の3手順ですぐに appengine のプロジェクトができあがり開発がすぐに開始できます。

  1. mvn archetype:generateして
  2. mvn eclipse:eclipse して
  3. eclipse に取り込む

この手順で作成したプロジェクトでできる事は以下の通り。

  • eclipseからGooglePluginを使って開発用のWebコンテナ上でアプリケーションを起動する(起動構成も生成されます)
  • eclipseからappengineの環境を利用する自動テストを実行できる
  • mavenからも自動テストを実行できる
  • makeSyncCallServletが組み込まれ、設定済みの状態になっている(デプロイ環境ではアプリケーション開発者権限を持つアカウントのみがアクセスできる設定になっています)

できない事は以下の通り。

  • JDOは一切組み込まれません。
  • mavenから開発用サーバを起動できない。mavenから開発サーバを起動するプラグインが1.2.6に対応しているかどうか未確認のため、組み込んでいません。

一番軽い構成になっているgaej-simple-pluginをが、wicketやcubby以外のフレームワーク用に拡張するのに便利だと思います。

2009年11月7日土曜日

#appengine の「データストアの読み込み専用状態」をエミュレートする

先日もメンテナンスがあり、AppEngineのデータストアが読み込み専用状態になってましたね。データストアへの書き込みを行うアプリケーションではこの「読み込み専用状態」時の対策をする必要があるのですが、「読み込み専用状態」の時の振る舞いをテストする方法を書いておきます。自動テストができる範囲は広ければ広い程よいですもんね。

ApiProxy.Delegateを実装・適用するだけ

ApiProxy.Delegateを実装して、makeSyncCall()メソッド内で「Datastoreへの書き込み」の時に ApiProxy.CapabilityDisabledException( )を投げてやるだけ。私は下記のようなユーティリティを作ってテストしてます。

static <R>R runOnReadOnlyMode(Callable<R> callable) throws Exception {
  @SuppressWarnings("unchecked")
  final ApiProxy.Delegate<Environment> backupedDelegate = ApiProxy.getDelegate();
  Delegate<Environment> delegate = new Delegate<Environment>() {

    public void log(Environment arg0, LogRecord arg1) {
      backupedDelegate.log(arg0, arg1);
    }

    public byte[] makeSyncCall(Environment arg0, String service, String method, byte[] arg3)
        throws ApiProxyException {
      if (service.equalsIgnoreCase("datastore_v3")
          && (method.equalsIgnoreCase("Put") || method.equalsIgnoreCase("Delete"))) {
        throw new ApiProxy.CapabilityDisabledException(service, method);
      }
      return backupedDelegate.makeSyncCall(arg0, service, method, arg3);
    }
  };
  ApiProxy.setDelegate(delegate);
  try {
    return callable.call();
  } finally {
    ApiProxy.setDelegate(backupedDelegate);
  }
}

こんなカンジにできるので、他にもDatastoreTimeoutExceptionやらApiProxyException系の例外、例えばApiProxy.UnknownExceptionなんかが発生したときの振る舞いも簡単に自動試験できますね!

不思議

Datastoreについては上記の方法で簡単にApiProxy.CapabilityDisabledExceptionをエミュレートできるのですが、MemcacheServiceについては簡単には行かない事が判明しています。

  • MemcacheServiceは例外をキャッチした際に、それをログ出力するだけのハンドラでExceptionを握りつぶす実装になっている
  • 先日の読み込み専用モードの時にログが記録されていない気がする=Memcacheサービスは読み込み専用状態に書き込み要求を受けても例外をスローしない…?

前者についてはMemcacheService#setErrorHandler()メソッドに、new com.google.appengine.api.memcache.StrictErrorHandler()を設定してやればちゃんと例外が飛ぶのでまぁなんとかなるのですが、後者については次回のメンテナンスの時になるまで調査ができませんね。ひょっとするとMemcacheサービスの仕様なのかな?という気もします。「常にMemcacheから値を取得できるとは限らない」という前提なので、書き込みに失敗しても問題ない、という仕様なのかな?それであれば、そもそも試験する必要がありませんね。
どなたか前回のメンテナンス時にMemcacheサービスから書き込みエラーとか何かの例外を受け取った人がおられたら、わかる範囲で良いので教えてください!

読み込み専用モードを考慮してアプリ側で工夫できる事もある

がっつりデータストアに書き込む必要がある処理はどうしよーも無いのですが、例えばカウンタの記録など「書き込みがメインでは無いが、書き込み処理も付随する」ような機能はちょっと工夫しておけば読み込み専用時にも稼働させる事ができます。

私が稼働させているAppEngineアプリのひとつに空うさぎというFriendFeed/Twitter/RSSクライアントアプリケーションのファイル管理用の小さいアプリケーションがあるのですが、このアプリの中にファイルのダウンロード件数を記録する、という機能があります。この機能は「ファイルのイメージをレスポンスする」と同時に「ダウンロード数カウンタを増加させるTaskをQueueに投入する」という実装をしています。投入されたTaskはデータストアの書き込みに成功しない場合はHttpStatusの500を返すように実装しているので、読み込み専用時は常に500を返す事になります。その結果、読み込み専用モードが解除されるまではAppEngineのTaskQueueに残り続けるので、読み込み専用モードが解除された頃にはカウンタの整合性はいずれ正しくなる、という動作をします。AppEngineはまだベータバージョンですし、工夫でしのげる場所はうまく実装しておきたいですね。

2009年11月4日水曜日

#appengine でスキーマ変更に対応するバッチ処理を行う

2009/11/05追記

ひがさんより指摘を頂いて、30秒制限に関する補足を本文中に青字で追記しました。いつもありがとうございます、助かります>ひがさん

ここから本文

タイトルの処理について、いくつかノウハウを書いておきます。ポイントは以下の2点。

  • 全てのエンティティにスキーマバージョンを保持する
  • ローカル環境からデプロイ環境へ直結してバッチ処理を実行する事で、30秒制限なんて無視してしまう

実例をもとに説明してみます。最近、appengine java night用のまとめページとかに使おうとしているサイトを運営していて、そこに「TwitterでAppEngine関連についてつぶやかれた内容を収集する」という機能を実装しました。しかし、つぶやきを保存する際の投稿者の情報として「Name」を保持しているものの「ScreenName」を保持しておらず、投稿者のタイムラインページへのリンクを作成できないという問題がおこっていました。なので、つぶやき保存用のエンティティに「ScreenName」という属性を新たに追加し、つぶやきの保存時にはその値を取得するように修正しました。しかし、この修正以前に保存されたつぶやきについては、ScreenName属性を持たない状態になってしまっているので、その値を設定してやる必要があります。今回はこれをバッチ処理しようと思います。
あ、この問題は、バッチ処理の説明(このエントリ)を書くための布石であって、不具合では無いんですよっっ?

全てのエンティティにスキーマバージョンを保持する

主に今回の修正のような「属性の追加」が危険なのです。AppEngineでは「特定の属性を持たないエンティティ」というスキャンができません。インデックスを定義していても、そのインデックス定義に関する属性を持たないエンティティはそもそもインデックスのエントリが作成されないからです。そこで、エンティティの定義にスキーマバージョンを最初から用意しておく事をおすすめします。今回の修正だと、エンティティ用のクラスの修正は以下のようになっています。

ローカル環境からデプロイ環境へ直結してバッチ処理を実行

移行処理をしてやるエンティティの抽出は上に書いた「スキーマバージョン」の存在により、簡単に抽出できるようになりましたので、移行処理の準備は問題ありません。
が、移行処理自体はどのような手段を使うのか?が問題です。AppEngineでは1リクエスト30秒の制限があるので、一度に大量のデータは処理できません。

  • 特定の件数で制限してcronする?
  • 特定の件数ずつばらしてtaskqueueを使う?
まぁ、どちらでも可能です。でも、本来動作させたいプロダクトコードとは関係ないモジュールをサーバにデプロイするのもあんまり気持ちよいものではありません。そこでローカル環境からバッチ処理を行おう、というのが今回の趣旨です。データストアに対する一回の操作(Datastore#put()とかDatastore#get()とか、データストアサービスに対する一回のメソッド実行)に関しては30秒制限がある事は変わりませんが、それらを何度でも実行できるので、処理全体としては30秒制限も関係無くなりますしね。今回の処理だと、以下のような手順を行います。

  1. schemaVersion == 1の条件で移行対象のエンティティを取得する。
  2. 念のため、それらのエンティティのバックアップを保存しておく。
  3. UserId(Twitterのユーザ)で集約し、Twitterのユーザに対応するscreenNameを取得して、そのユーザに該当するエンティティを以下のように更新する
    • 取得した screenNameを設定
    • スキーマバージョンを2に設定
  4. 更新したエンティティをデプロイ環境へ保存する。

それぞれの手順内の細かい処理は省いたスケルトン部分のコードは次のようになります。

setUpBeforeClass(); // ローカルのAppEngine環境を開始する
try {
  // デプロイ環境のMakeSyncCallServletに接続するためのアカウント情報を入力させる
  getAccountInfo();
  // 移行対象のエンティティを取得する
  final List<Tweet> tweets = getOldEntities(); 
  // ローカルのデータストアにバックアップを作成する
  backupToLocalDatastore(tweets);
  // データ移行済みのエンティティを作成する
  final List<Tweet> updated = createUpdatedEntity(tweets, 50);
  // デプロイ環境側のデータストアに、データ移行済みのエンティティを保存する
  MakeSyncCallServletDelegate.runInDelegateWithAuth(new Runnable() {

    @Override
    public void run() {
      executeBatch(updated);
    }
  }, email, password, SERVER, SERVLET);
} finally {
  tearDownAfterClass(); // ローカルのAppEngine環境を終了する
}

デプロイ環境との直結には、デプロイ側にMakeSyncCallServlet、ローカル側MakeSyncCallServletDelegateという、自作のリモート接続の仕組みを使っています。ソースコードの全体は次のリンクから見る事ができます。このソースだと、AppEngineの制限よりもTwitterの制限が回避できなくて困ったりするんで、そのあたり上限の件数を設定したりしていますけど、実際にアプリ内の用件でバッチ処理を行うときにはあまり関係ないですね。あと、処理後にデプロイ環境で使われているMemcacheを全てクリアしたりもしています。プロダクトコード内の処理が走らない限りMemcacheにキャッシュされたデータを使用する設計も多いだろうし、そういう事も想定されるならMemcacheのクリアも忘れずに実行しておかないといけません。

MakeSyncCall関連については以下の資料を参考にしてください。LT用であんまり詳しくはないですけれども、大枠は資料の通りです。実際にはこのサーブレットをweb.xml内でセキュリティをかけて配置しています。

ちなみに、この直結の仕組みを使うとクライアント側アプリケーションから直接Datastore.query()..とかDatastore.put()...といった操作をする、昔のクラサバのような組み方でアプリケーションを作れるって事ですね!

2009年10月17日土曜日

#appengine java night #2( #ajn2 )に参加した

ゲットしたノウハウ

  • JDOのEntityGroupで、親Entityに子Entityを保持するOwnedと、KeyのみでのEntityGroupの構築、でパフォーマンスに倍くらいの差がある。
    • これは気づいてなかった!
  • OpenCVの存在

@yuroyoroがスピーカーの勇姿を激写した

感想

参加人数が前回よりはるかに多くなりそうだった&経験者比率が低くなりそうだったので、前回ほどプレゼン中の質問とかが出ないかも?という心配をしていましたが、結構質問も出ていいカンジになってくれて良かったです。ぶいてくのたけざきさんがいい具合に突っ込みを入れてくださったのにも助けられました。また前回同様、ところどころひがさんが補足or質問してくれる形式は良いですね。また、今後発表者となる方は、資料は普段の1.5-2倍時間がかかると思ってくださいw 今回は1.5倍かかりますた。

shin1ogawaの発表資料

自分の内容は経験者向けのトーク、という事で未経験者や前回参加していない方には不親切だったと思います。そのあたり申し訳なかったです…といいつつ、今後スピーカーをやるとしても、そういった路線でやっていきますのでよろしく!
とはいえ私での不手際もやってしまいました。会場のタイムテーブルの流れにあわせて本編で話すべき自動テストのプレゼンとLTを入れ替えて話したんですが、LTの方は自動テストの話ありきで書いていたのです。それなのに逆にしてしまった。そんなワケでLTに関してはかなり「?」となった人も多かったと思います、すんませんすんません。LTの方は、「RemoteAPIの代わりとして使える仕組みを作ったよ!」て話でした。

感謝

めちゃくちゃカッコイイ会場を提供してくださった株式会社リクルート様、リクルートメディアラボの川崎様、大変ありがとうございました。また今回も開催に関するしきりをしてくださったスティルハウス佐藤さん楠元さん(10/19追記:漢字を間違っていたので修正しました、失礼しました)、貴重なお話をしてくださったひがさん、ありがとうございました!

宣伝

マッシュアップと言っても、以前のような「複数のサービスを組み合わせる」といった意味ではなく、最近は「いろんなPlatform、デバイスで動作させる」などなど広い意味で使われるそぅです。対象となっているAPI、Platformのどれかひとつでも使っていれば参加できるそーなので、皆さんも参加してみましょう!もちろん、Google App Engineも対象です。…というワケで、自分も登録しました。嫁ちゃんとペアで参加しようかなーと思います。