WebSocket 介绍以及配合 STOMP 的使用
Pin Young Lv9

由于近期需要使用 WebSocket 的部分功能,然而在工作过程中,发现自己对这部分知识点不是很了解,而且对于后台同学提出的 WebSocket 和 STOMP 的组合,不知如何下手。经过相关资料查证,分享与大家,如有纰漏,希望不吝指出。
本文行文为三个部分,分别讲述:Socket 是什么,WebSocket 是什么,STOMP 是什么,如何结合后两者投入使用。

1. Socket

目前来说,我们经常说的 Socket 的有好几种意思,而且这几种意思还都与通信有关,他们分别是:

  1. Socket 连接
    socket 连接,是端到端的一种连接方式,连接上之后,双方可以互发数据,完成交互;socket 连接的建立也是一个三次握手的过程,经过这个过程之后,双方都可以通过事件监听来获取来自对方的消息(connect, data, close …),也可以主动发送消息给对方(Socket.write)。Socket 连接在不同语言的网络模块均有提供,以上方法都是 node 的 net 模块提供的一些方法和事件,可以用来建立一个完整的 socket 连接。
  2. Socket 抽象封装层
    这一种意思是说,它是作为我们所说的网络分层结构里面的,网络层和应用层之间的一层抽象封装。它的作用,就是将功能强大的网络层的操作做了一个封装,将其复杂的操作,抽象为几个简单的接口供应用层调用,以实现进程在网络中通信。按照网络上流行的说法,TCP/IP(网络层)是功能强大的发动机引擎,Socket 层是汽车,我们只需要动动方向盘,就能调动起强大的引擎为我所用。
  3. 套接字
    这个部分,说的是 Socket 连接建立起来之后,双方维护的一个对象,用来发送和接受数据包。一个 Socket 连接建立,对应的是连接两端对应的一对套接字对象,其维护的信息为:连接使用的协议,本地主机的 IP 地址,本地进程的协议端口,远地主机的 IP 地址,远地进程的协议端口。通过如上信息,即可确定传输的位置和传输的方式。

2. WebSocket

  1. 是什么
    WebSocket 是 H5 规范提出的一种应用层协议(与 HTTP 处于同一层级),是建立在 TCP/IP 协议族之上的一种长连接,可进行全双工通信。
  2. 为什么需要它
    它的提出确实是极其必要的。主要有两方面的考虑:一是,在H5规范的描述下,web应该是一个丰富多彩的世界,能提供应用程序级别的使用体验。既然是应用程序级别体验,自然应该有应用程序级别的网络基础支持,而这种支持就应该包含长连接,实时通信这种级别的支持;二是,使用目前的 HTTP 协议,模拟出两端长连接的效果(轮询,阻塞),消耗太大。
  3. 实现的过程
    WebSocket 连接实现的过程分为两个部分:建立连接的过程,连接之后的 Socket 通信过程。
    WebSocket 连接建立的过程,是用到了 HTTP 请求的。在一开始建立连接的过程中,希望建立连接的客户端会向服务端发送一个 HTTP 请求,询问服务器是不是支持 WebSocket,并且告诉服务端,我使用 WebSocket 请求,希望服务端进行相应的响应。
    此处为了区分普通的 HTTP 请求,此处上传了其他的头部信息:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 随机生产的 Base64 字符串,用于安全校验
    Sec-WebSocket-Key:n0seXxkGzvDzqsH8ZkDfcg==
    // 指定子协议和版本号
    Sec-WebSocket-Protocol:v10.stomp, v11.stomp
    Sec-WebSocket-Version:13
    // 请求服务器升级为WebSocket
    Connection:Upgrade
    Upgrade:WebSocket

    // 服务端的回应
    Connection:Upgrade
    Sec-WebSocket-Accept:8BiqnztuCvGwd9ine9abKXjtzE0=
    Sec-WebSocket-Protocol:v10.stomp
    Upgrade:WebSocket
    在客户端校验 Sec-WebSocket-Accept 通过之后,连接即可建立完成。这之后的信息通讯均是WebSocket定义的通过长连接进行的,而且此长连接会复用刚才 HTTP 请求建立的 TCP 长连接。之后的消息发送,消息接受,连接建立,连接关闭等交互,与 Socket 基本类似。
  4. 如何使用 node 搭建一个简单的ws服务器
    此处的 demo 是,通过 sockjs,建立一个ws服务器,连接两个或者多个客户端,当某一个客户端发送消息给服务器,服务器可以主动将该消息发送给别的客户端。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 服务端主要代码
    var http = require('http');
    var sockjs = require('sockjs');

    // 建立 socket 连接
    var sockjs_opts = {sockjs_url: "http://cdn.jsdelivr.net/sockjs/1.0.1/sockjs.min.js"};

    var sockjs_echo = sockjs.createServer(sockjs_opts);
    sockjs_echo.on('connection', function(conn) {
    conn.on('data', function(message) {
    conn.write(message);
    });
    });

    sockjs_echo.installHandlers(server, {prefix:'/echo'});
    server.listen(9999, '0.0.0.0');
1
2
3
4
5
6
7
8
9
10
// 客户端主要代码
var sockjs_url = '/echo';
var sockjs = new SockJS(sockjs_url);

sockjs.onopen = function() {print('[*] open', sockjs.protocol);};
sockjs.onmessage = function(e) {print('[.] message', e.data);};
sockjs.onclose = function() {print('[*] close');};

// 产生交互信息
sockjs.send(‘some message’);

点击查看sockjs官方完整demo

