美文网首页即时通讯
iOS中WebSocket的使用

iOS中WebSocket的使用

作者: MambaYong | 来源:发表于2022-08-01 17:15 被阅读0次

    简介

    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: websocketConnection: 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 lenHTTP中的content-lengh一样用来表明数据的长度。
    • Masking-key表示掩码,从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作,如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。

    掩码的目的是为了避免被网络代理服务器误认为是HTTP请求,从而招致代理服务器被恶意脚本攻击,采用掩码对客户端的数据进行掩码操作后,中间人代理就无法预测其数据流量,无法进行缓存,所以在WebSocket中客户端到服务器的数据是一定要经过掩码处理的。

    • Payload data要发送的数据,如果太大的话就要进行分片发送。

    实际应用

    项目主要演示的是一个问答的App,客户端新建一个question后会通过WebSocketquestion传给服务器,服务器收到这些question后会存到数据库,当通过网页回答了此问题后,服务器会通过WebSocket主动告知客户端对应的question的回答状态,客户端会同步更新question的状态。

    服务端搭建

    服务端这里采用了SwiftVapor框架来搭建服务端程序,这里只贴出服务端程序的主要代码实现,我们的重点是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,并更新数据库将questionanswered状态改为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 }
        }
    
    }
    
    • alertkeyboard都是封装好的,这里的代码就不展示了。
    • 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进行了使用,当然实际开发中我们也可以使用第三方来实现 。

    相关文章

      网友评论

        本文标题:iOS中WebSocket的使用

        本文链接:https://www.haomeiwen.com/subject/ssmrwrtx.html