作者 | 李增光
杏仁后端工程师。「 只有变秃,才能变强!」
HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的。
HTTP 不足在于它与服务器的全双工通信依靠轮询实现,对于需要从服务器主动发送数据的情境,会给服务器资源造成很大的浪费,WebSocket 是针对 HTTP 在这种情况下的补充。
对于 WebSocket 来说,它必须依赖 HTTP 协议进行一次握手,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。
WebSocket 是一个完整的应用层协议,包含一套标准的 API。
Request URL: ws://localhost:8080/his-websocket/533/1giglbas/websocket
Request Method: GET
Status Code: 101
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: Upgrade
Cookie: Idea-e2c8f53c=ddd6f37a-65a0-4101-94f1-8864d9c71c68; sidebarStatus=0; JSESSIONID=03F59B3EE783F1CFEF2072D05835FA36; XSRF-TOKEN=50348e10-af01-441a-bb53-017ae18d0e09; SESSION=1cfa5aa3-57ec-44bb-ada7-47deb95c67b2
Host: localhost:8080
Origin: http://localhost:8080
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: D+ar5ktXfJ5mPzgvSIXZ/A==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Upgrade: websocket
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36
可以发现,这段类似 HTTP 协议的握手请求中,多了几个东西。
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: D+ar5ktXfJ5mPzgvSIXZ/A==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
这个就是 Websocket 的核心了,告诉 Tomcat、Nginx 等服务器: 注意啦,我发起的是 Websocket 协议,快点帮我找到对应的服务器处理。
Upgrade: HTTP 协议提供了一种特殊的机制,这一机制允许将一个已建立的连接升级成新的、不相容的协议。 这里表示要升级协议为 Websocket 。
Sec-WebSocket-Key: 是一个 Base64 encode 的值,这个是浏览器随机生成的,告诉服务器: 不要忽悠我,我要验证你是不是真的是 Websocket 助理。
Sec-WebSocket-Version: 是告诉服务器所使用的 Websocket Draft(协议版本),在最初的时候,Websocket 协议还在 Draft 阶段,各种奇奇怪怪的协议都有,而且还有很多奇奇怪怪不同的东西,什么 Firefox 和 Chrome 用的不是一个版本之类的,当初 Websocket 协议太多可是一个大难题。 不过现在还好,已经定下来啦~大家都使用的一个东西。
Sec_WebSocket-Protocol: 是一个用户定义的字符串,用来区分同 URL 下,不同的服务所需要的协议,标识了客户端支持的子协议的列表。
Sec-WebSocket-Extensions: 是客户端用来与服务端协商扩展协议的字段,permessage-deflate 表示协商是否使用传输数据压缩,client max window_bits 表示采用 LZ77 压缩算法时,滑动窗口相关的 SIZE 大小。
然后服务器会返回下列东西,表示已经接受到请求。
Connection: upgrade
Date: Wed, 25 Sep 2019 09:20:06 GMT
Sec-WebSocket-Accept: 1bISo8QakTaeaNEatm9g1yFMGaY=
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
Upgrade: websocket
Sec-WebSocket-Accept: 这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key。 服务器: 好啦好啦,知道啦,给你看我的 ID CARD 来证明行了吧,如果服务端没有返回此字段,客户端会抛出“ Error during WebSocket handshake ”错误,并关闭连接。
客户端通过验证服务端返回的 Sec-WebSocket-Accept 的值,来确定两件事情:
服务端是否理解 WebSocket 协议,如果服务端不理解,那么它就不会返回正确的 Sec-WebSocket-Accept,则建立WebSocket连接失败。
服务端返回的 Response 是对于客户端的此次请求的,而不是之前的缓存。 主要是防止有些缓存服务器返回缓存的 Response。
至此,客户端与服务端的 WebSocket 连接就已经建立成功。 此时的TCP连接不会释放。 客户端和服务端可以互相通信了。
只需建立一次 Request/Response 消息对,之后都是 TCP 连接,避免了需要多次建立 Request/Response 消息对而产生的冗余头部信息。 节省了大量流量和服务器资源。 因此被广泛应用于线上 WEB 游戏和线上聊天室的开发。
WebSocket 发送是以帧为单位的。 而 WebSocket 协议上并没有规定其消息发送的详细格式。 那就意味着每个使用 WebSocket 的开发者,都需要自己在服务端和客户端定义一套规则,来传输信息。 那么,有没有已经造好的轮子呢? 答案肯定是有的。 这就是 STOMP 。
STOMP 即 Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许 STOMP 客户端与任意 STOMP 消息代理(Broker)进行交互。
STOMP 协议可以建立在 WebSocket 之上,也可以建立在其他应用层协议之上。 并不是为 WS 所设计的, 它其实是消息队列的一种协议, 和 AMQP,JMS 是平级的。只不过由于它的简单性恰巧可以用于定义 WS 的消息体格式。目前很多服务端消息队列都已经支持了 STOMP,比如 RabbitMQ,Apache ActiveMQ 等。很多语言也都有 STOMP 协议的客户端解析库,像 JAVA 的 Gozirra,C 的 libstomp,Python 的 pyactivemq,JavaScript 的 stomp.js 等等。
stomp.js
会使用浏览器原生的
WebSocket class
去创建 WebSocket。
但是利用
Stomp.over(ws)
这个方法可以使用其他类型的 WebSockets。
STOMP 是一种基于帧的协议,一帧有一个命令
一个 STOMP 帧由三部分组成: 命令,Header(头信息),Body(消息体) 。
命令使用 UTF-8 编码格式,命令有 SEND、SUBSCRIBE、MESSAGE、CONNECT、CONNECTED 等。
Header 也使用 UTF-8 编码格式,它类似 HTTP的 Header,有 content-length,content-type 等。
Body 可以是二进制也可以是文本。 注意 Body 与 Header 间通过一个空行(EOL)来分隔。
来看一个实际的帧例子:
SEND
destination:/broker/roomId/1
content-length:57
{“type":"ENTER","content":"o7jD64gNifq-wq-C13Q5CRisJx5E"}
第 1 行: 表明此帧为 SEND 帧,是 COMMAND 字段。
第 2 行: Header 字段,消息要发送的目的地址,是相对地址。
第 3 行: Header 字段,消息体字符长度。
第 4 行: 空行,间隔 Header 与 Body。
第 5 行: 消息体,为自定义的 JSON 结构。
STOMP 服务端被设计为客户端可以向其发送消息的一组目标地址。 STOMP 协议并没有规定目标地址的格式,它由使用协议的应用自己来定义。 例如 /topic/a,/queue/a,queue-a 对于 STOMP 协议来说都是正确的。 应用可以自己规定不同的格式以此来表明不同格式代表的含义。 比如应用自己可以定义以 /topic 打头的为发布订阅模式,消息会被所有消费者客户端收到,以 /user 开头的为点对点模式,只会被一个消费者客户端收到。
对于 STOMP 协议来说, 客户端会扮演下列两种角色的任意一种:
作为生产者,通过 SEND 帧发送消息到指定的地址。
作为消费者,通过发送 SUBSCRIBE 帧到已知地址来进行消息订阅,而当生产者发送消息到这个订阅地址后,订阅该地址的其他消费者会通过 MESSAGE 帧收到该消息。
实际上,WebSocket 结合 STOMP 相当于构建了一个消息分发队列,客户端可以在上述两个角色间转换,订阅机制保证了一个客户端消息可以通过服务器广播到多个其他客户端,作为生产者,又可以通过服务器来发送点对点消息。
WebSocket 和 STOMP 了解完毕,现在,我们完全可以定义一套自己的 Socket 服务。 但是本着不要重复造轮子的原则,Google 一下,就会发现 Spring 已经为我们提供好了一个轮子,如果你使用 SpringBoot,那么使用讲更加方便,只需引入一个依赖即可: spring-boot-starter-websocket
,在使用之前,先来了解一下 Spring 中的 WebSocket 架构。
图片来自 spring 官网: https://docs.spring.io/spring-framework/docs/5.0.0.BUILD-SNAPSHOT/spring-framework-reference/html/websocket.html
生产者型客户端(左上组件): 发送 SEND 命令到某个目的地址(destination)的客户端。
消费者型客户端(左下组件): 订阅某个目的地址(destination), 并接收此目的地址所推送过来的消息的客户端。
request channel : 一组用来接收生产者型客户端所推送过来的消息的线程池。
response channel : 一组用来推送消息给消费者型客户端的线程池。
broker: 消息队列管理者,也可以成为消息代理。它有自己的地址(例如“/topic”),客户端可以向其发送订阅指令,它会记录哪些订阅了这个目的地址(destination)。
应用目的地址( 图中的“ /app ” ) : 发送到这类目的地址的消息在到达 broker 之前,会先路由到由应用写的某个方法。相当于对进入 broker 的消息进行一次拦截,目的是针对消息做一些业务处理。
非应用目的地址( 图中的“ /topic ” ,也是消息代理地址) : 发送到这类目的地址的消息会直接转到 broker。不会被应用拦截。
SimpAnnotatonMethod : 发送到应用目的地址的消息在到达 broker 之前,先路由到的方法。这部分代码是由应用控制的。
首先,生产者通过发送一条 SEND 命令消息到某个目的地址(destination),服务端 request channel 接受到这条 SEND 命令消息,如果目的地址是应用目的地址则转到相应的由应用自己写的业务方法做处理(对应图中的 SimpAnnotationMethod),再转到 broker(SimpleBroker)。如果目的地址是非应用目的地址则直接转到 broker。broker 通过 SEND 命令消息来构建 MESSAGE 命令消息,再通过 response channel 推送 MESSAGE 命令消息到所有订阅此目的地址的消费者。废话不多说,下面直接上代码。
让我们以 spring官网上的一个 demo 来看看实际的代码。
在 Spring 中,STOMP 消息会被路由到以 Controller 注解标识的类中。 即我们需要定义一个控制器类,并使用 Controller 注解来标识它,然后在其中实现具体的消息处理方法,我们创建一个名为 GreetingController 的类:
@Controller
和
@RestController
类同时具有 HTTP 请求处理和 WebSocket 消息处理方法。
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(1000); // simulated delay
return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
}
/hello
这个 destination 的消息,都会被路由到这个方法进行处理。
/topic/greetings
。
/hello
这个 destination 的信息,并将处理的结果,发送到所有订阅了
/topic/greetings
这个 destination 的客户端。
其中模拟的延时,其本质是为了演示在 WebSocket 中,我们无需考虑超时这样的问题。 客户端与服务端连接建立后,服务端可以根据实际场景,在“任何有需要”的时候“推送”消息到客户端,直到连接释放。
The STOMP destination is used for simple prefix-based routing. For example the "/app" prefix could route messages to annotated methods while the "/topic" and "/queue" prefixes could route messages to the broker.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
//启用SimpleBroker,使得订阅到此"topic"前缀的客户端可以收到消息.
config.enableSimpleBroker("/topic");
// //将"app"前缀绑定到MessageMapping注解指定的方法上。如"app/hello"被指定用greeting()方法来处理
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// “/gs-guide-websocket”即为客户端尝试建立连接的地址。
registry.addEndpoint("/gs-guide-websocket").withSockJS();
}
}
首先我们定义了一个 Spring 的配置类: WebSocketConfig,并使用 @EnableWebSocketMessageBroker
注解启用 WebSocket 的 broker,即使用 broker 来处理消息。
在该配置类中主要包含两部分内容,一个是消息代理,另一个是 Endpoint,消息代理指定了客户端订阅地址,以及发送消息的路由地址; Endpoint 指定了客户端建立连接时的请求地址。
借助于 SimpMessagingTemplate 我们可以在 任何时机进行消息推送,如下:
Sending a message to a destination can also be done from anywhere in the application with the help of a messaging template
For example, an HTTP POST handling method can broadcast a message to connected clients, or a service component may periodically broadcast stock quotes.
@Controller
public class GreetingController {
@Autowired
private SimpMessagingTemplate template;
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(1000); // simulated delay
return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
@GetMapping("/say/{word}")
@ResponseBody
public void greet(@PathVariable String word) {
template.convertAndSend("/topic/greetings", new Greeting("Hello, " + HtmlUtils.htmlEscape(word) + "!"));
}
}
至此,服务端的配置工作就完成了,非常简单。 现在,让我们实现一个前端页面,来验证服务的工作情况。
针对 STOMP,前端我们采用 JavaScript 的 stomp 的客户端实现 stomp.js 以及 WebSocket 的实现 SockJS。 此处只展示核心代码。
Stomp websocket使用socket实现双工异步通信能力。 但是如果直接使用 websocket 协议开发程序比较繁琐,我们可以使用它的子协议 Stomp。
SockJS sockjs 是 websocket 协议的实现,增加了对浏览器不支持 websocket 的时候的兼容支持 SockJS 的支持的传输的协议有 3 类: WebSocket, HTTP Streaming, and HTTP Long Polling。 默认使用 websocket,如果浏览器不支持 websocket,则使用后两种的方式。 SockJS 使用"Get /info"从服务端获取基本信息。 然后客户端会决定使用哪种传输方式。 如果浏览器使用 websocket,则使用 websocket。 如果不能,则使用 Http Streaming,如果还不行,则最后使用 HTTP Long Polling。
//使用SockJS和stomp.js来打开“gs-guide-websocket”地址的连接,这也是我们使用Spring构建的SockJS服务。
function connect() {
var socket = new SockJS('/gs-guide-websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
//连接成功后的回调方法
setConnected(true);
console.log('Connected: ' + frame);
//订阅/topic/greetings地址,当服务端向此地址发送消息时,客户端即可收到。
stompClient.subscribe('/topic/greetings', function (greeting) {
//收到消息时的回调方法,展示欢迎信息。
showGreeting(JSON.parse(greeting.body).content);
});
});
}
//断开连接的方法
function disconnect() {
if (stompClient !== null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
//将用户输入的名字信息,使用STOMP客户端发送到“/app/hello”地址。它正是我们在GreetingController中定义的greeting()方法所处理的地址.
function sendName() {
stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}
WebSocket 与 Spring Security 集成,也很方便,参见:https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#websocket-authentication。
本文介绍了 WebSocket 的协议,作为对 HTTP 协议的一种补充,WebSocket 可以作为 B/S 架构下的客户端与服务端的双向通信。 我们又介绍了如何使用 STOMP 协议在 WebSocket 之上建立一个简单的 B/S 的发布订阅机制,尤其是在 Spring 体系下,使用非常简单,希望对大家有所帮助。
参考资料:
https://spring.io/guides/gs/messaging-stomp-websocket/
https://spring.io/projects/spring-security
全文完
以下文章您可能也会感兴趣:
简单说说spring的循环依赖
Mysql redo log 漫游
一个 AOP 缓存失效问题的排查
从对称加密到非对称加密再到认证中心 -- https 的证书申请
简单聊聊 TCP 的可靠性
一篇文章带你搞懂 Swagger 与 SpringBoot 整合
延时队列:基于 Redis 的实现
你真的懂 Builder 设计模式吗?论如何实现真正安全的 Builder 模式
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。