2010年1月23日土曜日

#appengine ja night #4( #ajn4 )に参加した

1/22(金)にappengine java night #4が開催され、今回も参加してきました。今回からは、javaに拘らないappengineネタを!という事で、appengine ja nightという名称に変えて開催されることになりました。また今回はajn恒例の「LTを肴に懇親会」については、交流メインでTalk少なめ!という方向で参加者同士の交流もしやすかったです。

ゲットしたノウハウ

  • モデルのリファクタを可能にするための、トレーサビリティは重要
  • 最も説明したい内容の理解を進めていくために、超簡単な内容から始めて少しづつ変化するステップアップを徐々に積み重ねて説明する事はとても良い
  • appengineのデータストアのメンテナンスに、コンソール形式のインタプリタはかなり便利
  • 御座候のあんこは甘さひかえめ

浅海智晴さん(@asami224): DSL駆動開発×AppEngine

どこにappengineのお話が…?というカンジだったけど、自分もMDAな人なのでモデグラミングの話はとても面白かった。その中でも心に残った点がふたつあった。

ひとつは「テキストでモデリングできて、それをセマンティックに解析できるとリファクタも容易になる」ということ。確かにそうで、リファクタとかも大事だけどなにより記述もお手軽。最近同僚から教えてもらったQuick Sequence Diagram Editorというツールはテキストでシーケンス図を記述できるツールなんだけど、これが大変便利。実際には書ける・見れるでは機能として不十分で、「ソースを解析して取り出すAPI」「Webブラウザ等で共有できる」といった機能が提供されていないと今風の開発環境としてはベストとは言えないんだけど、その最初のステップを踏み出せている良いツールです。これが、シーケンス図だけではなくてユースケース、コラボレーション、アクティビティ、ステートチャート図あたりにも欲しいところ。逆に実装に近いクラス図なんてものは必要ないかなぁ。ソースを書いてIDEから追えるものはどうでもいいかな。

もうひとつ心に残ったのは、「全部を自動生成しようなんて思ってない」という点。ジェネレーションギャップパターンとかもあったりするけど、よほど大規模で無い限りはそれすら必要ないかも。テストケースが残っていれば、実装ってのはそんなに時間がかかるものでもないので、設計の変更があった場合はテストケースに変更を加え(ここが自動生成であればありがたい)、自動生成後に実装を作り直しても良いと思う。フレームワークがあって実装に必要なライブラリも揃っていて、そこへテストケースがあれば実装はそんなに手間ではないと思うし。自分としては設計から永続装用のソースとテストケースが自動生成されて、後はちょっとしたスケルトンが自動生成されれば片方向の方が使い易いかな。

