CA Beat エンジニアのブログ

CA Beat エンジニアのブログ

Google App Engineをメインに技術情報を発信しています。

Amebaでブログを始めよう!
$CA Beat エンジニアのブログ-13.gae_default

CA Beat エンジニアリーダーのヤマサキ(@vierjp)です。

みなさま新年あけましておめでとうございます。
本年もよろしくお願い致します。m(`・ω・´)m


CA Beatのシステム基盤 第三回はJson形式のレスポンスを返すAPIを簡単に作成するための仕組みです。
API用共通Controllerは私がCA Beatへの入社直前に家で作って持ち込んだ物で、
これまでの仕事で作ったシステム基盤(AppEngineに限らず)の仕組みを色々と踏襲しています。


○AppEngineでは特にAPIを作成する機会が多い

弊社で過去に作成したiPhone・Androidアプリは
ほとんどをネイティブアプリ(非WebViewアプリ)として作っていて、
AppEngine側はネイティブアプリから呼ばれるAPIの提供が主な役割でした。
そのため開発のメインは自然と「APIの作成」でした。

最近は弊社でもWebViewやブラウザで表示することを前提としたWebアプリ(htmlで画面を描画する機能)を作る機会が増えてきましたが、
その場合もユーザー向け画面はJSPを使うよりも「html + Ajax」を使う機会が多いです。

理由は下記に書かれているように、
「GAE/JでWebアプリを作る場合にJSPを使うとjasperの起動でスピンアップに時間がかかるから」です。

参考:AppEngine/Jのspin-upを劇的に改善する方法 - ひがやすを blog


CA Beatのコーポレートサイトは一部動的コンテンツがありますが、JSPは使っていません。
基本的に画面はhtmlにした上で、
「ブログ記事一覧」のような動的コンテンツはデータ取得用のAPIを作成した上で、
AjaxでAPIからデータを取得して表示しています。

ただし同じURLで「PC向け画面」と「スマートフォン向け画面」をUserAgentで分岐する要件があるため、
htmlに直接アクセスする作りにはせずにControllerを使ってforward先のhtmlを分岐しています。
そのため画面表示時にスピンアップが発生する可能性はありますが、
Controllerのforward先は静的なhtmlなので「Jasperの初期化」は不要です。


「リクエストパラメーター以外の条件による分岐」が無い場合にはhtmlに直接アクセスする作りにするか、
Controllerを経由した上で長めのFrontEndCache(後述)を設定するのも良いかと思います。
Controllerを経由しておけば後で「htmlにアクセスする前にJavaの処理をしたくなった場合」にも改修が容易でしょう。



○htmlから呼ばれるAPIの実装例

公開にあたり、実際のコードとは若干変えていますが、
APIは「標準のControllerを拡張したAPI用共通Controller」を継承して以下のように実装します。


public class GetBlogEntriesController extends SampleAppJsonController<MemcacheableHashMap<String, Object>> {

@Override
@UseFrontEndCache(expire = 60)
@UseMemcache(key = "GET_BLOG_ENTRIES_KEY")
public MemcacheableHashMap<String, Object> runImpl() throws Exception {
// リクエストパラメータのチェック
ExtendedValidators validators = getValidators(request);
validators.add("blogId", validators.required());
validators.validate();

// リクエストパラメータの取得
String blogId = asString("blogId");
String nextPageKey = asString("nextPageKey");

// BlogKeyを生成
Key blogKey = Datastore.createKey(Blog.class, blogId);

// ブログエントリー情報を取得する
BlogEntryMeta meta = BlogEntryMeta.get();
ModelQuery<BlogEntry> query = DatastoreWrapper.query(BlogEntry.class);
query.filter(meta.blogKey.equal(blogKey));
query.sort(meta.entryCreateDate.desc).limit(10);
// カーソルの指定がある場合クエリに追加
if (StringUtils.isEmpty(nextPageKey) == false) {
query.encodedStartCursor(nextPageKey);
}
S3QueryResultList<BlogEntry> queryResultList = query.asQueryResultList();
// 次ページ取得用カーソルを取得
String newNextPageKey = queryResultList.getEncodedCursor();

List<GetBlogEntriesApiRecordDto> dtoList = new ArrayList<GetBlogEntriesApiRecordDto>();
for (BlogEntry blogEntry : queryResultList) {
GetBlogEntriesDto dto = new GetBlogEntriesDto();
dto.setEntryDescription(blogEntry.getEntryDescription());
dto.setEntryTitle(blogEntry.getEntryTitle());
dto.setEntryImageUrl(blogEntry.getEntryImageUrl());
dto.setEntryUrl(blogEntry.getEntryUrl());
dto.setEntryCreateDate(blogEntry.getEntryCreateDate());
dtoList.add(dto);
}

MemcacheableHashMap<String, Object> apiResponse = MemcacheableHashMap.newHashMap();
apiResponse.put("blogEntries", dtoList);

if (dtoList.isEmpty() == false && queryResultList.hasNext()) {
apiResponse.put("nextPageKey", newNextPageKey);
}
return apiResponse;
}
}




○Model→Jsonへの変換処理

上記コードのrunImplメソッドが返したオブジェクトをそのままJSONICでJsonに変換して返しています。
(Slim3標準のModel→Jsonの仕組みは使っていません)

{
"blogEntries": [
{
"entryCreateDate": "2012/12/25 01:00:00 000",
"entryDescription": "
CA Beat エンジニアリーダーのヤマサキ(@vierjp)です。CA Beatのシステム基盤 第二回は「自動Entityキャッシュ」です。これは実装時に意識する事無くEntityのデータを自動でMemcacheに追加・削除する処理です。○きっかけは「appengine ja night」「appengine ja night #19」で@najeira さんが紹介していた、AppEngin",
"entryImageUrl": "http://stat.ameba.jp/user_images/20121214/01/cabeat-e/59/20/p/o0400028012329235132.png",
"entryTitle": "14.CA Beatのシステム基盤 第二回「自動Entityキャッシュ」",
"entryUrl": "http://ameblo.jp/cabeat-e/entry-11433839320.html"
},

・・・・・

],
"nextPageKey": "E-ABAOsB8E*********DYuaHRtbAwU"
}
}


この例ではMap型で返していますが、実際にはModelを始めとするオブジェクト型を返すことができます。



○エラー処理

CABeatのWebアプリではプログラムから返すエラーを大きく3種類に分けて扱っています。
・入力チェックエラー
・業務エラー
・システムエラー

・入力チェックエラー(InputException)
パラメーターが不正な場合に発生するエラーのうち、
「パラメーターの文字列の内容だけを見て判定できるもの」
例えば
 ・「***は数値を入力してください」
 ・「***はメールアドレスの形式でなければなりません」
といったエラーがこれに該当します。
基本的には「ユーザーがエラーメッセージに沿って入力内容を変更することで解消できるエラー」です。

上記のコードの「ExtendedValidators」は
Slim3標準の入力チェック処理に「追加のチェック処理」を加えるために拡張しているものです。
通常Slim3の「Validators#validate()」は入力チェックに引っかかるとfalseを返しますが、
「JsonController#getValidators」メソッドで取得した「ExtendedValidators」の
「ExtendedValidators#validate()」でチェックに引っかかった場合「InputException」をthrowします。

「runImpl」メソッドから「InputException」がthrowされると、
JsonControllerは「入力チェックエラー時の形式」でレスポンスのjsonを生成して返します。

画面を表示するControllerの場合には「Validators#validate()」はbooleanを返す方が汎用性が高いでしょうが、
「API専用のController」の場合は「入力チェックエラー→即エラーレスポンス」なので、
結果のboolean型を判定する手間を省くため、
「getValidators」メソッドで取得したExtendedValidatorsは
入力チェックエラー時に即座に例外を発生するようにしています。

この場合結果のJsonは以下のようになります。

{
“inputErrors” : [
“blogId” : “id required.”
]
}



・業務エラー(BizException)
パラメーターが不正な場合に発生するエラーのうち、
「パラメーターの文字列の内容だけを見て判定できないもの」
言い換えれば「サーバー側の状態と比較して初めて判別できるもの」です。
(一般的な「業務エラー」という言葉の定義では上の入力チェックエラーも含んでいるかも。。)

例えば「ユーザー登録処理」において
「そのユーザー名はすでに使われています」というエラーを表示するようなケースでは、
Datastoreを参照して同じユーザー名のデータがすでに登録されているかどうかをチェックして初めて
エラーの有無を判別することができます。
このようなエラーのケースを(弊社内では)「業務エラー」と呼称しています。

これも「ユーザーがエラーメッセージに沿って入力内容や状態を変更することで解消できるエラー」です。

「そのユーザー名はすでに使われています」の処理の例

Key userKey = Datastore.createKey(User.class, userName);
User user = Datastore.getOrNullWithoutTx(User.class,userKey);
if ( user != null ){
throw new BizException("errors.alreadyExist","userName");
}


本来この処理を厳密に行うならTransaction中にチェックしなければいけませんが、ここではわかりやすいように簡単に書いています


業務エラーは「ユーザーに入力項目の改善を促す」のが目的なので、
エラーの具体的内容をユーザーに通知する必要があります。
そのため、BizExceptionのコンストラクタには「エラーID」と「エラーの原因となった項目」を指定します。

「runImpl」メソッドから「BizException」がthrowされると、
JsonControllerは「業務エラー時の形式」でレスポンスのjsonを生成して返します。

{
“globalErrors” : [
“errors. alreadyExist.userName” : “The username you enter is already exist.”
]
}




・システムエラー(SysException)
「システム側の問題で発生するエラー」で、
 ・正常に動作している前提において想定していない動作である
 ・(上記2つのエラーと違って)ユーザーの操作によって解消できない
が特徴です。

チェック例外を非チェック例外に置き換えたい場合は
以下のように発生した例外をコンストラクタの引数にしてthrowします。

}catch(Exception e){
throw new SysException(e);
}



仕様上存在していなければならないデータが存在しない場合等で明示的にSysExceptionをthrowする

if ( hogeMaster == null ){
throw new SysException("エラーメッセージ");
}


システムエラーの具体的内容をユーザーに通知する必要は無いので、
SysExceptionのコンストラクタにはユーザーに見せるための「エラーID」は指定せず、
ログを見てわかりやすくするためのエラーメッセージのみ指定します。

また、入力エラーチェックと業務エラーチェックをしっかりと行なっている前提においては、
その他の例外は全て「システムエラー」として処理して問題無いはずです。

そのため「SysException」だけでなく
「InputExceptionやBizException等の特別に定義された例外」以外の例外がrunImplからthrowされた場合にも
JsonControllerは「システムエラー時の形式」でレスポンスのjsonを生成して返します。

{
“globalErrors” : [
“errors.system” : “unexpected system error.”
]
}


これらを基本として、実際には「返したいステータスコード」に合わせて派生する例外クラスがいくつかあります。


○Interceptor

Slim3は「AOPは必要ない」と判断した上であえてAOPの機能を付けなかったそうですが、
私はAOPが大好きなのでControllerでInterceptorを使えるようにしました。
・横断的な処理を確実に差し込める
・実装メソッドにアノテーションを記述することで制御できるので楽
といったところはやはり魅力的だなぁ、と。

ここでAOPを使うために初期化に時間がかかるライブラリ等を使ってしまうと
スピンアップに時間がかかってしまうので、
基本的にはChain(Chain of Responsibility)と「rumImplを呼び出すための一度のリフレクション」だけでInterceptorを適用できるようにし、できるだけ速度が低下しないようにしています。



同種の機能のAPIで使用するControllerのスーパークラスで以下のように定義します。
同じシステム内でも外部からアクセスできるPubicなAPIと認証の必要なPrivateなAPI、
または機能単位等、業務的な位置づけで分けます。

・同種の機能のAPIで使用するControllerのスーパークラス

public abstract class SampleAppJsonController<T> extends JsonController<T> {

/*
* (非 Javadoc)
*
* @see jp.vier.sample.gae.controller.api.JsonController#setUpInterceptors()
*/
@Override
protected List<Interceptor> setUpInterceptors() {
List<Interceptor> interceptors = new ArrayList<Interceptor>();
interceptors.add(new FrontEndCacheInterceptor());
interceptors.add(new MeasureTimeInterceptor());
interceptors.add(new TraceInterceptor());
interceptors.add(new AllowCrossDomainInterceptor());
interceptors.add(new CheckMaintenanceInterceptor());
interceptors.add(new MemcacheInterceptor());
return interceptors;
}
}




その上で必要なインターセプターを用意してやれば、アノテーションだけで以下のような事ができます。

・FrontEndCacheを設定するための指定(パラメーターはキャッシュが有効な秒数)
@UseFrontEndCache(expire = 60)

レスポンスをFrontEndCacheに保存すると、FrontEndCacheが有効な期間は
リクエストがアプリケーションサーバーまで来ず、その前にあるWebサーバーが「キャッシュされたレスポンス」を返します。

この場合インスタンスに対する課金すらされないので、劇的に課金額が下がります。

ただし「有効期間を指定してのキャッシュ」のみが可能で明示的なキャッシュの削除はできません。
結果、運用者が間違ったデータを登録してしまった場合やユーザーによる不適切な投稿があった場合に、
データを削除してもキャッシュの有効期間内は表示し続けてしまう事になります。
そのため一定の長期間更新が無いことがわかっている場合でも、あまり長くし過ぎない方が良いでしょう。

また、キャッシュのKey的な値は「Pathとリクエストパラメーター」なので、
それ以外の条件(UserAgent等)でレスポンスの内容を変えている場合には使えません。

参考資料
AZusaar!でのappengine活用事例 #ajn19 (FronendCacheについてはP19~)

appengine ja night 16 BT Frontend cache control


・rumImplの返り値を丸ごとMemcacheに格納するための指定
@UseMemcache(key = MemcacheConsts.GET_BLOG_ENTRIES_KEY)

Interceptorは、アノテーションで指定したKeyにリクエストパラメーターも組み合わせてMemcacheのKeyを生成し、
Memcacheから「rumImplの返り値」の取得を試みます。
既にMemcacheにキャッシュがある場合にはrumImplの処理は実行せずにキャッシュデータを返し、
Memcacheにキャッシュが無い場合にはrumImplの処理を実行し、結果をMemcacheに保存します。


runImplが返すオブジェクトで「Memcacheable」インタフェースを実装すればMemcacheの有効期限の時刻を指定することもできます。
(「MemcacheableHashMap」は「Memcacheable」インタフェースをimplementしたHashMapです)

ユーザーによって頻繁に表示するデータが更新されることのないAPIなら
レスポンスを丸ごとMemcacheに保存する仕組みは手軽に速度の向上と課金額の低下を見込めます。
「一分程度のFrontEndCache」と「データが更新するまで(明示的には)削除しないMemcache」を組み合わせることで、
「インスタンスに課金されないFrontEndCache」と
「インスタンスには課金されるけどその他のDatastoreの参照等の課金がされず、かつ必要に応じて明示的に削除することができるMemcache」
の両方をバランス良くいいとこどりすることができます。


・Ajaxでクロスドメインでのアクセスを許可するための指定
@AllowCrossDomain(allowDomain = "www.example.com", allowMethods = AllowMethod.GET)

こちらはなんとなく想像がつくと思うので説明は省略します。


他にも実行時間を測ったりログを出力したり、業務的なチェックを行ったり、httpsでのアクセスを強制したりと、
必要に応じて使える色々なインターセプターやアノテーションを用意しました。
(たくさんのインターセプターを適用しすぎるとさすがに遅くなりそうなので基本的には必要なインターセプターのみ適用していますが)


○まとめ

今回は「APIを作るための共通Controller」についてでした。
よく使う処理を基盤に隠蔽することで、実装時の記述量を減らすようにしています。

現状「JsonController」という名前からも分かる通りJson形式でしか返せませんが、
アノテーションで「@Response("json")」のように指定できるようにすればより汎用的になるでしょう。


それでは今年もよろしくお願いします!(`・ω・´)