一、STOMP 协议介绍
1、STOMP与WebSocket的关系
首先STOMP 与 WebSocket不是一个层级的协议。STOMP是一种简单的文本协议,在WebSocket协议基础之上运行。STOMP是应用层协议,当与WebSocket协议共同使用时,WebSocket 负责底层的连接和数据传输,而 STOMP 负责消息的格式化和路由。
2、STOMP协议
STOMP的帧格式是由一个命令、若干头部、和一个可选的消息体组成的。
命令
每个帧都以一个命令开始。命令是一个文本字符串,定义了帧的类型。 在我们的项目中我们封装的命令如下:
CONNECT
- 客户端向服务端发起连接,用于建立与服务器的新连接。
- 重要头部:
- accept-version: 表示客户端支持的 STOMP 协议版本(如 1.0,1.1,1.2)。
- host: 指定客户端想要连接的虚拟主机。
CONNECTED
- 服务器对客户端的 CONNECT 请求的响应,确认连接成功并协商协议版本。
- 重要头部:
- version: 服务器选择的 STOMP 协议版本。
DISCONNECT
- 客户端请求断开连接
- 重要头部:
- receipt: 请求确认断开连接成功。
SEND
- 用于发送消息到指定的目标(如队列或主题),客户端将消息发送到服务器。
- 重要头部:
- destination: 消息的目标地址(如 /queue/a)。
- content-type: 消息体的 MIME 类型。
MESSAGE
- 收到消息,服务器向客户端发送的消息。
- 重要头部:
- destination: 消息来自的目标。
- message-id: 消息的唯一标识符。
- subscription: 消息对应的订阅 ID。
SUBSCRIBE
- 发起订阅,客户端请求订阅某个目标的消息,注册客户端以接收来自指定目标的消息。
- 重要头部:
- destination: 要订阅的目标。
RECEIPT
- 收到凭据,服务器确认收到客户端的请求
- 重要头部:
- receipt-id: 对应请求的标识符。
UNSUBSCRIBE
- 发起退订,客户端请求取消先前的订阅。停止接收从某个目标发送的消息。
- 重要头部:
- id: 要取消的订阅的唯一标识符。
ACK
- 确认信息,客户端确认已收到并成功处理消息
- 重要头部:
- id: 要确认的消息的标识符。
NACK
- 描述:客户端通知服务器消息未被成功处理,告知服务器消息处理失败,可能需要重新发送。
- 重要头部:
- id: 未被确认的消息的标识符。
ERROR
- 收到错误,服务器向客户端发送的错误信息。
- 重要头部:
- message: 错误描述信息。
二、封装消息
由于STOMP协议比较简单,所以针对STOMP封装主要是针对Command、Header、以及整体的Message
1、Commands
用于定义所有的命令,代码这里就不贴了。
2、Header
用于封装用到的header key
- id
- accept-version
- heart-beat
- destination
- message-id
- receipt
- receipt-id
- subscription
- ack
以上key的含义在上面介绍的Command中基本都有罗列,这里就不再罗列了。
示例代码:
kotlin
data class Header(val key: String, val value: String) {
override fun toString(): String {
return "$key:$value"
}
}
3、Message
我们将一帧封装成为一条消息,其包含command、headers、content
示例代码:
kotlin
data class Message(
val command: String,
val headers: List<Header>,
val payload: String? = null
) {
fun getHeaders(): List<Header> {
return headers
}
fun getPayload(): String? {
return payload
}
fun getCommand(): String {
return command
}
fun headerValue(key: String): String? {
val header = header(key)
return header?.value
}
fun header(key: String): Header? {
for (header in headers) {
if (header.key == key) return header
}
return null
}
override fun toString(): String {
return "Message {command='$command', headers=$headers, payload='$payload'}"
}
}
这样我们针对STOMP的协议就封装好了。下面就处理编码、解码。
三、编码 & 解码
1、编码
编码接口:
kotlin
interface IEncode {
/**
* 编码
*/
fun encode(input: Message): String
}
编码主要是将我们封装的Message转化为String,然后交给WebSocket对象发送出去。
所以最终的String 就是将命令、Header、消息内容按照STOMP协议的格式构建起来。
具体实现示例:
scss
fun encode(input: Message): String {
val sb = StringBuilder()
sb.append(input.command).append("\n")
val headers = msgHeaders(input)
val hCnt = headers.size
for ((index, header) in headers.withIndex()) {
sb.append(header.key)
.append(":")
.append(header.value)
if (index < hCnt - 1) {
sb.append("\n")
}
}
sb.append("\n\n")
val payload = input.payload
if (payload != null) {
sb.append(payload)
}
sb.append("\u0000")
return sb.toString()
}
2、解码
解码接口
kotlin
interface IDecode {
/**
* 解码
*/
fun decode(content: String): Message
}
解码就是将我们收到的String转化为我们封装的Message对象。解码时主要是注意一下几点:
- 判断收到的content是否为空
- 注意存在拆包的情况,例如接收到消息,但是结束符所在位置为-1,那么说明发生了拆包,继续等待
- 注意校验command、header,例如收到的command是否是合法的
四、整个链路
整个链路由于我们是依赖OkHttp中的WebSocket,所以关于如何建联OkHttp已经为我们封装好了,我们在上层封装时只需要将WebSocket 链路逻辑与STOMP 协议串联到一起就行了。
1、建立连接
建立连接主要分为三步:
- 构建WebSocket对象,建立连接
- 发送CONNECT命令
- 接收到服务端的CONNECTED,确认已连接
构建WebSocket示例代码
ini
OkHttpClient client = new OkHttpClient.Builder().build();
Request request = new Request.Builder()
.url(authUrl)
.build();
//建立连接
webSocket = client.newWebSocket(request, this);
在 WebSocketListener 的onOpen回调中,发送CONNECT命令
示例代码:
ini
List<Header> headers = new ArrayList<>();
headers.add(new Header(Header.VERSION, SUPPORTED_VERSIONS));
send(new Message(Commands.CONNECT, headers));
其中send方法就是调用WebSocket.send()方法,入参是String,需要先使用编码接口将Message转为String。
示例代码:
ini
webSocket.send(encodeImpl.encode(message));
服务端在接收到客户端的CONNECT命令后,如果确认可以进行连接,则会发送CONNECTED命令给到客户端。
客户端中的代码可以集中在 WebSocketListener 的onMessage回调中处理接收到的消息。接收到消息之后优先调用 decodeImpl.decode()
将String转为Message。如果Command为CONNECTED则确认已经连接成功,可以回调给上层。后面就可以正常收发消息了。
2、订阅 & 退订
在与服务端建立连接之后,通常我们会直接发送 SUBSCRIBE 命令,Header中添加destination(订阅地址)、id(订阅ID) 订阅某一个地址的消息。
后续收到服务端的Message命令消息中,其包含 subscription (订阅ID),当其与我们订阅消息中的id相同时,则就是这个订阅地址需要的消息。
值得注意的是,客户端可以同时订阅多个地址。例如在我们的业务中,业务地址与心跳地址就不同。
取消订阅也比较简单,发送 UNSUBSCRIBE 命令,携带订阅的id就可以了。
3、断开连接
断开连接有两种路径:
- 第一种是直接调用调用WebSocket提供的close方法。
- 第二种先发送 DISCONNECT
直接断开
第一种是直接调用调用WebSocket提供的close方法。webSocket.close();
这种方案不需要管STOMP协议,直接断开连接,这种方式简单粗暴,使用这种方式直接忽略了STOMP协议,需要提前与服务端沟通好。
通过STOMP协议
- 先发送 DISCONNECT 命令,并在Header中携带receipt 凭证
- 收到 RECEIPT 命令后,确认凭证正确后,再调用
webSocket.close();
五、断线重连
上一节中主要聊了整个链路的封装逻辑,这一节介绍一下上层的断线重连逻辑。我这里简单的将断线重连总结为三板斧:
- 心跳
- 网络变化重连
- 链接探测
1、心跳
在我们的项目中,STOMP的心跳有独立的订阅地址,所以在我们封装的STOMP库中,我们是独立封装的心跳逻辑。心跳地址、心跳间隔都支持外部设置。
示例代码:
ini
List<Header> headers = new ArrayList<>();
String id = UUID.randomUUID().toString();
headers.add(new Header(Header.ID, id));
headers.add(new Header(Header.DESTINATION, destination));
headers.add(new Header(Header.RECEIPT_ID, String.valueOf(1)));
Message message = new Message(Commands.SEND, headers, data);
send(message);
2、网络变化
在上层我们会监听网络的变化,针对网络的断链及时的进行断线重连,确保网络发生变化后,长连接可以尽快回复。网络监听的代码比较简单这里我就不贴出来了。
3、链接探测
为了保证App在前后台等场景可以快速感知到长连接是否真正的连接,我们在STOMP SDK中添加了基于心跳的探测逻辑。这个逻辑我另外一篇文章中有详细介绍过,这里我贴一下地址就不重复写了。如何设计IM类App(协议选择、断线重连、链接探测等)
六、总结
本文介绍了利用OkHttp封装STOMP的主体逻辑,可以看到STOMP本身算是一个比较简单的协议,封装下来也比较简单,希望读者看过此篇后能有一定的收获。这篇应该是农历新年前的最后一篇了,在这里也祝大家新年快乐!