…と、浅海先生が伝えたかった内容をひとつもキャッチアップできていない気がしないでもないw だってappengineの話が無(ry…まぁ、受け止め方は人それぞれという事で。ちなみに、資料の半分程度しか話を進められなかったかぁ、とか思ってたけど甘かった。公開された資料を見る限り、1/3くらいしか進んでいなかったようです!!

あらかわ(@ashigeru)さん:Transaction Puzzlers

説明の内容より、説明の進め方に感動しました。伝えたかった内容は、一番最後のBASE Transactionの話ただひとつだったのだろうな、と。それを導くためのステップが素晴らしかった!!単純な操作についてパターンとしての命名をし、擬似言語を使ってシンプルな記述でステップをすこしずつ進めていく…。資料は公開されているので、永久保存されると良いと思います。

誰かがashigeru言語をappengineプラットフォームで動かしてくれるんだと思います(他力本願)。

あらかわさんと言えば、会社を上げてappengineに取り組んでおられて、その技術力も一番であると思われるグルージェント社なのだが、appengineエンジニアとして腕に覚えがある方は狙ってみてもいいのかもしれませんね。

@urekatさん:jruby関連

JRubyの方のトークで、自分としてはやっぱり「irbでmakeSyncCall()」が印象的だった。会場も大いに湧きましたが、実際コマンドラインベースでちょこっとデータストアのデータを処理するにはLL系のインタプリタは大変便利ですね。

後うれしいなと思ったのは資料にも書かれている内容で「Low level API を知る=DataStoreの仕組みを知る」という風に理解をしていく人が増えてきた事。ちょっと何か作るとかならいいんだけど、それなりにappengineプラットフォーム向けのアプリを組むのであれば、JDOから入るのは間違いの元ですね。@urekatさんはTinyDSというLLAPIのラッパを作られているようで、それを通じて理解されたようです。JRubyでのスタンダートとなると良いですねぇ。

資料も公開されています

@shin1x1さん:ホッテントリを集計するサービス

ajnのために大阪から来ましたというハッタリ、PHPerとデカデカと紹介しつつ今回のトークネタはPythonだったり、素晴らしい方でした。ネタのメインは御座候botというbotの紹介と、はてなのホッテントリを集計分析するサービスの紹介。

御座候botはあえてリンクを用意しませんが、気になる方は"御座候"とつぶやいてみましょう。

ホッテントリの集計をするサービスはhttp://hot.hatebu.jp/です。なんとこのサービス自身はホッテントリ入りしていないらすぃ。数日前に自分もTLでこのサービスの立ち上げを知ったので有名なサイトだと思ってたけど、まだはてブは少ないようで勿体無い。って事で自分も微力ながら協力できればと思い、deliciousにぶくましておきました。

@kimteam

いつものように、@kimteaがTLのまとめをしてくれています。こういうの大事なので感謝です。

感謝

第2回でも会場を提供して下さり、今回もまた会場を提供してくださった株式会社リクルート様、リクルートメディアラボの川崎様、大変ありがとうございました。いつも開催に関するしきりをしてくださるスティルハウス佐藤さん、ありがとうございます&次回もよろしくお願いいたします。そして、スピーカーの皆様、面白いお話を大変ありがとうございました!!

過去の参加報告

追記

本文中でappengine ja nightに名称を変更した、とか書いてた癖にエントリのタイトルがappengine java nightのままでした。@bluerabbit777jpさんに指摘いただき修正しました。

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月20日水曜日

MightyMeetingが面白い

MightyMeetingというサービス&アプリが面白いので紹介を。TechCrunchで紹介されているのを見て、試してみた。

何ができるかというと、サービス上にアップロードしたPDF等を、他の複数のPCから見る/見せることができる。これだけなら大概のストレージサービスと変わらないわけで、正確に表現すると「アップロードしたファイルの再生中のセッションを複数のPCから見る/見せることができる」というコト。再生は、iPhoneアプリから行うことができる。

手順

iPhoneからスライドを操作し、PCを使っている人にその再生状況を見せる、という手順を試してみる。

  1. PCからMightyMeetingのWebページを開き、SignUpする。
  2. 動作を試すために、Webページでログイン後に表示される画面で、適当なPDFをUploadしておく。
  3. iPhoneのAppStoreで"Mighty Meeting"でアプリを検索し、インストールする。
  4. アプリを起動すると以下の左側の画像のように、Uploadしておいたファイルが表示される。

    ファイル一覧からファイルを選択すると右の画像のようにスライドの一覧が表示される。右の画面の状態で、左下のアイコンをクリックする。
  5. するとセッションを開始するかメールで送信するかの選択肢が表示されるので、"Meet Now Online"を選択する。
  6. セッションが開始されると最初のスライドが表示され、以下の左の画像のような画面の状態になる。

    左の画面の状態で画面下の"People"ボタンをクリックすると、右の画像のようなセッションの参加者の一覧が表示される。最初は自分しか表示されていないので、右上の"Invite"ボタンをタップしてセッションに参加させたい人へメールを送信する。
  7. メールを送信したアドレスに、再生中のセッションを見るためのWebページのURLが送信されるので、受信者はそれをクリックしてブラウザで開いてセッションに参加するコトができる。その際、ユーザ名の入力を求められるが、MightyMeetingのアカウントを持っている必要はない。
  8. 参加者が増えると、以下の画像のように"People"の画面にユーザ名が表示される。

    "People"画面から左下のスライドアイコンをタップするとスライドに戻る。iPhone上のスライド表示状態でスライドをドラッグしてページを進めたり戻したりすると、その操作がPCで見ている参加者のブラウザ上の画面にも反映される。
  9. 参加者が増えたり、参加者がCharを送信するとiPhone側でもそのコトが通知される。

こんなカンジのサービス&アプリです。なにげに便利そうですね。勉強会などでプレゼンをする際に「画面やスクリーンが遠くてみづらいなど、自分のマシンで見たい人はこのリンクを開いてねー」とTwitterなりMLなりにリンクを送信して、プレゼン用のスライドに合わせてiPhoneやらでも操作する、てのもアリかな。ただ、音声は送れない(?)のと、チャットのインターフェースが残念であるのが少し勿体無い。PC用の画面ではチャットを開くとスライドと表示が切り替わっちゃうので、不便なんだよね。Gmail内のGoogleTalkみたいにポップアップしてくれるとかなり良いカンジなんだけど。音声とチャットが両方便利になれば、かなりスゴイかもしれないなぁ。

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月17日日曜日

Programmers Beer Nightに参加してきた

Programmers Beer Nightというイベントに参加させていただいた。色んな分野の色んなスゴイ方々が集まった面白い集いになっており、参加出来てとても光栄ですた。2celebさんKZOO1972さん、お誘いいただきありがとうございました!!&遅刻してゴメンナサイ、参加前に既に懇親会をひとつ済ませていたら時間を読み違えました。

当日はビール他色々飲み放題でしたが、開催者のおふたりの交渉によってヒューガルデンまで飲み放題でかなりお得なカンジでした。お店は六本木 Public BAR Abbot's Choice アボットチョイス - 六本木/パブ、バー、西洋各国料理(その他) [食べログ/公式]で、気さくな店員さんたちが印象的な、色んなビールを楽しむことができる落ち着いたカンジのお店です。フィッシュ&チップスが人気メニューで、お魚が日替わりになるようです。自分は芋しか食ってませんでした、なんか勿体無かったなぁ。
ヒューガルデン大好きな方のためにひとつ面白いメニューを紹介しておきます。ギガヒューガルデン(?)というものがあり、、10分以内に飲みきれば無料になるそうです。その量4.5Lですが、チャレンジしてみてはどうでしょうか。




当然、呑みきれなかったら代金を支払う必要がありますが、そーなってもお得な価格設定になっています。酔っていたのでいくらぐらいだったか覚えてませんけど。
2celebさんのにこやかな表情と合わせてさらに紹介しておきます。

追記

2celebさんがコメントで教えてくれた情報で、ギガヒューガルデンは\8,500だと判明しました。量的には\14,000分だそうで、それが\8,500です、スゴイですね。そんなに飲むか?とかそのあたりはおいとくとして、お得ですね!

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月7日木曜日

「これなしでは生きていけない」ウェブ・サービスやガジェット

今年もTechCruchに新春吉例第5回:私が毎日使うお気に入りのサービスとガジェット2010年版 という記事が上がっていたので、影響されて自分なりの物も書いてみる。ちなみに去年の年始にもtechcrunchに影響されて書いた

Slimtimer

去年に引き続き登場。Time Trackをするサービスで、ASlimTimerという、嫁ちゃんが作ってくれた専用クライアントを使っている。 去年はSlimTimerの紹介と同時に次のような事を書いていた。

これが全ての行動のフロントとなる…なるべき!と考えている。こいつでの操作を全てFriendFeedやTwitterに流す事で自分の行動を細かく記録できる…特に社内での行動はそうすべき!と考えている。行動を見せる事からやらないと、社内の可視化や透明化の根本的な部分が実現できないと思う。

これについてはまだ実現できていない。今年は去年開始されたGoogle App Engine/Javaを使ってSlimTimerのクローンを立ち上げようかなーと思っている。

FriendFeed

去年に引き続き登場。去年からこのサービスへの依存度が大幅に上がった。詳しくは別途記載。

クライアントソフト:空うさぎ

通知機能が強力なFriendFeed/Twitter/RSS専用クライアント。これも嫁ちゃんが作ってくれたソフト。以下のようにして使っている。

  • FriendFeedのリアルタイム通知、コメントのPost
  • Twitterのひととおりの操作。去年末くらいからFriendFeed経由のTwitterフィードがかなりリアルタイムな状態に戻ったから、TwitterのFetchはあまり必要なくなってきた。
  • Gmailに届いた新着メールの通知。Basic認証を使ったRSSを登録することで、AppsのGmailの通知もできるのが便利。
  • Redmine/Trac等のITSのプロジェクトタイムラインの通知。
  • GoogleAlertのRSS配信を通知。

後半みっつはRSSで配信されていたらなんでも通知できるから機能的にはひとつなんだけど、使い勝手はとても良い。 普段のRSSリーダに登録しているものはGoogle Readerで読んでいるけど、リアルタイム性の高いものは空うさぎに登録するのが便利。 キーバインドも細かくカスタマイズ(hjklバインドとか)できるし、Growlっぽい通知も細かくカスタマイズ(特定の条件の場合だけクリックを必要にしたり、表示時間を変えたり)できて嬉しい。
こんなカンジで自分になじむ専用クライアントがあるため、極力このクライアントの対象であるFriendFeedで色々やる事になって依存度が上がった。 元々Socialな機能を使っていなかったTumblrでやっていたことはFriendFeedに移行してしまった。

Twitter

去年の引き続き登場。国内の一般メディアでもよく取り上げられ始めたみたいだ。 去年の夏ごろに立ち上がったAppEngine Ja Nightという活動(コミュニティ?)でも、Twitterが前提となっている雰囲気があったりする。
去年はこんな事を書いていたようだ。

また、社内の可視化に使うための「準」フロントにもなるとずっと考えていたけど、最近の各種サービスのローンチを見ているとどぅやらその考え方は正しいよぅだ。だけど、実際のフロントは「Time Track」で、その内容を「Microblog」でフィード、一部コメントはそこへの返信で…といぅ形が最も自然に使えて浸透し易いシステムじゃないかなーとか考えている。

Gmail, Google Calendar, Google Reader, Google Sites, Blogger

当然このあたりには依存しまくっている。Gmail, GoogleCalendarはiPhoneとSyncの設定をすることで便利に使っている。 Google ReaderはiPhoneのByLineというアプリを使うことで移動中などのちょっとした空き時間でフィルタを行い、 PCでフィルタして読む対象にした記事(スターをつけてる)をまとめて読んでいる。去年後半からこの使い方をしていた。

Remember The Milk

これも去年に引き続き有料サービスの方を利用中。iPhoneでもTODOというRemember The Milkと同期できるアプリがあり、それを使ってより便利になった。
ちなみに去年は以下のような事を書いていたようだ。

個人的なタスク管理。かなり優秀で、有料サービスの方を使用している。今はEclipseのPluginを作りかけていたりもする。Remember the MilkのタスクをTimeTrackingに取り込んで使用するのが気持ちいい。 困っているのは、個人のタスク管理以外のタスク管理はまた別に存在していて、TracやらRedmineやらJIRAやらも併用しているという事。あ、ついでに書くと、仕事でのタスクは必ず1タスク6h以内をメドに分割して使う。進捗率は0% or 100%みたいなカンジで使うのがリズムが良い(50%の状態もあったりするけどw)。

ガジェット:iPhone 3G

去年も登場。ByLine(App)を使ったGoogleReaderとの連携、ToDo(App)を使ったRemember the milkとの連携、NatshLiphone(App)を使ったTwitterの利用、GoogleアカウントのSyncを使ったGoogleCalendar, Gmail, コンタクトリストとの連携…。 そして去年以上に便利になったのは散歩の時のGPSの利用。徒歩ルート検索の精度が高くて面白い。

クライアントソフト:Chrome

Safariがメイン・Firefoxがサブだったのが、Chromeがメイン・Safariがサブとなった。 FirefoxはFirebugを使いたいときだけ起動する程度だけど、ChromeにFirebugのInspectの機能が入ったらFirefoxはもっと出番が減りそう。 今はChromeのExtension機能が復活するのを待ってるトコ。Xmarksと1passwordが動作してくれないとツライ。

各種エゴサーチっぽいアナライザ

自分がアウトプットした内容に興味を持った人を逆探知して、その人のアウトプットを自分の新たなアンテナに追加するのが大変便利になったと思う。 TwitterのRTやらなにやらたくさんのサービスが登場して便利に鳴ったなー。

まとめ

他にも去年紹介した中で引き続き使っているのは「Evernote」「delicious」あたり。去年登場したけどほとんど使わなくなったのは「Tumblr」「Passpack」「Assembla」あたり。 TumblrはFriendFeedで、Passpackは1passwordで、AssemblaはSF.jpでそれぞれ置き換えられたカンジ。

去年の同じ記事では以下のようなまとめを書いていた。

ちなみに、技術者であればたくさんアンテナを広げて色んなサービス(そのローンチも)を「知っているか」「見たか」「触ったか」という事がめっちゃ大事だと思う。そういった様々なサービスの登場を細かくチェックして、見たり触ったりして、「どんな使い方をさせるのか」「どんな便利なUIを持つのか」…といった「パターン」を吸収して行くべきだ。こういった知識と経験なが無いと、絶対に良いものは作れないし提案も出来ない、と感じてる。

今年も同様にアンテナを広げて色々見ていきたいと思います。

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さんが誤字を指摘してくれましたので、修正しました。