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)




0 件のコメント:

コメントを投稿