Objective-C で RFC を斜め読みして WebSocket サーバを書いた話

この記事は tech.kayac.com Advent Calendar 2015 の21日目、 @Gemmbu がお送りします。

NOTE
これ作る前までは WiiU って本体に対してコントローラが複数繋げられると思っていたんですが、一つだけなんですね

Apple TV がリリースされたことですし、何かアプリを作りたいなと思って

  • Apple TV は居間で使うから、一人でやるよりみんなでできるのがいい
  • みんなで使うのに Apple TV アプリに加えて iOS/Android アプリを落とすのはめんどい
  • それなら Apple TV アプリ内に HTTP/WebSocket サーバ立ち上げてそこみんなのスマフォをつなげればいいのでは?

って上記を試せるコードをサクッと書いてみましたので、サンプルプロジェクトを実装しましょう

ソースコードの入手

cocoapods 対応とかしていないので https://github.com/KAMEDAkyosuke/ramaladni からサクッと入手しましょう。

$ git clone git@github.com:KAMEDAkyosuke/ramaladni.git
// サブモジュールも入れましょう
$ cd ramaladni
$ git submodule init
$ git submodule update

NOTE
ramaladni は Diable3 のユニークアイテムです。Socket を開けることができます。http://us.battle.net/d3/en/item/ramaladnis-gift

プロジェクトへの導入

RamaladniSample という名前で適当な Apple TV アプリを作成して以下の画像のようにプロジェクトへ Ramaladni.xcodeproj をドラッグします。

appletv_ramaladni_sample_poject_tree.png

また、プロジェクトの依存関係を設定します。

appletv_ramaladni_sample_build_phases.png

HTTPサーバを動かす

AppDelegate.m に以下のようなコードを書きましょう

#import "AppDelegate.h"

#import <Ramaladni/Ramaladni.h>    // Ramaladni.framework を使うために必須ですね。

@interface AppDelegate ()
// RAMWebServer のインスタンスをどこかに保持しましょう
@property (nullable, nonatomic, strong) RAMWebServer *webServer;
@end

@implementation AppDelegate


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.

    __weak typeof(self) wself = self;
    // サーバは処理をブロックしてしまうので、メインスレッドとは別のスレッドで動かしましょう
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // ポートと runLoop を指定します。
        // runLoop を指定しない場合メインの runLoop で動作します。
        wself.webServer = [RAMWebServer serverWithPort:8080
                                               runLoop:[NSRunLoop currentRunLoop]];
        // path に応じてレスポンスを返します
        // path は正規表現で表現します。`@".*"` は全てのレスポンスに対応するということになります
        RAMHTTPHandler *handler;
        handler = [RAMHTTPHandler handleWithPattern:@".*"
                                             blocks:^(RAMHTTPRequest * _Nonnull req, void (^ _Nonnull complete)(RAMHTTPResponse * _Nonnull res))
        {
            // レスポンスオブジェクトを作って、返します
            RAMHTTPResponse *res = [[RAMHTTPResponse alloc] init];
            res.header[@"Content-Type"] = @"text/html; charset=UTF-8";
            res.statusCode = 200;
            res.body = [@"HELLO Ramaladni Server\n" dataUsingEncoding:NSUTF8StringEncoding];
            complete(res);
        }];

        // 作成したハンドラを登録してサーバを起動させます。
        wself.webServer.httpHandler = handler;
        [wself.webServer start];
    });

    return YES;
}

簡単に curl でアクセスすると以下のようなレスポンスが得られるはずです

$ curl -v http://localhost:8080/
*   Trying ::1...
* connect to ::1 port 8080 failed: Connection refused
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Type: text/html; charset=UTF-8
* no chunk, no close, no size. Assume close to signal end
< 
HELLO Ramaladni Server
* Closing connection 0

NOTE
RAMHTTPHandler は next を指定することパターンに合致しない場合、次のハンドラに処理を任せることができます。いわゆる Chain of Responsibility パターンです。

NOTE
RAMHTTPFileHandler ってあらかじめ用意した静的なファイルを返すのに便利なクラスもあります

WebSocketサーバを動かす

次は WebSocket サーバです。 AppDelegate.m に以下のようなコードを書きましょう

#import "AppDelegate.h"

#import <Ramaladni/Ramaladni.h>
#import <CommonCrypto/CommonDigest.h>    // Sec-WebSocket-Accept を生成するために必要です

@interface AppDelegate ()
@property (nullable, nonatomic, strong) RAMWebServer *webServer;
@end

