Bonjourを使ってiPhone同士で通信する(1) – NSNetService

同一wifi内のiPhone同士で通信しあうにはBonjourを使うと簡単にできるらしい。簡単にっていうのはアドレスの割当やらホストの解決、公開サービスの検索なんかを自動的にやってくれるみたいだけど、それ以外でも結構大変だったのでその記録。

主な手順としては、

  1. サーバ側がNSNetServiceを使ってサービスを起動
  2. クライアント側はNSNetServiceBrowserを使ってサーバとサービスを検索
  3. 見つかればNSInputStreamNSOutputStreamを使ってデータをやり取り

という感じで結構簡潔。

まずはプロジェクトの準備。新規プロジェクトをTab Bar Appricationから作成します。左のタブをServer(ServerViewController)、右のタブをClient(ClientViewController)として進めます。

ServerViewController.h

ServerViewController.hの重要な部分だけ抽出

#import <UIKit/UIKit.h>
#import <sys/socket.h>
#import <netinet/in.h>
@interface ServerViewController : UIViewController <NSNetServiceDelegate,NSStreamDelegate> {
    NSInputStream *inputStream;        //入力データを処理するストリーム
    NSOutputStream *outputStream;      //出力データを処理するストリーム
    NSNetService *netService;          //ネットワークサービスを提供
    CFSocketRef listeningSocket;       //ソケット

    //その他の変数の定義等
}

@property (nonatomic,retain) NSInputStream *inputStream;
@property (nonatomic,retain) NSOutputStream *outputStream;
@property (nonatomic,retain) NSNetService *netService;
@property (nonatomic) CFSocketRef listeningSocket;

-(void)startServer;            //サービスを起動する
-(void)startRecieve:(int)fd;   //データを受信する
-(void)returnMessage:(int)fd;  //データを送り返す
static void AcceptCallBack(CFSocketRef s, 
                           CFSocketCallBackType type,
                           CFDataRef address, 
                           const void *data, 
                           void *info);  //ソケットがデータを受け取った時のコールバック

@end

ServerViewController.h

ServerViewController.mの処理。基本的には、-(void)startRecieve:(int)fdでサービスを起動してクライアントからの接続を待つ→クライアントデータが送られてくるとAcceptCallBackが呼ばれるので、-(void)startRecieve:(int)fdで受信を開始→-(void)returnMessage:(int)fdでクライアントにデータを送り返すという流れです。

サービスの起動

/*
 *サービスを起動する(IBActionとかviewDidLoadとかから呼び出す)
 */
-(void)startServer{
    NSString *name = @"Test";    //サービス名を指定
    int port = 12345;            //使用するポートを指定
    int sc;                      //使用するソケットを参照するためのディスクリプタ
    struct sockaddr_in addr;
    //
    sc = socket(AF_INET,SOCK_STREAM,0);
    addr.sin_len = sizeof(addr);
    addr.sin_family = AF_INET;
    addr.sin_port = 0;
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(sc,(const struct sockaddr *)&addr, sizeof(addr));
    listen(sc, 5);
    socklen_t addrLen;
    addrLen = sizeof(addr);
    getsockname(sc, (struct sockaddr *)&addr, &addrLen);
    addrLen = sizeof(addr);
    port = ntohs(addr.sin_port);
    //
    CFSocketContext context = {0,self,NULL,NULL,NULL};
    self.listeningSocket = CFSocketCreateWithNative(NULL,
                                                    sc,
                                                    kCFSocketAcceptCallBack,
                                                    AcceptCallBack,
                                                    &context);
    CFRunLoopSourceRef rls;
    CFRelease(self.listeningSocket);
    sc = -1;
    rls = CFSocketCreateRunLoopSource(NULL, self.listeningSocket, 0);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), rls, kCFRunLoopDefaultMode);
    CFRelease(rls);
    //
    self.netService = [[NSNetService alloc] initWithDomain:@"local." 
                                                      type:@"_testService._tcp." 
                                                      name:name 
                                                      port:port];
    [self.netService scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
    [self.netService publishWithOptions:NSNetServiceNoAutoRename];
    self.netService.delegate = self;
}

-(void)startServerは、ソケットを作ってNSNetServiceを使ってサービスを公開って感じっぽいです。ソケットを作る部分については、C言語の理解が足りないので大分怪しいですがしばらくは定型で覚えておこうと思います。注意すべきは、

self.netService = [[NSNetService alloc] initWithDomain:ドメイン名 
                                                      type:サービスタイプ 
                                                      name:サービス名 
                                                      port:ポート];

