WEB服务端推送技术发展总结Websocket

基于HTTP服务器推送技术发展过程介绍,从早期的Comet模式到Websocket,一些比较好的实现框架比如DWR、Jetty、Netty等

应用场景

WEB页面需要实时显示的场景:

  • 监控系统:后台硬件热插拔、LED、温度、电压发生变化、告警信息等
  • 即时通信系统:用户登录通知、发送信息、广播消息
  • 即时报价系统:后台数据库内容发生变化实时通知页面、或者交互数据处理后实时推送到页面

“服务器推”技术在现实应用中有一些解决方案,本文将这些解决方案分为三类:一类需要在浏览器端安装插件,基于套接口传送信息,或是使用 RMI、CORBA 进行远程调用;而另一类则无须浏览器安装任何插件、基于 HTTP 长连接;还有一种就是Websocket目前最优方案

几种实现方式简介

短连接轮询

前端用定时器,每间隔一段时间发送请求来获取数据是否更新,这种方式可兼容ie和支持高级浏览器。通常采取setInterval或者setTimeout实现。通过递归的方法,在获取到数据后每隔一定时间再次发送请求,这样虽然无法保证两次请求间隔为指定时间,但是获取的数据顺序得到保证

长轮询

客户端像传统轮询一样从服务端请求数据,服务端会阻塞请求不会立刻返回,直到有数据或超时才返回给客户端,然后关闭连接,客户端处理完响应信息后再向服务器发送新的请求。长轮询解决了频繁的网络请求浪费服务器资源可以及时返回给浏览器

iframe

iframe方式是在页面中插入一个隐藏的iframe,利用其src属性在服务器和客户端之间创建一条长连接,服务器向iframe传输数据(通常是HTML,内有负责插入信息的javascript),来实时更新页面

WebSocket

WebSocket是一种全新的协议,随着HTML5草案的不断完善,越来越多的现代浏览器开始全面支持WebSocket技术了,它将TCP的Socket(套接字)应用在了web page上,从而使通信双方建立起一个保持在活动状态连接通道。全双工、异步。
WebSocket在建立握手连接时,数据是通过http协议传输的,“GET/chat HTTP/1.1”,这里面用到的只是http协议一些简单的字段。但是在建立连接之后,真正的数据传输阶段是不需要http协议参与的。

Server sent Event(sse)

sse与长轮询机制类似,区别是每个连接不只发送一个消息。客户端发送一个请求,服务端保持这个连接直到有新消息发送回客户端,仍然保持着连接,这样连接就可以消息的再次发送,由服务器单向发送给客户端。SSE本质是发送的不是一次性的数据包,而是一个数据流。sse只适用于高级浏览器,ie不支持.因为ie上的XMLHttpRequest对象不支持获取部分的响应内容,只有在响应完成之后才能获取其内容。

对比

长连接、websocket、SSE等比较

汇总

对于简单的推送需求又不考虑兼容低版本浏览器,推荐使用server-sent Events。

如果需要多条双向数据实时交互或需要二进制传输,推荐websocket。

对于还要考虑低版本浏览器,那么还是用轮询来实现功能。

基于客户端Socket的服务器推技术

Flash XMLSocket

如果 Web 应用的用户接受应用只有在安装了 Flash 播放器才能正常运行, 那么使用 Flash 的 XMLSocket 也是一个可行的方案

这种方案实现的基础是:

  • Flash 提供了 XMLSocket 类。
  • JavaScript 和 Flash 的紧密结合:在 JavaScript 可以直接调用 Flash 程序提供的接口。

Java Applet 套接口

在客户端使用 Java Applet,通过 java.net.Socket 或 java.net.DatagramSocket 或 java.net.MulticastSocket 建立与服务器端的套接口连接,从而实现“服务器推”。

这种方案最大的不足在于 Java applet 在收到服务器端返回的信息后,无法通过 JavaScript 去更新 HTML 页面的内容。

基于 HTTP 长连接的”服务器推”技术

Comet 简介

关于 Comet 技术最新的发展状况请参考关于 Comet 的 wiki