3. STOMP

Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许 STOMP 客户端与任意 STOMP 消息代理(Broker)进行交互。 简单来说,就好像HTTP定义了TCP的相关细节一样,STOMP在WebSocket协议之上,告诉信息交互的双方,消息的格式是什么,应该怎样收发的文本协议。具体的定义内容为:
STOMP 是基于 frame(帧)的协议,每个frame都包含了一个 command,一系列的可选 headers 和消息本身的 body,如下:

1
2
3
4
5
COMMAND
header1:value1
header2:value2

Body^@

上面的空行部分必需,分割 headers 和 body。除了上述的帧内容的定义,协议还对不同的操作定义了不同 COMMAND 的帧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 客户端:
SEND // 发送消息到服务端,可添加自定义的 header,body 携带内容
SUBSCRIBE // 用于注册给定目的地send帧,被注册的目的地收到任何消息豆浆通过MESSAGE帧发送过来
UNSUBSCRIBE // 取消注册监听
BEGIN // 事务操作开始
COMMIT // 事务提交
ABORT // 事务过程中的回滚
ACK // 确认订阅消息的消费
NACK // NACK有ACK相反地作用。它地作用是告诉 server client 不想消费这个消息
DISCONNECT // 断开连接
// 服务端
CONNECT // 连接建立
RECEIPT // server 成功处理请求带有 receipt 的 client frame 后的返回
ERROR // 如果出错的话,server将发送ERRORframe
MESSAGE // 将订阅的消息发送给client

更多命令详解,可参考STOMP协议参考

4. 结合使用

在了解了上诉两个协议之后,我们需要把两方结合起来,让 WebSocket 消息操作变得规范,可控,易于理解。因为 STOMP 协议和 WebSocket 都有已经实现了且可靠的库,在这里我们直接采用。WebSocket 采用 sockjs,STOMP 采用 stompjs。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 服务端主要代码:
var http = require("http");
var StompServer = require('stomp-broker-js');

var server = http.createServer();

server.listen(61614);

var stompServer = new StompServer({
server: server,
path: '/stomp'
});
// 将监听的客户端放入列表中,方便服务端在接受到消息之后进行转发
stompServer.on('connected', function(sessionId, headers){
var clientId = headers.clientId;
if(clientId) {
clients.push(clientId);
}
});

stompServer.on('error', function(error) {
// 将订阅对象减少一个(错误对象)
clients.splice(clients.length - 1, 1);
return;
});

// 在每次对应的 roomid 频道收到消息时,转发给所有的订阅者
stompServer.subscribe(config.desination, function(msg, headers) {
for(var i = 0; i < clients.length; i++) {
// 如果时debug,打印每次的请求和消息
if(config.debug) {
console.log('header: \n' + headers);
console.log('msg: \n' + msg);
}
// 消息转发
stompServer.send(clients[i], {'content-type': 'application/json'}, JSON.stringify({
headers: headers,
msg: msg
}));
}
});

此处的服务端代码,是直接传入创建的 server,即可使得 server 支持 STOMP 协议。其实在这一步时做了很多工作。其中就有,调用 stompjs 库,将 sockjs 的消息发送用 stomp 进行改写,将 WebSocket 的方法统统用 STOMP 协议的方法进行了包装一遍。这里举消息包装和方法包装的例子说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 当调用 websocket 的 send 方法的时候
this.send = function (topic, headers, body) {
// 将消息内容组装成 stomp 协议的一帧
var _headers = {};
if (headers) {
for (var key in headers) {
_headers[key] = headers[key];
}
}
var frame = {
body: body,
headers: _headers
};
var args = {
dest: topic,
frame: this.frameParser(frame)
};
this.onSend(selfSocket, args);
}

this.onSend = function (socket, args, callback) {
...
this.emit('send', {frame: {headers: frame.headers, body: bodyObj}, dest: args.dest});
// 将消息发送给订阅方
this._sendToSubscriptions(socket, args);
if (callback) {
callback(true);
}
};

this._sendToSubscriptions = function (socket, args) {
...
// 确定订阅方,凭借上 command,进行发送
args.frame.command = "MESSAGE";
var sock = sub.socket;
if (sock !== undefined) {
stomp.StompUtils.sendFrame(sock, args.frame);
} else {
this.emit(sub.id, args.frame.body, args.frame.headers);
}
};
// 发送的时候,还是采用 WebSocket 的发送
function sendFrame(socket, _frame) {
var frame_str = null;
if (!_frame.hasOwnProperty('toString')) {
var frame = new Frame({
'command': _frame.command,
'headers': _frame.headers,
'body': _frame.body
});
frame_str = frame.toString();
} else {
frame_str = _frame.toString();
}
// WebSocket 发送
socket.send(frame_str);
return true;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 客户端主要代码:
var url = "ws://localhost:61614/stomp";
var client = Stomp.client(url);

function afterConnect(roomid) {
btn.addEventListener('click', function () {
var msg = input.value;
// 发送信息
client.send(roomid, {}, msg);
}, false);
}

function createConnect(roomid, uid) {
client.connect(headers, function (error) {
if (error.command == "ERROR") {
console.error(error.headers.message);
} else {
afterConnect(roomid);
// 订阅自己的客户端id,方便收到服务器发送过来的信息
client.subscribe(uid, function (msg) {
var body = msg.body;
if (msg.headers['content-type'] == 'application/json') {
body = JSON.parse(msg.body)
}
});
}
});
}

点击查看完整demo

总结

在各方面了解完 WebSocket 和 STOMP 相关内容之后,其实我们可以发现,STOMP 是个很简单的协议,但是这个简单协议却能有效的规约前后端的交互过程,使交互过程清晰有效。这种用简单高效的抽象,完成通用复杂的工作的方法,其实是很值得我们去借鉴的。另外,在完成这部分内容的探索学习过程中,还顺便学习了一下 npm 包发布的相关内容。感觉学习新东西确实是总能给人带来益处,大家加油!