RESTfullな汎用APIサーバjsonengineで超簡単にBBSを作ってみる
iOS5よりNSJSONSerializationという標準で扱えるクラスが追加され、Objective-CとJSONは非常に相性がよくなった気がします。CoreDataも構造型ストレージ感が強いのでなんでもJSON-Object変換でという流れになりそうな予感です。サーバ側も昨今ではGraph APIを筆頭に段々とNoSQL的なリソースを直に扱う平たいAPIが増えてきました。それに伴いサーバ側のロジックは簡素化していきクライアント側のロジックが複雑化するという流れが予想されるわけですが、その是非はさておき、今回はその流行りに乗った超汎用RESTfulなGoogle App Engineモジュールのjsonengineを紹介します。
jsonengineとは
http://code.google.com/p/jsonengine/
jsonengineとはGoogle App Engine上で動作する汎用的なJSONストレージを実現したJavaモジュールで、ストレージにBigTableが利用されているのでスケーラビリティは非常に高いです。
これの何が便利かというと、Java・Pythonなどの開発が一切不要(Eclipse等の開発ツールも不要)、AppEngine上にデプロイして管理画面で設定するだけでREST APIによってJSONストレージとして利用できます。GET/POST/DELETE/PUTのHTTPメソッドを使って、JSONドキュメントを直接操作するので、オンラインでDBを使っている感覚です。また、一旦デプロイしてしまえば開発中サーバ側を弄ることがありません。
10分もかからずにサーバ設置が可能なのでオンライン上のデータを扱うアプリのサーバプロトタイプに非常に有効だと思います。
jsonengineサーバ構築
AppEngineを初めて使う場合は、AppEngineのサイトからサインアップしてください。ここでは既にGoogle App Engineのサインアップが済んでいる状態として進めます。
AppEngine上にアプリケーションを作成
AppEngineのアプリケーション管理画面で「Create Application」からApplication Identifierを登録します。
Identifierはアクセスする際のURLに含まれます。※) http://{identifier}.appspot.com/
今回、私は"hmorijsonenginetest"で作っています。
Google App Engine SDK for Javaのダウンロード
Google App EngineのダウンロードサイトからSDKをダウンロードします。
※2012/11/25時点では、ver1.7.3が最新
ダウンロードしたらPathにスペースが含まれない任意の作業フォルダに解凍して"appengine-java-sdk-dir"とリネームします。
jsonengineのダウンロード
jsonengineのサイトからリリースモジュールをダウンロードします。
ダウンロードしたらPathにスペースが含まれない任意の作業フォルダに解凍して"jsonengine-dir"とリネームします。
デプロイ
jsonengine-dir/war/WEB-INF/appengine-web.xmlをテキストエディタで開き、aplicationタグをAppEngineで作成したApplication Identifierに書き換えます。
threadsafeタグをtrueで追加します。※threadsafeタグが必須になった模様
appengine-web.xml
<!-- Generated from app.yaml. Do not edit. --> <appengine-web-app xmlns='http://appengine.google.com/ns/1.0'> <application>hmorijsonenginetest</application> <version>2010-11-06</version> <static-files> </static-files> <resource-files> </resource-files> <ssl-enabled>true</ssl-enabled> <precompilation-enabled>true</precompilation-enabled> <sessions-enabled>false</sessions-enabled> <admin-console> <page name='jsonengine Admin' url='/_admin/'/> <page name='jsonengine BBS Sample' url='/samples/bbs.html'/> </admin-console> <threadsafe>true</threadsafe> </appengine-web-app>
ターミナルよりappcfg.shを使ってデプロイします。(※Windowsの場合はappcfg.cmdを使用)
./appengine-java-sdk-dir/bin/appcfg.sh update jsonengine-dir/war
Googleアカウントのemailとパスワードを入力して、"Update completed successfully."となればデプロイ完了です。
hmori-mbp:jetest hmori$ ./appengine-java-sdk-dir/bin/appcfg.sh update jsonengine-dir/war/ Reading application configuration data... 2012/11/25 17:26:51 com.google.apphosting.utils.config.AppEngineWebXmlReader readAppEngineWebXml ???: Successfully processed jsonengine-dir/war/WEB-INF/appengine-web.xml 2012/11/25 17:26:51 com.google.apphosting.utils.config.AbstractConfigXmlReader readConfigXml ???: Successfully processed jsonengine-dir/war/WEB-INF/web.xml 2012/11/25 17:26:51 com.google.apphosting.utils.config.AbstractConfigXmlReader readConfigXml ???: Successfully processed jsonengine-dir/war/WEB-INF/queue.xml 2012/11/25 17:26:51 com.google.apphosting.utils.config.IndexesXmlReader readConfigXml ???: Successfully processed jsonengine-dir/war/WEB-INF/datastore-indexes.xml Beginning server interaction for hmorijsonenginetest... Email: hmori99@gmail.com Password for hmori99@gmail.com: 0% Created staging directory at: '/var/folders/9q/gr5hbvpd4g96ryps5tcv92900000gn/T/appcfg1338647429479990427.tmp' 5% Scanning for jsp files. 8% Compiling jsp files. 2012/11/25 17:27:13 com.google.apphosting.utils.config.AbstractConfigXmlReader readConfigXml ???: Successfully processed /var/folders/9q/gr5hbvpd4g96ryps5tcv92900000gn/T/appcfg1338647429479990427.tmp/WEB-INF/web.xml 20% Scanning files on local disk. 25% Initiating update. 28% Cloning 68 static files. 31% Cloning 155 application files. 40% Uploading 0 files. 52% Initializing precompilation... 90% Deploying new version. 95% Will check again in 1 seconds. 98% Will check again in 2 seconds. 99% Will check again in 4 seconds. 99% Will check again in 8 seconds. 99% Closing update: new version is ready to start serving. 99% Uploading index definitions. 99% Uploading task queues. Update completed successfully. Success. Cleaning up temporary files...
http://{myappid}.appspot.com/samples/bbs.html で正常に動作しているか確認ができます。
iOSアプリ
今回は簡易BBSアプリみたいなのを作ってみます。
↓こんな感じの
- 初期表示時及び更新ボタン押下時に最新20件のrecordを取得しTableViewに反映
- postボタン押下時に1件のrecordを作成、完了時に最新20件を再取得しTableViewに反映
- Editにて行削除時に対象recordのdocIdをキーに削除リクエスト発行しTableViewに反映
- searchボタン押下時にuserに入力された文字列でrecordを取得しTableViewに反映
recordの構造は{"user":"名前", "msg","メッセージ本文"}とします。idや更新メタ情報は自動的にjsonengineで付加されます。
付加される項目
- _docId: 各ドキュメントに振られる一意なID
- _createdAt: ドキュメントが生成された時点のタイムスタンプ(long型)
- _createdBy: ドキュメントを生成したユーザのユーザID
- _updatedAt: ドキュメントが更新された時点のタイムスタンプ(long型)
- _updatedBy: ドキュメントを更新したユーザのユーザID
発行するAPI
GET /_je/records?sort=_createdAt.desc&limit=20
最新で20件の取得(初期表示時、更新ボタン時など)
GET /_je/records?cond=user.eq.'input_user'
userが指定値に一致するrecordを取得(search押下時)
POST /_je/records BODY: _doc={'user':'input_user', 'msg':'input_msg'}
入力値で新規recordを作成(post押下時)
DELETE /_je/records/<docId>
指定docIdのrecordを削除(行の削除時)
制限事項
Google App Engineの制限によるものですが、いくつかの念頭に置いておく必要があります。
・1ドキュメントのサイズは1MB以下
・1プロパティのサイズは500文字以下 (インデックス制限)
・sort条件または非等価演算子(不等号)を使う場合、同時に2つ以上プロパティの条件は指定できない
公式ドキュメント
http://code.google.com/p/jsonengine/wiki/HowToUse
http://code.google.com/p/jsonengine/wiki/HowToUseQuery
日本語訳ドキュメント
http://jxck.bitbucket.org/jsonengine-doc-ja/build/html/HowToUse.html
http://jxck.bitbucket.org/jsonengine-doc-ja/build/html/HowToUseQuery.html
ソースコード
Request発行部
static NSString * const urlData = @"https://hmorijsonenginetest.appspot.com/_je/records"; - (void)requestGetUser:(NSString *)user { NSMutableString *url = [NSMutableString stringWithString:urlData]; [url appendString:@"?"]; if (user && user.length > 0) { [url appendString:@"cond="]; [url appendString:[[NSString stringWithFormat:@"user.eq.%@", user] urlencode]]; } else { [url appendString:@"sort=_createdAt.desc&limit=20"]; } NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; [request setHTTPMethod:@"GET"]; __block __weak JEViewController *weakSelf = self; [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *errer) { weakSelf.records = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil]; [weakSelf.tableView reloadData]; }]; } - (void)requestPostUser:(NSString *)user msg:(NSString *)msg { NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlData]]; [request setHTTPMethod:@"POST"]; NSString *postString = [NSString stringWithFormat:@"_doc={'user':'%@', 'msg':'%@'}", self.userTextField.text, self.msgTextField.text]; NSData *postData = [postString dataUsingEncoding:NSUTF8StringEncoding]; [request setHTTPBody:postData]; __block __weak JEViewController *weakSelf = self; [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) { [weakSelf requestGetUser:nil]; }]; } - (void)requestDeleteDocId:(NSString *)docId { NSMutableString *url = [NSMutableString stringWithFormat:@"%@/%@" ,urlData, docId]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; [request setHTTPMethod:@"DELETE"]; __block __weak JEViewController *weakSelf = self; [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *errer) { [weakSelf requestGetUser:nil]; }]; }
初期化処理
- (void)viewDidLoad { [super viewDidLoad]; [self requestGetUser:nil]; }
UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return _records.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"]; NSDictionary *rec = [_records objectAtIndex:indexPath.row]; NSString *user = [rec objectForKey:@"user"]; NSString *msg = [rec objectForKey:@"msg"]; cell.textLabel.text = user; cell.detailTextLabel.text = msg; return cell; } - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { if (editingStyle == UITableViewCellEditingStyleDelete) { NSDictionary *rec = [_records objectAtIndex:indexPath.row]; [self requestDeleteDocId:[rec objectForKey:@"_docId"]]; [_records removeObjectAtIndex:indexPath.row]; [self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade]; } }
問題点
今回、実験したdocTypeの権限レベルはpublicなのでデータがフルアクセス可能な状態です。尚、jsonengineの認証方法はGoogleアカウントかOpenIDをサポートしているようですがiOSからは使い勝手が悪いので、もしアクセス制限をかけるとするならjsonengine自体にBASIC認証をかけてしまった方が手っ取り早いかもしれません。
実際にはjsonengineは制限が強いこともありプロトタイプ用として考えるべきと思います。とはいえこのお手軽さは非常に便利なのでオススメです。
サンプル
今回作成したiOSのソースをGithubにアップしました。
https://github.com/hmori/JsonEngineTest