2013年2月22日金曜日

21.Google Cloud Endpointsを試してみた (2/3)

このエントリーをはてなブックマークに追加




Ryo Yamasaki(@vierjp)です。

今回は、前回の「Google Cloud Endpointsを試してみた (1/2)」の続きです。

前回はEndpoints使用時のサーバー側について書きましたが、
今回はEndpointsの真骨頂(たぶん)、「自動生成されたクライアントライブリを使ってAPIを実行する」を
Androidで試してみました。

と言っても悩んだところやハマったところはだいたいサーバー側で、
Endpointsに関するクライアント側の実装は本当に簡単です。


◯Androidアプリの環境設定

・プロジェクトの作成

eclipseで「新規」→「Android」→「Android アプリケーション・プロジェクト」で
プロジェクトを作成する。

対応するAndroidのバージョンは
・「最小必須SDK」はAPI 8 (Android 2.2 Froyo)
・ターゲットSDKは API 17 (Android 4.2 Jelly Bean)
としました。
(この範囲のバージョンで動けばいまどき十分なんじゃないかな、ということで)

・AndroidManifest.xmlにパーミッションを追加

Endpoints を使うために必要なパーミッションはインターネットアクセスのみです。
<uses-permission android:name="android.permission.INTERNET"/>

・依存ライブラリの配置

サーバーアプリプロジェクトで生成したクライアントコードのディレクトリ「endpoint-libs」以下、
前回の例なら「PROJECT_LOC/endpoint-libs/libnewTestEndpoint-v2/newTestEndpoint/libs」
から下記のファイルを取得してAndroidプロジェクトの「libs」にコピーします。
・google-api-client-1.13.2-beta.jar
・google-oauth-client-1.13.1-beta.jar
・google-http-client-1.13.1-beta.jar
・guava-jdk5-jdk5-13.0.jar
・jsr305-1.3.9.jar
・google-http-client-gson-1.13.1-beta.jar (when using GSON)
・gson-2.1.jar
・google-api-client-android-1.13.2-beta.jar
・google-http-client-android-1.13.1-beta.jar

参考:
Using Endpoints in an Android Client - Google App Engine — Google Developers

・生成されたクライアントコードの配置

・Android側プロジェクトの直下に「endpoint-libs」フォルダを作成する 
・「endpoint-libs」直下からサーバー側の「libnewTestEndpoint-v2」フォルダにリンクする
 「endpoint-libs」を右クリック→「新規」→「フォルダー」
 →「拡張」→「Link to alternate location」をチェック
 →「参照」でサーバー側プロジェクトの「endpoint-libs/libnewTestEndpoint-v2」を選択してOK。
 (ワークスペースのディレクトリは「WORKSPACE_LOC」という変数でも指定できます)



この機能は「Linked Resources」という、eclipse内でのシンボリック・リンクのようなものです。
通常は「自動生成されたクライアント・ライブラリ」のフォルダを
サーバー側プロジェクトからクライアント側プロジェクトにコピーするのですが、
「Linked Resources」にしておけば
「クライアント・ライブラリ」を再生成した際に毎回クライアント側にコピーする必要が無くなります。
Endpoints以外でも便利に使えるタイミングがあるので地味にオススメです。

・ビルド・パスの設定

プロジェクトを右クリック→「プロパティ」→「Javaのビルド・パス」→「ソース」タブ→「フォルダの追加」で、
「endpoint-libs/libnewTestEndpoint-v2/newTestEndpoint/newtestendpoint-v2-generated-source」
を追加。
これによってサーバー側で生成したコードをAndroid側で使用できるようになります。


これでAndroidでEndpointsを利用するための準備が整いました。


◯Android側の実装サンプル

・クライアント・ライブラリの実行例

前回書いたのと同じですが、
クライアント側からの呼び出しは簡単で以下のように自動生成されたメソッドを呼び出すだけです。
NewTestEndpoint.Builder builder = new NewTestEndpoint.Builder(AndroidHttp.newCompatibleTransport(),
   new GsonFactory(), null);
mEndpoint = builder.build();
ListData listdata = mEndpoint.listData().setLimit(20);
CollectionResponseNewTestV2Dto response = listdata.execute();

・実装サンプル

作ったのはAppEngineのカーソルを使ったシンプルなオートページングのサンプルです。
よく見る「一定件数ずつデータを取得して必要に応じて続きを取得する」機能ですね。


・MainActivity.java

public class MainActivity extends Activity {
 private static final String TAG = "EndpointsAndroidSample";
 /**
  * ページング件数
  */
 private static int PAGING_LIMIT = 20;
 /**
  * Endpoint
  */
 private NewTestEndpoint mEndpoint;
 /**
  * ListView
  */
 private ListView mListView;
 /**
  * ListViewのフッター(ローディング画像の表示)
  */
 private View footer;
 /**
  * ListViewが使うAdapterクラス
  */
 private TestAdapter mAdapter;
 /**
  * ListViewに表示するListデータ
  */
 private List<NewTestV2Dto> mList;
 /**
  * 次ページのデータを取得するためのCursor(AppEngineの)
  */
 private String mCursor;
 /**
  * Endointからデータを取得するためのAsyncTask
  */
 private AsyncTask<Void, Void, CollectionResponseNewTestV2Dto> mTask;

 @Override
 protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);

  // ListViewを生成
  ListView listView = (ListView) getListView();
  listView.addFooterView(getFooter());
  listView.setAdapter(getAdapter());
  listView.setOnScrollListener(new OnScrollListener() {
   @Override
   public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
    if (totalItemCount == firstVisibleItem + visibleItemCount) {
     Log.d(TAG, "onScroll");
     additionalReading(false);
    }
   }

   @Override
   public void onScrollStateChanged(AbsListView view, int scrollState) {
   }
  });

 }

 private ListView getListView() {
  if (mListView == null) {
   mListView = (ListView) findViewById(R.id.listview);
  }
  return mListView;
 }

 private View getFooter() {
  if (footer == null) {
   footer = getLayoutInflater().inflate(R.layout.listview_footer, null);
  }
  return footer;
 }

 private void visibleFooter() {
  getListView().addFooterView(getFooter());
 }

 private void invisibleFooter() {
  getListView().removeFooterView(getFooter());
 }

 private TestAdapter getAdapter() {
  if (mAdapter == null) {
   mAdapter = new TestAdapter(this, R.layout.list_row, getList());
   additionalReading(true);
  }
  return mAdapter;
 }

 private List<NewTestV2Dto> getList() {
  if (mList == null) {
   mList = new ArrayList<NewTestV2Dto>();
  }
  return mList;
 }

 /**
  * ListViewのデータをクリアする(reload用)
  */
 private void clearList() {
  if (mList == null) {
   return;
  } else {
   mList.clear();
  }
 }

 private void additionalReading(boolean isForce) {

  Log.d(TAG, "additionalReading isForce:" + isForce);

  // 既に読み込み中ならスキップ
  if (mTask != null && mTask.getStatus() == AsyncTask.Status.RUNNING) {
   Log.d(TAG, "running asyncTask.");
   return;
  }

  // 読み込み回数が最大値以上ならスキップ。
  if (isForce == false && mCursor == null) {
   Log.d(TAG, "got AllData.");
   return;
  }
  // AsyncTaskを実行してデータを取得する
  mTask = new QueryDataTask(this, PAGING_LIMIT, mCursor).execute();
 }

 /**
  * ListViewのデータを追加する
  */
 private void addListData(List<NewTestV2Dto> addList) {
  List<NewTestV2Dto> list = getList();
  list.addAll(addList);
 }

 private class QueryDataTask extends AsyncTask<Void, Void, CollectionResponseNewTestV2Dto> {

  private Context context;
  private int limit;
  private String cursor;

  public QueryDataTask(Context context, int limit, String cursor) {
   this.context = context;
   this.limit = limit;
   this.cursor = cursor;
  }

  @Override
  protected CollectionResponseNewTestV2Dto doInBackground(Void... unused) {
   CollectionResponseNewTestV2Dto response = null;
   try {
    if (mEndpoint == null) {
     NewTestEndpoint.Builder builder = new NewTestEndpoint.Builder(AndroidHttp.newCompatibleTransport(),
       new GsonFactory(), null);
     mEndpoint = builder.build();
    }
    ListData listdata = mEndpoint.listData().setLimit(limit);
    if (cursor != null) {
     listdata.setCursor(cursor);
    }
    Log.d(TAG, "execute API.");
    response = listdata.execute();

    return response;
   } catch (IOException e) {
    // Log.d(TAG, e.getMessage(), e);
    // throw new RuntimeException(e);
    Toast.makeText(context, "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show();
    return null;
   }
  }

  @Override
  protected void onPostExecute(CollectionResponseNewTestV2Dto response) {
   if (response == null || response.getItems() == null) {
    return;
   }
   // 結果のListデータを取得する
   List<NewTestV2Dto> items = response.getItems();
   // ログ
   for (NewTestV2Dto dto : items) {
    Log.d(TAG, "onPostExecute - " + dto);
   }

   // 結果のListデータをセットして描画
   addListData(items);
   getListView().invalidateViews();

   // カーソルを保持する
   mCursor = response.getNextPageToken();
   // カーソルを取得できなければ次ページは無いのでfooterを消す
   if (mCursor == null) {
    invisibleFooter();
   }
  }
 }

 @Override
 public boolean onCreateOptionsMenu(Menu menu) {
  // Inflate the menu; this adds items to the action bar if it is present.
  getMenuInflater().inflate(R.menu.main, menu);
  return true;
 }

 @Override
 public boolean onOptionsItemSelected(MenuItem item) {
  Toast.makeText(this, "Selected Item: " + item.getTitle(), Toast.LENGTH_SHORT).show();
  if (item.getItemId() == R.id.action_reload) {
   clearList();
   visibleFooter();
   new QueryDataTask(this, PAGING_LIMIT, null).execute();
  }
  return true;
 }
}

・TestAdapter.java

public class TestAdapter extends ArrayAdapter<NewTestV2Dto> {
 private List<NewTestV2Dto> items;
 private LayoutInflater inflater;
 private int textViewResourceId;

 public TestAdapter(Context context, int textViewResourceId, List<NewTestV2Dto> items) {
  super(context, textViewResourceId, items);
  this.textViewResourceId = textViewResourceId;
  this.items = items;
  this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
 }

