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等等。
浏览器提供了不同的WebSocket的协议,一些老的浏览器不支持WebSocket的脚本或者使用别的名字。默认下,
stomp.js
会使用浏览器原生的WebSocket class
去创建WebSocket。但是利用Stomp.over(ws)
这个方法可以使用其他类型的WebSockets。
STOMP是一种基于帧的协议,一帧有一个命令
一个STOMP帧由三部分组成: 命令,Header(头信息),Body(消息体)
来看一个实际的帧例子:
SEND
destination:/broker/roomId/1
content-length:57
{“type":"ENTER","content":"o7jD64gNifq-wq-C13Q5CRisJx5E"}
STOMP服务端被设计为客户端可以向其发送消息的一组目标地址。STOMP协议并没有规定目标地址的格式,它由使用协议的应用自己来定义。 例如/topic/a,/queue/a,queue-a对于STOMP协议来说都是正确的。应用可以自己规定不同的格式以此来表明不同格式代表的含义。比如应用自己可以定义以/topic打头的为发布订阅模式,消息会被所有消费者客户端收到,以/user开头的为点对点模式,只会被一个消费者客户端收到。
对于STOMP协议来说, 客户端会扮演下列两种角色的任意一种:
实际上,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),服务端request channel接受到这条SEND命令消息,如果目的地址是应用目的地址则转到相应的由应用自己写的业务方法做处理(对应图中的SimpAnnotationMethod),再转到broker(SimpleBroker)。如果目的地址是非应用目的地址则直接转到broker。broker通过SEND命令消息来构建MESSAGE命令消息, 再通过response channel推送MESSAGE命令消息到所有订阅此目的地址的消费者。 废话不多说,下面直接上代码。
让我们以spring官网上的一个demo来看看实际的代码
在Spring中,STOMP消息会被路由到以Controller注解标识的类中。即我们需要定义一个控制器类,并使用Controller注解来标识它,然后在其中实现具体的消息处理方法,我们创建一个名为GreetingController的类:
Spring Framework允许
@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()) + "!");
}
}
使用**@MessageMapping()**注解来标识所有发送到/hello
这个destination的消息,都会被路由到这个方法进行处理。
使用**@SendTo()**注解来标识这个方法返回的结果,都会被发送到它指定的destination,/topic/greetings
。
greeting()方法的作用是,处理所有发到/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
参考资料:
https://spring.io/guides/gs/messaging-stomp-websocket/
https://spring.io/projects/spring-security
源码地址: https://github.com/itguang/websocket-demo