写在前头

在我们了解了 Netty 之后我们知道了 Netty 是一个网络框架,支持众多网络协议,其中就包括 WebSocket 协议。今天我们就使用 Netty 的这部分功能结合 SpringBoot 来构建一个实时通讯的应用。这里贴上一张图来看一下我们要到达的效果。

java 通讯框架 java 聊天框架_java

我们在写这个应用需要弄明白 Netty 的基本概念,SpringBoot 的基本使用以及 WebSocket 的基础知识。
- SpringBoot 开箱使用 (一)
- Java 网络框架 Netty(一)—— 概念
- WebSocket 学习笔记(一)

如果没有掌握相关的基础知识,建议可以先去学习一下自己不具备的相关知识。

整理思路

咱们需要写一个聊天室, 主要需要使用到的是 Netty 的 WebSocket 能力,就想上一篇中我们使用到了 Netty 的HTTP 能力一样,我们这里使用 SpringBoot 只是为了给浏览器返回静态的 Html 页面,当然我们也可以不需要 Spring 这一部分。直接用浏览器打开本地的 Html 文件查看。

Code

我们这里只看 Netty 构建 WebSocket 服务这一块的核心代码。

  • NettyConfig.java
public class NettyConfig {

    // 存储所有连接的 channel
    public static ChannelGroup group = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    // host name 和监听的端口号,需要配置到配置文件中
    public static String WS_HOST = "127.0.0.1";
    public static int WS_PORT = "9090";

}
  • WebSocketHandler.java
    和上一篇中构建 Http 的 Handler 一样,我们这里需要一个 WebSocketHandler 来处理进来的连接。我们让 WebSocketHandler 继承自 SimpleChannelInboundHandler
public class WebSocketHandler extends SimpleChannelInboundHandler<Object> {

    private WebSocketServerHandshaker handshaker;

    private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);

    private Gson gson = new Gson();

    // onmsg
    // 有信号进来时
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        if(msg instanceof FullHttpRequest){
            handHttpRequest(ctx, (FullHttpRequest) msg);
        }else if(msg instanceof WebSocketFrame){
            handWsMessage(ctx, (WebSocketFrame) msg);
        }
    }

    // onopen
    // Invoked when a Channel is active; the Channel is connected/bound and ready.
    // 当连接打开时,这里表示有数据将要进站。
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        NettyConfig.group.add(ctx.channel());
    }

    // onclose
    // Invoked when a Channel leaves active state and is no longer connected to its remote peer.
    // 当连接要关闭时
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        broadcastWsMsg( ctx, new WsMessage(-11000, ctx.channel().id().toString() ) );
        NettyConfig.group.remove(ctx.channel());
    }

    // onmsgover
    // Invoked when a read operation on the Channel has completed.
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        ctx.flush();
    }

    // onerror
    // 发生异常时
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        ctx.close();
    }

    // 集中处理 ws 中的消息
    private void handWsMessage(ChannelHandlerContext ctx, WebSocketFrame msg) {
        if(msg instanceof CloseWebSocketFrame){
            // 关闭指令
            handshaker.close(ctx.channel(), (CloseWebSocketFrame) msg.retain());
        }

        if(msg instanceof PingWebSocketFrame) {
            // ping 消息
            ctx.channel().write(new PongWebSocketFrame(msg.content().retain()));
        }else if(msg instanceof TextWebSocketFrame){
            TextWebSocketFrame message = (TextWebSocketFrame) msg;
            // 文本消息
            WsMessage wsMessage = gson.fromJson(message.text(), WsMessage.class);
            logger.info("接收到消息:" + wsMessage);
            switch (wsMessage.getT()){
                case 1: // 进入房间
                    // 给进入的房间的用户响应一个欢迎消息,向其他用户广播一个有人进来的消息
                    broadcastWsMsg( ctx, new WsMessage(-10001,wsMessage.getN()) );
                    AttributeKey<String> name = AttributeKey.newInstance(wsMessage.getN());
                    ctx.channel().attr(name);
                    ctx.channel().writeAndFlush( new TextWebSocketFrame( 
                        gson.toJson(new WsMessage(-1, wsMessage.getN()))));
                    break;

                case 2: // 发送消息
                    // 广播消息
                    broadcastWsMsg( ctx, new WsMessage(-2, wsMessage.getN(), wsMessage.getBody()) );
                    break;
                case 3: // 离开房间.
                    broadcastWsMsg( ctx, new WsMessage(-11000, wsMessage.getN(), wsMessage.getBody()) );
                    break;
            }
        }else {
            // donothing, 暂时不处理二进制消息
        }
    }

    // 处理 http 请求,WebSocket 初始握手 (opening handshake ) 都始于一个 HTTP 请求
    private void handHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) {
        if(!req.decoderResult().isSuccess() || !("websocket".equals(req.headers().get("Upgrade")))){
            sendHttpResponse(ctx, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, 
                HttpResponseStatus.BAD_REQUEST));
            return;
        }
        WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory("ws://" 
            + NettyConfig.WS_HOST + NettyConfig.WS_PORT, null, false);
        handshaker = factory.newHandshaker(req);
        if(handshaker == null){
            WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
        } else {
            handshaker.handshake(ctx.channel(), req);
        }
    }

    // 响应非 WebSocket 初始握手请求
    private void sendHttpResponse(ChannelHandlerContext ctx,  DefaultFullHttpResponse res) {
        if(res.status().code() != 200){
            ByteBuf buf = Unpooled.copiedBuffer(res.status().toString(), CharsetUtil.UTF_8);
            res.content().writeBytes(buf);
            buf.release();
        }
        ChannelFuture f = ctx.channel().writeAndFlush(res);
        if(res.status().code() != 200){
            f.addListener(ChannelFutureListener.CLOSE);
        }
    }

    // 广播 websocket 消息(不给自己发)
    private void broadcastWsMsg(ChannelHandlerContext ctx, WsMessage msg) {
        NettyConfig.group.stream()
                .filter(channel -> channel.id() != ctx.channel().id())
                .forEach(channel -> {
                    channel.writeAndFlush( new TextWebSocketFrame( gson.toJson( msg )));
                });
    }
}
  • ServerInitializer.java
    使用 ChannelPipeline 将各种 ChannerHandler 串起来
