【iOS】URLSessionWebSocketTaskを利用したWebSocket通信
IT技術
はじめに
こんにちは!株式会社ライトコードの福岡本社でモバイルエンジニアやってる こー です!
2021.11.05YOUは何しにライトコードへ?〜こーくん編〜プロジェクト内で安心感を与えられる存在になりたい!今回は、弊社のエンジニアである高さんにフィーチャー!技術力に定評のあ...
今回は業務上で行った iOS アプリで WebSocket 通信を行う 方法について、備忘録的に書いていこうと思います。
WebSocket通信とは?
まず、「WebSocket通信とはなんぞや?」という方もいらっしゃると思うので、軽く解説します。
WebSocketは、クライアント↔サーバー間でリアルタイムで双方向の通信を行うためのプロトコル です。
通信プロトコルでは、最も有名かつ標準的なものとして HTTP がありますが、主に以下のような違いがあります。
HTTP | WebSocket | |
---|---|---|
通信方式 | クライアント→サーバーの単方向通信 | クライアント↔サーバーの双方向通信 |
接続 | リクエスト毎に接続を確立 | ハンドシェイク後に接続を永続 |
リアルタイム性 | 難しい | 容易に可能 |
データプロトコル | テキストベースプロトコル | バイナリプロトコル |
このような特性から、チャットアプリ のような「リアルタイムでデータ通信が必要」で「双方向の通信セッションを長時間維持したい」サービスや機能に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などのテキストベースのデータに加え、バイナリデータもやり取りできます。
そのため、メッセージも
string(text: String)
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 = nil
でURLSessionWebSocketTask
インスタンスを忘れずに解放して、3. サーバーからのメッセージを待機する で実行していた受信処理のループを止めるようにします。
ATSの例外設定を追加
最後に、ATSの例外設定を追加します。
iOSアプリでは、セキュアな通信を保証するためにATS(=App Transport Security)を利用したTLS接続を行います。
デフォルトではlocalhost
との通信も「セキュアでない通信」とされ拒否されているため、Info.plist
に上記のように例外設定を追加し、localhost
との通信を有効にします。
さいごに
今回は iOSでWebSocket通信を行う方法 について解説しました。
標準SDKで専用APIが用意されており、直感的に実装することができましたね。
WebSocket は Firebase Realtime Database などで利用されており(ライブラリ内にラップされていますが)、同期的でシームレスな機能をアプリで実現することができます。
記事を最後までご覧いただきありがとうございました!
皆さんのスキルアップに少しでも寄与できていれば幸いです。
それではまた!
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「クレヨンしんちゃんは人生のマニュアル」が口癖なモバイルアプリとバックエンドやってる人
おすすめ記事
immichを知ってほしい
2024.10.31