@implementation AppDelegate


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.

    __weak typeof(self) wself = self;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        wself.webServer = [RAMWebServer serverWithPort:8080
                                               runLoop:[NSRunLoop currentRunLoop]];

        // "^/connect" に接続した際には WebSocket へプロトコルをアップグレードさせる処理を書きます
        wself.webServer.httpHandler
        = [RAMHTTPHandler handleWithPattern:@"^/connect"
                                                      blocks:^(RAMHTTPRequest * _Nonnull req, void (^ _Nonnull complete)(RAMHTTPResponse * _Nonnull res))
        {
            // see: http://tools.ietf.org/html/rfc6455
            // お約束の処理です。
            // クライアントからsec-websocket-key で送られてきた値と
            // "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" を連結し
            // sha1 かけて base64 したものをレスポンスヘッダの Sec-WebSocket-Accept に設定します
            NSString *key = [NSString stringWithFormat:@"%@%@",
                             req.header[@"sec-websocket-key"],
                             @"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"];
            NSData *src = [key dataUsingEncoding:NSUTF8StringEncoding];
            unsigned char digest[CC_SHA1_DIGEST_LENGTH];
            CC_SHA1(src.bytes, (CC_LONG)src.length, digest);
            NSData *data = [NSData dataWithBytes:digest length:CC_SHA1_DIGEST_LENGTH];
            NSString *base64 = [data base64EncodedStringWithOptions:0];

            // レスポンスオブジェクトを作ります
            RAMHTTPResponse *res = [[RAMHTTPResponse alloc] init];
            res.statusCode = 101;
            res.header[@"Upgrade"] = @"websocket";
            res.header[@"Connection"] = @"Upgrade";
            res.header[@"Sec-WebSocket-Accept"] = base64;
            complete(res);
        }];

        // WebSocket へクライアントから書き込みが行われた際に呼び出される処理を登録します
        wself.webServer.webSocketHandler
        = [RAMWebSocketHandler handleWithBlocks:^(RAMWebServer * _Nonnull server, RAMWebSocketConnection * _Nonnull conn, RAMWebSocketFrame * _Nonnull frame) {
            // 接続しているクライアント全てを取得し、
            // 書き込まれたデータをそのまま返す
            NSArray<RAMWebSocketConnection*> *connections = [server webSocketConnections];
            frame.enableMask = NO;
            NSData *data = [frame data];
            for(RAMWebSocketConnection *c in connections){
                [c writeData:data];
            }
        }];

        [wself.webServer start];
    });

    return YES;
}

簡単に wscat でアクセスすると以下のようなレスポンスが得られるはずです

$ wscat -c ws://localhost:8080/connect
connected (press CTRL+C to quit)
> foo
< foo
> bar
< bar
> 

NOTE
複数の wscat から繋ぐと全てのクライアントへメッセージが送られていることがわかります

NOTE
webSocketHandler の中で frame.enableMask = NO としている理由は http://tools.ietf.org/html/rfc6455 の 5.2. Base Framing Protocol に書かれています。簡単にいうとクライアントからサーバへ送るデータは同じデータでも毎回ランダムで生成される XOR する必要がありますが、サーバからクライアントへ送るデータは XOR する必要がありません。

これでHTTP/WebSocketサーバを動かすことができました。簡単ですね。 コード行数もこれぐらいしかないので、年末年始にサクッと読んでみるとどうでしょうか?

$ wc -l ./*
      26 ./Info.plist
      22 ./RAMConnectionProtocol.h
      39 ./RAMHTTPConnection.h
     178 ./RAMHTTPConnection.m
      22 ./RAMHTTPFileHandler.h
      74 ./RAMHTTPFileHandler.m
      22 ./RAMHTTPHandler.h
      58 ./RAMHTTPHandler.m
      21 ./RAMHTTPHandlerProtocol.h
      26 ./RAMHTTPRequest.h
     168 ./RAMHTTPRequest.m
      19 ./RAMHTTPResponse.h
      69 ./RAMHTTPResponse.m
      40 ./RAMWebServer.h
     274 ./RAMWebServer.m
      35 ./RAMWebSocketConnection.h
     178 ./RAMWebSocketConnection.m
      34 ./RAMWebSocketFrame.h
     103 ./RAMWebSocketFrame.m
      21 ./RAMWebSocketFrameStreamParser.h
     149 ./RAMWebSocketFrameStreamParser.m
      20 ./RAMWebSocketHandler.h
      31 ./RAMWebSocketHandler.m
      19 ./RAMWebSocketHandlerProtocol.h
      30 ./Ramaladni.h
    1678 total

まとめ

以外と動作確認をするだけなら HTTP/WebSocket サーバってサクッとかけるんだなぁと。 ボードゲームとかカードゲーム好きなので、これ使って作ってみたい人を探していますので気になる人は声かけてください。

明日は

アドベントカレンダー22日目を担当してくださるのは、川崎さんです。

カヤックではエンジニアを大募集しています

カヤックでは普通の iOS エンジニアだけでなく、やりたいことを実現するためならObjective-CでHTTP/WebSocket サーバをサクッと書いちゃうようなエンジニアを募集しております。 http://www.kayac.com/recruit/career