RESTfullな汎用APIサーバjsonengineで超簡単にBBSを作ってみる

iOS5よりNSJSONSerializationという標準で扱えるクラスが追加され、Objective-CJSONは非常に相性がよくなった気がします。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が利用されているのでスケーラビリティは非常に高いです。
これの何が便利かというと、JavaPythonなどの開発が一切不要(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 で正常に動作しているか確認ができます。

docTypeの設定

docTypeとはDBのテーブルのような概念で管理画面よりdocTypeの名前とアクセス権限レベルを設定します。尚、jsonengineではスキーマ的な概念は存在せず、docTypeには異なった構造のJSONドキュメントも挿入が可能です。

下記のURLから管理画面を開きます。
http://{myappid}.appspot.com/_admin


ここでは"records"というdocTypeをpublicで作成します。本来なら権限をprivateにするべきだとは思いますが、ここでは一旦フルアクセス権限のpublicで進めます。

サーバ側で行うことは以上です。


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