Spring 与 WebSocket 编程
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 onclick="connect();">Connect</button> <button disabled="disabled" onclick="disconnect();">Disconnect</button> </div> <div> <label>What is your name?</label><input type="text" /> <button onclick="sendName();">Send</button> <p></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。
参考资料
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论