URLSessionWebSocketTaskを使ってiOSのWebSocketクライアントを作る

この記事はiOS #2 Advent Calendar 2020の10日目の記事です。

こんにちは。株式会社ビデオマーケットの仙台オフィスでMIRAILのiOS/Androidアプリ開発を担当しているasmzです。

ちょっと業務でiOSアプリでのWebSocketクライアントの実装方法を調査していて、その中でApple公式のAPIとして用意されているURLSessionWebSocketTaskの使い方を初めて知ったので、今回は簡単なWebSocketクライアントを実装しながらその実装方法をまとめてみたいと思います。

WebSocketとは

詳細は省きますが、Webアプリケーションにおいて双方向通信を実現するための技術仕様です。

e-words.jp

WebSocketではサーバとクライアントがコネクションを確立後、そのコネクションを用いてメッセージの送受信を行うことで、低コストなリアルタイム通信を可能としています。

URLSessionWebSocketTaskとは

WWDC 2019で発表され iOS13より追加されたWebSocketクライアント処理を行うためのクラスで、従来のHTTP通信を行うURLSessionTaskのサブクラスとして実装されています。

developer.apple.com

developer.apple.com

これまでiOSアプリでWebSocket通信を実装するものとしてはNetwork.frameworkなどがありましたが、URLSessionWebSocketTaskはそれをより手軽に扱えるようにした感じのクラスです。

WebSocketクライアント実装

それでは早速、具体的な実装方法の説明に入っていきましょう。

タスク生成

まず最初にURLSessionWebSocketTaskのインスタンスを生成する必要があります。

let urlSession = URLSession(configuration: .default)
let webSocketTask = urlSession.webSocketTask(with: "ws://localhost:5001/")

ここでWebSocketサーバのURLを指定します。

接続

接続は通常のURLSessionと同様にresume()を実行するだけです。

webSocketTask.resume()

メッセージ送信

コネクションが確立したら、send()でメッセージの送信が可能となります。

// Defined:
public func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)

ここで出てくる URLSessionWebSocketTask.Message は以下のように文字列とバイナリの列挙型となっており、テキストメッセージとバイナリデータの2種類の形式で送信可能となっています。

// Defined:
public enum Message {
  case data(Data)
  case string(String)
}
let msg = URLSessionWebSocketTask.Message.string("テキストメッセージ")
webSocketTask.send(msg) { error in
  if let error = error {
    print(error)  // some error handling
  }
}

2種類の形式と言っても、JSONなどのテキストで送受信すれば他にもいろんな情報を詰め込むことは可能です。

メッセージ受信

サーバからのメッセージはreceive()completionHandlerで受けとることができます。

// Defined:
public func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)

ただしここで注意点として、receive()メソッドはメッセージ受信時の1回しか呼ばれません。

そのため、継続してメッセージを受信したい場合は、以下のようにreceive()メソッドをWrapし、メッセージ受信後にこのWrapperメソッドを再帰的に呼び出す必要があります。

func receiveMessage() {
  webSocketTask.receive { [weak self] result in
    switch result {
      case .success(let message):
        switch message {
          case .string(let text):
            print("Received! text: \(text)")
          case .data(let data):
            print("Received! binary: \(data)")
          @unknown default:
            fatalError()
        }
        receiveMessage()  // <- 継続して受信するために再帰的に呼び出す
      case .failure(let error):
        print("Failed! error: \(error)")
    }
  }
}

この辺、Delegateなど設定すれば自動的に入ってくると良いなと思うのですが、現状そういった方法は用意されていないようです。

切断

コネクション切断にはcancel()を使用します。

// Defined: 
open func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)

切断時には切断コード値と理由をサーバへ通知することができます。URLSessionWebSocketTask.CloseCodeは以下のように定義されています。

public enum CloseCode : Int {
  case invalid = 0
  case normalClosure = 1000
  case goingAway = 1001
  case protocolError = 1002
  case unsupportedData = 1003
  case noStatusReceived = 1005
  case abnormalClosure = 1006
  case invalidFramePayloadData = 1007
  case policyViolation = 1008
  case messageTooBig = 1009
  case mandatoryExtensionMissing = 1010
  case internalServerError = 1011
  case tlsHandshakeFailure = 1015
}
webSocketTask.cancel(with: .goingAway, reason: nil)

接続ステータス監視

URLSessionWebSocketDelegateを用いて、WebSocketのコネクションが確立・切断されたタイミングを監視し、アプリ側でハンドリングすることが可能です。

[コネクション確立]

// Defined:
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?)

[コネクション切断]

// Defined:
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)

なお、URLSessionWebSocketDelegateURLSessionTaskDelegateのサブクラスなので、通信エラーなどの場合はURLSessionTask利用時と同じようにurlSession(_:task:didCompleteWithError:)で補足できます。

実装サンプル

クライアント

というわけで、上記の内容を踏まえて簡単なチャット風WebSocketクライアントアプリを用意してみました。

github.com

WebSocketClientクラスがWebSocketクライアント本体となります。

なお普段の業務ではSwiftUI使っていないのですが、今回は興味本位でSwiftUIで実装してみました。(SwiftUIでの開発には全く慣れていないので、UI周りの実装については正直自信ありません。。)

サーバ

今回のエントリの範疇ではないですが、とりあえず動作確認したかったので雑に以下のNode.jsのサーバをlocalhostに立ち上げました。

var server = require('ws').Server;
var s = new server( { port: 5001 } );

s.on('connection',function(ws){
    ws.on('message',function(message){
        console.log("Received: " + message);
        s.clients.forEach(function(client){
            client.send(message);
        });
    });
    ws.on('close',function(){
        console.log('closed.');
    });
});

やってることは、単純にクライアントから受信したメッセージを全クライアントに返すだけです。

これでWebSocketサーバを立ち上げると、URLは ws://localhost:5001 となります。

ちなみに ws はHTTPでの通信となるので、iOSでの実行の際はATS設定が必要です。

実行結果

シミュレータを2台立ち上げて、同一WebSocketサーバと接続した状態での挙動です。

f:id:asmz0:20201207173734g:plain

一方のアプリでメッセージを送信すると、もう一方のアプリもリアルタイムでメッセージを受信していることがわかるかと思います。

おわりに

以上、URLSessionWebSocketTaskを用いたWebSocket通信処理の基本的な実装手順を紹介しました。

個人的な感想としてはちょっとreceive()の扱いにクセがあるように思いましたが、公式APIのみでだいぶ手軽にWebSocketが扱えるようになったという印象です。

なお、URLSessionWebSocketTaskが使えるのはiOS13以上となりますのでご注意ください。(ここまで調べといて何ですが、私はそれでURLSessionWebSocketTaskを使うのは諦めて、サードパーティ製のライブラリを使用することにしました。。)


参考