聊聊OkHttp实现WebSocket细节,包括鉴权和长连接保活及其原理!

9,954 阅读10分钟

一、序

OkHttp 应该算是 Android 中使用最广泛的网络库了,我们通常会利用它来实现 HTTP 请求,但是实际上它还可以支持 WebSocket,并且使用起来还非常的便捷。

那本文就来聊聊,利用 OkHttp 实现 WebSocket 的一些细节,包括对 WebSocket 的介绍,以及在传输前如何做到鉴权、长连接保活及其原理。

二、WebSocket 简介

2.1 为什么使用 WebSocket?

我们做客户端开发时,接触最多的应用层网络协议,就是 HTTP 协议,而今天介绍的 WebSocket,下层和 HTTP 一样也是基于 TCP 协议,这是一种轻量级网络通信协议,也属于应用层协议。

WebSocket 与 HTTP/2 一样,其实都是为了解决 HTTP/1.1 的一些缺陷而诞生的,而 WebSocket 针对的就是「请求-应答」这种"半双工"的模式的通信缺陷。

「请求-应答」是"半双工"的通信模式,数据的传输必须经过一次请求应答,这个完整的通信过程,通信的同一时刻数据只能在一个方向上传递。它最大的问题在于,HTTP 是一种被动的通信模式,服务端必须等待客户端请求才可以返回数据,无法主动向客户端发送数据。

这也导致在 WebSocket 出现之前,一些对实时性有要求的服务,通常是基于轮询(Polling)这种简单的模式来实现。轮询就是由客户端定时发起请求,如果服务端有需要传递的数据,可以借助这个请求去响应数据。

轮询的缺点也非常明显,大量空闲的时间,其实是在反复发送无效的请求,这显然是一种资源的损耗。

虽然在之后的 HTTP/2、HTTP/3 中,针对这种半双工的缺陷新增了 Stream、Server Push 等特性,但是「请求-应答」依然是 HTTP 协议主要的通信方式。

WebSocket 协议是由 HTML5 规范定义的,原本是为了浏览器而设计的,可以避免同源的限制,浏览器可以与任意服务端通信,现代浏览器基本上都已经支持 WebSocket。

虽然 WebSocket 原本是被定义在 HTML5 中,但它也适用于移动端,尽管移动端也可以直接通过 Socket 与服务端通信,但借助 WebSocket,可以利用 80(HTTP) 或 443(HTTPS)端口通信,有效的避免一些防火墙的拦截。

WebSocket 是真正意义上的全双工模式,也就是我们俗称的「长连接」。当完成握手连接后,客户端和服务端均可以主动的发起请求,回复响应,并且两边的传输都是相互独立的。

2.2 WebSocket 的特点

WebSocket 的数据传输,是基于 TCP 协议,但是在传输之前,还有一个握手的过程,双方确认过眼神,才能够正式的传输数据。

WebSocket 的握手过程,符合其 "Web" 的特性,是利用 HTTP 本身的 "协议升级" 来实现。

在建立连接前,客户端还需要知道服务端的地址,WebSocket 并没有另辟蹊径,而是沿用了 HTTP 的 URL 格式,但协议标识符变成了 "ws" 或者 "wss",分别表示明文和加密的 WebSocket 协议,这一点和 HTTP 与 HTTPS 的关系类似。

以下是一些 WebSocket 的 URL 例子:

ws://cxmydev.com/some/path
ws://cxmydev.com:8080/some/path
wss://cxmydev.com:443?uid=xxx

而在连接建立后,WebSocket 采用二进制帧的形式传输数据,其中常用的包括用于数据传输的数据帧 MESSAGE 以及 3 个控制帧:

  • PING:主动保活的 PING 帧;
  • PONG:收到 PING 帧后回复;
  • CLOSE:主动关闭 WebSocket 连接;

更多 WebSocket 的协议细节,可以参考《WebSocket Protocol 规范》,具体细节,有机会为什么再开单篇文章讲解。

了解这些基本知识,我们基本上就可以把 WebSocket 使用起来,并且不会掉到坑里。

我们再小结一下 WebSocket 的特性:

  1. WebSocket 建立在 TCP 协议之上,对服务器端友好;
  2. 默认端口采用 80 或 443,握手阶段采用 HTTP 协议,不容易被防火墙屏蔽,能够通过各种 HTTP 代理服务器;
  3. 传输数据相比 HTTP 更轻量,少了 HTTP Header,性能开销更小,通信更高效;
  4. 通过 MESSAGE 帧发送数据,可以发送文本或者二进制数据,如果数据过大,会被分为多个 MESSAGE 帧发送;
  5. WebSocket 沿用 HTTP 的 URL,协议标识符是 "ws" 或 "wss"。

那接下来我们就看看如何利用 OkHttp 使用 WebSocket。