基于 AJAX 的长轮询(long-polling)方式

AJAX 的出现使得 JavaScript 可以调用 XMLHttpRequest 对象发出 HTTP 请求,JavaScript 响应处理函数根据服务器返回的信息对 HTML 页面的显示进行更新。使用 AJAX 实现“服务器推”与传统的 AJAX 应用不同之处在于

  • 服务器端会阻塞请求直到有数据传递或超时才返回。
  • 客户端 JavaScript 响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。
  • 当客户端处理接收的数据、重新建立连接时,服务器端可能有新的数据到达;这些信息会被服务器端保存直到客户端重新建立连接,客户端会一次把当前服务器端所有的信息取回。

无须安装插件;IE、Mozilla FireFox 都支持 AJAX。

这种长轮询方式下,客户端是在 XMLHttpRequest 的 readystate 为 4(即数据传输结束)时调用回调函数,进行信息处理。当 readystate 为 4 时,数据传输结束,连接已经关闭。Mozilla Firefox 提供了对 Streaming AJAX 的支持, 即 readystate 为 3 时(数据仍在传输中),客户端可以读取数据,从而无须关闭连接,就能读取处理服务器端返回的信息。IE 在 readystate 为 3 时,不能读取服务器返回的数据,目前 IE 不支持基于 Streaming AJAX。

基于 Iframe 及 htmlfile 的流(streaming)方式

iframe 是很早就存在的一种 HTML 标记, 通过在 HTML 页面里嵌入一个隐蔵帧,然后将这个隐蔵帧的 SRC 属性设为对一个长连接的请求,服务器端就能源源不断地往客户端输入数据。

使用 iframe 请求一个长连接有一个很明显的不足之处:IE、Morzilla Firefox 下端的进度栏都会显示加载没有完成,而且 IE 上方的图标会不停的转动,表示加载正在进行

使用 Comet 模型开发自己的应用

  • 不要在同一客户端同时使用超过两个的 HTTP 长连接
  • 服务器端的性能和可扩展性
  • 控制信息与数据信息使用不同的 HTTP 连接
  • 在客户和服务器之间保持“心跳”信息
  • Pushlet - 开源 Comet 框架

HTML5 WebSocket

WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。目前主流浏览器都支持。

现在,很多网站为了实现推送技术,所用的技术都是 Ajax 轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。

HTML5 定义的 WebSocket 协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。

SpringBoot WebSocket

spring集成websocket非常简单,主要步骤如下:

1. 添加Maven依赖

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>4.3.13.RELEASE</version>
</dependency>

2. WebSocketConfig 配置 使用@ServerEndpoint创立websocket endpoint

1
2
3
4
5
6
7
@Configuration  
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

3. WebSocketServer

日志使用lombok

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
@ServerEndpoint("/websocket/{sid}")
@Log4j2
@Component
public class WebSocketServer {
//在线连接数。应该把它设计成线程安全的。
private static int onlineCount = 0;
//concurrent包的线程安全Set,用来存放每个客户端对应的WebSocket对象。
private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<WebSocketServer>();
//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;
//接收sid
private String sid="";
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session,@PathParam("sid") String sid) {
this.session = session;
webSocketSet.add(this); //加入set中
addOnlineCount(); //在线数加1
log.info("有新窗口开始监听:" + sid + ",当前在线人数为" + getOnlineCount());
this.sid = sid;
try {
sendMessage("连接成功");
} catch (IOException e) {
log.error("websocket IO异常");
}
}

/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
webSocketSet.remove(this); //从set中删除
subOnlineCount(); //在线数减1
log.info("有一连接关闭!当前在线人数为" + getOnlineCount());
}

