• トップ
  • ブログ一覧
  • 【iOS】URLSessionWebSocketTaskを利用したWebSocket通信
  • 【iOS】URLSessionWebSocketTaskを利用したWebSocket通信

    こー(エンジニア)こー(エンジニア)
    2024.10.03

    IT技術

    はじめに

    こんにちは!株式会社ライトコードの福岡本社でモバイルエンジニアやってる こー です!

    featureImg2021.11.05YOUは何しにライトコードへ?〜こーくん編〜プロジェクト内で安心感を与えられる存在になりたい!今回は、弊社のエンジニアである高さんにフィーチャー!技術力に定評のあ...

    今回は業務上で行った iOS アプリで WebSocket 通信を行う 方法について、備忘録的に書いていこうと思います。

    WebSocket通信とは?

    まず、「WebSocket通信とはなんぞや?」という方もいらっしゃると思うので、軽く解説します。

    WebSocketは、クライアント↔サーバー間でリアルタイムで双方向の通信を行うためのプロトコル です。

    通信プロトコルでは、最も有名かつ標準的なものとして HTTP がありますが、主に以下のような違いがあります。

    HTTPWebSocket
    通信方式クライアント→サーバーの単方向通信クライアント↔サーバーの双方向通信
    接続リクエスト毎に接続を確立ハンドシェイク後に接続を永続
    リアルタイム性難しい容易に可能
    データプロトコルテキストベースプロトコルバイナリプロトコル

    このような特性から、チャットアプリ のような「リアルタイムでデータ通信が必要」で「双方向の通信セッションを長時間維持したい」サービスや機能にWebSocketが用いられることが多いです。

    iOSアプリにWebSocket通信を実装

    それでは、iOSアプリでWebSocket通信を実現するための実装例を見ていきましょう。

    今回は、 ローカルサーバーとWebSocketで通信を行う クライアントクラスを作ります。

    URLSessionWebSocketTask というクラスがiOSの標準SDKに用意されており、これを使ってWebSocket通信を簡単に実装することができます。

    まずはコード全体に一度目を通した後、要点を絞って解説していきます。

    1protocol WebSocketClientProtocol {
    2    var delegate: WebSocketClientDelegate? { get set }
    3    func connect() async throws
    4    func send(text: String) async throws
    5    func send(data: Data) async throws
    6    func disconnect()
    7}
    8
    9protocol WebSocketClientDelegate: AnyObject {
    10    func webSocketClient(didConnect client: WebSocketClient)
    11    func webSocketClient(_ client: WebSocketClient, didReceiveText text: String)
    12    func webSocketClient(_ client: WebSocketClient, didReceiveData data: Data)
    13    func webSocketClient(didDisconnect client: WebSocketClient)
    14}
    15
    16/// MARK: - WebSocket通信を行うクライアントクラス
    17class WebSocketClient: NSObject, WebSocketClientProtocol {
    18
    19    enum Host {
    20        case local
    21
    22        var urlString: String {
    23            switch self {
    24            case .local:
    25                return "wss://localhost:8080"
    26            }
    27        }
    28    }
    29
    30    weak var delegate: WebSocketClientDelegate?
    31
    32    private var webSocketTask: URLSessionWebSocketTask?
    33    private let session: URLSession
    34    private let url: URL
    35
    36    // 1. セッションインスタンスの生成
    37    init(host: WebSocketClient.Host) {
    38        self.url = URL(string: host.urlString)!
    39        self.session = URLSession(
    40            configuration: .default,
    41            delegate: AllowLocalhostSignedCertificateDelegate(),
    42            delegateQueue: nil
    43        )
    44    }
    45
    46    // 2. サーバーとの接続を確立する
    47    func connect() async throws {
    48        self.webSocketTask = session.webSocketTask(with: url)
    49        webSocketTask?.resume()
    50        delegate?.webSocketClient(didConnect: self)
    51        try await receive()
    52    }
    53
    54    // 3. サーバーからのメッセージを待機する
    55    private func receive() async throws {
    56        while webSocketTask != nil {
    57            do {
    58                let message = try await webSocketTask?.receive()
    59
    60                switch message {
    61                    case .string(let text):
    62                        print("Received text message: \(text)")
    63                        delegate?.webSocket(self, didReceiveText: text)
    64                    case .data(let data):
    65                        print("Received data message: \(data)")
    66                        delegate?.webSocket(self, didReceiveData: data)
    67                }
    68            } catch {
    69                // 既にWebSocketTaskが開放されている場合、エラーはスルーする
    70                guard let _ = webSocketTask else {
    71                    return
    72                }
    73                print("WebSocket receive error: \(error)")
    74                disconnect()
    75                throw error
    76            }
    77        }
    78    }
    79
    80    // 4.1 サーバーにテキストメッセージを送信する
    81    func send(text: String) async throws {
    82        let message = URLSessionWebSocketTask.Message.string(text)
    83        do {
    84            try await webSocketTask?.send(message)
    85        } catch {
    86            print("WebSocket send text error: \(error)")
    87            throw error
    88        }
    89    }
    90
    91    // 4.2 サーバーにバイナリメッセージを送信する
    92    func send(data: Data) async throws {
    93        let message = URLSessionWebSocketTask.Message.data(data)
    94        do {
    95            try await webSocketTask?.send(message)
    96        } catch {
    97            print("WebSocket send data error: \(error)")
    98            throw error
    99        }
    100    }
    101
    102    // 5. サーバーとの接続を切断する
    103    func disconnect() {
    104        webSocketTask?.cancel(with: .goingAway, reason: nil)
    105        webSocketTask = nil
    106        delegate?.webSocketClient(didDisconnect: self)
    107    }
    108}
    109
    110// MARK: - 認証チャレンジのハンドリングデリゲートクラス
    111class AllowLocalhostSignedCertificateDelegate: NSObject, URLSessionDelegate {
    112    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
    113
    114        let protectionSpace = challenge.protectionSpace
    115        // 以下条件に合致する場合、信頼されていない証明書であっても受け入れ接続を許可する
    116        //   - "localhost" での接続
    117        //   - サーバー認証である
    118        guard protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
    119              protectionSpace.host == "localhost",
    120              let serverTrust = protectionSpace.serverTrust else {
    121            return (.performDefaultHandling, nil)
    122        }
    123
    124        return (.useCredential, URLCredential(trust: serverTrust))
    125    }
    126}

    1. セッションインスタンスの生成

    1    // 1. セッションインスタンスの生成
    2    init(host: WebSocketClient.Host) {
    3        self.url = URL(string: host.urlString)!
    4        self.session = URLSession(
    5            configuration: .default,
    6            delegate: AllowLocalhostSignedCertificateDelegate(),
    7            delegateQueue: nil
    8        )
    9    }
    10
    11    // ~~ 中略 ~~ //
    12
    13// MARK: - 認証チャレンジのハンドリングデリゲートクラス
    14class AllowLocalhostSignedCertificateDelegate: NSObject, URLSessionDelegate {
    15    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
    16
    17        let protectionSpace = challenge.protectionSpace
    18        // 以下条件に合致する場合、信頼されていない証明書であっても受け入れ接続を許可する
    19        //   - "localhost" での接続
    20        //   - サーバー認証である
    21        guard protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
    22              protectionSpace.host == "localhost",
    23              let serverTrust = protectionSpace.serverTrust else {
    24            return (.performDefaultHandling, nil)
    25        }
    26
    27        return (.useCredential, URLCredential(trust: serverTrust))
    28    }
    29}

    まず URLSessionWebSocketTask のインスタンス生成を行いますが、ここで 認証チャレンジのハンドリングデリゲートクラス を作って渡す必要があります。

    通常WebSocketのURLはwsスキームですが、HTML通信同様にセキュア通信のスキームwssがあり、通信対象のサーバーがセキュア通信である場合は、サーバー接続を行う際に認証チャレンジが行われます。

    この時、サーバー側に証明書が存在していなかったり、独自証明書を利用している場合は接続が拒否されてしまいます。

    そのため、今回は「localhostの通信」&「サーバー認証」である場合に通信を許可する認証チャレンジのハンドリングデリゲートクラスを作り初期化の際に渡すようにします。

    2. サーバーとの接続を確立

    1    // 2. サーバーとの接続を確立する
    2    func connect() async throws {
    3        self.webSocketTask = session.webSocketTask(with: url)
    4        webSocketTask?.resume()
    5        delegate?.webSocketClient(didConnect: self)
    6        try await receive()
    7    }

    URLSessionWebSocketTask#resume()を実行することで、指定URLのサーバーとの接続を確立します。

    この接続は 明示的に切断するまで継続します。

    冒頭で述べたように、ここが1回のリクエスト-レスポンスで接続が切断されるHTMLと大きく異なるところですね。

    切断のコードは 5. サーバーとの接続を切断する で紹介します。

    その直後、 後述するreceive()を実行することで、接続の確立と同時にサーバーからのメッセージ受信待機状態に入ります。

    3. サーバーからのメッセージを待機する

    1    // 3. サーバーからのメッセージを待機する
    2    private func receive() async throws {
    3        while webSocketTask != nil {
    4            do {
    5                let message = try await webSocketTask?.receive()
    6
    7                switch message {
    8                    case .string(let text):
    9                        print("Received text message: \(text)")
    10                        delegate?.webSocket(self, didReceiveText: text)
    11                    case .data(let data):
    12                        print("Received data message: \(data)")
    13                        delegate?.webSocket(self, didReceiveData: data)
    14                }
    15            } catch {
    16                // 既にWebSocketTaskが開放されている場合、エラーはスルーする
    17                guard let _ = webSocketTask else {
    18                    return
    19                }
    20                print("WebSocket receive error: \(error)")
    21                disconnect()
    22                throw error
    23            }
    24        }
    25    }

    URLSessionWebSocketTask#receive()で、返ってきているサーバーからのメッセージを受信することができます。

    WebSocketのデータ通信はバイナリプロトコルのため、JSONなどのテキストベースのデータに加え、バイナリデータもやり取りできます。

    そのため、メッセージも

    1. string(text: String)
    2. data(data: Data)

    の2種類を受信することができ、それぞれに対して処理を書くことができます。(今回はテストのためデリゲートメソッドに飛ばすのみに留めてます)

    サーバーからエラーが返ってきている場合は例外がスローされるため、do-catchでエラー処理も記述します。

    今回の場合はdisconnect()を呼び、エラーが返された場合は切断していますが、切断せず接続を確立したままにもできます。

    受信処理は以上ですが、これだけでは1回の実行で終了してしまいますね。

    そのため、while webSocketTask != nilブロックで受信処理を囲みループさせることで、URLSessionWebSocketTaskインスタンスが生存している間は永続的にサーバーからのメッセージを待機させるようにします。

    4. サーバーにメッセージを送信する

    1    // 4.1 サーバーにテキストメッセージを送信する
    2    func send(text: String) async throws {
    3        let message = URLSessionWebSocketTask.Message.string(text)
    4        do {
    5            try await webSocketTask?.send(message)
    6        } catch {
    7            print("WebSocket send text error: \(error)")
    8            throw error
    9        }
    10    }
    11
    12    // 4.2 サーバーにバイナリメッセージを送信する
    13    func send(data: Data) async throws {
    14        let message = URLSessionWebSocketTask.Message.data(data)
    15        do {
    16            try await webSocketTask?.send(message)
    17        } catch {
    18            print("WebSocket send data error: \(error)")
    19            throw error
    20        }
    21    }

    クライアントからもURLSessionWebSocketTask#send()で「テキスト」「バイナリデータ」の両方を送信できます。

    それぞれのデータをURLSessionWebSocketTask.Messageでラップして送信します。

    これも送信が失敗した場合は例外をスローするので、エラーハンドリングを書いておきましょう。

    5. サーバーとの接続を切断する

    1    // 5. サーバーとの接続を切断する
    2    func disconnect() {
    3        webSocketTask?.cancel(with: .goingAway, reason: nil)
    4        webSocketTask = nil
    5        delegate?.webSocketClient(didDisconnect: self)
    6    }

    確立された接続はURLSessionWebSocketTask#cancel()で切断できます。

    この時、webSocketTask = nilURLSessionWebSocketTaskインスタンスを忘れずに解放して、3. サーバーからのメッセージを待機する で実行していた受信処理のループを止めるようにします。

    ATSの例外設定を追加

    最後に、ATSの例外設定を追加します。

    iOSアプリでは、セキュアな通信を保証するためにATS(=App Transport Security)を利用したTLS接続を行います。

    デフォルトではlocalhostとの通信も「セキュアでない通信」とされ拒否されているため、Info.plistに上記のように例外設定を追加し、localhostとの通信を有効にします。

    さいごに

    今回は iOSでWebSocket通信を行う方法 について解説しました。

    標準SDKで専用APIが用意されており、直感的に実装することができましたね。

    WebSocket は Firebase Realtime Database などで利用されており(ライブラリ内にラップされていますが)、同期的でシームレスな機能をアプリで実現することができます。

    記事を最後までご覧いただきありがとうございました!

    皆さんのスキルアップに少しでも寄与できていれば幸いです。

    それではまた!

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    こー(エンジニア)
    こー(エンジニア)
    Show more...

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background