public class ServerInitializer extends ChannelInitializer<Channel> {

    @Override
    protected void initChannel(Channel channel) throws Exception {
        ChannelPipeline pipeline = channel.pipeline();
        pipeline.addLast("http-codec", new HttpServerCodec());
        pipeline.addLast("aggregator",new HttpObjectAggregator(65536));
        pipeline.addLast("http-chunked", new ChunkedWriteHandler());
        pipeline.addLast("handler", new WebSocketHandler());
    }
}
  • ServerBootStrap.java
    启动和销毁 Netty 的程序
@Component
public class ServerBootStrap {
    private final EventLoopGroup bossGroup = new NioEventLoopGroup();
    private final EventLoopGroup workGroup = new NioEventLoopGroup();
    private Channel channel;

    public ChannelFuture start(InetSocketAddress address) {
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ServerInitializer())
                .option(ChannelOption.SO_BACKLOG, 128)
                .childOption(ChannelOption.SO_KEEPALIVE, true);

        ChannelFuture future = bootstrap.bind(address).syncUninterruptibly();
        channel = future.channel();
        return future;
    }

    public void destroy() {
        if(channel != null) {
            channel.close();
        }
        NettyConfig.group.close();
        workGroup.shutdownGracefully();
        bossGroup.shutdownGracefully();
    }
}

由于我们需要使用到 SpringBoot,当然你也可以不用,你可以写个 main 方法,然后在里面 new 一个 ServerBootStrap 的实例调用其 start 方法即可,我们这里让 SpringBoot 的主程序 App.java 实现 CommandLineRunner 接口,然后在 run 方法中这样操作。

@SpringBootApplication
public class App implements CommandLineRunner{

    private static final Logger logger = LoggerFactory.getLogger(App.class);

    @Autowired
    private ServerBootStrap ws;

    public static void main(String[] args) throws Exception {
        // SpringApplication 将引导我们的应用,启动 Spring,相应地启动被自动配置的 Tomcat web 服务器。    
        SpringApplication.run(App.class, args);
    }

    // 注意这里的 run 方法是重载自 CommandLineRunner
    @Override
    public void run(String... args) throws Exception {
        logger.info("Netty's ws server is listen: " + NettyConfig.WS_PORT);
        InetSocketAddress address = new InetSocketAddress(NettyConfig.WS_HOST, NettyConfig.WS_PORT);
        ChannelFuture future = ws.start(address);

        Runtime.getRuntime().addShutdownHook(new Thread(){
            @Override
            public void run() {
                ws.destroy();
            }
        });

        future.channel().closeFuture().syncUninterruptibly();
    }

}

前端的代码,就不贴在这儿了,前端的代码逻辑很简单,在代码仓库中对应
- 页面 /resources/templates/chat.html
- js /resources/public/js/chat.js
- css /resources/public/css/chat.css

代码仓库

https://github.com/xiaop1ng/PlayWithSpringBoot