WebSocket介绍
在相当长的一段时间里面,我们为了web页面具有良好的交互及实时性,采用了Long Polling,Server Sent Events,Comet等技术,这些技术在特定场景下都能解决问题,但WebSocket的出现提供了一种新的可能性。WebSocket是HTML5定义的一种协议,这种协议可以实现client与server间全双工、双向的通信。
我们知道,目前的Web服务绝大部分都是基于HTTP的,因此为了使得WebSocket能够被广泛使用,WebSocket决定使用HTTP来作为初始的握手(handshake)。WebSocket的握手基于HTTP中的协议升级机制,当服务端收到这个HTTP的协议升级请求后,如果支持WebSocket协议则返回HTTP状态码101。这样,WebSocket的握手便成功了,之后client与server会使用之前HTTP请求所使用的TCP连接来相互发送消息。
这个关于WebSocket的知乎回答解释的比较有趣,感兴趣的可以看下,本文对于WebSocket的具体协议不做展开。
WebSocket的子协议
如上所述,WebSocket在握手之后便直接基于TCP进行消息通信,但WebSocket只是TCP上面非常轻的一层,它仅仅将TCP的字节流转换成消息流(文本或二进制),至于怎么解析这些消息的内容完全依赖于应用本身。
因此为了协助client与server进行消息格式的协商,WebSocket在握手的时候保留了一个子协议字段。
这个子协议字段并不是必须的,而且这个字段的值也不是固定的。对于简单的应用,我们完全可以自己约定消息的格式;但对于稍微复杂点的应用,我们可能会希望能够希望快速开发,而不用花费大部分精力来制定复杂的消息格式。
那么问题来了:现在有可用的子协议吗?
答案是肯定的。
STOMP协议是一个简单的消息通信协议,最初只是在脚本语言中使用,但由于其简单实用已经被广泛使用。
我们也可以在WebSocket中将它作为子协议来进行消息通信。
对于Java开发者来说,由于Spring框架提供了STOMP的支持,可以拿来就用,没有比这更好的了。
Spring与STOMP
STOMP中定义了三种消息:
- SEND:client向server发送消息
- SUBSCRIBE:client向server订阅某种类型的消息
- MESSAGE:server向client分发消息
Spring的spring-messaging模块支持STOMP协议,包含了消息处理的关键抽象。下面是一个简单的消息处理示意图:
关键实体的作用如下:
- Message:消息,里面带有header和payload。
- MessageHandler:处理client消息的实体。
- MessageChannel:解耦消息发送者与消息接收者的实体。举个例子,client可以发送消息到channel,而不用管这条消息最终被谁处理。
- Broker:存放消息的中间件,client可以订阅broker中的消息。
WebSocket的浏览器兼容性问题
关于WebSocket的另外一个问题是,目前相当一部分浏览器不支持WebSocket协议,譬如IE浏览器只在IE10或者更高版本支持WebSocket。另外,一些受限的代理也可能会禁止HTTP的协议升级,从而阻碍WebSocket的握手与使用。
因此我们在计划使用WebSocket的时候,需要考虑兼容性问题。在不能使用WebSocket的场景下,我们希望client与server仍然可以通过其他方式通信。
这样的话我们写代码的时候岂不是非常困难?因为我们既需要实现WebSocket,同时还需要对于不支持WebSocket的client实现其他方式的通信(例如Long Polling)?
幸运的是,我们拥有SockJS这么一个解决方案,它向上层暴露一致的WebSocket API,但具体实现会因浏览器而异。SockJS涉及到client端以及server端的实现,client端使用SockJS-client.js,而服务端则根据语言使用各自的SockJS实现。
而Spring中已经集成了SockJS,我们只需要一行代码就可以引入SockJS了。是不是很赞?
Talk is cheap, show Me the code
啰嗦了那么多,我们来动手写个小demo吧!这个demo需要准备以下环境:
- JDK 1.8+
- Maven 3.0+
首先,创建根目录 mkdir messaging-stomp-websocket。
然后在根目录下创建pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>gs-messaging-stomp-websocket</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.3.5.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
</dependencies>
<properties>
<java.version>1.8</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
我们先来写server端的代码。
在根目录下使用mkdir -p src/main/java/hello来建立层级目录。
在这个demo中,client会向server发送包含名字的JSON格式的消息:
{
"name": "Dengshenyu"
}
sever收到消息后,返回表示欢迎的消息:
{
"content": "Hello, Dengshenyu!"
}
在server端,我们分别用两个POJO(Plain Old Java Object)来表示这两种消息:
src/main/java/hello/HelloMessage.java:
package hello;
public class HelloMessage {
private String name;
public String getName() {
return name;
}
}
src/main/java/hello/Greeting.java:
package hello;
public class Greeting {
private String content;
public Greeting(String content) {
this.content = content;
}
public String getContent() {
return content;
}
}
现在我们创建一个消息处理的controller,当client发送到“/hello”的STOMP消息,我们会交给这个controller来处理。和Spring MVC里面的请求dispatch一样。
src/main/java/hello/GreetingController.java:
package hello;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(3000); // simulated delay
return new Greeting("Hello, " + message.getName() + "!");
}
}
需要注意的是:
- @MessageMapping表明一个消息被发送到“/hello”时,这个方法会被调用处理该消息。
- @SendTo表明这个方法处理完后所产生的值会被作为消息发送到“/topic/greetings”这个broker。
最后创建Spring配置类来完成WebSocket、STOMP以及SockJS的配置。
src/main/java/hello/WebSocketConfig.java:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/hello").withSockJS();
}
}
需要注意的是:
- @Configuration表明这是一个Spring的配置类。
- @EnableWebSocketMessageBroker表明启用WebSocket消息中间件,以及WebSocket消息处理。
- configureMessageBroker()方法用来配置消息中间件,它通过调用enableSimpleBroker()来创建一个基于内存的消息中间件,这个消息中间件会接收所有需要返回给client的以“/topic”为前缀的消息。同时configureMessageBroker()方法还为@MessageMapping标记的方法所绑定的消息设置了一个“/app”的消息前缀。
- registerStompEndpoints()方法注册了一个“/hello”的endpoint,同时使用了SockJS。这表明client需要使用SockJS来连接这个endpoint。
至此server端已经完成。下面我们写一个简易的client。
首先,使用mkdir -p src/main/resources/static/来建立client端目录。
然后,由于我们需要用到SockJS以及STOMP,因此我们需要下载sockjs-0.3.4.js以及stomp.js,并放在src/main/resources/static/目录下。
最后,我们写一个简单的html页面:
src/main/resources/static/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<script src="sockjs-0.3.4.js"></script>
<script src="stomp.js"></script>
<script type="text/javascript">
var stompClient = null;
function setConnected(connected) {
document.getElementById('connect').disabled = connected;
document.getElementById('disconnect').disabled = !connected;
document.getElementById('conversationDiv').style.visibility = connected ? 'visible' : 'hidden';
document.getElementById('response').innerHTML = '';
}
function connect() {
var socket = new SockJS('/hello');
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/greetings', function(greeting){
showGreeting(JSON.parse(greeting.body).content);
});
});
}
function disconnect() {
if (stompClient != null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
function sendName() {
var name = document.getElementById('name').value;
stompClient.send("/app/hello", {}, JSON.stringify({ 'name': name }));
}
function showGreeting(message) {
var response = document.getElementById('response');
var p = document.createElement('p');
p.style.wordWrap = 'break-word';
p.appendChild(document.createTextNode(message));
response.appendChild(p);
}
</script>
</head>
<body onload="disconnect()">
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable
Javascript and reload this page!</h2></noscript>
<div>
<div>
<button id="connect" onclick="connect();">Connect</button>
<button id="disconnect" disabled="disabled" onclick="disconnect();">Disconnect</button>
</div>
<div id="conversationDiv">
<label>What is your name?</label><input type="text" id="name" />
<button id="sendName" onclick="sendName();">Send</button>
<p id="response"></p>
</div>
</div>
</body>
</html>
- 这个页面关键的JS代码在于connect()方法和sendName()方法,connect()方法用来建立WebSocket连接,成功之后则向server端订阅“/topic/greetings”的消息。sendName()方法用来向server端发送消息。
- 通过这个页面可以看到,SocketJS提供了WebSocket的API,STOMP可以像使用WebSocket一样使用SockJS对象,但实际上SockJS会根据浏览器来不同实现,可能并没有使用WebSockJS来和server通信。
至此,我们代码已经基本写完了!我们来运行下,写一个运行类:
src/main/java/hello/Application.java:
package hello;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
进到根目录中,在命令行下输入mvn spring-boot:run。运行起来后在浏览器中访问http://localhost:8080。
撒花~~