iOS中WebSocket的使用
简介
HTTP
协议是无状态的协议,采用的是请求/应答的模式,所以只能是客户端发送请求,服务器响应请求,服务器是无法给客户端主动推送消息的,而有时候客户端需要在服务器数据更新的时候及时的进行更新界面或者其他的逻辑处理,以前的方案是客户端通过轮询不断的发送HTTP
请求到服务器来拿到服务器最新的数据,非常的麻烦。
WebSocket
连接允许客户端和服务器之间进行全双工通信,以便任一方都可以通过建立的连接将数据推送到另一端。WebSocket
只需要建立一次连接,就可以一直保持连接状态。这相比于轮询方式的不停建立连接显然效率要大大提高。
WebSocket
WebSocket
在建立连接之前也是需要经过握手的,而且当初WebScoket
为了兼容性,在握手的时候使用HTTP请求来完成握手,客户端发送HTTP
请求,其中头部headers
信息会包含如下信息:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
- 其中
Upgrade: websocket
和Connection: Upgrade
用来告诉服务器想升级为WebScoket协议。 -
Sec-WebSocket-Protocol
表示所使用的WebSocket具体协议,Sec-WebSocket-Protocol
是协议的版本。 -
Sec-WebSocket-Key
为一个Base64加密后的秘钥,Origin
用来指明请求的来源。 -
Origin
头部主要用于保护Websocket服务器免受非授权的跨域脚本调用Websocket API
的请求,也就是不想没被授权的跨域访问与服务器建立连接,服务器可以通过这个字段来判断来源的域并有选择的拒绝。
服务器收到了连接请求后响应如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
-
101
状态码表示服务器同意升级为WebSocket
协议。 -
Sec-WebSocket-Accept
是服务器拿到客户端上送的Sec-WebSocket-Key
加密后的数据,客户端利用相同的加密算法对Sec-WebSocket-Key
进行加密然后与后台返回的进行比对。 -
Sec-WebSocket-Protocol
服务器采用的协议。
WebSocket Data
Websocket
数据是以Frame
流的形式进行传输,其格式如下:
-
FIN
指明是否还有下一帧数据。 -
RSV1-3
一般为0。 -
opcode
表明数据的类型以及如何处理数据。 -
MASK
这个是指明payload data
是否被计算掩码,这个和后面的Masking-key
有关。 -
Payload len
和HTTP
中的content-lengh
一样用来表明数据的长度。 -
Masking-key
表示掩码,从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作,如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。
掩码的目的是为了避免被网络代理服务器误认为是
HTTP
请求,从而招致代理服务器被恶意脚本攻击,采用掩码对客户端的数据进行掩码操作后,中间人代理就无法预测其数据流量,无法进行缓存,所以在WebSocket
中客户端到服务器的数据是一定要经过掩码处理的。
-
Payload data
要发送的数据,如果太大的话就要进行分片发送。
实际应用
项目主要演示的是一个问答的App
,客户端新建一个question
后会通过WebSocket
将question
传给服务器,服务器收到这些question
后会存到数据库,当通过网页回答了此问题后,服务器会通过WebSocket
主动告知客户端对应的question
的回答状态,客户端会同步更新question
的状态。
服务端搭建
服务端这里采用了Swift
的Vapor
框架来搭建服务端程序,这里只贴出服务端程序的主要代码实现,我们的重点是iOS
客户端的实现,在实际开发中服务端的实现也是后台开发人员所需要完成的,而且后台采用的技术栈也不是固定的。
Websocket的链接
func connect(_ ws: WebSocket) {
let uuid = UUID()
self.lock.withLockVoid {
self.sockets[uuid] = ws
}
ws.onBinary { [weak self] ws, buffer in
guard let self = self,
let data = buffer.getData(
at: buffer.readerIndex, length: buffer.readableBytes) else {
return
}
self.onData(ws, data)
}
ws.onText { [weak self] ws, text in
guard let self = self,
let data = text.data(using: .utf8) else {
return
}
self.onData(ws, data)
}
self.send(message: QnAHandshake(id: uuid), to: .socket(ws))
}
- 服务端是要区分不同客户端发来的
WebSocket
链接的,这里在用UUID
来实现。 - 连接成功后告诉了客户端。
func onData(_ ws: WebSocket, _ data: Data) {
let decoder = JSONDecoder()
do {
let sinData = try decoder.decode(QnAMessageSinData.self, from: data)
switch sinData.type {
case .newQuestion:
let newQuestionData = try decoder.decode(NewQuestionMessage.self,from: data)
self.onNewQuestion(ws, sinData.id, newQuestionData)
default:
break
}
} catch {
logger.report(error: error)
}
}
}
func onNewQuestion(_ ws: WebSocket, _ id: UUID, _ message: NewQuestionMessage) {
let q = Question(content: message.content, askedFrom: id)
self.db.withConnection {
q.save(on: $0)
}.whenComplete { res in
let success: Bool
let message: String
switch res {
case .failure(let err):
self.logger.report(error: err)
success = false
message = "Something went wrong creating the question."
case .success:
self.logger.info("Got a new question!")
success = true
message = "Question created. We will answer it as soon as possible :]"
}
try? self.send(message: NewQuestionResponse(
success: success,
message: message,
id: q.requireID(),
answered: q.answered,
content: q.content,
createdAt: q.createdAt
), to: .socket(ws))
}
}
- 收到客户端发来的
NewQuestion
时,首先存到数据库。 - 当解析
NewQuestion
成功时,发送NewQuestionResponse
消息回客户端。
func send<T: Codable>(message: T, to sendOption: WebSocketSendOption) {
logger.info("Sending \(T.self) to \(sendOption)")
do {
let sockets: [WebSocket] = self.lock.withLock {
switch sendOption {
case .id(let id):
return [self.sockets[id]].compactMap { $0 }
case .socket(let socket):
return [socket]
case .all:
return self.sockets.values.map { $0 }
case .ids(let ids):
return self.sockets.filter { key, _ in ids.contains(key) }.map { $1 }
}
}
let encoder = JSONEncoder()
let data = try encoder.encode(message)
sockets.forEach {
$0.send(raw: data, opcode: .binary)
}
} catch {
logger.report(error: error)
}
}
- 回消息给客户端时需要拿到
UUID
对应的WebSocket
来发送消息。 -
WebSocket
发送消息时opcode
采用的是binary
形式。
func answer(req: Request) throws -> EventLoopFuture<Response> {
guard let questionId = req.parameters.get("questionId"),
let questionUid = UUID(questionId) else {
throw Abort(.badRequest)
}
return Question.find(questionUid, on: req.db)
.unwrap(or: Abort(.notFound))
.flatMap { question in
question.answered = true
return question.save(on: req.db).flatMapThrowing {
try self.wsController.send(message:
QuestionAnsweredMessage(questionId: question.requireID()),
to: .id(question.askedFrom))
return req.redirect(to: "/")
}
}
}
- 首先在数据库中找到当前回答
question
,并更新数据库将question
的answered
状态改为true
。 - 通过
WebSocket
发送问题已回答消息给客户端,同时利用重定向刷新当前H5
页面。
上面的服务端实现要熟悉大致的实现逻辑即可,不同的后台语言实现的逻辑都是一样的。
iOS客户端的实现
struct ContentView: View {
@State var newQuestion: String = ""
@ObservedObject var keyboard: Keyboard = .init()
@ObservedObject var socket: WebSocketController = .init()
var body: some View {
VStack(spacing: 8) {
Text("Your asked questions:")
Divider()
List(socket.questions.map { $1 }.sorted(), id: \.id) { q in
VStack(alignment: .leading) {
Text(q.content)
Text("Status: \(q.answered ? "Answered" : "Unanswered")")
.foregroundColor(q.answered ? .green : .red)
}
}
Divider()
TextField("Ask a new question", text: $newQuestion, onCommit: {
guard !self.newQuestion.isEmpty else { return }
self.socket.addQuestion(self.newQuestion)
self.newQuestion = ""
})
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
.edgesIgnoringSafeArea(keyboard.height > 0 ? .bottom : [])
}
.padding(.vertical)
.alert(item: $socket.alertWrapper) { $0.alert }
}
}
-
alert
和keyboard
都是封装好的,这里的代码就不展示了。 -
websocket
的逻辑都在WebSocketController
的这个类中。
func connect() {
self.session = URLSession(configuration: .default)
self.socket = session.webSocketTask(with:
URL(string: "ws://localhost:8080/socket")!)
self.listen()
self.socket.resume()
}
func listen() {
self.socket.receive { [weak self] (result) in
guard let self = self else { return }
switch result {
case .failure(let error):
print(error)
let alert = Alert(
title: Text("Unable to connect to server!"),
dismissButton: .default(Text("Retry")) {
self.alert = nil
self.socket.cancel(with: .goingAway, reason: nil)
self.connect()
}
)
self.alert = alert
return
case .success(let message):
switch message {
case .data(let data):
self.handle(data)
case .string(let str):
guard let data = str.data(using: .utf8) else { return }
self.handle(data)
@unknown default:
break
}
}
self.listen()
}
}
-
WebSocket
的链接使用的是URLSessionWebSocketTask
。 - 服务器发送的
WebSocket
消息在self.socket.receive
回调中处理,URLSessionWebSocketTask.receive
每次只会注册一次 ,在执行完回调后需要再次注册这个方法。
func addQuestion(_ content: String) {
guard let id = self.id else { return }
let message = NewQuestionMessage(id: id, content: content)
do {
let data = try encoder.encode(message)
self.socket.send(.data(data)) { (err) in
if err != nil {
print(err.debugDescription)
}
}
} catch {
print(error)
}
}
- 当在
contentView
中添加新的question
后会触发addQuestion
方法。 - 构建消息结构体,并利用
WebScoket
通过二进制流发送给了服务端。
上文我们提到过
WebSocket
在发送消息时当数据量较大时需要进行分片发送,同时客户端发送给服务器的数据必须利用masking-key
进行掩码处理,同时发送时需要设置opcode
等,这些都被URLSessionWebSocketTask
在背后默默处理了。
func handle(_ data: Data) {
do {
let sinData = try decoder.decode(QnAMessageSinData.self, from: data)
switch sinData.type {
case .handshake:
print("Shook the hand")
let message = try decoder.decode(QnAHandshake.self, from: data)
self.id = message.id
case .questionResponse:
try self.handleQuestionResponse(data)
case .questionAnswer:
try self.handleQuestionAnswer(data)
default:
break
}
} catch {
print(error)
}
}
func handleQuestionAnswer(_ data: Data) throws {
let response = try decoder.decode(QuestionAnsweredMessage.self, from: data)
DispatchQueue.main.async {
guard let question = self.questions[response.questionId] else { return }
question.answered = true
self.questions[response.questionId] = question
}
}
func handleQuestionResponse(_ data: Data) throws {
let response = try decoder.decode(NewQuestionResponse.self, from: data)
DispatchQueue.main.async {
if response.success, let id = response.id {
self.questions[id] = response
let alert = Alert(title: Text("New question received!"),
message: Text(response.message),
dismissButton: .default(Text("OK")) { self.alert = nil })
self.alert = alert
} else {
let alert = Alert(title: Text("Something went wrong!"),
message: Text(response.message),
dismissButton: .default(Text("OK")) { self.alert = nil })
self.alert = alert
}
}
}
- 服务器收到新的
question
后会发送QuestionResponse
的一个确定,客户端收到QuestionResponse
的回复后存储消息并展示消息,同时进行弹窗提示。 - 收到
QuestionAnswer
的回复后,通过questionId
找到相应的question
并更新其answered
状态。 - 由于采用的是
combine
的响应式编程,所以在主线程更新数据源后会同步更新UI
。
总结
本文主要介绍了WebSocket
的一些概念,同时从前后端的角度进行了实际的项目演示,iOS
客户端在实现的过程中采用了原生的URLSessionWebSocketTask
进行了使用,当然实际开发中我们也可以使用第三方来实现 。