で、ドキュメントによると、ドメイン名は@"local."のように最後にピリオド(.)を付ける事。
サービスタイプにはサービス名と転送レイヤー情報が必要で、それぞれの前にアンダースコア(_)を付ける必要があります。例えばTCP上でHTTPサービスを探すためには"_http._tcp."を使用します。ここでサービス名は任意の文字列が使えるようです。

データ(リクエスト)の受信

/*
 *ソケットがデータを受け取った時のコールバック
 */
static void AcceptCallBack(CFSocketRef s, 
                           CFSocketCallBackType type,
                           CFDataRef address, 
                           const void *data, 
                           void *info) {
    [(ServerViewController *)info startRecieve:*(int *)data];
}
/*
 *ソケットを指定してデータを受け取る
 */
-(void)startRecieve:(int)fd{
    CFReadStreamRef readStream;
    CFWriteStreamRef writeStream;
    CFStreamCreatePairWithSocket(NULL, fd, &readStream, &writeStream);
    self.inputStream = (NSInputStream *)readStream;
    self.outputStream = (NSOutputStream *)writeStream;
    CFRelease(readStream);
    CFRelease(writeStream);
    
    self.inputStream.delegate = self;
    [self.inputStream setProperty:(id)kCFBooleanTrue 
                           forKey:(NSString *)kCFStreamPropertyShouldCloseNativeSocket];
    [self.inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] 
                                forMode:NSRunLoopCommonModes];
    [self.inputStream open];
    [self returnMessage:fd];
}
/*
 *NSStreamのイベントの処理
 */
-(void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode{
    switch (eventCode) {
        case NSStreamEventOpenCompleted:
            break;
        case NSStreamEventHasBytesAvailable:
            NSUInteger bytesRead;
            uint8_t buffer[32768];
            bytesRead = [self.inputStream read:buffer maxLength:sizeof(buffer)];
            if (bytesRead == -1) {
            }else if(bytesRead == 0){
            }else{
                NSMutableData *result = [[NSMutableData alloc] init];
                NSInteger bytesWritten;
                NSInteger bytesWrittenSoFar;
                bytesWrittenSoFar = 0;
                do{
                    [result appendBytes:&buffer[bytesWrittenSoFar] 
                                 length:bytesRead-bytesWrittenSoFar];
                    bytesWritten = [result length];
                    if (bytesWritten == -1) {
                        break;
                    }else{
                        bytesWrittenSoFar += bytesRead;
                    }
                }while (bytesWrittenSoFar != bytesRead);
                NSString *str = [[NSString alloc] initWithData:result 
                                                      encoding:NSUTF8StringEncoding];
            }
            break;
        case NSStreamEventHasSpaceAvailable:
            break;
        case NSStreamEventErrorOccurred:
            break;
        case NSStreamEventEndEncountered:
            break;
        default:
            break;
    }
}

-(void)startRecieve:(int)fdでは、CFStreamCreatePairWithSocket()でCFReadStreamRefとCFWriteStreamRefへの参照を取得し、inputStreamに割り当ててdelegateを設定する事で受信データを処理する事ができます。
NSStreamDelegateの-(void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCodeが呼ばれたらNSMutableDataにバッファからデータを書き込んでいく事で受け取ったデータを復元します。今回はNSStringにしてますが、どういうデータをやりとりするかを決めておけば何でも受け渡しできるんじゃないでしょうか(データ量が多いと処理が増えると思いますが)。

データの送信

/*
 *ソケットを指定してデータを送り返す
 */
-(void)returnMessage:(int)fd{
    [self.outputStream setDelegate:self];
    [self.outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] 
                                 forMode:NSRunLoopCommonModes];
    [self.outputStream open];
    NSData *data = [[NSString stringWithString: @"受信完了"] 
                    dataUsingEncoding:NSUTF8StringEncoding];
    [self.outputStream write:[data bytes] 
                   maxLength:[data length]];
}

-(void)returnMessage:(int)fdでクライアントにデータを返します。-(void)startRecieve:(int)fdで取得したoutputStreamをNSRunLoopに登録し、[self.outputStream write:[data bytes]
maxLength:[data length]];を呼ぶ事でデータを送り返す事が出来るようです。

以上がサーバ側の処理の流れ。長くなるのでクライアント側の処理は別に書きます。あと、実際のアプリで使う時はエラー処理をしっかりしないと審査で落ちると思われます。今回は複雑になるので無視してますが、複数クライアントからの同時リクエストとか、データ転送中の切断とかいろいろ、先は長そうです。

ちなみにBonjourは同一のネットワーク上に無いと探せないみたいなので、不特定の近くにいる人と通信したいときなんかはサーバを用意して、位置情報に基づいてユーザを管理するような仕組みが必要になりそう。

お問い合わせやご意見などは@yasnisまでどうぞ