 @Override
 public View getView(int position, View convertView, ViewGroup parent) {
  View view = convertView;
  if (view == null) {
   // Viewがnullなら新しくビューを生成
   view = inflater.inflate(textViewResourceId, null);
  }
  // 表示すべきデータの取得
  NewTestV2Dto item = items.get(position);
  if (item != null) {
   // ID
   TextView idView = (TextView) view.findViewById(R.id.textview_testlist_id);
   if (idView != null) {
    idView.setText(item.getId());
   }
   // 名前
   TextView nameView = (TextView) view.findViewById(R.id.textview_testlist_name);
   if (nameView != null) {
    nameView.setText(item.getName());
   }
   // 作成時刻
   TextView createDateView = (TextView) view.findViewById(R.id.textview_testlist_createDate);
   if (createDateView != null) {
    String dateString = item.getCreateDate().toString();
    createDateView.setText(dateString);
   }
  }
  return view;
 }
}


作成したのはこの2クラスのみです。(リソースのxmlは除く)
 やはりAPIの通信部分が無くなると実装量は減ります。
 APIやEndpoints に関連するのは「MainActivity.java」の157行目から167行目の呼び出し部分くらいで、
それ以外のコードは純粋にAndroidのコードです。

ほとんど下記ページを参考にさせてもらいました。
visible true: ListViewで最後尾までスクロールしたら自動的に要素を追加読み込みするサンプル

多少でも手を入れてるとソースまるごと貼り付けるのは勇気が要りますね。
私は一応Androidディベロッパーですが、Androidの経験は少ないのでお手柔らかに。(;´∀`)

本当はデータの追加・更新・削除も作りたかったんですが、
慣れないAndroidの実装が大変で力尽きました。。
まあEndpointsを試す目的においてはこれで十分かな、と。
やってみたかったオートページングはやりきったゾ。(`・ω・´)


・おまけ

・Java Scriptでのクライアント実装方法

Using Endpoints in a JavaScript Client - Google App Engine — Google Developers
今回試していませんが、Android以上に楽そうな気がします。
こっちでサンプル作るべきだったかも。

・iOSでのクライアント実装方法

Using Endpoints in an iOS Client - Google App Engine — Google Developers

iOSに関してはこちらのサイトで詳しく解説されています。
「Google Cloud Endpoints」 - Web先端技術味見部 - NAVER まとめ


・まとめ

Androidのクライアント側の実装内容は以上です。
重要なところは前回で書いてしまっていた気もしますが、
特にクライアント側に関しては環境設定の作業もAPI呼び出し部分の実装量も少なく、
実装がかなり楽になりそうに感じました。

個人的には、あとはFrontend Cacheが動作することさえ確認できれば、
自前でAPI作る形式から乗り換えるのはアリかな、と思ってます。

特にiPhone・Android両方に対応する必要があるようなケースや、
Endpointsが自動でやってくれている通信部分の処理を隠蔽する自前の基盤を現時点で持っていないなら、
Endpointsを使うことで結構な工数を削減できるように思います。


ではまた(`・ω・´)ノシ


次回 「Google Cloud Endpointsを試してみた (3/3)」 はJavaScriptでのクライアント実装例です。




このエントリーをはてなブックマークに追加

2013年2月19日火曜日

20.Google Cloud Endpointsを試してみた (1/3)

このエントリーをはてなブックマークに追加




Ryo Yamasaki(@vierjp)です。

Google App Engine 1.7.5 から使えるようになった Cloud Endpoints を試してみました。
結構ボリュームが大きくなってしまったので、3部構成です。
今回は概要とサーバー側について、
次回がクライアントをAndroidにした場合の実装例、
三回目がクライアントをJava Scriptにした場合の実装例です。


◯概要

Google Cloud Endpoints は
App Engineのバックエンドからエンドポイントおよびクライアント・ライブラリを生成可能にするツール、
ライブラリ、および機能で構成され、Webアプリケーションへのクライアントアクセスを単純化します。

EndpointsはWebクライアントやAndroidやAppleのiOSなどのモバイルクライアント向けの
Webバックエンド(*1)を作成することを容易にします。

モバイル開発者に対しては、Endpointsは、共有Webバックエンドを開発するための簡単な方法を提供し、
また、OAuth 2.0認証などの重要なインフラストラクチャを提供することで
Endpointsを使わない場合に必要とされる多くの作業を不要にします。

さらに、バックエンドはAppEngineのバックエンドであるため、
モバイル開発者はAppEngineで利用可能な全てのサービスや機能、
例えばDatastore、Google Cloud Storage、Mail、Url Fetch、Task Queues等を使用することができます。
バックエンドにAppEngineを使うことで
開発者は「ロードバランス」「スケーリング」「サーバーメンテナンス」といった、
システム管理者の仕事から開放されます。

EndpointsなしでApp Engineのバックエンド用のモバイルクライアントを作成することは可能です。
しかしながら、Endpointsを使うことでApp Engineとの通信を処理するためのラッパーを記述する必要がなくなるため
このプロセスが容易になります。
Endpointsによって生成されたクライアント·ライブラリを使用すると、
単に直接のAPI呼び出しを行うことが可能になります。(*2)

*1.「バックエンド」はクライアントアプリに対応する「サーバーアプリ」の意味で理解すれば良いと思います。
  AppEngineの機能の「Backends」はここでは関係ありません。
*2.生成されたライブラリのメソッドを実行すれば透過的にAPIを実行して結果を取得することができます。(次項参照)

参考:
Overview of Google Cloud Endpoints - Google App Engine — Google Developers


◯クライアント側のAPI実行例 (Android用)

NewTestEndpoint.Builder builder = new NewTestEndpoint.Builder(AndroidHttp.newCompatibleTransport(),
   new GsonFactory(), null);
mEndpoint = builder.build();
ListData listdata = mEndpoint.listData().setLimit(20);
CollectionResponseNewTestV2Dto response = listdata.execute();
「NewTestEndpoint」「ListData」「CollectionResponseNewTestV2Dto」等のクラスは
サーバー上に作られたAPIに対応する形で生成されたクラスです。
このようにサーバーの存在を意識せず、
ライブラリのメソッドを呼び出すのと同様の感覚でサーバー上のAPIを実行できるわけです。
クライアント側の処理についての詳細は次回に。


◯必要なもの

・Google App Engine Java SDK  (1.7.5以上)
・Google Plugin for Eclipse (1.7.5と一緒にリリースされたバージョン以降)
・Android Development Tools (最新を使いましょう)

参考:
Overview of Google Cloud Endpoints - Google App Engine — Google Developers
Using Endpoints in an Android Client - Google App Engine — Google Developers


◯Endpointsの簡単な処理の流れ

触ってみた感じ、処理の流れとしては
  1. クライアントアプリから「クライアント・ライブラリ」のメソッドを呼び出す。
  2. Cloud Endpoints(「/_ah/api/*」)にアクセスする
  3. Cloud Endpointsが「GAEアプリに配置したServlet」(「/_ah/spi/*」)にアクセスする
  4. 「/_ah/spi/*」が「実装したサービスの処理」を呼び出して結果を返す
という流れになっているようでした。


◯今回やること

・サーバー側にCRUD(データの作成・取得・更新・削除)用のAPIを作成する (第一回)
 (とりあえずOAuth認証はなし)
・クライアントライブラリの生成 (第一回)
・GAEアプリのデプロイ (第一回)
・API ExplorerからAPIを実行して試してみる (第一回)
・Androidアプリを作ってそこからEndpointsを通してAPIを実行する (第二回)


◯サーバー側アプリの環境設定

・プロジェクトの作成

eclipseで「新規」→「プロジェクト」から、
「Slim3プロジェクト」もしくは「AppEngine用Webアプリプロジェクト」を選択して新規のプロジェクトを作成する。
今回DatastoreアクセスにSlim3を使いたかったのでSlim3プロジェクトにしました。
既存のプロジェクトにEndpointsを追加したい場合はそのまま設定を追加すればOKです。

・web.xmlの記述

「AppEngine用Webアプリプロジェクト」を作成すると最初からweb.xmlにEndpointsのための初期設定が書かれていますが、
 Slim3プロジェクトとして作成した場合でも同様の記述を書いてやればOKです。
 
  SystemServiceServlet
  com.google.api.server.spi.SystemServiceServlet
  
   services
   
  
 
 
  SystemServiceServlet
  /_ah/spi/*
  
 

・「init-param」の「services」のvalueは空でOK
Endpointのクラスを作成すると自動的に追記されるので最初は空でOKです。

・「<security-constraint>」はコメントアウトしています。
「<security-constraint>」の指定はドキュメントには書かれていますが自動生成されたweb.xmlには書かれていません。
 SSLでのアクセスをを強制する記述ですが、これをONにした場合、
 EndointsからGAEアプリにAPIの定義情報を取得しにくるため(?)にアクセスされる
「/_ah/spi/BackendService.getApiConfigs」へのリクエストが 正常に完了しないのでコメントアウトしました。

参考:Generating Client Libraries - Google App Engine — Google Developers

・Java7には対応していないようです

Java7の構成にして@Apiアノテーションをつけたクラスを作成すると以下のエラーが発生します。
--------------------------------------------------
ビルド中にエラーが発生しました。
プロジェクト 'EndpointsServerSample' でビルダー 'Google App Engine Project Change Notifier' の実行中にエラーが発生しました。
jp/vier/sample/endpoints/endpoint/v2/NewTestV2Service : Unsupported major.minor version 51.0
--------------------------------------------------
EndPoints関連の自動生成処理でエラーが発生するようです。

* 2013/04/12 最新のSDKとEclipse-Pluginを使ったところ、↑の問題は解消しています。


◯公開するサービスのクラスを作成する

・作成するメソッド

メソッド名処理概要
getDataユニークなIDを指定してデータを一件取得する
deleteDataユニークなIDを指定してデータを一件削除する
putDataユニークなIDと任意で「名称」を指定してデータを一件追加する
listDataデータを一覧で取得する。任意で「limit」と「カーソル」を指定可能

・APIとして公開するサービスクラス

@Api(
        name = "newTestEndpoint",
        version = "v2",
        description = "Test API description hogev2")
public class NewTestV2Service {

    private Logger log = Logger.getLogger(NewTestV2Service.class.getName());

    @ApiMethod(httpMethod = HttpMethod.GET)
    public NewTestV2Dto getData(@Named("id") String id) {
        log.info("test:v2 id:" + id);

        Key key = Test.createkey(id);
        Test test = Datastore.getOrNull(Test.class, key);

        NewTestV2Dto dto = new NewTestV2Dto();
        BeanUtil.copy(test, dto);

        log.info("test:dto" + dto);
        return dto;
    }

    @ApiMethod(httpMethod = HttpMethod.DELETE)
    public NewTestV2Dto deleteData(@Named("id") String id) {
        // log.info("books:" + books.toString());
        log.info("test:v2 id:" + id);
        Key key = Test.createkey(id);
        Test test = Datastore.getOrNull(Test.class, key);
        if (test == null) {
            return null;
        } else {
            Datastore.delete(key);
        }

        NewTestV2Dto dto = new NewTestV2Dto();
        BeanUtil.copy(test, dto);

        return dto;
    }

    @ApiMethod(httpMethod = HttpMethod.PUT)
    public NewTestV2Dto putData(@Named("id") String id,
            @Nullable @Named("name") String name) {
        log.info("test:v2 id:" + id + " name:" + name);

        Key key = Test.createkey(id);

        Test test = Datastore.getOrNull(Test.class, key);
        if (test == null) {
            test = new Test(key);
            if (name == null) {
                name = "name Of " + id;
            }
        }
        test.setId(id);
        test.setName(name);
        Datastore.put(test);

        NewTestV2Dto dto = new NewTestV2Dto();
        BeanUtil.copy(test, dto);

        log.info("test:dto" + dto);
        return dto;
    }

    @ApiMethod(
            httpMethod = HttpMethod.GET,
            cacheControl = @ApiMethodCacheControl(
                    noCache = false,
                    maxAge = 12000))
    public CollectionResponse<Newtestv2dto> listData(
            @Nullable @Named("limit") Integer limit,
            @Nullable @Named("cursor") String cursor) {

        if (limit == null || limit > 20) {
            limit = 20;
        }

        TestMeta meta = TestMeta.get();
        ModelQuery<Test> query =
            Datastore.query(meta).sort(meta.createDate.desc).limit(limit);
        // ページングの指定があれば指定
        if (cursor != null) {
            query.encodedStartCursor(cursor);
        }
        S3QueryResultList<Test> queryResultList = query.asQueryResultList();
        log.info("test:v2 queryResultList:" + queryResultList);

        // DTOに詰め替え
        List<Newtestv2dto> dtoList = new ArrayList<Newtestv2dto>();
        for (Test test : queryResultList) {
            NewTestV2Dto dto = new NewTestV2Dto();
            BeanUtil.copy(test, dto);
            dtoList.add(dto);
        }
        log.info("test:v2 dtoList:" + dtoList);

        Builder<Newtestv2dto> builder =
            CollectionResponse.<Newtestv2dto> builder();
        builder.setItems(dtoList);
        if (queryResultList.hasNext()) {
            builder.setNextPageToken(queryResultList.getEncodedCursor());
        }
        return builder.build();
    }
}

* サンプルコードが長くならないようにトランザクション管理は省略しています。


・アノテーションの種類

アノテーション概要
@Api
APIのクラスを特定するため、クラスに指定します。
以下の3つの属性は後述の「API Explorer」の表示に関わってくるので、最低限下記の3つは指定しましょう。
name
APIの名称です。
Androidのクライアントコードとして生成されるEndpoint関連クラスのクラス名にもなります。
version
APIは多くの場合に複数バージョン並行稼動させで、
そのバージョン名を指定します。(v1等)
description
API ExplorerにAPIの説明として表示されます。
@ApiMethod 
APIのメソッド毎の設定をするために指定します。
特に指定しなくても動くようですが、
なんとなくHttpMethodを明示的に指定したかったのと、
公開しているメソッドがわかりやすくていいかな、ということで付けました。
(明示的にHttpMethodを指定しない場合メソッド名から自動で決められるそうです)
@Namedメソッドの引数に対して指定します。
@ApiMethodのPathで「URLと引数のマッピング」を指定しないなら
必須のようです。
@Nullable引数にNullを許可する場合に指定します。
これを指定する場合には@Named属性が必須です。
他にもドキュメントに書いていないところで
@ApiMethodCacheControl@ApiMethod内で指定できたりします。
FrontendCache使えるのか!?とwktkして「listData」メソッドに指定したのですが、、
キャッシュ効きませんでした。orz
何か条件が足りないのかもしれません。(´・ω・`)

