本章主要内容
- WebSocket
- ChannelHandler,Decoder和Encoder
- 启动基于Netty的应用
- 测试WebSocket
Netty提供了很多WebSocket相关的支持,包括不同的版本。在你的应用中可以无脑使用这些技术。因为你不需要详细了解那些底层协议相关的知识,只需要使用它提供的简单的API即可。
本章我们将学习开发一个关于WebSocket的例子,来理解如何在实际项目中使用它。你也可以将例子中的一些代码集成到自己的应用中,复用这些代码。
一、挑战
为了说明WebSocket的实时性,这个例子我们将会用WebSocket实现一个WEB版本的即时通讯应用,类似QQ那样。这个应用很类似QQ中的群聊天功能,一个用户发消息,其他用户都能收到。
从上图可以看出,一个用户发了消息,然后服务端转发给其他用户。不过我们主要实现服务器端部分,因为客户端我们会使用浏览器。现在就开始实现这个应用。
二、实现
WebSocket使用HTTP升级机制将HTTP连接转成WebSocket连接。因此WebSocket应用都是先从HTTP开始,然后升级到WebSocket。升级发生的时间是应用指定的。有的是上来就升级,有的是访问了指定的URL之后才升级。
这个例子中,当URL以/ws结尾时才开始升级到WebSocket,否则就返回一个网页给客户端。一旦升级之后就通过WebSocket传输数据。
主要的逻辑都通过ChannelHandler来实现,方便以后复用。下一小节就详细介绍这些ChannelHandler。
2.1、处理HTTP请求
上一小节说过,服务端将同时处理HTTP请求和WebSocket请求,因为服务端也要返回HTML页面,比如聊天室页面,用于客户端展示。
因此我们需要编写一个ChannelInboundHandler,处理客户端的HTTP请求,消息实体使用FullHttpRequest。
package com.nan.netty.chatroom;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedNioFile;
import java.io.RandomAccessFile;
public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private final String wsUri;
public HttpRequestHandler(String wsUri) {
this.wsUri = wsUri;
}
@Override
public void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
if (wsUri.equalsIgnoreCase(request.uri())) {
//如果是WebSocket请求,则保留数据并传递到下一个ChannelHandler
ctx.fireChannelRead(request.retain());
} else {
if (HttpUtil.is100ContinueExpected(request)) {
//收到100-continue,则返回给客户端100
send100Continue(ctx);
}
boolean keepAlive;
ChannelFuture future;
try (RandomAccessFile file = new RandomAccessFile(this.getClass().getResource("/").getPath() + "../resources/index.html", "r")) {
HttpResponse response = new DefaultHttpResponse(request.protocolVersion(), HttpResponseStatus.OK);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=UTF-8");
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, file.length());
keepAlive = HttpUtil.isKeepAlive(request);
if (keepAlive) {
//如果需要keep-alive,则添加相应头信息
response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
}
ctx.write(response);
if (ctx.pipeline().get(SslHandler.class) == null) {
//不用加密使用零内存复制发送文件
future = ctx.writeAndFlush(new DefaultFileRegion(file.getChannel(), 0, file.length()));
} else {
future = ctx.writeAndFlush(new ChunkedNioFile(file.getChannel()));
}
}
//如果不是keep-alive,则关闭Channel
if (!keepAlive) {
future.addListener(ChannelFutureListener.CLOSE);
}
}
}
private static void send100Continue(ChannelHandlerContext ctx) {
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE);
ctx.writeAndFlush(response);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}
上面实现的HttpRequestHandler坐了以下的事情:
- 首先检查请求路径是否是WebSocket,如果是WebSocket则使用retain()方法和fireChannelRead(msg)方法将数据传递下去。
- 如果客户端发送的100,则返回100。
- 如果不是WebSocket请求,则返回一个HttpResponse,返回的内容就是一个HTML文件的内容。
2.2、处理WebSocket请求
名称 | 描述 |
BinaryWebSocketFrame | 二进制数据类型 |
TextWebSocketFrame | 文本数据类型 |
ContinuationWebSocketFrame | 属于前一个二进制类型或文本类型 |
CloseWebSocketFrame | 关闭请求 |
PingWebSocketFrame | 请求发送了PongWebSocketFrame |
PongWebSocketFrame | 响应发送了PingWebSocketFrame |
而我们的应用,一般只需要处理下面四种类型:
- CloseWebSocketFrame
- PingWebSocketFrame
- PongWebSocketFrame
- TextWebSocketFrame
幸运的是,其实只需要处理TextWebSocketFrame,其他的可以使用Netty提供的WebSocketServerProtocolHandler处理,Netty帮助开发者简化开发工作,下面的代码就是处理TextWebSocketFrame的ChannelHandler。
package com.nan.netty.chatroom;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
public class TextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private final ChannelGroup group;
public TextWebSocketFrameHandler(ChannelGroup group) {
this.group = group;
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
//当WebSocket连接成功
if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
//不需要再处理HTTP请求
ctx.pipeline().remove(HttpRequestHandler.class);
//告诉已经连接的客户端有新的客户端连接进来了
group.writeAndFlush(new TextWebSocketFrame("Client " + ctx.channel() + " joined"));
//将新的客户端添加到ChannelGroup中
group.add(ctx.channel());
} else {
super.userEventTriggered(ctx, evt);
}
}
@Override
public void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
//消息转发给每一个客户端
group.writeAndFlush(msg.retain());
}
}
主要的业务逻辑已经实现完了,现在只需要再实现一个ChannelInitializer,用来初始化每个Channel。
2.3、初始化ChannelPipeline
最后一步就是需要初始化ChannelPipeline,添加所有需要的ChannelHandler。前面的章节讲过,只需要继承ChannelInitializer,然后重写initChannel(…)方法即可,如下面的代码。
package com.nan.netty.chatroom;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.group.ChannelGroup;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
public class ChatServerInitializer extends ChannelInitializer<Channel> {
private final ChannelGroup group;
public ChatServerInitializer(ChannelGroup group) {
this.group = group;
}
@Override
protected void initChannel(Channel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new ChunkedWriteHandler());
pipeline.addLast(new HttpObjectAggregator(64 * 1024));
pipeline.addLast(new HttpRequestHandler("/ws"));
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
pipeline.addLast(new TextWebSocketFrameHandler(group));
}
}
大部分情况下initChannel(...)方法都是用来设置新注册Channel的ChannelPipeline,主要代码就是将应用中需要的ChannelHandler添加到ChannelPipeline中。
我们来看看上边的代码中我们添加的那些ChannelHandler的作用。
名称 |
描述 |
HttpServerCodec |
解码HTTP请求,编码HTTP响应 |
ChunkedWriteHandler |
用来写文件内容 |
HttpObjectAggregator |
解码HttpRequest/HttpContent
/LastHttpContent聚合成FullHttpRequest, |
HttpRequestHandler |
这个我们自定义的实现,普通HTTP请求返回HTML,WebSocket传递给 下一个ChannelHandler |
WebSocketServerProtocolHandler |
处理WebSocket请求的Ping/Pong/Close事件 |
TextWebSocketFrameHandler |
我们自定义实现的处理WebSocket文本消息,转发给其他客户端 |
WebSocketServerProtcolHandler比较特殊一些,所以这里多做一些介绍。WebSocketServerProtocol只处理WebSocket的Ping/Pong/Close请求,所以可以通过它帮忙将应用升级到WebSocket协议。
通过执行握手连接,一旦成功后就可以修改ChannelPipeline,添加需要的编解码器,把不需要的编解码器移除。我们刚初始化后的ChannelPipeline如下图。
一旦WebSocket握手完成,则就会发生变化。WebSocketServerProtocolHandler会将HttpRequestDecoder替换成WebSocketFrameDecoder,将HttpResponseEncoder替换成WebSocketFrameEncoder。除此之外它还会移除其他不需要的ChannelHandler,例如我们本例中HttpObjectAggregator,然后我们在实现代码中移除了HttpRequestHandler,升级到WebSocket后的ChannelPipeline如下图。
上面ChannelPipeline的更新是Netty背后帮我们完成的,这种方式还是很灵活的,并且可以将不同的任务分配到不同的ChannelHandler中。
三、编写服务端启动类和客户端
主要的业务逻辑都已经完成,现在只需要按照Netty提供的固定套路,设置服务端启动器然后启动服务端即可。
package com.nan.netty.chatroom;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.util.concurrent.ImmediateEventExecutor;
import java.net.InetSocketAddress;
public class ChatServer {
//使用DefaultChannelGroup保存所有WebSocket客户端
private final ChannelGroup channelGroup = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE);
private final EventLoopGroup group = new NioEventLoopGroup();
private Channel channel;
/**
* 启动服务端
*/
public ChannelFuture start(InetSocketAddress address) {
//服务端启动器
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(group)
.channel(NioServerSocketChannel.class)
.childHandler(createInitializer(channelGroup));
ChannelFuture future = bootstrap.bind(address);
future.syncUninterruptibly();
channel = future.channel();
return future;
}
/**
* 创建Channel初始化器
*/
protected ChannelInitializer<Channel> createInitializer(ChannelGroup channelGroup) {
return new ChatServerInitializer(channelGroup);
}
/**
* 释放资源
*/
public void destroy() {
if (channel != null) {
channel.close();
}
channelGroup.close();
group.shutdownGracefully();
}
/**
* 主方法
*/
public static void main(String[] args) {
//服务端监听端口
int port = 9999;
final ChatServer endpoint = new ChatServer();
ChannelFuture future = endpoint.start(new InetSocketAddress(port));
Runtime.getRuntime().addShutdownHook(new Thread(() -> endpoint.destroy()));
future.channel().closeFuture().syncUninterruptibly();
}
}
服务端的代码已经完成,现在我们实现一个客户端。本例中我们使用浏览器作为客户端,所以需要编写一个HTML页面,并使用JS的WebSocket API与服务端连接并通信。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>聊天室</title>
</head>
<body style="display: flex; justify-content: center; align-items: center">
<form onsubmit="return false;">
<h3>WebSocket聊天室:</h3>
<div>
<textarea id="responseText" style="width: 500px; height: 300px;"></textarea>
</div>
<div>
<input type="text" name="message" style="width: 430px" value="我是Netty">
<input type="button" value="发送消息" onclick="send(this.form.message.value)">
</div>
</form>
<script type="text/javascript">
var socket;
if (!window.WebSocket) {
window.WebSocket = window.MozWebSocket;
}
if (window.WebSocket) {
socket = new WebSocket("ws://localhost:9999/ws");
socket.onmessage = function(event) {
console.log(event);
var ta = document.getElementById('responseText');
ta.value = ta.value + '\n' + event.data
};
socket.onopen = function(event) {
var ta = document.getElementById('responseText');
ta.value = "连接开启!";
};
socket.onclose = function(event) {
var ta = document.getElementById('responseText');
ta.value = ta.value + "连接被关闭";
};
} else {
alert("你的浏览器不支持 WebSocket!");
}
function send(message) {
if (!window.WebSocket) {
return;
}
if (socket.readyState == WebSocket.OPEN) {
socket.send(message);
} else {
alert("连接没有开启.");
}
}
</script>
</body>
</html>
这个HTML文件的存放位置要和HttpRequestHandler代码中的路径一致,不然会发生找不到文件异常,比如本例中,我使用的是Gradle工具,所以将这个文件放到了Resources目录下。
现在就可以尝试一下我们的聊天室了。首先启动服务端,然后在浏览器中输入http://localhost:9999/,最好使用Chrome浏览器,然后就能看到如下页面。
然后再打开一个浏览器标签,同样访问地址http://localhost:9999/,可以同样看到上图画面,并且第一个打开的浏览器页面会编程如下所示。
说明另一个客户端已经成功连接,并且通知了已经存在的客户端,当然,发消息功能也是没问题。
四、加密
上面我们已经实现了聊天室的主要功能,但是可能有人会提出,消息内容很私密,需要我们需要在传输过程中加密。
一般来说,添加加密功能不能一件容易的工作,往往需要大改项目。但是因为我们使用的是Netty,只需要将SslHandler添加到ChannelPipeline即可。虽然说还需要改一点点代码,但也是很简单的修改,不过如果还需要配置SslContext,可能就会麻烦一些。
总体来说,使用Netty添加加密功能还是很简单的,主要是ChannelPipeline的存在,这里再次感谢它。
因为我们需要将SslHandler添加到ChannelPipeline中,所以需要改造一下ChatServerInitializer,这里我们继承ChatServerInitializer再开发一个有加密功能的初始化类。
package com.nan.netty.chatroom;
import io.netty.channel.Channel;
import io.netty.channel.group.ChannelGroup;
import io.netty.handler.ssl.SslHandler;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
public class SecureChatServerInitializer extends ChatServerInitializer {
private final SSLContext context;
public SecureChatServerInitializer(ChannelGroup group, SSLContext context) {
super(group);
this.context = context;
}
@Override
protected void initChannel(Channel ch) throws Exception {
//父类原样初始化
super.initChannel(ch);
SSLEngine engine = context.createSSLEngine();
engine.setUseClientMode(false);
//加密解密的SslHandler需要放到首位
ch.pipeline().addFirst(new SslHandler(engine));
}
}
安全的ChannelInitializer逻辑很简单,就是将SslHandler添加到ChannelPipeline第一个位置。下一步呢,我们就是需要在启动服务端时候使用这个新的SecureChatServerInitializer,并且在构造的时候将SSLContext传入,但是SSLContext是需要SSL证书的,这里为了方便就不向专业结构申请了,直接使用JDK自带的keytool工具生成自签名证书,当然这个证书是不被浏览器认可的,所以不能用在生产环境中。
我这边使用Windows系统,Linux或其他系统基本操作一致。打开CMD窗口,执行下面命令。
keytool -genkey -keysize 2048 -validity 365 -keyalg RSA -keypass netty123 -storepass netty123 -keystore netty.jks
keytool就是JDK自带的工具,大家应该把JAVA安装目录下面的bin目录加到环境变量中了吧,参数意义如下。
- -keysize 2048 密钥长度2048位
- -validity 365 证书有效期365天
- -keyalg RSA 使用RSA非对称加密算法
- -keypass netty123 密钥的访问密码
- -storepass netty123 密钥库的访问密码
- -keystore netty.jks 指定生成的密钥库文件
package com.nan.netty.chatroom;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.group.ChannelGroup;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.security.KeyStore;
public class SecureChatServer extends ChatServer {
private final SSLContext context;
public SecureChatServer(SSLContext context) {
this.context = context;
}
@Override
protected ChannelInitializer<Channel> createInitializer(ChannelGroup group) {
//Channel初始化使用安全版本的
return new SecureChatServerInitializer(group, context);
}
public static void main(String[] args) {
int port = 9999;
SSLContext sslContext = null;
//读取SSL证书文件,配置SSLContext
try (InputStream ksInputStream = new FileInputStream(SecureChatServer.class.getResource("/").getPath() + "../resources/netty.jks")) {
KeyStore ks = KeyStore.getInstance("JKS");
ks.load(ksInputStream, "netty123".toCharArray());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, "netty123".toCharArray());
sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), null, null);
} catch (Exception e) {
e.printStackTrace();
}
final SecureChatServer endpoint = new SecureChatServer(sslContext);
ChannelFuture future = endpoint.start(new InetSocketAddress(port));
Runtime.getRuntime().addShutdownHook(new Thread(() -> endpoint.destroy()));
future.channel().closeFuture().syncUninterruptibly();
}
}
现在还有最后一步,将之前的HTML文件里面的WebSocket连接修改为wss://localhost:9999/ws,因为我们使用了Ssl连接,那自然WebSocket协议也要修改成Ssl连接的。<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>聊天室</title>
</head>
<body style="display: flex; justify-content: center; align-items: center">
<form onsubmit="return false;">
<h3>WebSocket聊天室:</h3>
<div>
<textarea id="responseText" style="width: 500px; height: 300px;"></textarea>
</div>
<div>
<input type="text" name="message" style="width: 430px" value="我是Netty">
<input type="button" value="发送消息" onclick="send(this.form.message.value)">
</div>
</form>
<script type="text/javascript">
var socket;
if (!window.WebSocket) {
window.WebSocket = window.MozWebSocket;
}
if (window.WebSocket) {
socket = new WebSocket("wss://localhost:9999/ws");
socket.onmessage = function(event) {
console.log(event);
var ta = document.getElementById('responseText');
ta.value = ta.value + '\n' + event.data
};
socket.onopen = function(event) {
var ta = document.getElementById('responseText');
ta.value = "连接开启!";
};
socket.onclose = function(event) {
var ta = document.getElementById('responseText');
ta.value = ta.value + "连接被关闭";
};
} else {
alert("你的浏览器不支持 WebSocket!");
}
function send(message) {
if (!window.WebSocket) {
return;
}
if (socket.readyState == WebSocket.OPEN) {
socket.send(message);
} else {
alert("连接没有开启.");
}
}
</script>
</body>
</html>
好了,代码都写完了,现在我们启动安全版的服务端,在浏览器输入https://localhost:9999/,一般浏览器都会提示这是不安全的连接,因为我们的证书是自签名的嘛。剩下的聊天功能和前面的一样,这里就不详说了。