Netty4实战第十一章:WebSockets

本文介绍了使用Netty实现WebSocket聊天室的过程,包括处理HTTP请求、升级到WebSocket连接及消息转发等关键步骤,并展示了如何轻松地添加加密功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本章主要内容

  • WebSocket
  • ChannelHandler,Decoder和Encoder
  • 启动基于Netty的应用
  • 测试WebSocket
  现在很多地方都可以看到“real-time-web”这个术语。大部分用户在访问网站时希望能实时获取信息。

  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文件的内容。
  当然,上边这个ChannelHandler只处理了普通HTTP的请求,我们还需要处理WebSocket的请求,然后将收到的数据转发,下一小节就来学习这个知识点。

2.2、处理WebSocket请求

  Netty提供了6种不同的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,
使用它你接收的使用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 指定生成的密钥库文件
  然后需要填写一些信息,最后确认一步输入“y”即可,如下图。
  有的情况下会出现无权限访问文件的问题,就要看看你的用户有没有目录权限了,Windows系统的同学可以打开具有管理员权限的CMD窗口解决这种问题。
  然后将生成的证书复制到指定目录,我这里还是放到项目的Resources目录下,然后编写安全版本的启动类。
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/,一般浏览器都会提示这是不安全的连接,因为我们的证书是自签名的嘛。剩下的聊天功能和前面的一样,这里就不详说了。

五、总结

  这一章我们主要学习了如何使用Netty提供的WebSocket开发一个聊天室功能。学习了如何处理HTTP请求,已经如何转到WebSocket请求,并知道如何转发数据。也应该明白了为什么WebSocket相比较HTTP比较重要,当然也大概能知道WebSocket的使用场景。
  下一章中我们将学习Web 2.0其他的东西,这些东西可以增加你的应用更有魅力。下一章主要内容就是SPDY,看看它的优点,已经适合什么样的业务场景。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值