参考:
Adding Endpoint Annotations - Google App Engine — Google Developers


・API定義ファイルの自動生成

前述の@APIを指定したクラスを保存したタイミングで、
「WEB-INF」直下に「*.api」ファイルが生成されます。
上のクラスの場合は「WEB-INF/newTestEndpoint-v2.api」というファイル名になります。
このファイルは「API名 + バージョン番号」に対して一つ生成されます。
例えば
他に同じ名前のバージョン1のクラスがあれば、
「WEB-INF/newTestEndpoint-v1.api」が生成され、
別のAPI名のクラスがあればそれに対応するファイルも生成されます。

@APIを指定したクラス」の記述内容に問題がある場合にこのファイルが生成されない事がよくあります。
記述内容に問題があっても明確なコンパイルエラー以外は保存時にエラーが通知されないので、
このファイルが存在しているかは常にチェックしておく必要があります。
(問題のある記述があるとエラーなしにファイルが削除されます)


◯クライアントライブラリの生成

・eclipse-Pluginから生成する

「プロジェクトを右クリック」→「Generate Cloud Endpoint Client Library」
とするだけで作成できます。
上述の「NewTestV2Service」の場合、以下のファイルが作成されます。
・「WEB-INF/newTestEndpoint-v2-rest.discovery」
・「WEB-INF/newTestEndpoint-v2-rpc.discovery」
・「endpoint-libs/libnewTestEndpoint-v2」フォルダ
 (自動生成されたクライアント向けのライブラリが格納されています)

・コマンドラインから生成する

sh /Applications/eclipse4.2/plugins/com.google.appengine.eclipse.sdkbundle_1.7.5/appengine-java-sdk-1.7.5/bin/endpoints.sh get-java-client-lib\
 jp.vier.sample.endpoints.endpoint.v2.NewTestV2Service
上述の「NewTestV2Service」の場合、以下のファイルが作成されます。
・「WEB-INF/newTestEndpoint-v2-rest.discovery」
・「WEB-INF/newTestEndpoint-v2-rpc.discovery」
・「WEB-INF/newTestEndpoint-v2-java.zip」
 (コマンドから生成した場合に「endpoint-libs/libnewTestEndpoint-v2」の代わりに生成されます。中身はほぼ同じ。)

・生成されるファイルがわかる参考画像



参考:
Generating Client Libraries - Google App Engine — Google Developers


◯デプロイしてみる

通常のGAEアプリと同様にデプロイできます。


◯API Explorerから実行してみる

APIの一覧表示と、APIをテスト実行する機能が提供されます。
URLは
https://[your_app_id].appspot.com/_ah/api/explorer
です。

・API一覧

サービスクラスに定義した各メソッドが一覧で表示されます。


・API実行画面



クライアントを作成しなくてもここからAPIを実行してテストでき、とても便利です。

必要に応じてパラメーターを入力して「Execute」ボタンを実行するとAPIを実行して結果を表示します。

OAuthで認証してのテストもここからできるので、
クライアント無しに一通りテストできるのではないでしょうか。
入力項目はクラスの記述内容に応じて表示されたり、必須項目になったりします。

参考:
Testing and Deploying Endpoints - Google App Engine — Google Developers


◯気がついた挙動


新しいAPIを追加したり、APIの設定を変更した場合に反映までに若干のタイムラグがある
 APIの設定を変更した場合には
 デプロイしてからそのAPIを使えるようになるまで(API Explorerに反映されるまで)
 少し時間がかかります。(数秒〜20秒くらい)
 APIの定義を変えずにメソッド内の挙動を変えただけならすぐに反映されます。
 ログを見た感じ、
 ・デプロイするとAppEngineからEndpointのサーバー(?)に通知が行く。
 ・EndointのサーバーからAppEngineアプリにアクセスしてAPIの定義情報を取得しにくる
  (「/_ah/spi/BackendService.getApiConfigs」というURL)
 これによってAPIExplorerに表示されるようになり、
 かつ実際にそのAPIが使えるようになる。
 この際に、たまに「API backend's app revision」が違う、というエラーで400になるが、
 最後に200を返すまでリトライされてその後使えるようになる。

一度配置したAPI(@Apiの単位)はアプリから削除しても消えない
 クラス内のメソッドの追加・削除はちゃんと反映されました。
 (ミスった時に困る。Datastoreのインデックスみたいにvacuum機能とかあるのだろうか)

一度配置したAPIのdescriptionは変更しても反映されない。
なぜか反映されない。

実装コード中でシステムエラー(500)が発生するとクライアントには503が返る
これはたぶん意図的。


◯ハマリどころとかポイント

・設定を間違えていると「WEB-INF」直下の「*.api」ファイルが生成されない場合がある
 この場合記述で何か間違っているから修正するべし。(理由は表示されない><)

・@Apiのname属性の値を大文字から始めるとエラー
 その場ではエラーにならないが、
 ・Client Libraryの生成でエラーになる
 ・APIs Explorerに認識されない。

・同じAPIのバージョン違いを配置する場合、クラス名を変えたほうがいい。
 クラス名・メソッド名が同じパッケージ違いのクラスを作って@Apiのversionを変える構成にしたところ、
 ApiExplorerからそれぞれのバージョンのAPIを呼んだ際に
 どちらも同じクラス(一方のバージョンのクラス)が実行された。
 AppEngineのアプリへはどちらのバージョンでも
 「/_ah/spi/NewTestService.listTestDatas」というURLにアクセスしていたため、
 区別がつかないようです。
 APIのバージョンはクラスに指定するアノテーションの「@Api」に記述するので、
 元々バージョン毎にクラスを作成する必要があります。
 その上で「クラス名.メソッド名」をバージョン毎にユニークにする必要があるので、
 クラス名にバージョン番号を含めてしまうのが手っ取り早いでしょう。

* 2013/04/12追記 最新のSDKとEclipse-Pluginを使ったところ、↑の問題は解消しています。
おそらくVer.1.7.6で、内部的にアクセスされているURLが
「/_ah/spi/MessageV1Endpoint.list」