/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, Session session) {
log.info("收到来自窗口" + sid + "的信息:"+message);
//群发消息
for (WebSocketServer item : webSocketSet) {
try {
item.sendMessage(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}

/**
*
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误");
error.printStackTrace();
}
/**
* 实现服务器主动推送
*/
public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}

/**
* 群发自定义消息
*
*/
public static void sendInfo(String message,@PathParam("sid") String sid) throws IOException {
log.info("推送消息到窗口" + sid + ",推送内容:" + message);
for (WebSocketServer item : webSocketSet) {
try {
//这里可以设定只推送给这个sid的,为null则全部推送
if(sid==null) {
item.sendMessage(message);
}else if(item.sid.equals(sid)){
item.sendMessage(message);
}
} catch (IOException e) {
continue;
}
}
}

public static synchronized int getOnlineCount() {
return onlineCount;
}

public static synchronized void addOnlineCount() {
WebSocketServer.onlineCount ++;
}

public static synchronized void subOnlineCount() {
WebSocketServer.onlineCount --;
}
}

4. 消息推送

还可以直接通过Controller处理消息,WebSocketServer.sendInfo()发送消息至客户端

1
2
3
4
5
6
7
8
9
10
11
12
//推送数据接口
@ResponseBody
@RequestMapping("/socket/push/{cid}")
public String pushToWeb(@PathVariable String cid,String message) {
try {
WebSocketServer.sendInfo(message,cid);
} catch (IOException e) {
e.printStackTrace();
return cid + "#" + e.getMessage();
}
return cid;
}

5. 页面发起socket请求

建立一个html页面如下,直接浏览器打开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<html>
<script>
// 初始化一个 WebSocket 对象
var ws = new WebSocket("ws://localhost:8080/api/websocket/1234");

// 建立 web socket 连接成功触发事件
ws.onopen = function () {
// 使用 send() 方法发送数据
ws.send("发送数据");
alert("数据发送中...");
};

// 接收服务端数据时触发事件
ws.onmessage = function (evt) {
var received_msg = evt.data;
alert("数据已接收...");
};

// 断开 web socket 连接成功触发事件
ws.onclose = function () {
alert("连接已关闭...");
};
</script>
</html>

websocket tocken权限验证

方法一:

集成shiro WebSocketConfig配置修改 获取shiro用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class WebSocketConfig extends ServerEndpointConfig.Configurator {

/**
* 修改握手,就是在握手协议建立之前修改其中携带的内容
* @param sec
* @param request
* @param response
*/
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {

sec.getUserProperties().put("user", ShiroKit.getUser());
//sec.getUserProperties().put("name", "abc");
super.modifyHandshake(sec, request, response);
}

@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

服务修改

1
2
3
4
5
6
7
8
9
10
11
public void onOpen (Session session){
this.session = session;
//注入userService
this.userService = applicationContext.getBean(UserServiceImpl.class);
//设置用户
this.shiroUser = (ShiroUser) session.getUserProperties().get("user");
webSocketSet.add(this);
addOnlineCount();
System.out.println("有新链接加入!当前在线人数为" + getOnlineCount());
}

方法二:

通过拦截器实现HandshakeInterceptor在 beforeHandshake 方法中进行校验权限

WebSocketConfig改用如下模式

1
2
3
4
5
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry.addHandler(MyTextWebSocketHandler, "/websocket").addInterceptors(myWebSocketInterceptor)
.setAllowedOrigins("*");
}

扩展

Tomcat Websocket并发问题

广播消息出现异常,可以对session进行独立加锁,或者给每个session建立队列。
还可以参考org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator

1
2
3
4
5
6
7
8
9
10
11
12
13
// Parameters:
// delegate - the WebSocketSession to delegate to
// sendTimeLimit - the send-time limit (milliseconds)
// bufferSizeLimit - the buffer-size limit (number of bytes)
ConcurrentWebSocketSessionDecorator concurrentWebSocketSessionDecorator = new ConcurrentWebSocketSessionDecorator(ws.getSession(), 3000, 10240);
// ...
concurrentWebSocketSessionDecorator.sendMessage(msg);

// 构造方法2
public ConcurrentWebSocketSessionDecorator(WebSocketSession delegate,
int sendTimeLimit,
int bufferSizeLimit,
ConcurrentWebSocketSessionDecorator.OverflowStrategy overflowStrategy) // 默认超过限制关闭session,可以自定义overflow策略

参考