WebSocket协议开发

时间:2022-05-17 23:02:51

一直以来,网络在很大程度上都是围绕着HTTP的请求/响应模式而构建的。客户端加载一个网页,然后直到用户点击下一页之前,什么都不会发生。在2005年左右,Ajax开始让网络变得更加动态了。但所有的HTTP通信仍然是由客户端控制的,这就需要用户进行互动或定期轮询,以便从服务器加载新数据。

长期以来存在着各种技术让服务器得知有新数据可用时,立即将数据发送到客户端。这些技术种类繁多,例如“推送”或Comet。最常用的一种黑客手段是对服务器发起链接创建假象,被称为长轮询。利用长轮询,客户端可以打开指向服务器的HTTP连接,而服务器会一直保持连接打开,直到发送响应。服务器只要实际拥有新数据,就会发送响应(其他技术包括Flash、XHR multipart请求和所谓的HTML Files)。长轮询和其他技术都非常好用,在Gmail聊天等应用中会经常使用它们。

但是,这些解决方案都存在一个共同的问题:由于HTTP协议的开销,导致它们不适用于低延迟应用。

为了解决这些问题,WebSocket将网络套接字引入到了客户端和服务端,浏览器和服务器之间可以通过套接字建立持久的连接,双方随时都可以互发数据给对方,而不是之前由客户端控制的一请求一应答模式。

HTTP协议的弊端

将HTTP协议的主要弊端总结如下。

(1)HTTP协议为半双工协议。半双工协议指数据可以在客户端和服务端两个方向上传输,但是不能同时传输。它意味着在同一时刻,只有一个方向上的数据传送;

(2)HTTP消息冗长而繁琐。HTTP消息包含消息头、消息体、换行符等,通常情况下采用文本方式传输,相比于其他的二进制通信协议,冗长而繁琐;

(3)针对服务器推送的黑客攻击。例如长时间轮询。

现在,很多网站为了实现消息推送,所用的技术都是轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP request,然后由服务器返回最新的数据给客户端浏览器。这种传统的模式具有很明显的缺点,即浏览器需要不断地向服务器发出请求,然而HTTP request的header是非常冗长的,里面包含的可用数据比例可能非常低,这会占用很多的带宽和服务器资源。

比较新的一种轮询技术是Comet,使用了Ajax。这种技术虽然可达到双向通信,但依然需要发出请求,而且在Comet中,普遍采用了长连接,这也会大量消耗服务器带宽和资源。

为了解决HTTP协议效率低下的问题,HTML5定义了WebSocket协议,能更好地节省服务器资源和带宽并达到实时通信。

WebSocket入门

WebSocket是HTML5开始提供的一种浏览器与服务器间进行全双工通信的网络技术,WebSocket通信协议于2011年被IETF定为标准RFC6455,WebSocket API被W3C定为标准。

在WebSocket API中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道,两者就可以直接互相传送数据了。WebSocket基于TCP双向全双工进行消息传递,在同一时刻,既可以发送消息,也可以接收消息,相比于HTTP的半双工协议,性能得到很大提升。

下面总结一下WebSocket的特点。

  1. 单一的TCP连接,采用全双工模式通信;
  2. 对代理、防火墙和路由器透明;
  3. 无头部信息、Cookie和身份验证;
  4. 无安全开销;
  5. 通过“ping/pong”帧保持链路激活;
  6. 服务器可以主动传递消息给客户端,不再需要客户端轮询。

WebSocket连接建立

建立WebSocket连接时,需要通过客户端或者浏览器发出握手请求,请求消息示例如图:

WebSocket协议开发

服务端返回给客户端的应答消息如图:

WebSocket协议开发

为了建立一个WebSocket连接,客户端浏览器首先要向服务器发起一个HTTP请求,这个请求和通常的HTTP请求不同,包含了一些附加头信息,其中附加头信息“Upgrade: WebSocket”表明这是一个申请协议升级的HTTP请求。服务器端解析这些附加的头信息,然后生成应答信息返回给客户端,客户端和服务器端的WebSocket连接就建立起来了,双方可以通过这个连接通道*地传递信息,并且这个连接会持续存在直到客户端或者服务器端的某一方主动关闭连接。

请求消息中的“Sec-WebSocket-Key”是随机的,服务器端会用这些数据来构造出一个SHA-1的信息摘要,把“Sec-WebSocket-Key”加上一个魔幻字符串“258EAFA5-E914- 47DA-95CA-C5AB0DC85B11”。使用SHA-1加密,然后进行BASE-64编码,将结果做为“Sec-WebSocket-Accept”头的值,返回给客户端。

WebSocket生命周期

握手成功之后,服务端和客户端就可以通过“messages”的方式进行通信了,一个消息由一个或者多个帧组成,WebSocket的消息并不一定对应一个特定网络层的帧,它可以被分割成多个帧或者被合并。