三、WebSocket之OkHttp

3.1 建立 WebSocket 连接

借助 OkHttp 可以很轻易的实现 WebSocket,它的 OkHttpClient 中,提供了 newWebSocket() 方法,可以直接建立一个 WebSocket 连接并完成通信。

fun connectionWebSockt(hostName:String,port:Int){
  val httpClient = OkHttpClient.Builder()
      .pingInterval(40, TimeUnit.SECONDS) // 设置 PING 帧发送间隔
      .build()
  val webSocketUrl = "ws://${hostName}:${port}"
  val request = Request.Builder()
      .url(webSocketUrl)
      .build()
  httpClient.newWebSocket(request, object:WebSocketListener(){
    // ...
  })
}

我想熟悉 OkHttp 的朋友,对上面这端代码不会有疑问,只是 URL 换成了 "ws" 协议标识符。另外,还需要配置 pingInterval(),这个细节后文会讲解。

调用 newWebSocket() 后,就会开始 WebSocket 连接,但是核心操作都在 WebSocketListener 这个抽象类中。

3.2 使用 WebSocketListener

WebSocketListener 是一个抽象类,其中定义了比较多的方法,借助这些方法回调,就可以完成对 WebSocket 的所有操作。

var mWebSocket : WebSocket? = null
fun connectionWebSockt(hostName:String,port:Int){
  // ...
  httpClient.newWebSocket(request, object:WebSocketListener(){
    override fun onOpen(webSocket: WebSocket, response: Response) {
      super.onOpen(webSocket, response)
      // WebSocket 连接建立
      mWebSocket = webSocket
    }

    override fun onMessage(webSocket: WebSocket, text: String) {
      super.onMessage(webSocket, text)
      // 收到服务端发送来的 String 类型消息
    }

    override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
      super.onClosing(webSocket, code, reason)
      // 收到服务端发来的 CLOSE 帧消息,准备关闭连接
    }

    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
      super.onClosed(webSocket, code, reason)
      // WebSocket 连接关闭
    }

    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
      super.onFailure(webSocket, t, response)
      // 出错了
    }
  })
}

WebSocketListener 的所有方法回调中,都包含了 WebSocket 类型的对象,它就是当前建立的 WebSocket 连接实体,通过它就可以向服务端发送 WebSocket 消息。

如果需要在其他时机发送消息,可以在回调 onOpen() 这个建立连接完成的时机,保存 webSocket 对象,以备后续使用。

OkHttp 中的 WebSocket 本身是一个接口,它的实现类是 RealWebSocket,它定义了一些发送消息和关闭连接的方法:

  • send(text):发送 String 类型的消息;
  • send(bytes):发送二进制类型的消息;
  • close(code, reason):主动关闭 WebSocket 连接;

利用这些回调和 WebSocket 提供的方法,我们就可以完成 WebSocket 通信了。

3.3 Mock WebSocket

有时候为了方便我们测试,OkHttp 还提供了扩展的 MockWebSocket 服务,来模拟服务端。

MockWebSocket 需要添加额外的 Gradle 引用,最好和 OkHttp 版本保持一致:

api 'com.squareup.okhttp3:okhttp:3.9.1'
api 'com.squareup.okhttp3:mockwebserver:3.9.1'

MockWebServer 的使用也非常简单,只需要利用 MockWebSocket 类即可。

var mMockWebSocket: MockWebServer? = null
fun mockWebSocket() {
  if (mMockWebSocket != null) {
    return
  }
  mMockWebSocket = MockWebServer()
  mMockWebSocket?.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {

    override fun onOpen(webSocket: WebSocket, response: Response) {
      super.onOpen(webSocket, response)
      // 有客户端连接时回调
    }

    override fun onMessage(webSocket: WebSocket, text: String) {
      super.onMessage(webSocket, text)
      // 收到新消息时回调
    }

    override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
      super.onClosing(webSocket, code, reason)
      // 客户端主动关闭时回调
    }

    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
      super.onClosed(webSocket, code, reason)
      // WebSocket 连接关闭
    }

    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
      super.onFailure(webSocket, t, response)
      // 出错了
    }
  }))
}

Mock WebSocket 服务端,依然需要用到我们前面讲到的 WebSocketListener,这个就比较熟悉,不再赘述了。

之后就可以通过 mMockWebSocket 获取到这个 Mock 的服务的 IP 和端口。

val hostName = mMockWebSocket?.getHostName()
val port = mMockWebSocket?.getPort()
val url = "ws:${hostName}:${port}"

需要注意的是,这两个方法需要在子线程中调用,否者会收到一个异常。

虽然有时候在服务端完善的情况下,我们并不需要使用 Mock 的手段,但是在学习阶段,依然推荐大家在本地 Mock 一个服务端,打一些日志,观察一个完整的 WebSocket 连接和发送消息的过程。

