Objective-Cでsocket.ioクライアントを書いてみた

今年の目標はブログを書いてみるというのと去年から引き続きiOSとnodejs周りのプログラミングを勉強するという事なんですが、これら全部を一緒にやってしまえ!ということでクライアント側がiPhoneでサーバ側がnode,socket.ioという構成でなにか作ってみたいと思っています。でやったこと調べたことをブログにまとめていくと。

ということでiOSネイティブで動くsocket.ioクライアントはTitaniumもいれていくつかあるみたいですがObjective-Cの勉強のためにsocket.ioクライアントクローンを書いてみました。
NNSocketIO

雰囲気

JSで書くsocket.ioのコードと同じノリにしたかったのでこんなスタイルにしてみました。

NSURL* url = [NSURL URLWithString:@"http://localhost:8080"];
NNSocketIO* io = [NNSocketIO io];
__block id<NNSocketIOClient> client = [io connect:url];
[client on:@"connect" listener:^(NNArgs* args) {
    NSLog(@"Connected");
    [client send:@"Hello world!"];
}];
[client on:@"message" listener:^(NNArgs* args) {
    NSString* msg = [args get:0];
    NSLog(@"Message received! %@", msg);
}];
[client on:@"disconnect" listener:^(NNArgs* args) {
    NSLog(@"Bye!");
}];

NNSocketIOインスタンスは本家socket.ioクライアントのioオブジェクトに相当するので実際にアプリに載せる場合はretainしてアプリ側でシングルインスタンスをキープするイメージです。

NNArgsはJSの可変引数を無理矢理それっぽくObjective-Cで扱うためのNSArray的な代物です。nilをまま使たり、存在しない第n引数にアクセスしてもNSRangeExceptionが飛ばないようになっていたりします。

文字列ベースの送受信

文字列をsendメソッドで送ります。受信は'message'イベントリスナで拾います。
messageイベントリスナのコールバック引数は最大1で受信したメッセージの文字列が入ってきます。

[client on:@"connect" listener:^(NNArgs* args) {
    [client send:@"Hello world!"];
}];
[client on:@"message" listener:^(NNArgs* args) {
    NSString* msg = [args get:0];
}];

サーバ側のackが欲しい場合は以下のようにsendメソッドにackリスナを指定します。

[client send:msg listener:^(NNArgs* args){
    NSLog(@"Got an acknowledgment!");
}];

文字列ベースのメッセージはsocket.ioサーバ側が自動的に応答を返す(サーバアプリ側から任意の値で応答を返せない)のでNNArgsは常に空となります。

JSONベースの送受信

JSON形式のデータの取り扱いにはJSONKitを使用しています。
JSON形式のデータをsendメソッドで送ります。明確にJSONであることを示すためclientオブジェクトのプロパティjsonのsendメソッドを使用します。
(本家のFlagsと同じノリにしています)
受信は文字列ベースの受信と同じですが、引数0の型はJSONデータを表すNSDictionaryかNSArrayが入ります。

[client on:@"connect" listener:^(NNArgs* args) {
    NSDictionary* msg  = [NSDictionary dictionaryWithObjectsAndKeys:@"foo", @"firstname", @"bar", @"lastname",nil];
    [client.json send:msg];
}];
[client on:@"message" listener:^(NNArgs* args) {
    NSDictionary* msg = [args get:0];
}];

またサーバ側のackが欲しい場合は文字列ベースの場合と同じくack用のリスナを指定して送信します。
JSONベースのメッセージも同じくサーバ側が自動的に応答を引数なしで行うのでNNArgsは常に空となります。

Eventベースの送受信

イベント名を指定してemitメソッドで送信します。
文字列/JSONベースの送信と異なりNNArgsで引数をいくつでも指定できます。

受信はイベント名ごとに設定したリスナで行います。
リスナが呼ばれる際のNNArgsにはサーバ側で発火したイベントの引数分入ってきます。

[client on:@"connect" listener:^(NNArgs* args) {
    NSMutableDictionary* profile = [NSMutableDictionary dictionary];
    [profile setObject:@"taro" forKey:@"name"];
    [profile setObject:[NSNumber numberWithInt:20] forKey:@"age"]; 
    NNArgs* args = [[NNArgs args] add:profile];
    [client emit:@"profile" args:args];
}];
[client on:@"profile" listener:^(NNArgs* args) {
    NSDictionary* profile = [args get:0];
}];

サーバアプリ側のackが欲しい場合は以下のようにemitメソッドにackリスナを指定します。

[client on:@"connect" listener:^(NNArgs* args) {
    NSMutableDictionary* profile = [NSMutableDictionary dictionary];
    [profile setObject:@"taro" forKey:@"name"];
    [profile setObject:[NSNumber numberWithInt:20] forKey:@"age"]; 
    NNArgs* args = [[NNArgs args] add:profile];
    [client emit:@"profile" args:args listener:^(NNArgs* args) {
        NSLog(@"Got an acknowledgment!");
        NSDictionary* result = [args get:0];
    }];
}];

Eventベースのメッセージはでsocket.ioサーバ側のアプリが明示的に応答を返します。
NNArgsにはアプリ側が応答する際に指定した引数が入ってきます。

クライアントの名前空間

本家と同じく単一のコネクションを使って複数の名前空間に属するクライアントを利用できます。

NSURL* url = [NSURL URLWithString:@"http://localhost:8080"];
NSURL* newsurl = [NSURL URLWithString:@"http://localhost:8080/news"];
NSURL* echourl = [NSURL URLWithString:@"http://localhost:8080/echo"];
NNSocketIO* io = [NNSocketIO io];
__block id<NNSocketIOClient> root = [io connect:url];
__block id<NNSocketIOClient> news = [io connect:newsurl];
__block id<NNSocketIOClient> echo = [io connect:echourl];

本家のsocket.ioクライアントと違うところ

トランスポートレイヤはwebsocketのみ

websocket一択となっておりプラッガブルになっていません。
実装はNNWebSocketを使用しておりWebSocket protocol version 8ベースになっています。

接続失敗時のリトライ

  • 初回接続に失敗してもリトライを行います。
  • reconnectingイベントは発行しません。
  • reconnectイベントは発行しません。

でちゃんと動くの?

ここまで書いてなんなんですがテストケースはsocket.io v0.8.7ベースで行っていますが実機ではまだ動かしていません。これからアプリに組み込んで試してみたいと思います。