WebSocket是一种全双工通信的协议,其通信在TCP连接上进行,所以属于应用层协议。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket编程中,浏览器和服务器只需要完成一次升级握手就直接可以创建持久性的连接,并进行双向数据传输。
该开源项目的地址为https://gitee.com/crazymaker/Websocket_chat_room。
11.1 WebSocket协议简介
WebSocket协议的目标,是在一个独立的持久连接上提供全双工双向通信。客户端和服务器可以向对方主动发送和接受数据。
11.1.1 Ajax短轮询和Long Poll长轮询的原理
- Ajax短轮询: 让浏览器每隔几秒就发送一次请求,询问服务器是否有新信息
- Long Poll长轮询: 原理与Ajax短轮询差不多,都是采用轮询的方式,不过采取的是服务端阻塞模型
11.1.2 WebSocket与HTTP之间的关系
WebSocket的通信连接建立的前提需要借助HTTP协议,完成通信连接建立之后,通信链接上的双向通信就与HTTP协议无关了
WebSocket协议的握手和通信过程如图11-2所示。
11.2 WebSocket回显演示程序开发
11.2.1 WebSocket回显程序的客户端代码
使用JavaScript实现WebSocket协议通信相对简单,这里分为三个步骤进行介绍
建立WebSocket连接
socket = new WebSocket("ws://192.168.0.5:18899/ws","echo");
以上调用的WebSocket()方法的第一个参数为服务端的WebSocket监听URL地址,第二个参数为服务端配置的WebSocket子协议(业务协议),子协议为应用程序自己使用的某个标识或者命名,客户端与服务端保持一致即可。
监听WebSocket连接的open事件 ```javascript socket.onopen = function (event) { var target = document.getElementById(‘responseText’); target.value = “Web Socket 连接已经开启!”; };
3. 监听WebSocket连接的message消息事件
```javascript
socket.onmessage = function (event) {
var ta = document.getElementById('responseText');
ta.value = ta.value + '\n' + event.data
};
完整的WebSocket回显演示程序的客户端JavaScript脚本大致如下:
<script type="text/javascript">
var socket;
if (!window.WebSocket) {
window.WebSocket = window.MozWebSocket;
}
//获取浏览器上URL中的主机名称
var domain = window.location.host;
if (window.WebSocket) {
//建立WebSocket连接
socket = new WebSocket("ws://"+domain+"/ws","echo");
socket.onmessage = function (event) {
var ta = document.getElementById('responseText');
ta.value = ta.value + '\n' + event.data
};
//连接打开事件
socket.onopen = function (event) {
var target = document.getElementById('responseText');
target.value = "Web Socket 连接已经开启!";
};
//连接关闭事件
socket.onclose = function (event) {
var target = document.getElementById('responseText');
target.value = ta.value + "Web Socket 连接已经断开";
};
} else {
alert("Your browser does not support Web Socket.");
}
//发送WebSocket消息,在JavaScript发送WebSocket消息时调用
function send(message) {
if (!window.WebSocket) {
return;
}
if (socket.readyState == WebSocket.OPEN) {
//通过套接字发送消息
socket.send(message);
} else {
alert("The socket is not open.");
}
}
</script>
11.2.2 WebSocket相关的Netty内置处理类
WebSocket协议中大致包含了5种类型的数据帧,与这5种数据帧相对应,Netty包含了5种WebSocket数据帧的封装类型。这些类型都是WebSocketFrame类的子类
与服务端WebSocket通信相关的Netty内置Handler处理器
其中WebSocketServerProtocolHandler
是非常关键的处理器,负责开始升级握手和控制帧的处理,可以理解为握手处理器,握手完成后,双方的通信协议会从HTTP升级到WebSocket协议
以WebSocket回显演示程序为例,以在协议升级之前,通道Pipeline处理流水线的状态如下图所示:
在握手升级过程中,握手处理器WebSocketServerProtocolHandshakeHandler
会被加入到Pipeline
流水线上,负责进行协议的升级握手。 握手完成之后,握手处理器会将解码器HttpRequestDecoder
替换为WebSocketFrameDecoder
对应WebSocket
版本的子类实例,也会将编码器HttpResponseEncoder
替换WebSocketFrameEncoder
对应版本的子类实例。
在握手完成协议升级之后,通道Pipeline处理流水线的状态如下图所示
11.2.3 WebSocket的回显服务器
package com.crazymakercircle.netty.Websocket;
//省略import
@Slf4j
public final class WebSocketEchoServer
{
//流水线装配器
static class EchoInitializer extends
ChannelInitializer<SocketChannel> {
@Override
public void initChannel(SocketChannel ch)
{
ChannelPipeline pipeline = ch.pipeline();
//HTTP请求解码器
pipeline.addLast(new HttpRequestDecoder());
//HTTP响应编码器
pipeline.addLast(new HttpResponseEncoder());
//HttpObjectAggregator 将HTTP消息的多个部分合成一条完整的HTTP消息
pipeline.addLast(new HttpObjectAggregator(65535));
//WebSocket协议处理器,配置WebSocket的监听URI、协议包长度限制
pipeline.addLast(
new WebSocketServerProtocolHandler("/ws", "echo",
true, 10 * 1024));
//增加网页的处理逻辑
pipeline.addLast(new WebPageHandler());
//TextWebSocketFrameHandler 是自定义的WebSocket业务处理器
pipeline.addLast(new TextWebSocketFrameHandler());
}
}
/**
* 启动
*/
public static void start(String ip) throws Exception
{
//创建连接监听reactor 轮询组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
//创建连接处理 reactor 轮询组
EventLoopGroup workerGroup = new NioEventLoopGroup();
try
{
//服务端启动引导实例
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.DEBUG))
.childHandler(new EchoInitializer());
//监听端口,返回同步通道
Channel ch = b.bind(18899).sync().channel();
log.info("WebSocket服务已经启动 http://{}:{}/",ip,18899);
ch.closeFuture().sync();
} finally
{
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
在上面的代码中,演示程序所设置的WebSocket服务监听的URL为“/ws”、子协议为“echo”。这就要求客户端在发起Websocket连接时,需要使用同样的URL和子协议,否则会连接失败。 所以,当服务端收到客户端的URL为“/ws”、子协议为“echo”的HTTP请求时,握手处理器WebSocketServerProtocolHandler将启动协议升级机制, 着手将HTTP协议升级为WebSocket协议,握手完成之后, 双方正式进入WebSocket双向通信阶段
11.2.4 WebSocket的业务处理器
WebSocket回显服务器的业务处理器为TextWebSocketFrameHandler,在监听到握手完成事件之后,将WebSocket通信中不需要的WebPageHandler网页处理器移除。
package com.crazymakercircle.netty.Websocket;
//省略import
@Slf4j
public class TextWebSocketFrameHandler extends
SimpleChannelInboundHandler<WebSocketFrame>
{
@Override
protected void channelRead0(ChannelHandlerContext ctx,
WebSocketFrame frame) throws Exception
{
//Ping 和Pong 帧已经被前面WebSocketServerProtocolHandler处理器处理过了
if (frame instanceof TextWebSocketFrame)
{
//取得WebSocket的通信内容
String request = ((TextWebSocketFrame) frame).text();
log.debug("服务端收到:" + request);
//回显字符串
String echo = Dateutil.getTime() + ":" + request;
//构造TextWebSocketFrame文本帧,用于回复
TextWebSocketFrame echoFrame = new TextWebSocketFrame(echo);
//发送回显字符串
ctx.channel().writeAndFlush(echoFrame);
} else
{
//如果不是文本消息,抛出异常
//本演示不支持二进制消息
String message = "unsupported frame type: " +
frame.getClass().getName();
throw new UnsupportedOperationException(message);
}
}
//处理用户事件
@Override
public void userEventTriggered(ChannelHandlerContext ctx,
Object evt) throws Exception
{
//判断是否为握手成功事件,该事件表明通信协议已经升级为 WebSocket 协议
if (evt instanceof
WebSocketServerProtocolHandler.HandshakeComplete)
{
//握手成功,移除 WebPageHandler,因此将不会接收到任何HTTP请求
ctx.pipeline().remove(WebPageHandler.class);
log.debug("WebSocket HandshakeComplete 握手成功");
log.debug("新的WebSocket 客户端加入,通道为:" + ctx.channel());
} else
{
super.userEventTriggered(ctx, evt);
}
}
}
11.3 WebSocket协议通信的原理
11.3.1 抓取WebSocket协议的本机数据包
WebSocket回显演示程序的测试用例代码,具体如下:
package com.crazymakercircle.NettyTest;
//省略import
/**
* WebSocket回显服务器的测试用例
**/
@Slf4j
public class WebSocketEchoTester
{
@Test
public void startServer() throws Exception
{
//抓包说明:由于WireShark只能抓取经过所监控的网卡的数据包
//因此请求到localhost的本地包,默认是不能抓取到的。
//如果要抓取本地的调试包,需要通过route指令增加服务器IP的路由表项配置
//只有这样,让发往本地localhost的报文才会经过路由网关所绑定的网卡 //从而,发往localhost的本地包就能被抓包工具从监控网卡抓取到
//具体的办法是通过增加路由表项来完成,其命令为route add,下面是一个例子
//route add 192.168.0.5 mask 255.255.255.255 192.168.0.1
//以上命令表示:目标为192.168.0.5的报文,经过192.168.0.1网关绑定的网卡
//该路由项在使用完毕后,建议删除,其删除指令如下:
//route delete 192.168.0.5 mask 255.255.255.255 192.168.0.1删除
//如果没有删除,则所有本机报文都经过网卡到达路由器
//然后,绕一圈再回来,会很消耗性能
//如果该路由表项并没有保存,在电脑重启后将会失效
//注意:以上用到的本地IP和网关IP需要结合自己的电脑网卡和网关去更改
//启动WebSocket回显服务器
WebSocketEchoServer.start("192.168.0.5");
}
}
11.3.2 WebSocket握手过程
握手请求HTTP报文需要携带一些WebSocket协议规范约定的请求头,主要有如下几个:
- Sec-WebSocket-Key请求头
- Upgrade请求头
- Connection请求头
- Sec-WebSocket-Version请求头
- Sec-WebSocket-Protocol请求头
响应报文中所涉及的比较重要的响应头包括如下几项: