一、序

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 协议的讲解。

参考:


热文推荐:

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

posted @ 2020-03-23 11:37 承香墨影 阅读(15585) 评论(0) 推荐(1) 编辑
摘要: 一. 序 Handler 机制算是 Android 基本功,面试常客。但现在面试,多数已经不会直接让你讲讲 Handler 的机制,Looper 是如何循环的,MessageQueue 是如何管理 Message 等,而是基于场景去提问,看看你对 Handler 机制的掌握是否扎实。 本文就来聊聊 阅读全文
posted @ 2020-02-20 09:42 承香墨影 阅读(1910) 评论(0) 推荐(2) 编辑
摘要: 问:给出两个 非空 的链表,来表示两个非负的整数。其中,它们各自的位数是按照 逆序 的方式存储的,并且每个结点只能存储 一位数字 。将这两个链表相加起来,返回一个新的链表,表示他们之和。 例如:342 + 465 = 807 两数相加这道题,处理的就是最简单的数学加法运算,只是它是建立在链表的基础之 阅读全文
posted @ 2019-12-24 12:19 承香墨影 阅读(1140) 评论(0) 推荐(1) 编辑
摘要: 本文首发自公众号「承香墨影(ID:cxmyDev)」,欢迎关注。 一. 序 我又来讲链表题了,这道题据说是来自字节跳动的面试题。 为什么说是「据说」呢?因为我也是看来的,觉得题目还是挺有意思,但是原作者给出的方案,我想了想觉得还有优化空间,就单独拿出来讲讲。 就像本文的题目说的,这是一道关于链表翻转 阅读全文
posted @ 2019-11-05 14:08 承香墨影 阅读(1375) 评论(1) 推荐(1) 编辑
摘要: 在 方法中,通过逐步位运算,就可以让返回值,保持在 2 的 N 次幂。以方便在扩容的时候,快速计算数据在扩容后的新表中的位置。 那么当我们从外部传递进来 1w 时,实际上经过 方法处理之后,就会变成 2 的 14 次幂 16384,再算上负载因子 0.75f,实际在不触发扩容的前提下,可存储的数据容 阅读全文
posted @ 2019-10-30 14:20 承香墨影 阅读(2791) 评论(4) 推荐(7) 编辑
摘要: 本文首发自公众号:承香墨影(ID:cxmyDev),欢迎关注。 一. 序 越来越多的公司 App,都开始淘金海外,寻找更多的机会。然而海外市场千差万别,无论是市场还是用户的使用习惯,都有诸多的不同。 当你接触一款出海 App 的时候,除了需要了解海外 Google Service 的整个生态圈,还要 阅读全文
posted @ 2019-09-23 16:47 承香墨影 阅读(14139) 评论(0) 推荐(0) 编辑
摘要: 一. 序 EventBus 是一个基于观察者模式的事件订阅/发布框架,利用 EventBus 可以在不同模块之间,实现低耦合的消息通信。 EventBus 因为其使用简单且稳定,被广泛应用在一些生产项目中。 通常我们就是使用 EventBus 分发一些消息给消息的订阅者,除此之外我们还可以通过 Ev 阅读全文
posted @ 2019-09-18 14:14 承香墨影 阅读(2637) 评论(0) 推荐(1) 编辑
摘要: 一. 序 最新的 Android 版本 Q,已经发布了 Android Q Beta 3,虽然没有正式发布,但是不少用户已经加入了测试计划,抢先体验 Android Q 的新功能。 近期不少体验用户反馈,自己的设备升级到 Beta 3 之后,会出现触不及防的强制重启。谷歌方面已经确认,是运行了 Pr 阅读全文
posted @ 2019-05-30 11:48 承香墨影 阅读(924) 评论(0) 推荐(0) 编辑
摘要: 虽然 Android Studio 的负责人 Jeffery 已经澄清,只是 Kotlin First 而不是 Kotlin Must,并不需要将 App 用 Kotlin 重写一遍。但是 OkHttp 已经开始用 Kotlin 重写的工作,我们来看看这次重写后带来的差异。 一. 序 在今年的 Go 阅读全文
posted @ 2019-05-17 13:41 承香墨影 阅读(4855) 评论(1) 推荐(0) 编辑
摘要: 一、序 在将 App 发布到市场之前,很重要的一个步骤就是为 APK 进行签名,大部分时候,这个操作隐藏在了打包的流程中,而不被我们注意到。 签名的作用,除了证明 App 的所有权之外,还可以帮助 Android 市场和设备校验 APK 的正确性 Android 签名是自证明的,并不会对证书进行 C 阅读全文
posted @ 2019-05-14 11:18 承香墨影 阅读(5843) 评论(0) 推荐(1) 编辑
点击右上角即可分享
微信分享提示