「/_ah/spi/jp.vier.sample.endpoints2.message.v1.MessageV1Endpoint.list 」
のようにパッケージ名も含むように変わりました。
そのため
「jp.vier.sample.endpoints2.message.v1.MessageEndpoint」
「jp.vier.sample.endpoints2.message.v2.MessageEndpoint」
のようにパッケージ名だけ変えればそれぞれのバージョンを区別して実行することができます。



・公開するメソッドの名前はJavaの定番のクラス名と被るような名前にしない方が良い
 メソッド名を「list」にするとクライアントコードのクラスに「List」という名前のクラスが生成されて、
  java.util.Listと衝突して面倒。
  「@ApiMethod」で別の「name」を指定してやれば回避できるけど、メソッド名で意識した方が簡単。

・公開メソッドの返り値をStringにしたら「*.api」が生成されなくなった
 静かなエラーになる。intやbooleanも同様。(リテラルはそのまま返せない)

・公開メソッドの返り値がList<String>の場合実行時エラー
 クライアントコードの生成もデプロイもできるが、
 実際にAPIが結果として「空でないList」を返すと実行時に500エラーになる。

・公開メソッドの返り値として自前のクラス型やList<自前のクラス型>は使える
 返り値が自前のクラス型でその中にString等リテラルのフィールドがあるのも問題ない。
・返り値にする自前のクラス型にはgetter・settterが必要。無いと実行時に500エラー。
 getter・settterが無くても代わりにアノテーション付ければ良いかも。
 
おそらくSlim3のModelもそのまま返すことができると思いますが、
わざわざDTOを作っているのは、複数バージョンの同時稼働を意識した場合に
「Entity定義の変更がそのままAPIのレスポンスに影響して良いのか?」という
Endpointそのものの挙動とは関係無い理由です。
Ajaxだけで使う場合のように、クライアントとAPIをまとめてデプロイするなら良いですが、
複数バージョンのクライアントが同時に存在し得るスマホアプリや別のシステムに提供するAPIは
この方針の方が無難じゃないかな、と。


◯まとめ

正直もう少し細かいエラーチェックと詳細なエラー原因の表示がないとちょっときついので、
ここは改善を期待したいところです。

クライアントコードを作成したり実行してみないとわからないエラーが結構あって、
またその際に詳細な理由が表示されない事が多いので
トライアンドエラーで原因の切り分けをするのに結構時間がかかりました。(´・ω・`)

この点が改善されれば簡単にAPIを公開できるソリューションとして良いと思います。


* 2013/02/19 15:29 ↓↓↓エラーログについての追記ここから↓↓↓

Google +でKazunori Satoさんに教えていただきました(´▽`)
クライアント・ライブラリー生成時のエラーは
eclipseの「Error」(エラー・ログ)タブにJavaのStackTraceが出力されていました。

試しに「listData」メソッドの「@Named」アノテーションを外してみると、
以下のように原因を特定できるレベルのStacktraceが出ました。

Caused by: java.lang.IllegalArgumentException: Error while processing method listData: Parameter type class java.lang.Integer cannot be a resource and thus should be annotated with @Named

「postData」メソッドの返り値をString型にしたところ以下のエラーになりました。
この場合はメソッド名が書かれていませんが「返り値の型が違う」ならすぐに探せるでしょう。
Caused by: java.lang.IllegalArgumentException: Type class java.lang.String cannot be used as a return type