帧都有自己对应的类型,属于同一个消息的多个帧具有相同类型的数据。从广义上讲,数据类型可以是文本数据(UTF-8[RFC3629]文字)、二进制数据和控制帧(协议级信令,如信号)。

WebSocket连接生命周期示意图如图:

WebSocket协议开发

WebSocket连接关闭

为关闭WebSocket连接,客户端和服务端需要通过一个安全的方法关闭底层TCP连接以及TLS会话。如果合适,丢弃任何可能已经接收的字节;必要时(比如受到攻击),可以通过任何可用的手段关闭连接。

底层的TCP连接,在正常情况下,应该首先由服务器关闭。在异常情况下(例如在一个合理的时间周期后没有接收到服务器的TCP Close),客户端可以发起TCP Close。因此,当服务器被指示关闭WebSocket连接时,它应该立即发起一个TCP Close操作;客户端应该等待服务器的TCP Close。

WebSocket的握手关闭消息带有一个状态码和一个可选的关闭原因,它必须按照协议要求发送一个Close控制帧,当对端接收到关闭控制帧指令时,需要主动关闭WebSocket连接。

Netty WebSocket协议开发

Netty基于HTTP协议栈开发了WebSocket协议栈,利用Netty的WebSocket协议栈可以非常方便地开发出WebSocket客户端和服务端。

场景设计

WebSocket服务端的功能如下:支持WebSocket的浏览器通过WebSocket协议发送请求消息给服务端,服务端对请求消息进行判断,如果是合法的WebSocket请求,则获取请求消息体(文本),并在后面追加字符串“欢迎使用Netty WebSocket服务,现在时刻:系统时间”。

客户端HTML通过内嵌的JS脚本创建WebSocket连接,如果握手成功,在文本框中打印“打开WebSocket服务正常,浏览器支持WebSocket!”。

服务端代码示例:

首先对WebSocket服务端的功能进行简单地讲解。WebSocket服务端接收到请求消息之后,先对消息的类型进行判断,如果不是WebSocket握手请求消息,则返回 HTTP 400 BAD REQUEST 响应给客户端。

WebSocket协议开发

服务端对握手请求消息进行处理,构造握手响应返回,双方的Socket连接正式建立,服务端返回的握手应答消息:

WebSocket协议开发

连接建立成功后,到被关闭之前,双方都可以主动向对方发送消息,这点跟HTTP的一请求一应答模式存在很大的差别。相比于HTTP,它的网络利用率更高,可以通过全双工的方式进行消息发送和接收。

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.stream.ChunkedWriteHandler; public class WebSocketServer {
public void run(int port) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(Channel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
//首先添加HttpServerCodec,将请求和应答消息编码或者解码为HTTP消息;
pipeline.addLast("http-codec", new HttpServerCodec());
//增加HttpObjectAggregator,它的目的是将HTTP消息的多个部分组合成一条完整的HTTP消息;
pipeline.addLast("aggregator", new HttpObjectAggregator(65536));
//添加ChunkedWriteHandler,来向客户端发送HTML5文件,
//它主要用于支持浏览器和服务端进行WebSocket通信;
ch.pipeline().addLast("http-chunked", new ChunkedWriteHandler());
//增加WebSocket服务端handler。
pipeline.addLast("handler", new WebSocketServerHandler());
}
});
Channel ch = b.bind(port).sync().channel();
System.out.println("Web socket server started at port " + port + '.');
System.out.println("Open your browser and navigate to http://localhost:" + port + '/');
ch.closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
} public static void main(String[] args) throws Exception {
int port = 8080;
if (args.length > 0) {
try {
port = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
e.printStackTrace();
}
}
new WebSocketServer().run(port);
}
} import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.websocketx.*;
import io.netty.util.CharsetUtil; import static io.netty.handler.codec.http.HttpResponseStatus.*;
import static io.netty.handler.codec.http.HttpHeaders.*;
import static io.netty.handler.codec.http.HttpVersion.*; public class WebSocketServerHandler extends SimpleChannelInboundHandler { private WebSocketServerHandshaker handshaker; @Override
public void messageReceived(ChannelHandlerContext ctx, Object msg)
throws Exception {
// 传统的HTTP接入
//第一次握手请求消息由HTTP协议承载,所以它是一个HTTP消息,执行handleHttpRequest方法来处理WebSocket握手请求。
if (msg instanceof FullHttpRequest) {
handleHttpRequest(ctx, (FullHttpRequest) msg);
}
// WebSocket接入
// 客户端通过文本框提交请求消息给服务端,WebSocketServerHandler接收到的是已经解码后的WebSocketFrame消息。
else if (msg instanceof WebSocketFrame) {
handleWebSocketFrame(ctx, (WebSocketFrame) msg);
}
} @Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.flush();
} private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception { // 如果HTTP解码失败,返回HTTP异常
// 首先对握手请求消息进行判断,如果消息头中没有包含Upgrade字段或者它的值不是websocket,则返回HTTP 400响应。
if (!req.getDecoderResult().isSuccess() || (!"websocket".equals(req.headers().get("Upgrade")))) {
sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1,BAD_REQUEST));
return;
}
// 构造握手响应返回,本机测试
// 握手请求简单校验通过之后,开始构造握手工厂,
// 创建握手处理类WebSocketServerHandshaker,
WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(
              "ws://localhost:8080/websocket", null, false);
