我们知道,Comet是在HTTP单向通信基础上模拟服务器与客户端浏览器的双向通讯,不同的Comet方案存在不同的缺陷,无论是跨浏览器层面还是规范限制。而且因为是一种模拟实现,所以它的效率并不高(如HTTP报头的开销,尤其发送消息较小的情况)。
基于这些原因,人们一直试图从规范角度寻找一种标准的替代方案。HTML5提供了一种全新的协议来解决这个问题,这就是WebSocket。它实现了客户端与服务器之间的全双工通信,可以更好的节省服务器资源及带宽以提供实时通讯。它建立在TCP之上,与HTTP一样通过TCP传输数据,而且同样使用HTTP的默认端口。WebSocket使得通过一种标准化的方式实现客户端与服务器之间全双工通信成为可能。
WebSocket是独立的基于TCP的协议,建立WebSocket链接时,客户端首先发送一个握手请求,服务器返回一个握手响应,握手为HTTP Upgrade请求 ,因此服务器可以通过HTTP端口进行处理,并将通信切换至WebSocket协议。握手成功后,客户端与服务器之间就可以基于WebSocket协议进行全双工通信了。
WebSocket与HTTP协议完全不同,它们之间的关系仅限于WebSocket的握手是通过HTTP协议的Upgrade请求完成的。WebSocket之所以如此设计,旨在不损害网络安全的前提下解决全双工通信的问题。
既然WebSocket是一种新的协议,那么它就同时需要客户端和服务器的支持。当前主流的浏览器均已支持WebSocket(Firefox 6、Safari 6、Chrome 14、Opera 12.10、IE 10)。服务器方面,主要的几款开源Servlet容器(Tomcat、Jetty、Undertow)都支持WebSocket。
WebSocket协议定义了ws://和wss://两个前缀来分别表示非加密和加密的链接。除去协议前缀外,其链接的具体语法格式与HTTP相同,如ws://127.0.0.1:8080/chat_demo/websocket/chat。
WebSocket握手请求格式如下:
GET /chat_demo/websocket/chat HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:47.0) Gecko/20100101 Firefox/47.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Sec-WebSocket-Version: 13
Origin: http://127.0.0.1:8080
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Key: WBQn4/IgSnD3KjrxvvJpbg==
Connection: keep-alive, Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
“Upgrade:websocket”表明这是WebSocket请求,“Sec-WebSocket-Key”是客户端发送的一个base64编码的密文,要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept”头信息作为应答。
服务器返回的握手响应如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: upafRZxTkaMPUBSr9VvuDXRambA=
Sec-WebSocket-Extensions: permessage-deflate
HTTP 101状态码表明服务端识别并切换为WebSocket协议,“Sec-WebSocket-Accept”是服务端采用与客户端一致的密钥计算出来的信息。
如果在与Web服务器集成的情况下使用使用WebSocket,通常需要Web服务器进行额外配置,具体可以参见各种Web服务器的配置方案,此处不再赘述:
Apache:http://httpd.apache.org/docs/2.4/mod/mod_proxy_wstunnel.html。
Nginx:https://www.nginx.com/blog/websocket-nginx/。
既然WebSocket是HTML5新增的特性,那么在使用时我们就要考虑浏览器旧版本兼容的问题,这也是Comet方案尽管存在各种问题,但仍旧被采用的原因。
Tomcat自7.0.5版本开始支持WebSocket,并且实现了Java WebSocket规范(JSR356 ),而在7.0.5版本之前(7.0.2版本之后)则采用自定义API,即WebSocketServlet。本节我们仅介绍Tomcat针对规范的实现。
根据JSR356的规定,Java WebSocket应用由一系列的WebSocket Endpoint组成。Endpoint是一个Java对象,代表WebSocket链接的一端,对于服务端,我们可以视为处理具体WebSocket消息的接口,就像Servlet之于HTTP请求一样(不同之处在于Endpoint每个链接一个实例)。
我们可以通过两种方式定义Endpoint,第一种是编程式,即继承类javax.websocket.Endpoint并实现其方法。第二种是注解式,即定义一个POJO对象,为其添加Endpoint相关的注解。
Endpoint实例在WebSocket握手时创建,并在客户端与服务端链接过程中有效,最后在链接关闭时结束。Endpoint接口明确定义了与其生命周期相关的方法,规范实现者确保在生命周期的各个阶段调用实例的相关方法。
Endpoint的生命周期方法如下:
当客户端链接到一个Endpoint时,服务器端会为其创建一个唯一的会话(javax.websocket.Session)。会话在WebSocket握手之后创建,并在链接关闭时结束。当生命周期中触发各个事件时,都会将当前会话传给Endpoint。
我们通过为Session添加MessageHandler消息处理器来接收消息。当采用注解方式定义Endpoint时,我们还可以通过@OnMessage指定接收消息的方法。发送消息则由RemoteEndpoint完成,其实例由Session维护,根据使用情况,我们可以通过Session.getBasicRemote获取同步消息发送的实例或者通过Session.getAsyncRemote获取异步消息发送的实例。
WebSocket通过javax.websocket.WebSocketContainer接口维护应用中定义的所有Endpoint。它在每个Web应用中只有一个实例,类似于传统Web应用中的ServletContext。
最后,WebSocket规范提供了一个接口javax.websocket.server.ServerApplicationConfig,通过它,我们可以为编程式的Endpoint创建配置(如指定请求地址),还可以过滤只有符合条件的Endpoint提供服务。该接口的实现同样通过SCI机制加载。
介绍完WebSocket规范中的基本概念,我们看一下Tomcat的具体实现。接下来会涉及到Tomcat链接器(Cotyte)和Web应用加载的知识,如不清楚可以阅读Tomcat官方文档。
Tomcat提供了一个javax.servlet.ServletContainerInitializer的实现类org.apache.tomcat.websocket.server.WsSci。因此Tomcat的WebSocket加载是通过SCI机制完成的。WsSci可以处理的类型有三种:添加了注解@ServerEndpoint的类、Endpoint的子类以及ServerApplicationConfig的实现类。
Web应用启动时,通过WsSci.onStartup方法完成WebSocket的初始化:
当服务器接收到来自客户端的请求时,首先WsFilter会判断该请求是否是一个WebSocket Upgrade请求(即包含Upgrade: websocket头信息)。如果是,则根据请求路径查找对应的Endpoint处理类,并进行协议Upgrade。
在协议Upgrade过程中,除了检测WebSocket扩展、添加相关的转换外,最主要的是添加WebSocket相关的响应头信息、构造Endpoint实例、构造HTTP Upgrade处理类WsHttpUpgradeHandler。
将WsHttpUpgradeHandler传递给具体的Tomcat协议处理器(ProtocolHandler)进行Upgrade。接收到Upgrade的动作后,Tomcat的协议处理器(HTTP协议)不再使用原有的Processor处理请求,而是替换为专门的Upgrade Processor。
根据I/O的不同,Tomcat提供的Upgrade Processor实现如下:
替换成功后,WsHttpUpgradeHandler会对Upgrade Processor进行初始化(按以下顺序):
通过这种方式,Tomcat实现了WebSocket请求处理与具体I/O方式的解耦。
首先,添加一个Endpoint子类,代码如下:
public class ChatEndpoint extends Endpoint {
private static final Set connections = new CopyOnWriteArraySet<>();
private Session session;
private static class ChatMessageHandler implements
MessageHandler.Partial<String> {
private Session session;
private ChatMessageHandler(Session session){
this.session = session;
}
@Override
public void onMessage(String message, boolean last) {
String msg = String.format("%s %s %s", session.getId(), "said:" ,message);
broadcast(msg);
}
};
@Override
public void onOpen(Session session, EndpointConfig config) {
this.session = session;
connections.add(this);
this.session.addMessageHandler(new ChatMessageHandler(session));
String message = String.format("%s %s", session.getId(), "has joined.");
broadcast(message);
}
@Override
public void onClose(Session session, CloseReason closeReason) {
connections.remove(this);
String message = String.format("%s %s", session.getId(),
"has disconnected.");
broadcast(message);
}
@Override
public void onError(Session session, Throwable throwable) {
}
private static void broadcast(String msg) {
for (ChatEndpoint client : connections) {
try {
synchronized (client) {
client.session.getBasicRemote().sendText(msg);
}
} catch (IOException e) {
connections.remove(client);
try {
client.session.close();
} catch (IOException e1) {
}
String message = String.format("%s %s",
client.session.getId(), "has been disconnected.");
broadcast(message);
}
}
}
}
为了方便向客户端推送消息,我们使用一个静态集合作为链接池维护所有Endpoint实例。
在onOpen方法中,首先将当前Endpoint实例添加到链接池,然后为会话添加了一个消息处理器ChatMessageHandler,用于接收消息。当接收到客户端消息后,我们将其推送到所有客户端。最后向所有客户端广播一条上线通知。
在onClose方法中,将当前Endpoint从链接池中移除,向所有客户端广播一条下线通知。
然后定义ServerApplicationConfig实现,代码如下:
public class ChatServerApplicationConfig implements ServerApplicationConfig {
@Override
public Set> getAnnotatedEndpointClasses(Set> scanned) {
return scanned;
}
@Override
public Set getEndpointConfigs(
Set> scanned) {
Set result = new HashSet<>();
if (scanned.contains(ChatEndpoint.class)) {
result.add(ServerEndpointConfig.Builder.create(
ChatEndpoint.class,
"/program/chat").build());
}
return result;
}
}
在ChatServerApplicationConfig中为ChatEndpoint添加ServerEndpointConfig,其请求链接为“/program/chat”。
最后添加对应的HTML页面:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<script type="application/javascript">"use strict";
var Chat = {};
Chat.socket = null;
Chat.connect = (function(host) {
if ('WebSocket' in window) {
Chat.socket = new WebSocket(host);
} else if ('MozWebSocket' in window) {
Chat.socket = new MozWebSocket(host);
} else {
Console.log('Error: WebSocket is not supported by this browser.');
return;
}
Chat.socket.onopen = function () {
Console.log('Info: WebSocket connection opened.');
document.getElementById('chat').onkeydown = function(event) {
if (event.keyCode == 13) {
Chat.sendMessage();
}
};
};
Chat.socket.onclose = function () {
document.getElementById('chat').onkeydown = null;
Console.log('Info: WebSocket closed.');
};
Chat.socket.onmessage = function (message) {
Console.log(message.data);
};
});
Chat.initialize = function() {
if (window.location.protocol == 'http:') {
Chat.connect('ws://' + window.location.host + '/chat_demo/program/chat');
} else {
Chat.connect('wss://' + window.location.host + '/chat_demo/program/chat');
}
};
Chat.sendMessage = (function() {
var message = document.getElementById('chat').value;
if (message != '') {
Chat.socket.send(message);
document.getElementById('chat').value = '';
}
});
var Console = {};
Console.log = (function(message) {
var console = document.getElementById('console');
var p = document.createElement('p');
p.style.wordWrap = 'break-word';
p.innerHTML = message;
console.appendChild(p);
while (console.childNodes.length > 25) {
console.removeChild(console.firstChild);
}
console.scrollTop = console.scrollHeight;
});
Chat.initialize();
]]>script>
head>
<body>
<div>
<p>
<input type="text" placeholder="type and press enter to chat" id="chat" />
p>
<div id="console-container">
<div id="console"/>
div>
div>
body>
html>
客户端实现并不复杂,只是要注意浏览器的区别。在添加完所有配置后,可以将应用部署到Tomcat查看效果,与Comet类似,我们可以同时开启两个客户端查看消息推送效果。
基于注解的定义要比编程式简单一些,首先定义一个POJO对象,并添加相关注解:
@ServerEndpoint(value = "/anno/chat")
public class ChatAnnotation {
private static final Set connections =
new CopyOnWriteArraySet<>();
private Session session;
@OnOpen
public void start(Session session) {
this.session = session;
connections.add(this);
String message = String.format("%s %s", session.getId(), "has joined.");
broadcast(message);
}
@OnClose
public void end() {
connections.remove(this);
String message = String.format("%s %s", session.getId(), "has disconnected.");
broadcast(message);
}
@OnMessage
public void incoming(String message) {
String msg = String.format("%s %s %s", session.getId(), "said:" ,message);
broadcast(msg);
}
@OnError
public void onError(Throwable t) throws Throwable {
}
private static void broadcast(String msg) {
for (ChatAnnotation client : connections) {
try {
synchronized (client) {
client.session.getBasicRemote().sendText(msg);
}
} catch (IOException e) {
connections.remove(client);
try {
client.session.close();
} catch (IOException e1) {}
String message = String.format("%s %s",client.session.getId(), "has been disconnected.");
broadcast(message);
}
}
}
}
@ServerEndpoint注解声明该类是一个Endpoint,并指定了请求的地址。
@OnOpen注解的方法在会话打开时调用,与ChatEndpoint类似,将当前实例添加到链接池。@OnClose注解的方法在会话关闭时调用。@OnError注解的方法在链接异常时调用。@OnMessage注解的方法用于接收消息。
使用注解方式定义Endpoint时,ServerApplicationConfig不是必须的,此时直接默认加载所有的@ServerEndpoin注解POJO。
我们可以直接将编程式示例中HTML页面中的链接地址改为“/anno/chat”查看效果。
《Tomcat架构解析》