WebSocket是一种高级网络协议,旨在提高Web应用程序的性能和响应能力。 我们将通过编写示例应用程序来探索Netty对它们的支持。
在第12章中,您将学习如何使用WebSocket实现双向数据传输,方法是构建一个聊天室服务器,其中多个浏览器客户端可以实时通信。 您还将看到如何通过检测客户端是否支持它,从应用程序中的HTTP切换到WebSocket协议。
我们将在第13章中总结第3部分,研究Netty对用户数据报协议(UDP)的支持。在这里,您将构建一个广播服务器和监视器客户端,可以适应许多实际用途。
本章讲介绍:
如果您关注网络技术的最新发展,您很可能会遇到实时网络短语,如果您有工程领域的实时应用程序经验,您可能会对这个术语的含义持怀疑态度。
因此,我们首先要澄清的是,这不是所谓的硬实时服务质量(QoS),其中保证了在指定时间间隔内交付计算结果。 仅仅HTTP的请求/响应设计使得这个问题非常严重,因为过去设计的方法都没有提供令人满意的解决方案。
虽然已经有一些关于正式定义定时Web服务语义的学术讨论,但普遍接受的定义似乎并未出现。 所以现在我们将接受来自维基百科的以下非权威性描述:
The real-time web is a network web using technologies and practices that
enable users to receive information as soon as it is published by its
authors, rather than requiring that they or their software check a source
periodically for updates.
实时网络是一种使用技术和实践的网络网络,使用户能够在作者发布信息后立即接收信息,而不是要求他们或他们的软件定期检查信息源以进行更新。
简而言之,一个成熟的实时网络可能不会即将到来,但其背后的想法正在推动对几乎即时访问信息的不断增长的期望。 我们将在本章中讨论的WebSocket协议是朝着这个方向的良好支持的步骤。
WebSocket协议是从头开始设计的,旨在为Web上的双向数据传输问题提供实用的解决方案,允许客户端和服务器随时传输消息,从而要求它们异步处理消息接收。 (最新的浏览器支持WebSocket作为HTML5的客户端API。)
Netty对WebSocket的支持包括所有正在使用的主要实现,因此在您的下一个应用程序中采用它非常简单。 与Netty一样,您可以完全使用协议,而无需担心其内部实现细节。 我们将通过创建基于WebSocket的实时聊天应用程序来证明这一点。
我们的示例应用程序将通过使用WebSocket协议实现基于浏览器的聊天应用程序来演示实时功能,例如您可能在Facebook的文本消息功能中遇到过。 我们将通过允许多个用户同时相互通信来进一步发展。
图12.1说明了应用程序逻辑:
这就是您期望聊天室工作的方式:每个人都可以与其他人交谈。 在我们的示例中,我们将仅实现服务器端,客户端是通过网页访问聊天室的浏览器。 正如您将在接下来的几页中看到的那样,WebSocket使编写此服务器变得简单。
称为升级握手的机制用于从标准HTTP或HTTPS协议切换到WebSocket。 因此,使用WebSocket的应用程序将始终以HTTP / S开头,然后执行升级。 当恰好发生这种情况时,应用程序是特定的; 它可能是在启动时或者在请求特定URL时。
我们的应用程序采用以下约定:如果请求的URL以/ ws结尾,我们将协议升级到WebSocket。 否则,服务器将使用基本HTTP / S. 连接升级后,所有数据都将使用WebSocket传输。 图12.2说明了服务器逻辑,它一如Netty,将由一组ChannelHandler实现。 在我们解释用于处理HTTP和WebSocket协议的技术时,我们将在下一节中对它们进行描述。
首先,我们将实现处理HTTP请求的组件。 此组件将提供访问聊天室的页面,并显示已连接客户端发送的消息。 代码清单12.1包含了这个HttpRequestHandler的代码,它为SimpleHttpRequest消息扩展了SimpleChannelInboundHandler。 请注意channelRead0() 的实现如何转发URI / ws的任何请求。
// Extends SimpleChannelInboundHandler to handle FullHttpRequest messages
public class HttpRequestHandler
extends SimpleChannelInboundHandler<FullHttpRequest> {
private final String wsUri;
private static final File INDEX;
static {
URL location = HttpRequestHandler.class
.getProtectionDomain()
.getCodeSource().getLocation();
try {
String path = location.toURI() + "index.html";
path = !path.contains("file:") ? path : path.substring(5);
INDEX = new File(path);
} catch (URISyntaxException e) {
throw new IllegalStateException("Unable to locate index.html", e);
}
}
public HttpRequestHandler(String wsUri) {
this.wsUri = wsUri;
}
@Override
public void channelRead0(ChannelHandlerContext ctx,
FullHttpRequest request) throws Exception {
// If a WebSocket upgrade is requested, increments the reference count(retain) and passes it to the next ChannelInboundHandler
if(wsUri.equalsIgnoreCase(request.getUri())) {
ctx.fireChannelRead(request.retain());
} else {
// Handlers 100 Continue requests in conformity with HTTP 1.1
if(HttpHeaders.is100ContinueExpected(request)) {
send100Continue(ctx);
}
// Reads index.html
RandomAccessFile file = new RandomAccessFile(INDEX, "r");
HttpResponse response = new DefaultHttpResponse(
request.getProtocolVersion(), HttpResponseStatus.OK);
response.headers().set(
HttpHeaders.Names.CONTENT_TYPE,
"text/plain; charset=UTF-8");
boolean keepAlive = HttpHeaders.isKeepAlive(request);
// If keepalive is requested, adds the required headers
if(keepAlive) {
response.headers().set(
HttpHeaders.Names.CONTENT_LENGTH, file.length());
response.headers().set(HttpHeaders.Names.CONNECTION,
HttpHeaders.Values.KEEP_ALIVE);
}
// Writes the HttpResponse to the client
ctx.write(response);
// Writes index.html to the client
if (ctx.pipeline().get(SslHandler.class) == null) {
ctx.write(new DefaultFileRegion(file.getChannel(), 0, file.length()));
} else {
ctx.write(new ChunkedNioFile(file.getChannel()));
}
// Writes and flushes the LastHttpContent to the client
ChannelFuture future = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
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();
}
}
如果HTTP请求引用URI / ws,则HttpRequestHandler在FullHttpRequest上调用 retain() 并通过调用 fireChannelRead(msg) 将其转发到下一个 ChannelInboundHandler . 需要调用retain(),因为在 channelRead() 完成后,它将调用 FullHttpRequest 上的 release() 来释放其资源。(请参阅第6章中对SimpleChannelInboundHandler的讨论。)
如果客户端发送HTTP 1.1标头Expect:100-continue,则HttpRequestHandler发送100 Continue响应。 在设置标头后,HttpRequestHandler将HttpResponse d写回客户端。 这不是FullHttpResponse,因为它只是响应的第一部分。 此外,此处不调 writeAndFlush()。
这是在最后完成的。
如果既不需要加密也不需要压缩,则可以通过将index.html e的内容存储在DefaultFileRegion中来实现最高效率。 这将利用零拷贝来执行传输。 因此,您需要检查ChannelPipeline中是否存在SslHandler。 或者,您使用ChunkedNioFile。
HttpRequestHandler写一个LastHttpContent来标记响应的结束。 如果未请求keepalive,则HttpRequestHandler将ChannelFutureListener添加到上次写入的 ChannelFuture 并关闭连接。 这是您调用 writeAndFlush() 来刷新所有以前写入的消息的地方。
这代表聊天服务器的第一部分,它管理纯HTTP请求和响应。 接下来我们将处理WebSocket帧,它们传输实际的聊天消息。
WebSocket Frames WebSockets以帧的形式传输数据,每个帧都代表消息的一部分。 完整的消息可能包含许多帧。
由IETF发布的WebSocket RFC定义了六个帧; Netty为每个人提供POJO实施。 表12.1列出了帧类型并描述了它们的用法。
Frame type | 描述 |
---|---|
BinaryWebSocketFrame | 包含 binary data |
TextWebSocketFrame | 包含 text data |
ContinuationWebSocketFrame | 包含 text 或者 binary 数据,他属于前一个 BinaryWebSocketFrame 或者 TextWebSocketFrame |
CloseWebSocketFrame | 表示CLOSE请求,包含关闭状态代码和短语 |
PingWebSocketFrame | 请求传输PongWebSocketFrame |
PongWebSocketFrame | 作为响应发送给一个 PingWebSocketFrame |
我们的聊天程序将会使用下面这些 frame 类型:
TextWebSocketFrame是我们实际需要处理的唯一一个。 根据WebSocket RFC,Netty提供了一个WebSocketServerProtocolHandler来管理其他的。
下面的清单显示了TextWebSocketFrames的ChannelInboundHandler,它还将跟踪其ChannelGroup中的所有活动WebSocket连接。
// Extends SimpleChannelInboundHandler and handle TextWebSocketFramemessages
pyblic class TextWebSocketFrameHandler
extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private final ChannelGroup group;
public TextWebSocketFrameHandler(ChannelGroup group) {
this.group = group;
}
// Overrides userEventTriggered() to handle custome events
@Override
public void userEventTriggered(ChannelHandlerContext ctx,
Object evt) throws Exception {
if(evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE)
{
ctx.pipeline().remove(HttpRequestHandler.class);
// Notifies all connected WebSocket clients that new Client has connected
group.writeAndFlush(new TextWebSocketFrame(
"Client" + ctx.channel() + " joined"));
group.add(ctx.channel());
} else {
super.userEventTriggered(ctx, evt);
}
}
@Override
public void channelRead0(ChannelHandlerContext ctx,
TextWebSocketFrame msg) throws Exception {
// Increments the reference count of the message and writes it to all connected clients in the ChannelGroup
group.writeAndFlush(msg.retain());
}
}
TextWebSocketFrameHandler只有很少的职责。 当与新客户端的WebSocket握手成功完成时,它通过写入ChannelGroup中的所有Channel来通知所有连接的客户端,然后将新Channel添加到ChannelGroup.
如果收到TextWebSocketFrame,它会调用retain()并使用writeAndFlush()将其传输到ChannelGroup,以便所有连接的WebSocket Channel都接收它。
和以前一样,调用 retain() 是必需的,因为当 channelRead0() 返回时TextWebSocketFrame 的引用计数将减少。 由于所有操作都是异步的,因此writeAndFlush() 可能会在以后完成,并且它不能访问已变为无效的引用。
由于Netty在内部处理大部分剩余功能,因此现在唯一要做的就是为每个创建的新Channel初始化ChannelPipeline。 为此,我们需要一个ChannelInitializer。
如您所知,要在 ChannelPipeline 中安装 ChannelHandler,您需要扩展 ChannelInitializer并实现 initChannel() 。 以下清单显示了生成的ChatServerInitializer的代码。
// Extends ChannelInitializer
public class ChatServerIntializer extends ChannelIntializer<Channel> {
private final ChannelGroup group;
public ChatServerIntializer(ChannelGroup group) {
this.group = group;
}
// Adds all needed ChannelHandlers to the ChannelPipeline
@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() 的调用通过安装所有必需的 ChannelHandler 来设置新注册 Channel 的ChannelPipeline。 表12.2总结了这些内容及其各自的职责。
ChannelHandler | Responsibility |
---|---|
HttpServerCodec | 将字节解码为HttpRequest,HttpContent,和LastHttpContent。 编码HttpRequest,HttpContent和LastHttpContent到字节。 |
ChunkedWriteHandler | 写入文件的内容。 |
HttpObjectAggregator | 将HttpMessage及其后续HttpContent聚合到单个FullHttpRequest或FullHttpResponse中(取决于它是否用于处理请求或响应)。 安装此选项后,管道中的下一个ChannelHandler将仅接收完整的HTTP请求。 |
HttpRequestHandler | 处理FullHttpRequest(未发送到 /ws URI)。 |
WebSocketServerProtocolHandler | 根据WebSocket规范的要求,处理WebSocket升级握手,PingWebSocketFrames,PongWebSocketFrames和CloseWebSocketFrames。 |
TextWebSocketFrameHandler | 处理TextWebSocketFrames和握手完成事件 |
Netty 的 WebSocketServerProtocolHandler 处理所有强制 WebSocket 帧类型和升级握手本身。 如果握手成功,则将所需的ChannelHandler添加到管道中,并删除不再需要的那些。
升级前的管道状态如图12.3所示。 这表示ChatServerInitializer初始化后的ChannelPipeline。
升级完成后,WebSocketServerProtocolHandler将替换带有WebSocketFrameDecoder的HttpRequestDecoder和带有WebSocketFrameEncoder的HttpResponseEncoder。 为了最大限度地提高性能,它将删除WebSocket连接不需要的任何ChannelHandler。 这些将包括图12.3中所示的HttpObjectAggregator和HttpRequestHandler。
图12.4显示了这些操作完成后的ChannelPipeline。 请注意,Netty目前支持四种版本的WebSocket协议,每种版本都有自己的实现类。 根据客户端(此处为浏览器)支持的内容,自动执行正确版本的WebSocketFrameDecoder和WebSocketFrameEncoder的选择。
图片的最后一部分是引导服务器并安装ChatServerInitializer的代码。 这将由ChatServer类处理,如此处所示。
public class ChatServer {
// Creates DefaultChannelGroup that will hold all connected WebSocket channels
private final ChannelGroup channelGroup = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE);
private final EventLoopGroup group = new NioEventLoopGroup();
private Channel channel;
public ChannelFuture start(InetSocketAddress address) {
// Bootstraps the server
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;
}
// Creates the ChatServerInitializer
protected ChannelInitializer<Channel> createInitializer(ChannelGroup group) {
return new ChatServerInitializer(group);
}
// Handles server shutdown and releases all resources
public void destroy() {
if(channel != null) {
channel.close();
}
channelGroup.close();
group.shutdownGracefully();
}
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Please give port as argument.");
System.exit(1);
}
int port = Integer.praseInt(args[0]);
final ChatServer endpoint = new ChatServer();
ChannelFuture future = endpoint.start( new InetSocketAddress(port));
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
endpoint.destroy();
}
});
future.channel().closeFuture().syncUninterruptibly();
}
}
这完成了应用程序本身。 现在让我们来测试吧。
chapter12目录中的示例代码包含构建和运行服务器所需的一切。 (如果您尚未设置包括Apache Maven在内的开发环境,请参阅第2章中的说明。)
我们将使用以下Maven命令来构建和启动服务器:
mvn -PChatServer clean package exec:exec
项目文件pom.xml配置为在端口9999上启动服务器。要使用其他端口,您可以编辑文件中的值或使用System属性覆盖它:
mvn -PChatServer -Dport=1111 clean package exec:exec
以下清单显示了命令的主要输出(已删除非必要行)。
您可以通过将浏览器指向http:// localhost:9999来访问该应用程序。 图12.5显示了Chrome浏览器中的UI。
该图显示了两个连接的客户端。 第一个是使用顶部的界面连接。 第二个客户端通过底部的Chrome浏览器命令行连接。 您会注意到两个客户端都发送了消息,并且每条消息都显示在两个客户端上。
这是一个非常简单的演示,说明WebSocket如何在浏览器中实现实时通信。
在现实生活中,您很快就会被要求为此服务器添加加密。 使用Netty,只需将SslHandler添加到ChannelPipeline并进行配置即可。 以下清单显示了如何通过扩展ChatServerInitializer来创建SecureChatServerInitializer来完成此操作。
// Adding encryption to the ChannelPipeline
// Extends ChatServerInitializer to add encryption
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 {
// Calls the parent's initChannel()
super.initChannel(ch);
SSLEngine engine = context.newEngine(ch.alloc());
// Adds the SslHandler to the ChannelPipeline
ch.pipeline().addFirst(new SslHandler(engine));
}
}
最后一步是调整ChatServer以使用SecureChatServerInitializer,以便在管道中安装SslHandler。 这给了我们这里显示的SecureChatServer。
// Adding encryption to the ChatServer
// SecureChatServer extends ChatServer to support encryption
public class SecureChatServer extends ChatServer {
private final SslContext context;
public SecureChatServer(SslContext context) {
this.context = context;
}
@Override
protected ChannelInitializer<Channel> createInitializer(ChannelGroup group) {
// Returns the previously created SecureChatServerInitializer to enable encryption
return new SecureChatServerInitializer(group, context);
}
public static void main(String[] args) throws Exception {
if(args.length != 1) {
System.err.println("Please give port as argument");
System.exit(1);
}
int port = Integer.parseInt(args[0]);
SelfSignedCertificate cert = new SelfSignedCertificate();
SslContext context = SslContext.newServerContext(
cert.certificate(), cert.privateKey());
final SecureChatServer endpoint = new SecureChatServer(context);
ChannelFuture future = endpoint.start(new InetSocketAddress(port));
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
endpoint.destroy();
}
});
future.channel().closeFuture().syncUninterruptibly();
}
}
这就是为所有通信启用SSL / TLS加密所需的全部内容。 和以前一样,您可以使用Apache Maven来运行应用程序。 它还将检索任何所需的依赖项。
现在,您可以从其HTTPS URL访问SecureChatServer:https://localhost:9999。
在本章中,您学习了如何使用Netty的WebSocket实现来管理Web应用程序中的实时数据。 我们介绍了支持的数据类型,并讨论了您可能遇到的限制。 虽然在所有情况下都可能无法使用WebSocket,但应该清楚它代表了一个重要的进步网络技术。