handshaker = wsFactory.newHandshaker(req);
if (handshaker == null) {
WebSocketServerHandshakerFactory.sendUnsupportedWebSocketVersionResponse(ctx.channel());
} else {
// 通过它构造握手响应消息返回给客户端,
// 同时将WebSocket相关的编码和解码类动态添加到ChannelPipeline中,用于WebSocket消息的编解码,
// 添加WebSocketEncoder和WebSocketDecoder之后,服务端就可以自动对WebSocket消息进行编解码了
handshaker.handshake(ctx.channel(), req);
}
}
private void handleWebSocketFrame(ChannelHandlerContext ctx,WebSocketFrame frame) {
// 判断是否是关闭链路的指令
// 首先需要对控制帧进行判断,如果是关闭链路的控制消息
// 就调用WebSocketServerHandshaker的close方法关闭WebSocket连接;
if (frame instanceof CloseWebSocketFrame) {
handshaker.close(ctx.channel(),(CloseWebSocketFrame) frame.retain());
return;
}
// 判断是否是Ping消息
// 如果是维持链路的Ping消息,则构造Pong消息返回。
if (frame instanceof PingWebSocketFrame) {
ctx.channel().write(new PongWebSocketFrame(frame.content().retain()));
return;
}
// 本例程仅支持文本消息,不支持二进制消息
// WebSocket通信双方使用的都是文本消息,所以对请求消息的类型进行判断,不是文本的抛出异常。
if (!(frame instanceof TextWebSocketFrame)) {
throw new UnsupportedOperationException(
String.format("%s frame types not supported", frame.getClass().getName()));
} // 返回应答消息
// 从TextWebSocketFrame中获取请求消息字符串,
String request = ((TextWebSocketFrame) frame).text();
// 对它处理后通过构造新的TextWebSocketFrame消息返回给客户端,
// 由于握手应答时动态增加了TextWebSocketFrame的编码类,所以,可以直接发送TextWebSocketFrame对象。
ctx.channel().write(
new TextWebSocketFrame(request
+ " , 欢迎使用Netty WebSocket服务,现在时刻:"
+ new java.util.Date().toString()));
} private static void sendHttpResponse(ChannelHandlerContext ctx,FullHttpRequest req, FullHttpResponse res) {
// 返回应答给客户端
if (res.getStatus().code() != 200) {
ByteBuf buf = Unpooled.copiedBuffer(res.getStatus().toString(),CharsetUtil.UTF_8);
res.content().writeBytes(buf);
buf.release();
setContentLength(res, res.content().readableBytes());
} // 如果是非Keep-Alive,关闭连接
ChannelFuture f = ctx.channel().writeAndFlush(res);
if (!isKeepAlive(req) || res.getStatus().code() != 200) {
f.addListener(ChannelFutureListener.CLOSE);
}
} @Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
cause.printStackTrace();
ctx.close();
}
}

html代码示例:

<html>
<head>
<meta charset="utf-8">
Netty WebSocket 时间服务器
</head>
<br>
<body>
<br>
<script type="text/javascript">
var socket;
if(!window.WebSocket){
window.WebSocket = window.MozWebSocket;
}
if(!window.WebSocket) {
socket = new WebSocket("ws://localhost:8080/websocket");
socket.onmessage = function(event){
var ta = document.getElementById('responseText');
ta.value = "";
ta.value = event.data;
};
socket.onopen = function(event){
var ta = document.getElementById('responseText');
ta.value = "打开websocket服务正常,浏览器支持";
};
socket.onclose = function(event){
var ta = document.getElementById('responseText');
ta.value = "";
ta.value = "websocket关闭";
};
}else{
alert("抱歉,你的浏览器不支持websocket协议");
} function send(message){
if(!window.WebSocket){
return;
}
if(socket.readyState==WebSocket.OPEN){
socket.send(message);
}else {
alert("websocket连接没有建立成功")
}
}
</script>
<form>
<input type="text" name="message" value="Netty WebSocket"/>
<br><br>
<input type="button" value="发送消息" onclick="send(this.form.message.value)">
<hr color="blue"/>
<h3>服务器返回的应答消息</h3>
<textarea id="responseText" style="width: 500px;height: 300px;"></textarea>
</form>
</body>
</html>