先に知ってればだいぶ苦労が減っただろうと思います。orz
(いつも「問題」しか開いてないんだ。。(´・ω・`))

Slim3 PluginのModelの内容チェックのように、エディタ上にエラーの箇所と内容が出てくれたら理想ですが・・・w

* 2013/02/19 15:29 ↑↑↑エラーログについての追記ここまで↑↑↑



既にサーバー側でAPIを作るための基盤を作りこんでいる場合には
API自体の作成工数はそれほど変わらないでしょうが、
「API Explorer」が生成されるのはとても便利です。

特にサーバー側とクライアント側で担当者が分かれている場合には、
サーバー側の担当者はAPI Explorerから実際に実行して簡単に挙動を確認することができますし、
いくらかドキュメント代わりになります。
(Javadocみたいに各項目やAPIに説明を追記できたらさらに良いなぁ、と思ったり)

また、何よりも次回に書く「クライアント側(Android)の実装」はとっても簡単で、
ハマるポイントも少ないと思います。
さらに「Java Script」「iPhone」「Android」と、
3種類のクライアント用コードを自動生成できるのはやはり魅力でしょう。


次回はAndroidでの実装例です。
それではまた(`・ω・´)ノシ

続く↓

Google Cloud Endpointsを試してみた (2/3)






このエントリーをはてなブックマークに追加

2013年2月15日金曜日

19.Google App Engine 1.7.5を早速試してみた

このエントリーをはてなブックマークに追加



Ryo Yamasaki(@vierjp)です。

Google App Engine 1.7.5がリリースされたので早速試してみました。


○関連リンク

Google App Engine Blog: App Engine 1.7.5 Released
SdkForJavaReleaseNotes - googleappengine - Google App Engine Java SDK
SDKダウンロードページ


○変更点

・High-Memory Instance(ハイメモリーインスタンス)の追加

使用可能なメモリの量が多いインスタンスが追加されています。
Admin ConsoleのApplication Settingを見ると「F4_1G」が追加されています。

・Frontend
Frontend classMemory limitCPU limitCost per hour per instance
F1 (default)128MB600MHz$0.08
F2256MB1.2GHz$0.16
F4512MB2.4GHz$0.32
F4_1G1024MB2.4GHz$0.48
参考:Adjusting Application Performance - Google App Engine — Google Developers


・Backend
class configurationMemory limitCPU limitCost per hour per instance
B1128MB600MHz$0.08
B2 (default)256MB1.2GHz$0.16
B4512MB2.4GHz$0.32
B4_1G1024MB2.4GHz$0.48
B81024MB4.8GHz$0.64
参考:Backends Java API Overview - Google App Engine — Google Developers

* 2013/02/15 02:35 Backendsについて追記



・Java7 Runtimeのサポート (Experimental)

In a future release, support for Java 6 will be removed, so it is a good idea to try running your existing Java 6-based app in the new Java 7 runtime while it is still optional.

将来のリリースでJava 6のサポートが削除されるだろうから、
Java7がオプションの間に既存のJava6ベースのアプリを新しいJava7ランタイムで実行してみる事をお勧めします。
(↑たぶんこんな感じ)


試してみた

・JDK1.7.0をダウンロードする (Java SE Downloads)
・「pkg」ファイルを実行してインストール(標準のjavaのpathが変わるので注意)
・eclipseに設定する
「環境設定」→「Java」→「インストール済みのJRE」→「追加」→「標準VM」
 JREホーム:/Library/Java/JavaVirtualMachines/jdk1.7.0_**.jdk/Contents/Home
 JRE名:JDK 1.7.0 (任意)

試しにJava7の構文を含むControllerを作ってデプロイしたところ期待通りに動作しました。
    protected Navigation run() throws Exception {
        ServletContext servletContext = ServletContextLocator.get();

        // JDK7 型推論
        Map<String,String> testMap = new HashMap<>();

        response.setContentType("text/plain; charset=UTF-8");

        // JDK7 try-with-resources
        try (PrintWriter writer = response.getWriter()) {
            writer.println("GAE Version : " + servletContext.getServerInfo());
            response.flushBuffer();
        }
        return null;
    }
参考:Java 7 Considerations - Google App Engine — Google Developers
   JDK7正式版の新機能一覧(Java言語仕様に関して) - R42日記



・バウンスメールの通知機能

「バウンスメール」とは
---- 引用 ここから --------------------------------------------------------------------
何らかのエラーにより、送信できずに送り返されてきたメールのこと。
ユーザーが送信したメールはMTAによって送信先相手へと届けられるが、
通信路や相手先メール・サーバの状態によっては、メールを相手にまで届けられないことがある。
このような場合は、
送信元のメール・アドレスに向けてエラーが発生したことを通知するメールが返信される。
これをバウンス・メールという。
-- 引用 ここまで --------------------------------------------------------------------
引用元:Insider's Computer Dictionary [バウンス・メール] - @IT

試してみた

・appengine-web.xml

 mail_bounce
↑を「<appengine-web-app>」直下に追記します。

・web.xml


 bouncehandler
 jp.vier.test.ver_1_7_5.servlet.BounceHandleServlet


 bouncehandler
 /_ah/bounce




 
  /_ah/bounce
 
 
   admin
 


・バウンスメールを受けとるServlet
public class BounceHandleServlet extends HttpServlet {

    private Logger log = Logger.getLogger(BounceHandleController.class
        .getName());

    public void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {

        HttpServletRequest request = RequestLocator.get();
        log.info("called.");

        try {
            BounceNotification bounce = BounceNotificationParser.parse(request);
            // The following data is available in a BounceNotification object
            String from = bounce.getOriginal().getFrom();
            String to = bounce.getOriginal().getTo();
            String subject = bounce.getOriginal().getSubject();
            String text = bounce.getOriginal().getText();
            String notifyFrom = bounce.getNotification().getFrom();
            String notifyTo = bounce.getNotification().getTo();
            String notifySubject = bounce.getNotification().getSubject();
            String notifyText = bounce.getNotification().getText();

            log.info("from : " + from);
            log.info("to : " + to);
            log.info("subject : " + subject);
            log.info("text : " + text);
            log.info("notifyFrom : " + notifyFrom);
            log.info("notifyTo : " + notifyTo);
            log.info("notifySubject : " + notifySubject);
            log.info("notifyText : " + notifyText);
        } catch (MessagingException e) {
            throw new RuntimeException(e);
        }
    }
}

・バウンスメールが返ってくるようなメールを送信するテスト用のController

「/sendErrorMail?flag=false」でアクセスする。

public class SendErrorMailController extends Controller {

    @Override
    protected Navigation run() throws Exception {

        Properties props = new Properties();
        Session session = Session.getDefaultInstance(props, null);

        boolean isSuccess = asBoolean("flag");
        String toAddress;
        if (isSuccess) {
            toAddress = "****@vier.jp";
        } else {
            toAddress = "test@example.jp";
        }

        Message msg = new MimeMessage(session);
        // Admin ConsoleのPermissionに登録してあるメールアドレスを送信元に指定する
        msg.setFrom(new InternetAddress("******@gmail.com", "Test Admin")); 
        msg.addRecipient(Message.RecipientType.TO, new InternetAddress(
            toAddress,
            "Recipient"));
        msg.setSubject("test message subject " + new Date().getTime());
        msg.setText("test message body.");
        Transport.send(msg);

        response.setContentType("text/plain; charset=UTF-8");
        // JDK7 try-with-resources
        try (PrintWriter writer = response.getWriter()) {
            writer.println("Sent Mail.");
            response.flushBuffer();
        }

        return null;
    }
}

・実行結果ログ


基本的にほとんど公式ドキュメントのサンプルのままです。
最初はバウンスメールを受け取った際のrequestも
AppRouterを使ってSlim3のControllerで処理しようとしたのですが、
BounceNotification bounce = BounceNotificationParser.parse(request);
でエラーになってしまったので結局サンプル通りのServletにしました(´・ω・`)
その時のエラーは以下の内容。
/_ah/bounce
java.lang.IllegalStateException: Input stream already read, or empty.
 at com.google.appengine.api.utils.HttpRequestParser.parseMultipartRequest(HttpRequestParser.java:48)
 at com.google.appengine.api.mail.BounceNotificationParser.parse(BounceNotificationParser.java:31)
 at jp.vier.test.ver_1_7_5.controller.BounceHandleController.run(BounceHandleController.java:38)
 ・・・

* 2013/02/15 03:01 ↓Slim3のSimpleControllerを使った方法について追記ここから↓

Google+でShinichi Ogawaさんに教えていただきました。(´▽`)
「Slim3で標準入力を取得したい場合」は「SimpleController」を使えば良いそうです。
上述の「BounceHandleServlet」と「web.xmlの定義」の代わりに以下を作成することで、
ServletではなくControllerで同じ処理を行うことができました。

・AppRouter.java
public class AppRouter extends RouterImpl {
    public AppRouter() {
        addRouting("/_ah/bounce", "/bounceHandle");
    }
}
・BounceHandleController.java
public class BounceHandleController extends SimpleController {

    private Logger log = Logger.getLogger(BounceHandleController.class
        .getName());

    @Override
    protected Navigation run() throws Exception {

        log.info("called.");

        HttpServletRequest request = RequestLocator.get();
        BounceNotification bounce = BounceNotificationParser.parse(request);
        // The following data is available in a BounceNotification object
        String from = bounce.getOriginal().getFrom();
        String to = bounce.getOriginal().getTo();
        String subject = bounce.getOriginal().getSubject();
        String text = bounce.getOriginal().getText();
        String notifyFrom = bounce.getNotification().getFrom();
        String notifyTo = bounce.getNotification().getTo();
        String notifySubject = bounce.getNotification().getSubject();
        String notifyText = bounce.getNotification().getText();

        log.info("from : " + from);
        log.info("to : " + to);
        log.info("subject : " + subject);
        log.info("text : " + text);
        log.info("notifyFrom : " + notifyFrom);
        log.info("notifyTo : " + notifyTo);
        log.info("notifySubject : " + notifySubject);
        log.info("notifyText : " + notifyText);
        return null;
    }
}

* 2013/02/15 03:01 Slim3のSimpleControllerを使った方法について追記ここまで↑


これによって「メールアドレスを登録したユーザーにメールを配信するようなシステム」で
使われていないメールアドレスを検知することで、
そのユーザーのステータスを変更したり
メール送信対象から除外してQUOTAやコストの節約をしたり
ができるようになるでしょう。

余談ですが、テスト用にシステムで使うメールアドレスは自分の所有するアドレス以外なら
「example.com」や「example.jp」等を使いましょう。

「example」はRFC2606で定義されている「予約されたセカンドレベル・ドメイン名」で、
このドメインは誰にも利用されていない事が決まっています。

間違っても実在するドメイン名のアドレスを適当に使ったりしないようにしましょう。
(昔働いていた職場で見たことがあります(-_-;))

参考:Receiving Bounce Notification - Google App Engine — Google Developers



・Cloud Storageのための Blobstore サービスの変更

Issue 8337 の対応で、
Cloud Storageを使った場合に Blobstore サービスがblobKeyの代わりにファイル名を返すようになった。

この方式でアップロードした場合にファイル名がランダムになった上に、
アップロード後のcallbackの中でCloudStorage上でのファイル名を取得できなかったようです。
それがblogKeyの代わりにファイル名を取得できるようになったのでしょうか。

むしろこの方式でCloud Storageにアップロードできるのを知らなかったのですが、
サイズが大きいファイル(アップに一分以上かかるような)の場合にはこの形式が良いのかな・・・?

参考:Blobstore Java API の概要 - Google App Engine — Google Developers



・Cloud Endpointsのサポート (Experimental)

Cloud Endpointsの概要は「appengine ja night #22 Google Cloud EndPoints」を参照。
これは別途まとまった時間に試してみようかと思います。


* 2013/02/19 15:50 追記
Blog @vierjp : Google Cloud Endpointsを試してみた (1/2)



参考:Google Cloud Endpoints - Google App Engine — Google Developers



・Eclipse Pluginのバージョンアップ

eclipseのメニューから「ヘルプ」→「更新の確認」でアップデートできます。



 ・SearchAPIのdeprecatedなクラスが削除された

これらのクラスを使っている場合は次のAppEngineのリリースまでに
これらのクラスを参照しない新しいバージョンをデプロイしないと動かなくなる、とのこと。
SearchAPIはまだ使ったこと無いけどこの辺のクラスは結構前からdeprecatedなのでしょうか。
SDKのリリースノートにしか書いてないので、
気づかないまま既存のアプリをそのまま運用してしまう人がいないかちょっと心配。



・前回のリリースで廃止された「Conversion API」はSDKから削除された

HTML, PDF, text, 画像を相互変換するAPIでしたが、いつのまにか無くなったのですね。。




 ・Channel APIの変更

Channel APIはチャネルが作成された場所に関係なく
アプリの任意のバージョンまたはBackendsからチャネルメッセージを送信できるようになった。



・DataNucleus Pluginが2.1.2にアップグレードした

JDO・JPA関連のライブラリだそうです。



・URL Fetch サービスがPATCHメソッドでのリクエストをサポート

したそうです。



・Windowsのローカル環境でDatastore callback アノテーションが機能していなかった問題を修正

Datastore callback アノテーションを使うとDatastoreのメソッドに前処理を挟んだりできます。
すっかり存在を忘れていましたがこのリリースを見て思い出しました。
以前読んだ時は家も職場もWindows環境だったので導入を諦めてしまいましたが、
put時にEntityの登録時刻や更新時刻をこれで自動でセットできたら楽ですね。

* 2013/02/15 09:55 追記
Slim3の「Automatic Values」という機能でこれができると知って愕然としている。。(´・ω・`)



参考:Datastore Callbacks - Google App Engine — Google Developers



このエントリーをはてなブックマークに追加

2013年2月12日火曜日

18.Google App Engineパターン (appengine ja night #23)

このエントリーをはてなブックマークに追加




Ryo Yamasaki(@vierjp)です。


2/13に開催された appengine ja night #23 の前半のセッションで
@proppy氏によるDatastoreを中心としたAppEngineのパターンについての説明がされました。
このセッションについて@proppy氏に直接質問させてもらって理解した事も含め、
自身の復習も兼ねて考察してみました。

既に結構時間が立っていますので若干今更感はあるのですが、
書き始めてみると思いのほか濃い内容になった気がします。

なお、後半のセッションでBrian氏によって語られた「Google Compute Engine」については
"公開されているドキュメントをベースに、ドキュメントに書かれている範囲で"(重要w)
可能であればajn #23のスライドの内容にも踏み込んで近いうちに記事を書こうかと考えています。

関連リンク:
appengine ja night #23 が開催されます - Google Japan Developer Relations Blog
appengine ja night #23 - YouTube (セッションの全てが動画で公開されています)
Optimizing Your App Engine App - Google IO 2012 (セッションのスライド)


 今回のブログはセッションのスライドに沿って書いていますので、
ブラウザの別ウインドウでスライドを開きつつ読んでいただけるとより一層お楽しみいただけます(´▽`)
また、このスライドには各ページに「Run」ボタンがついていて、
その画面の処理を実行した際のAppstatsの詳細画面に繋がります。

Appstatsには各処理の実行時間とコストが表示されるため、
・なぜそのコードがいけないのか
・なぜそう書くのが正しいのか
が可視化された形でわかるようになっています。
(詳しくは前記事の「appstats(Java)でRPCのコストと処理時間を調べてみよう」を参照)

それでは@proppy氏による「App Engine パターン」を順に見ていきます。

◯ 目次

#1 クエリよりもgetを優先的に使う
#2 不要なインデックスを作らない
#3 Projectionクエリを活用する
#4 ページングはoffsetではなくカーソルを使う
#5 キャッシュを活用する
#6 RPC呼び出しをシリアルに行わない
#7 RPC呼び出しをシリアルに行わない(2)
#8 トランザクション管理をしよう
#9 同一トランザクション内で更新後に参照しない
#10 整合性が必要な場合はグルーバルクエリを使わない


◯ #1 クエリよりもgetを優先的に使う

クエリで取得するのは速度的にも課金額的にも良くないので、
できるだけgetを使いましょう、というお話です。

P.3からはじまる#1は、
ユーザー名を条件に「User」Entityを取得するケースを例としています。
アンチパターンの例では保存時にKeyを明示的に指定せず
「name」というプロパティにユーザー名を保持しています。
その後データを取得するためにnameプロパティの値を条件にクエリしています。

P.4ではユーザー名をKeyのname値にする形でEntityを保存し、
その後Keyの値を指定して"get"しています。

AppEngineのクエリは「インデックステーブル」を利用して実行しています。
クエリする際には
1.インデックステーブルを参照する
2.インデックステーブルが示すEntityの実データを取得する
と2つのプロセスでEntityのデータを取得します。
そのためDatastore Readの回数は2回になります。

対してgetした場合にはDatastore Readの回数は1回です。
getに比べてクエリは課金額は増えるし処理的にも無駄があって遅い。
単純にコストに2倍の差があるのでシステム全体で見れば結構な影響があるでしょう。

ただしKey値は同一Kind内でユニークな値(RDBの主キー的な値)なので、
このようにユーザー名からKeyを生成してgetするデータ構造にするなら
システムの仕様として「ユーザー名がユニークである」という前提が必要です。

逆にこの前提があるなら「参照時にクエリではなくgetしたい」という理由だけではなく、
ユーザー名をユニークであることを保証するためにユーザー名をKeyに保持するのが自然です。

また、Keyでgetするかプロパティでクエリするかは、後述の#10の内容にも絡んできます。
データを確実に取得するためにも、仕様上ユーザー名がユニークであるなら、
ユーザー名をKey値に保持した上で「Keyでget」するのが良いでしょう。

DatastoreにおいてKeyはとても特別で、とても重要な値です。
1.データを高速に・かつ低コストに取得するための値 (#1の内容)
2.データを確実に取得するための値 (#10の内容)
3.データのユニーク性を保証するための値

RDBのデータ設計をする際は
「主キーにビジネス的な意味を持つ値を持たせないようにする」は無難な方針だと思いますが、
Datastoreにおいては各プロパティにRDBのようなユニーク制約を指定することはできないので、
値のユニーク性を保証するため、また必要なタイミングで(クエリではなく)getでデータを取得するため、
ビジネス的な意味を持つ値を積極的持たせることになると思います。

Slim3の「Datastore.putUniqueValue(uniqueIndexName, value)」がしている方法で
Key以外のプロパティ値のユニーク性も保証することができますが、
少し気をつけるべきことがあります。
このメソッドを呼んで「その値をユニークな値として確保する」のと、
その後の「その値を含むEntityのput処理」が別トランザクションになるので、
Entityのputで失敗してもこちらは自動でrollbackされません。

よって、このメソッド呼び出し後の処理でputに失敗してユーザーにシステムエラーを返した場合、
何らかの対処をしない限り次のリクエストで同じ値を使えなくなります。

例えばユーザー登録処理なら
「登録ボタン押す」→「システムエラー」→「もう一度登録ボタン押す」→「その名前は既に使われています」
はちょっと嫌かな、と。
逆に「ランダムなお客様番号の払い出し」のような処理なら積極的に使えます。
ちなみにこのメソッドも内部的な仕組みとしては
「value 」でKeyを生成しトランザクション内で「uniqueIndexName」をKind名とするEntityをputすることで
ユニーク性を保証しています。

少し話が膨らんでしまいましたが「Keyをどう定義するか」はDatastore設計においてとても重要です。


◯ #2 不要なインデックスを作らない

P.6から始まる#2では、
「不要なインデックスを作らないことで、コスト的にどれたけの違いがあるか」を示しています。

「不要なインデックスは作らないようにするべし」とは以前からよく言われていますが、
こうやって具体的な数値として見ると、改めてその効果に驚き、また、Appstatsの価値を実感します。
(Appstatsに関しては「appstats(Java)でRPCのコストと処理時間を調べてみよう」を参照)

P.6P.7のAppstatsに表示されるDatastore Writeの回数は以下の内訳になるかと思います。

○「secret」にインデックス有りのEntityの登録 (P.6)
 ・Entity実データ書き込み (Write x 1)
 ・インデックス(カインド・インデックス?)の書き込み (Write x 1)
 ・「secret」プロパティのシングルプロパティ・インデックスの昇順・降順の追加(Write x 2)
合計でDatastore Writeが4回

○「secret」にインデックス有りのEntityの更新 (P.6)
 ・Entity実データ更新(Write x 1)
 ・「secret」プロパティのシングルプロパティ・インデックス(昇順・降順の2つ)の
  変更前の値「iamunindexed」の削除 (Write x 2)
 ・「secret」プロパティのシングルプロパティ・インデックス(昇順・降順の2つ)の
  変更後の値「iamreallyunindexed」の追加 (Write x 2)
合計でDatastore Writeが5回


○「secret」にインデックス無しのEntityの登録 (P.7)
 ・Entity実データ書き込み (Write x 1)
 ・インデックス(カインド・インデックス?)の書き込み (Write x 1)
合計でDatastore Writeが2回

○「secret」にインデックス無しのEntityの更新 (P.7)
 ・Entity実データ更新 (Write x 1)
合計でDatastore Writeが1回

「シングルプロパティ・インデックスが有効なプロパティ」が1つあるだけで、
Entityの新規登録時のDatastoreWritesの回数は2倍、更新時は5倍となっています。

登録時は「シングルプロパティ・インデックスが有効なプロパティ」の数*2のWriteが追加で行われ、
更新時は「シングルプロパティ・インデックスが有効なプロパティ」の数*4のWriteが追加で行われます。

「シングルプロパティ・インデックスが有効なプロパティ」が2つなら、
この場合新規登録時のWrite回数は3倍、更新時は9倍となります。

実際にスライドの例に「secret2」を追加して試してみところ、この計算通りになりました。
from google.appengine.ext import ndb

class User(ndb.Model):
    _use_cache = False
    _use_memcache = False
    secret = ndb.StringProperty()
    secret2 = ndb.StringProperty()

k = User(secret='iamindexed',secret2='iamindexed2').put()
# in another request
user = k.get()
user.secret = 'iamreallyindexed'
user.secret2 = 'iamreallyindexed2'
user.put()

と言っても、プログラムが必要としないインデックスを完全に排除してしまうと、
DatastoreViewerからのクエリがしづらくなって運用上きつくなります。
現実的には
1.運用上必要な最低限のインデックスは残す
2.BigQueryに簡単に転送できる仕組みを用意する (TT終了マダー?(・∀・)っ/凵⌒☆チンチン)
のどちらかが必要でしょう。


◯ #3 Projectionクエリを活用する

P.9からの#3は「Projectionクエリ」についての説明です。
以前まで、Datastoreに対するクエリは「Entityを丸ごと取得する」ことしかできず、
「Entityに含まれる一部の値だけを取得する」ことはできませんでした。
 (クエリが「SELECT * FROM 〜〜」のみで、「SELECT hoge FROM 〜〜」ができなかった)
それを可能にするのが「Projectionクエリ」です。

#1に書いたとおり、通常クエリは以下の二段階のプロセスで行われます。

1.インデックステーブルを参照する
2.インデックステーブルが示すEntityの実データを取得する

スライドの例では、
「city」の値を条件にUserカインドをクエリして「email」の値を取得するのが目的です。
この場合に「city・email」の複合インデックスが存在しているなら、
このインデックスを参照することで「1」のプロセスの時点でemailの値を取得できます。
そのため「2」のプロセスを省くことができます。
これが「Projectionクエリ」の仕組み。(たぶんこれで合ってると思う・・・!)

Projectionクエリを使った場合、処理的には「Small Datastore Operation」になります。
「Small Datastore Operation」は「Datastore Read」に比べてそのコスト(課金額)は1/7です。
よって、Projectionクエリを使えるのであれば、
普通にクエリして結果を取得するのに比べて参照コストは大きく下がることになります。

しかしその代わりに
「プロジェクションクエリを実行するために必要な複合インデックス」
を用意する必要があります。
スライドにおいて「"maybe" cheaper」と書かれているのはそのためで、
もしこのインデックスを使用するProjectionクエリの実行頻度が少なく、
逆にこのインデックスに含まれる値の更新頻度が高いとしたら、
むしろインデックスを追加した分Datastore Writeのコストが上がり、
総合的にコスト高になる場合もあり得ます。
そのため、参照頻度と更新頻度を考慮した上で使用するのが良いでしょう。
(「必要なインデックスが別のクエリの都合で元々存在している」なら良いのですが)

また、ProjectionクエリはインデックステーブルのみにアクセスしてEntityそのものにはアクセスしないため、
インデックスの生成が遅延した場合(詳しくは#10を参照)、
「取得した値が実際に記録されている値よりも古い可能性がある」でしょうから、
この点も注意が必要かと思います。

他にProjectionクエリには以下の特徴があります。

・結果のdistinctができる
・インデックスを設定できない「Text型」「Blob型」はProjectionクエリでは使えない
・「=」や「IN」で条件に指定したプロパティを取得する事はできない
 ○ SELECT A FROM kind WHERE B = 1 (条件と取得するプロパティが異なる、はOK)
 ○ SELECT A FROM kind WHERE A > 1 (プロパティは同じだけどin equalityフィルターで条件指定はOK)
 ☓ SELECT A FROM kind WHERE A = 1 (equality フィルターで条件指定したプロパティを取得はNG)
 (これってクエリ投げる前から結果わかってるんじゃ・・と思ったけどList Propertyでややこしい話になるのかな?)

参考:Projection Queries - Google App Engine — Google Developers

残念ながら私の好きなSlim3は現段階でProjectionクエリに未対応です。
(そのため私は使った事がありません^-^;)
しかし自前で機能を追加してコードも公開している方がいらっしゃるので参考にすると良いかもしれません。
slim3でProjectionQueryを使えるようにしてみた - Orfeon Blog 


◯ #4 ページングはoffsetではなくカーソルを使う

P.12からの#4はページング処理において「offset」ではなく「カーソル」を使いましょうというお話。
「AppEngineでページングをする場合にはoffsetではなくカーソルを使うべし」
というのは以前からよく言われている話ですが、
ここではAppstatsによってそれが数値化されてわかりやすく示されています。

「1度のクエリで10件ずつ結果を取得する」とした場合、
プログラムで受け取る結果のデータはoffsetを使ってもカーソルを使っても同じで、
最初のクエリで「1~10件目」のデータを取得、
次のクエリで「11-20件目」のデータを取得となります。

しかし、Datastore Readの回数は、
offsetを使った2回目のクエリでは21回
カーソルを使った2回目のクエリでは11回(1ページ目取得時と同じ)
と大きく異なります。
offsetの場合には内部的に
「1ページ目も含めて2ページ目までのデータを全て読み込んで、その上で1ページ目の分のデータを切り捨てる」
としているため、このような結果になります。

スライドにはカーソルはoffsetに比べて「Faster, less bandwidth, cheaper」と書いてありますが、
ページ数が増えるほど「無駄に読み込むデータ」が増えていく、と考えれば理解は簡単です。
1ページ10件で100ページ目ならDatastore Readは「1001」になってしまい、
速度的にも課金額的にも大きな無駄があります。
(カーソルなら1000ページ目でもDatastore Readは「11」です)
また、offsetに大きな数字(5万とか)を指定すると
データの取得に時間がかかりすぎてタイムアウトしてしまうこともあり、(DeadlineExceededExceptionが発生)
システム的な限界を作ってしまいます。

Admin ConsoleのDatastore Viewerではリクエストパラメーターで「offset」の値を指定できるところを見ると、
内部的に「offset」を使っているのかもしれません。
1Entityのサイズにもよるのでしょうが、
実際にDatastore Viewerでoffsetに5万程度の数値を指定するとエラーになります。
もっともDatastore Viewerでoffsetを指定して結果を参照できないとそれはそれで不便なので、
これはトレードオフの結果なのかもしれません。

「指定したページに直接遷移する」という要件では「offset」を使いたくなる事もあるかもしれませんが、
コスト的にも高くつくし、システム的な限界が生まれていまうというデメリットがあるのでその点注意が必要です。


◯ #5 キャッシュを活用する

P.15から始まる#5は、可能であればキャッシュを有効に使おう、というお話です。
・Datastoreから取得するよりMemcacheから取得した方が速く、安い
・Memcacheから取得するより「サーバーインスタンスのメモリ」から取得した方が速い

ここで使っている「サーバーインスタンスのメモリにキャッシュする機能」は
AppEngineが標準の機能として提供しているわけではなく、
「In-Context Cache」と呼ばれる、Python用フレームワークNDBの機能です。
NDB Caching - Google App Engine — Google Developers
日本語の資料では「@najeiraさんのスライド」が詳しいと思います。
(キャッシュについてはP.10から)

NDBの「In-Context Cache」は、
「そのリクエスト内」または「そのトランザクション内」を有効なスコープとしてキャッシュします。
また、NDBは「Memcacheを使ったEntityの自動キャッシュ制御」も自動でやってくれます。
(なぜ「そのトランザクション内」というスコープがあるのか、おそらく#9で説明された件と合わせているのでしょう)

NDBや他の同様の機能を提供するフレームワークを使わずに「In-Context Cache」を行うなら、
「自分で都度変数に保存しておいて再利用する」か、
それを自動で行う「システム基盤」を作る必要があります。

・「Memcacheを使った自動Entityキャッシュ制御」についてSlim3をラップする形で実装している例
14.CA Beatのシステム基盤 第二回「自動Entityキャッシュ」|CA Beat エンジニアのブログ
(過去に私が書いたエントリーですが)


◯ #6 RPC呼び出しをシリアルに行わない

P.19からの#6は、
複数のEntityを操作したい場合には「get」「put」「delete」を何度も繰り返すのではなく、
「batch get」「batch put」「batch delete」を使いましょうというお話。
アプリケーションサーバーとDatastoreの間ではメソッド呼び出しのタイミングで通信が発生するので、
繰り返し実行すると通信回数が増え、無駄が大きくなります。

スライドの内容に合わせて言えば、
「1つのEntityをputするRPC呼び出しを順番に100回実行する」(P.19)のではなく、
「100個のEntityのputをまとめて一回のRPC呼び出しで実行する」(P.20)べき、
という事です。

P.21のNDBの例は「1つのEntityのputを100回実行する」のですが、
一つずつ順番に行うのではなく、非同期で同時に100回実行しています。
相手からの返事を待たずに次の呼び出しをしているので、
こちらも順番に実行するよりも速くなります。


◯ #7 RPC呼び出しをシリアルに行わない(2)

P.23P.25は複数のURLFectchを実行して結果を取得する例ですが、
話の趣旨としては前項のP.21と似ていて、
「一つずつ順番にアクセスすると遅いから、並列に実行しましょう」
というものです。
・順番に実行した場合全てのURLFetchの実行時間の合計の時間がかかりますが、
 並列に実行すれば全ての結果を取得するのに必要な時間は「一番遅いRPCの時間」だけになる
・非同期に実行する場合、この待ち時間の間に別の処理を実行することもできる
というメリットがあります。

ちなみに、DatastoreアクセスやURLFetchのような標準で提供されている非同期処理以外にも
自分で作った処理をマルチスレッドで実行することができます。
https://developers.google.com/appengine/docs/java/runtime?hl=en#The_Sandbox
制限として
1.「同時に実行するThread数は50まで」
2.「生成したスレッドの生存期間はリクエストを返すまで」
がありますが、
「1」は「java.util.concurrentパッケージ」を使って
Executor executor = Executors.newFixedThreadPool(50);
とすればそれで済みます。
「2」はこの処理のように「別スレッドで実行した全ての処理が終わるまで待つ」前提ならば
実用上何の問題もありません。
リクエストを受けたThreadの終了と関係なく非同期で処理を移譲して結果を待たないようにしたいなら、
これまで通りTaskQueueを使えばOKです。


◯ #8 トランザクション管理をしよう

P.26からはデータの整合性を保つためトランザクション管理をしましょう、というお話。

P.26がトランザクション管理なし
P.27がトランザクション管理あり
の例で、
どちらもEntity「User(kazo_sensei)」と「それを親EntityとするUser(proppy)」の2つをPutしています。

AppEngineでは「親EntityのKey」を指定して「EntityのKey」を生成することによって、
EntityGroupを構成することができます。
同一EntityGroupに所属するEntity同士は、トランザクション管理することができます。

トランザクション管理をすることによって、以下の事ができます。

・Datastoreの更新は「全か無かのどちらか」になる。

RDBでトランザクション管理した場合と同じで、
「成功して全部適用されるか、失敗して全部なかったことになるか」
のどちらかになることが保証されます。

・一貫性と独立性の保証

#9の中にも関連しますが、
Transaction開始後に最初の操作を行ったEntityのEntityGroupのスナップショットから参照するので、
トランザクション中に複数Entityを参照した場合にも
どちらか一方だけが他のトランザクションから更新された状態で読み出してしまうようなことはなく、
取得したデータの一貫性が保証されます。
(ただし同一トランザクション内で参照できるのは同一EntityGroupに属するEntityのみ)

・TaskQueueにも使える

TaskQueueへのTaskの登録もTransactionに参加できます。
これはAppEngineでデータの結果整合性を保つために重要になります。

Transcation中でTaskQueueにTaskを追加する場合、
上記の「全か無のどちらか」に「QueueへのTaskの追加」も含まれます。

つまり、「Entitiyの更新とTaskQueueへのTask登録」を同一トランザクションで行った場合、
「Entityの更新とTaskの登録の両方が成功する」か、
「Entityの更新とTaskの登録の両方が失敗する」のどちらかである、
という形で一貫性が保証されます。

TaskQueueへの登録がトランザクションに参加できる事」は
AppEngine上で動くアプリの開発を行うにおいてとても重要な事です。

AppEngineにおいて複数のEntityGroupをまたがって複数データの整合性を保つ方法は基本的に2つで、
一つがXGTx(クロスグループ・トランザクション)、
もう一つが「TaskQueueによる遅延更新で結果整合性を保つ」という方法。
(補償トランザクションは厳密ではないので除外しています)

TaskQueueにTaskを登録すると、基本的にはTaskが成功するまで自動でリトライされます。
よって、
「トランザクション内でのEntityの更新」と
「TaskQueueへのTaskの登録」を
一貫性を持って行うことができるなら、
トランザクション内で
Entityの更新と「別EntityGroupに属するEntityの更新を行うTaskの登録」をすることで、
「リアルタイムでは無いものの、複数のEntityGroupに対する更新を(結果)整合性を保証した上で行える」
と言えます。

この方法は
「Taskによって行う更新処理が遅延する」というデメリットはあるものの、
(通常Taskはすぐに処理されるが厳密にリアルタイムではないし、遅延する場合もある)
「他のトランザクションが対象のEntityを更新中はトランザクション内でのgetがエラーになるXGTx」と比べて、
スケーラビリティは高いように思います。

この時Taskで行う処理の内容は「別EntityGroupに属するEntityの更新」に限らず、
「Entityに対応するSearch APIのデータ(Document)を更新する処理」や、
「別のシステムに対してデータの更新をAPIで通知する処理」でも良いです。
トランザクション中でTaskQueueを使うことで、複数EntityGroup間だけではなく、
「AppEngineのDatastoreのデータ」と「別のデータや外部のシステム」との間でも
データの結果整合性を保つことができます。

逆に「EntityGroupをまたがる全てのEntityが同時に更新される」事が必要であれば、
TaskQueueを使った方法ではなく、XGTxが必要になります。

同時に更新される事が要求される例としては、
登録する「別のEntityGroupに所属する複数のEntity」が
別の処理において「そのEntityが存在するかどうかのチェック処理」で使われる場合があります。
この場合、これらのEntityはリアルタイムに、一貫性を持って、同時に登録されている必要があります。
この場合はTaskQueueを使った遅延更新では問題が起こるので、
「XGTxにする」か、「XGTxが必要無いようにデータ設計を見直す」必要があります。
(データ設計を見直しても無理なものは無理でしょうけど)


◯ #9 同一トランザクション内で更新後に参照しない

P.29からの#9は、
同一トランザクション内では
「トランザクション開始時のスナップショットからデータをgetする」ため、
トランザクション内で更新→削除とした場合には更新前のEntityを取得してしまうというお話です。

更新処理を行うトランザクションを終了した後、再度取得しましょう。


◯ #10 整合性が必要な場合はグルーバルクエリを使わない

P.32からは「クエリで取得するデータ」についての話です。
「グローバルクエリ」はいわゆる普通のクエリのことで、
・Ancestorクエリではない
・KeysOnlyクエリではない
・Projectionクエリでもない
検索条件を指定してEntityを取得するクエリです。

個人的には#10は今回のセッションの中で一番重要だと感じました。
なぜなら、最近まで多くの人が認識していなかったと思われる、重要かつ新しい情報が含まれています。

#1に書いたとおり、クエリを実行する際に内部的に行われている手順は
1.インデックステーブルを参照する
2.インデックステーブルが示すEntityの実データを取得する
です。

そして、#10の内容についてはこの「1」と「2」それぞれの処理において、
期待と異なる結果を取得してしまう場合があることを示しています。


・「1」の処理において期待と異なる結果を取得してしまうケース


これについては従来から広く認識されています。
インデックステーブルの更新が遅延することで
「クエリの条件に合わない結果が取得される事がある」
という問題です。
これは「Eventual Consistency」という言葉とセットで以前から知られています。
P.32の例では、
「kaz」と「proppy」をputした後の一回目のクエリの結果では「kaz」しか取得できていません。
「proppy」がまだインデックステーブルに反映されていないため取得できなかった、というケースを示しています。
(これは必ずこうなるというわけではなく、「こうなる場合がある」という話です)


・「2」の処理において期待と異なる結果を取得してしまうケース


こちらはつい最近までほとんどの人が認識していなかった話ではないでしょうか。
少なくとも私の周りで知っている人はいませんでした。
P.32の3回目のクエリでは、取得結果が「kaz:python,proppy:golang」と書かれています。
「kaz」のloveは「golang」に変更されているはずなのに「love」の値が「python」になっています。
これは「クエリの結果として取得したデータの値が最新ではない可能性がある」という事を示しています。

これまでは、
インデックスの更新が遅延することにより
「1」の処理においてクエリの条件に合わないデータが返ってくる可能性があるが、
「2」の処理において取得したEntityの値そのものは最新のデータである
というのが多くの方の認識だったのではないでしょうか。

取得した値が最新であるなら、
クエリの条件と合わない結果が返ってきても
値をチェックすることでクエリの条件に一致するデータなのかどうかをチェックすることができます。
しかし「取得した値が最新ではない場合がある」なら
取得したデータを確認してもこれをチェックすることはできません。

この「2」の処理が「batch getと同等の処理」であれば、取得したEntityのデータは最新であるはずです。
ですが、実際にはこの「2」の処理は「batch getと同等の処理」ではなく、
「古いデータを取得してしまう可能性のある別の何か」のようです。
セッションの中で「レプリケーション」という言葉が出てきていたので
「非同期にコピーされた最新ではないかもしれないデータ」を参照するのでしょうか。

対策として紹介されているのは
・Ancestorクエリ
・keys_only query + get_multi
の2つです。


・Ancestorクエリ (P.33)


P.32と違い、P.33のクエリ結果は全て「kaz:golang,proppy:golang」と正しい値になっています。
よって、Ancestorクエリでは最新のデータを正しく取得できる事が保証されているようです。
Ancestor Queryはトランザクションにも参加できるので、グローバルクエリとは仕組みも大きく違うのでしょう。

ただし、Ancestor Queryは親EntityのKeyを指定して実行するため、
「クエリ対象のEntityが同一EntityGroupに所属している」必要があります。
同一EntityGroupに所属するEntityはトランザクション中で一貫して更新することができる代わりに、
同時更新することができません。
(同じEntityGroupに所属するEntityAとEntityBを複数のトランザクションから同時に更新することもできません)

EntityGroupを使う場合「データの整合性」を確保できる代わりに、
「更新性能が下がる」というデメリットもついてきます。

EntityGrupの使用を検討する場合、同時更新性能の低下には相当の注意が必要です。
EntityGroupはそのEntityGroupに対する更新頻度をしっかり意識した上で使わないと、
「(更新処理において)満足にスケールアウトしないAppEngineアプリ」が出来上がる可能性があります。
(この辺のトレードオフをしてうまくバランスを取る事はAppEngineのデータ設計におけるポイントかと思います)

「更新するため」や「値をチェックするため」ではなく「表示するため」にクエリを実行しているなら、
次項の「keys_only query + get_multi」の方が使用する場面は多そうに思います。
その上で厳密な整合性が必要な場合には「Ancestor Query」を検討するのが良いでしょう。


・keys_only query + get_multi (P.34)


クエリの結果としてEntityを取得するのではなく、
最初のKeysOnlyクエリでEntityのKeyだけ取得し、Entityの実体は「batch get」で取得しています。
やっていることは上述の「1」と「2」の処理をプログラム側から別々に実行するような内容です。
しかし、「2」の処理として「batch get」をするため、確実に最新のデータを取得する事ができます。
逆に「1」の処理においては
従来の「インデックスの更新が遅延したことにより異なる結果を取得する問題」は残ります。
(P.34の一回目のクエリでは、直前にputした「proppy」を取得できないケースがある事が示されています)


・どの選択肢が正しいのか?


#10に関しては、他のほとんどのパターンのように「こうすれば万事OK」的な答えはありません。
基本的には「可能であれば極力getでデータを取得できるように作るべき」と考えていますが、
データをクエリで取得する場合にはメリットとデメリットを考慮した上で
複数の選択肢から状況に応じて選択する必要があります。

よって、クエリを実行するパターンには
(A) Ancestorクエリ ←参照においては最も確実な方法だが、同時更新の点でデメリットがある
(B)「keys_only query + get_multi」←「1」の処理でインデックスが遅延した場合の問題は残る
(C) グルーバルクエリ←「1」と「2」両方の処理で問題が発生し得る。
の3つの方法があり、#10では「グルーバルクエリ」を「アンチパターン」としているため、
「AかBから選択するのが良い」ということになります。

ちなみにBの「keys_only query + get_multi」は「#5 キャッシュを活用する」の話と組み合わせると、
「batch get」のタイミングでMemcacheから取得することができるので、
「Entityの自動キャッシュ制御」が無い場合に比べてコスト的・速度的なデメリットは少なくなります。
もし元々の処理でクエリの結果を明示的にキャッシュしていなかったのなら、
「Entityの自動キャッシュ制御」と組み合わせて「keys_only query + get_multi」することで、
条件によってはコストが下る可能性すらありそうです。

ところで、現状Slim3のModelQueryクラスには「keys_only query」でカーソルを使うためのメソッドが無いようです。
ModelQueryは拡張することが難しそうだったので、
「ModelQueryのソースコードに直接メソッドを追加してクラスパスの優先順位を利用してModelQueryだけ差し替える」
という対応をしているのですが、
バージョンアップの度に修正するのが面倒なので、
メソッド追加してもらうかコミットさせて欲しいな。。(´・ω・`)
(実は最初からあったりして・・・無いですよね?)


○更新対象のデータを取得する方法について

「更新のためのデータの取得」が正しくできない事はデータの整合性を崩す原因にするので、
多くの場合「表示するためのデータの取得」よりも厳密性が要求されます。

「1」の処理で起こり得る「インデックスの更新が遅延する」問題を考慮した上で
確実に目的のデータを取得して更新するためには、
データを「Ancestor Query」か「get」で取得できる必要があります。
グローバルクエリも「keys_only query + get_multi」も、
クエリ用のインデックスの更新が遅延して更新対象であるEntityを取得できない場合があるので
更新漏れのリスクがあります。
「keys_only query + get_multi」でクエリ条件と異なるEntityを取得してしまった場合なら
get後に値をチェックして更新対象から除外する事もできますが、
あるはずのデータを取得できなかった場合には対処のしようがありません。

「Ancestor Query」を使用するためには「EntityGroup」を構成する必要があり、
更新性能の低下というデメリットがセットで付いてくるので
使いどころはなかなか難しいです。
もちろんシステムの特性やそのEntityの更新頻度によっては
スケーラビリティをいくらか妥協してデータの整合性に寄せるのもアリなんでしょうが。

同一ユーザーによってのみ更新されるEntityのような、
「同時更新性能の低下が問題にならないEntity」であれば「Ancestor Query」を検討しつつ、
「必要なタイミングでKeyでgetできるようなデータ構造にしておく」のが良いでしょう。

「クエリがEventual Consistencyである」ということがデータ設計に与える影響は大きく、
私がデータ設計を行う際にも特に慎重に検討している部分です。
この点もAppEngineのデータ設計のポイントと言えるでしょう。



◯ まとめ

これまでに書いたエントリーと比べても随分長くなってしまいましたが、
以上が「@proppy氏による「App Engine パターン」についての考察でした。
若干違う方向に話が膨らみすぎた感もありますが、
AppEngineのDatastoreの「パターン・アンチパターン」という話になると、
どうしてもデータストア設計に関連した話に結びつけたくなってしまいました。

AppEngineの難しいところはやはりDatastoreの制約から来る「設計の難しさ」でしょう。
「データの整合性を確保した上でスケーラビリティを高く保つ」を必須条件とするなら、
・Datastoreの制約(クエリやトランザクション)に対する知識が必要。
・このセッションのような「パターン・アンチパターン」に関する知識が必要。
・設計者は「データ設計」だけではなく「プログラム設計」もできる必要がある。
 「Eventual Consistency」に関連して「必要なタイミングでクエリでなくgetでデータを取得できる必要がある」ため、
 プログラムはどのタイミングでどのような値を取得できるか、
 それに合わせてどのような値をKeyにすべきか、
 を考える必要があり、その結果プログラム設計とデータストア設計を並行して行う必要がある。
・Datstoreの制約の中で要件を実現するための知識やノウハウが必要
 (シャーディングカウンターとか、List Propertyを使った全文検索とか)
・「データの整合性とスケーラビリティを守りつつ実現する事が難しい仕様」については
 「ユーザーにとって不便にならない代替案」を考え提案できる必要がある。
 (安易にこれに傾くのもダメですけど。
 「AppEngineだからできません」という言葉はあまり聞きたくないし、言いたくない)

ただしこれらは全て「コーディングの難しさ」というより「設計の難しさ」で、
チーム開発を前提としても、
システム基盤とセットで「こういう時はこう書くべき」という指針を示すことで、
コーディングについては比較的なんとかなると思っています。
「そのクエリは実行可能か」
「これらの複数のEntityを同一トランザクションで更新することが可能か」
「このタイミングで確実にチェック処理を行うことが可能か」
といった事は設計レベルで確定していて、
エンジニア全員に「設計に必要な全ての知識」を要求するわけではないので。

逆にAppEngineのアプリは設計がとても重要で、この段階で成功か失敗か、大方決まるように思います。

しかし、設計の難しさを考慮しても
Datastoreの制約に沿って、パターン・アンチパターンを考慮して作ったシステムは
負荷に負ける気も、整合性に問題が生じる気もしません。
設計は少し大変でも、私は運用時におけるAppEngineの絶大な安心感が大好きです。
エンジニアが運用中のシステムのトラブルに追われることなく新規の開発に集中できる事は、
ビジネス的にもエンジニアの精神的にも重要だと思います。
そしてうまく設計できた時はとっても気持ちが良いですヽ(´▽`)ノ


今回の記事の中で私の認識が間違っている点を見つけた方は、ご連絡いただければ幸いです。
できれば優しく・・・(人ω・`)



このエントリーをはてなブックマークに追加