3.4 WebSocket 如何鉴权

接下来我们聊聊 WebSocket 连接的鉴权问题。

所谓鉴权,其实就是为了安全考虑,避免服务端启动 WebSocket 的连接服务后,任谁都可以连接,这肯定会引发一些安全问题。其次,服务端还需要将 WebSocket 的连接实体与一个真是的用户对应起来,否者业务无法保证了。

那么问题就回到了,WebSocket 通信的完整过程中,如何以及何时将一些业务数据传递给服务端?当然在 WebSocket 连接建立之后,立即给服务端发送一些鉴权的数据,必然是可以做到业务实现的,但是这样明显是不够优雅的。

前文提到,WebSocket 在握手阶段,使用的是 HTTP 的 "协议升级",它本质上还是 HTTP 的报文头发送一些特殊的头数据,来完成协议升级。

例如在 RealWebSocket 中,就有构造 Header 的过程,如 Upgrade、Connection 等等。

public void connect(OkHttpClient client) {
  // ...
  final Request request = originalRequest.newBuilder()
    .header("Upgrade", "websocket")
    .header("Connection", "Upgrade")
    .header("Sec-WebSocket-Key", key)
    .header("Sec-WebSocket-Version", "13")
    .build();
  //....
}

那么实际我们在 WebSocket 阶段,也可以通过 Header 传输一些鉴权的数据,例如 uid、token 之类,具体方法就是在构造 Request 的时候,为其增加 Header,这里就不举例说明了。

另外 WebSocket 的 URL 也是可以携带参数的。

wss://cxmydev.com:443?uid=xxx&token=xxx

3.5 WebSocket 保活

WebSocket 建立的连接就是我们所谓的长连接,每个连接对于服务器而言,都是资源。但服务器倾向于在一个连接长时间没有消息往来的时候,将其关闭。而 WebSocket 的保活,实际上就是定时向服务端发送一个空消息,来保证连接不会被服务端主动断开。

那么我们自己写个定时器,固定间隔向服务端 mWebSocket.send() 一个消息,就可以达到保活的目的,但这样发送的其实是 MESSAGE 帧数据,如果使用 WebSocket 还有更优雅的方式。

前文我们提到,WebSocket 采用二进制帧的形式传输数据,其中就包括了用于保活的 PING 帧,而 OkHttp 只需要简单的配置,就可以自动的间隔发送 PING 帧和数据。

我们只需要在构造 OkHttpClient 的时候,通过 pingInterval() 设置 PING 帧发送的时间间隔,它的默认值为 0,所以不设置不发送。

val httpClient = OkHttpClient.Builder()
      .pingInterval(40, TimeUnit.SECONDS) // 设置 PING 帧发送间隔
      .build()

这里设置的时长,需要和服务端商议,通常建议最好设置一个小于 60s 的值。

具体的逻辑在 RealWebSocket 类中。

public void initReaderAndWriter(String name, Streams streams) throws IOException {
  synchronized (this) {
    // ...
    if (pingIntervalMillis != 0) {
      executor.scheduleAtFixedRate(
        new PingRunnable(), pingIntervalMillis, pingIntervalMillis, MILLISECONDS);
    }
    // ...
  }
  // ...
}

PingRunnabel 最终会去间隔调用 writePingFrame() 用以向 WebSocketWriter 中写入 PING 帧,来达到服务端长连接保活的效果。

四、小结

到这里本文就介绍清楚 WebSocket 以及如何使用 OkHttp 实现 WebSocket 支持。

这里还是简单小结一下:

  1. WebSocket 是一个全双工的长连接应用层协议,可以通过它实现服务端到客户端主动的推送通信。
  2. OkHttp 中使用 WebSocket 的关键在于 newWebSocket() 方法以及 WebSocketListener 这个抽象类,最终连接建立完毕后,可以通过 WebSocket 对象向对端发送消息;
  3. WebSocket 鉴权,可以利用握手阶段的 HTTP 请求中,添加 Header 或者 URL 参数来实现;
  4. WebSocket 的保活,需要定时发送 PING 帧,发送的时间间隔,在 OkHttp 中可以通过 pingInterval() 方法设置;

额外提一句,OkHttp 在 v3.4.1 中添加的 WebSocket 的支持,之前的版本需要 okhttp-ws 扩展库来支持,但是那毕竟已经是 2016 年的事了,我想现在应该没有人在用那么老版本的 OkHttp 了。

本文对你有帮助吗?留言、转发、收藏是最大的支持,谢谢!如果本文各项数据好,之后会再分享一篇 OkHttp 中针对 WebSocket 的实现以及 WebSocket 协议的讲解。

参考:


热文推荐:

公众号后台回复成长『成长』,将会得到我准备的学习资料。