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: 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进行了使用,当然实际开发中我们也可以使用第三方来实现 。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容