基于SpringBoot聊天APP,后端Netty服务器的搭建流程

1,277 阅读17分钟

如果文章加载速度比较慢,可以访问我的个人博客:文章地址

1. Netty线程模型

在这里插入图片描述

2. Netty服务器搭建步骤

  • 1)构建一对主从线程池
  • 2)创建服务器启动类
  • 3)为服务器启动类并绑定Channel、主从线程组
  • 4)设置处理从线程池任务的助手类初始化器
  • 5)监听启动和关闭服务器

设置Channel初始化器

每一个Channel都是由一个或多个handler共同组成的管道(pipeline)

在这里插入图片描述
可以把管道看成一个大的拦截器,而每个handler就可以看成是若干个小的拦截器,当请求过来的时候,可以对请求进行一层一层的拦截!

3. 入门案例 1

3.1 Netty服务器启动类

/**
 * @Auther: csp1999
 * @Date: 2020/09/21/22:33
 * @Description: Netty服务器启动类:实现客户发送请求,服务器给予响应
 */
public class HelloNettyServer {

    public static final int PORT = 8888;

    public static void main(String[] args) throws InterruptedException {

        // 1.创建一对主从线程池
        // 主线程池(老板):用于接收客户端的请求连接,不做任何处理(大老板,只负责管理而不做具体劳动)
        EventLoopGroup boosGroup = new NioEventLoopGroup();
        // 从线程池(员工):主线程池会把任务全部丢给从线程池,让其去执行任务
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            // 2.创建服务器启动类
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            // 3.为服务器启动类并绑定Channel、主从线程组
            serverBootstrap.group(boosGroup, workerGroup)// 绑定主从线程组
                    .channel(NioServerSocketChannel.class)// 绑定channel:客户端与服务端通信的nio双向通道
                    // 4.设置处理从线程池任务的助手类初始化器
                    // 子处理器,用于处理从线程池交给的任务
                    .childHandler(new HelloNettyServerInitializer());
            
            // 5.监听启动和关闭服务器
            // 启动服务,并且设置端口号,同时启动方式为同步
            ChannelFuture channelFuture = serverBootstrap.bind(PORT).sync();
            // 监听关闭的channel,设置为同步方式
            channelFuture.channel().closeFuture().sync();
        } finally {
            // 优雅关闭
            boosGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

3.2 Netty服务初始化器

/**
 * @Auther: csp1999
 * @Date: 2020/09/22/17:28
 * @Description: Netty服务初始化器
 * Netty服务器启动类绑定Channel,并在channel子处理器中绑定该初始化器后,就会执行该初始化器的初始化方法
 */
public class HelloNettyServerInitializer extends ChannelInitializer<SocketChannel> {

    /**
     * 继承ChannelInitializer<SocketChannel>后需要重写的 channel初始化器方法
     * @param socketChannel
     * @throws Exception
     */
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        // 通过SocketChannel 获得对应的pipeline管道
        // 可以通过pipeline管道来为其添加handler
        ChannelPipeline channelPipeline = socketChannel.pipeline();

        /*
         * 通过管道添加 handler
         * HttpServerCodec 是由netty 自己提供的助手类,可以理解为拦截器
         * 当请求到服务器时,我们需要解码,响应到客户端做编码
         */
        // 添加一个handler ---> HttpServerCodec
        channelPipeline.addLast("HttpServerCodec",new HttpServerCodec());
        // 添加自定义助手类handler,给客户端浏览器渲染 hello netty~
        channelPipeline.addLast("CustomHandler",new CustomHandler());
    }
}

3.3 自定义助手类handler

/**
 * @Auther: csp1999
 * @Date: 2020/09/22/18:20
 * @Description: 自定义助手类handler:用于给客户端浏览器渲染 hello netty~
 * 需要继承SimpleChannelInboundHandler<HttpObject>抽象类,并重写相应方法!
 */
public class CustomHandler extends SimpleChannelInboundHandler<HttpObject> {

    /**
     * 从channel中数据读取和输出数据
     *
     * @param: ctx 上下文:可以通过上下文获取channel,也可以通过上下文,将响应结果渲染回客户端
     * @param: httpObject 消息的类型
     * @return: void
     * @create: 2020/9/22 18:25
     * @author: csp1999
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, HttpObject httpObject) throws Exception {
        // 获取channel
        Channel channel = ctx.channel();
        // 在控制台打印访问请求的远程地址
        System.out.println("远程地址:" + channel.remoteAddress());
        // 定义要向客户端发送的内容
        ByteBuf content = Unpooled.copiedBuffer("hello netty ~", CharsetUtil.UTF_8);
        // 构建http response: 用于把content响应到客户端的浏览器页面上!
        FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, content);
        // 为响应设置数据类型和内容长度
        response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");// 为响应设置数据类型
        response.headers().set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());// 为响应设置内容长度

        // 把响应渲染到html页面(客户端浏览器页面)上
        ctx.writeAndFlush(response);
    }

    @Override
    public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
        System.out.println("channel 注册...");
    }

    @Override
    public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
        System.out.println("channel 移除...");
    }

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        System.out.println("CustomHandler 添加...");
    }

    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        System.out.println("CustomHandler 移除...");
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("channel 活跃...");
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("channel 不活跃...");
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        System.out.println("channel 数据读取完毕...");
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        System.out.println("用户事件触发...");
    }

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
        System.out.println("channel 可写更改...");
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("channel 异常捕获...");
    }
}

3.4 启动测试

在这里插入图片描述
查看控制台打印:
在这里插入图片描述
测试成功!

3.5 案例 1流程总结

在这里插入图片描述

4. 入门案例 2 Netty + WebSocket

WebSocket:

  • 是一种持久化的协议
  • 可以主动实时的反馈服务端信息给客户端

4.1 搭建服务器启动类

/**
 * @Auther: csp1999
 * @Date: 2020/09/23/9:51
 * @Description: websocket + Netty 服务器启动类
 */
public class WebSocketServer {

    private static final int PORT = 8088;

    public static void main(String[] args) throws InterruptedException {

        // 1.创建一对主从线程池
        // 主线程池(老板):用于接收客户端的请求连接,不做任何处理(大老板,只负责管理而不做具体劳动)
        EventLoopGroup mainGroup = new NioEventLoopGroup();
        // 从线程池(员工):主线程池会把任务全部丢给从线程池,让其去执行任务
        EventLoopGroup subGroup = new NioEventLoopGroup();

        try {
            // 2.创建服务器启动类
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            // 3.为服务器启动类并绑定Channel、主从线程组
            serverBootstrap.group(mainGroup, subGroup)// 绑定主从线程组
                    .channel(NioServerSocketChannel.class)// 绑定channel:客户端与服务端通信的nio双向通道
                    // 4.设置处理从线程池任务的助手类初始化器
                    // 子处理器,用于处理从线程池交给的任务
                    .childHandler(new WebSocketServerInitializer());
            
            // 5.监听启动和关闭服务器
            // 启动服务,并且设置端口号,同时启动方式为同步
            ChannelFuture channelFuture = serverBootstrap.bind(PORT).sync();
            // 监听关闭的channel,设置为同步方式
            channelFuture.channel().closeFuture().sync();
        }finally {
            // 优雅关闭线程池
            mainGroup.shutdownGracefully();
            subGroup.shutdownGracefully();
        }
    }
}

4.2 创建服务初始化器

/**
 * @Auther: csp1999
 * @Date: 2020/09/23/9:59
 * @Description: WebSocket + Netty服务初始化器
 * Netty服务器启动类绑定Channel,并在channel子处理器中绑定该初始化器后,就会执行该初始化器的初始化方法
 */
public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel> {

    /**
     * 继承ChannelInitializer<SocketChannel>后需要重写的 channel初始化器方法
     * @param socketChannel
     * @throws Exception
     */
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {

        // 通过SocketChannel 获取客户端与服务端建立的管道(pipeline)
        // 可以通过pipeline管道来为其添加handler
        ChannelPipeline channelPipeline = socketChannel.pipeline();

        // 添加一个handler ---> HttpServerCodec: websocket基于http协议所需要的http编解码器
        channelPipeline.addLast(new HttpServerCodec());

        /*
         * 在http 上有一些数据流产生(数据流有大有小)我们需要对其进行处理
         * 因此 我们需要使用netty对该大数据流的读写提供支持
         * 这个类是:ChunkedWriteHandler
         */
        // 添加一个handler ---> ChunkedWriteHandler
        channelPipeline.addLast(new ChunkedWriteHandler());
        // 添加一个handler ---> HttpObjectAggregator: 对httpMessage进行聚合处理,聚合成response或request
        // 设置的最大内容数据长度maxContentLength: 1024 * 64
        channelPipeline.addLast(new HttpObjectAggregator(1024 * 64));

        /*
         * 基于http websocket 添加路由地址
         * WebSocketServerProtocolHandler 会帮助处理一些繁重复杂的事情
         * 比如处理握手动作的:handshaking(close、ping、ping):ping + ping = 心跳
         * 对于websocket来说 都是以frames 进行传输的 不同的数据类型对应的frames也不同
         */
        // 添加一个handler ---> WebSocketServerProtocolHandler:
        // websocketPath: /ws 可以自定义
        channelPipeline.addLast(new WebSocketServerProtocolHandler("/ws"));

        // 添加一个handler ---> 自定义的handler:用于处理聊天消息
        channelPipeline.addLast(new ChatHandler());
    }
}

4.3 自定义消息处理助手类(handler)

/**
 * @Auther: csp1999
 * @Date: 2020/09/23/10:49
 * @Description: 自定义助手类handler:用于处理聊天消息的handler
 * 需要继承SimpleChannelInboundHandler<TextWebSocketFrame>抽象类,并重写相应方法!
 * 由于传输数据的载体是 frame,这个 frame在 netty中是专门用于为websocket处理文本对象的,
 * frame就是消息的载体,这个类名为:TextWebSocketFrame
 */
public class ChatHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    // 用于记录和管理所有客户端(Client)的管道组ChannelGroup
    // 一个客户端(Client) 对应一个 Channel 通道~
    private static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

    /**
     * (客户端创建完成就会触发该方法):
     * 客户端与服务器端建立连接的时候就在管道组ChannelGroup 中添加该channel通道
     *
     * @param: ctx 上下文:可以通过上下文获取channel,也可以通过上下文,将响应结果渲染回客户端
     * @return: void
     * @create: 2020/9/23 11:01
     * @author: csp1999
     */
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        // 获取channel
        Channel channel = ctx.channel();
        // 在通道管理组ChannelGroup中添加该channel
        clients.add(channel);
    }

    /**
     * (浏览器关闭(用户离开客户端)就会触发该方法):
     * 客户端与服务器端断开连接的时候就从管道组ChannelGroup中移除该channel通道
     *
     * @param: ctx
     * @return: void
     * @create: 2020/9/23 11:01
     * @author: csp1999
     */
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        // 获取该客户端的channel
        Channel channel = ctx.channel();

        /*
         * 从通道管理组ChannelGroup中移除该channel
         *
         * 如果用户把相应的浏览器关闭也会自动移除该channel通道,
         * 所以即使不调用remove方法channel仍被移除,
         *
         * 因此:可以不写 clients.remove(channel); 这行代码
         */
        clients.remove(channel);
        System.out.println("客户端断开,channel 对应的长id为:" + ctx.channel().id().asLongText());
        System.out.println("客户端断开,channel 对应的短id为:" + ctx.channel().id().asShortText());
    }

    /**
     * 从channel中数据读取和输出数据
     *
     * @param ctx ctx 上下文:可以通过上下文获取channel,也可以通过上下文,将响应结果渲染回客户端
     * @param msg 客户端传输的消息:消息类型为TextWebSocketFrame
     * @throws Exception
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        // 获取客户端所传输的消息
        String content = msg.text();
        System.out.println("服务器接收到的消息内容:" + content);
        System.out.println("ip是:" + ctx.channel().remoteAddress());

        // 将数据刷新到客户端上面
        clients.writeAndFlush(
                new TextWebSocketFrame(
                        "[服务器在: ]" + LocalDateTime.now()
                                + "接收到消息,消息内容为:" + content
                )
        );
    }
}

4.5 WebSocket 相关 API

// 1.初始化websocket, ws: 和后端netty指定的协议前缀保持一致
var socket = new WebSocket("ws//ip:[port]");

// 2.生命周期方法:
onopen()// 客户端和服务端建立连接时,会触发该方法,只触发一次
onmessage()// 每当服务端向客户端推送消息时,就会触发此方法
onerror()// 客户端和服务端连接出错时候该方法触发
onclose()// 客户端和服务端连接关闭时触发该方法

// 3.主动方法
Socket.send()// 客户端向服务端发送消息
Socket.close()// 客户端端口与服务端的连接

4.6 前端整合WebSocket向服务端发送消息

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8" />
		<title>Netty 实时通信</title>
	</head>
	<body>
		<div id="app">
			发送消息: <input type="text" id="msgContent" />
			<button onclick="CHAT.chat()">消息发送</button>
			<hr>
			接收消息:
			<div id="receiveMsg"></div>
		</div>
	</body>
</html>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.4.0/jquery.min.js"></script>
<script src="https://unpkg.com/vue@next"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.20.0/axios.js"></script>
<script>
	window.CHAT = {
		socket: null,
		// 定义一个websocket初始化方法
		init: function() {
			// 判断浏览器是否支持websocket
			if (window.WebSocket) {
				// 1.如果支持,则创建websocket对象
				CHAT.socket = new WebSocket("ws://localhost:8088/ws")

				// 客户端和服务端建立连接时,会触发该方法,只触发一次
				CHAT.socket.onopen = function() {
					console.log("连接建立成功!")
				};

				// 客户端和服务端连接关闭时触发该方法
				CHAT.socket.close = function() {
					console.log("连接关闭!")
				};

				// 客户端和服务端连接出错时候该方法触发
				CHAT.socket.onerror = function() {
					console.log("连接发生异常!")
				};

			    // 每当服务端向客户端推送消息时,就会触发此方法
				CHAT.socket.onmessage = function(data) {
					// data为服务端响应到客户端的数据
					console.log("接收消息:" + data.data)
					var receiveMsg = document.getElementById("receiveMsg");
					var html = receiveMsg.innerHTML;
					// 新接收的消息内容嵌入到页面
					receiveMsg.innerHTML = html + "<br/>" + data.data;
				}
			} else {
				console.log("您的浏览器不支持websocket 协议")
			}
		},
		// 聊天发送方法:封装Socket.send()方法
		chat: function() {
			// 获取发送消息框中所输入的内容
			var msgContent = document.getElementById("msgContent").value;
			
			// 将客户端输入的消息内容发送给服务端
			CHAT.socket.send(msgContent);
		}
	}
	
	// 执行自定义的websocket初始化方法
	CHAT.init()
</script>

4.7 案例2 启动测试

前端页面测试向后端发送:
在这里插入图片描述
后端Netty服务器接收到消息,查看控制台打印:
在这里插入图片描述
测试成功!

4.8 消息发送流程分析

在这里插入图片描述

对于WebSocket + Netty 服务器端的内部处理流程,在案例 1 流程总结里面分析过了!

5. 扩展:聊天APP实战——聊天功能部分的代码

5.1 前端代码

5.2 后端代码

后端完整代码请参考:后端代码地址

消息相关实体类
  • DataContent:消息体实体类(内部封装了UserChatMsg)
  • UserChatMsg:消息内容实体类
WebSocketServer 服务器主启动类
/**
 * @Auther: csp1999
 * @Date: 2020/09/23/9:51
 * @Description: websocket + Netty 服务器启动类(这里将其嵌入springboot,伴随springboot主函数启动)
 */
@Component
public class WebSocketServer {

    /**
     * WebSocketServer 服务器实例化方法
     * (private)修饰,外部不能直接调用
     */
    private static class SingletionWSServer {
        static final WebSocketServer instance = new WebSocketServer();
    }

    /**
     * 外部可以调用的WebSocketServer 服务器实例
     * 在NettyBooter类中被调用,使其伴随springboot主函数启动的同时启动netty服务端
     *
     * (public)修饰,对外开放的方法
     *
     * @return
     */
    public static WebSocketServer getInstance() {
        return SingletionWSServer.instance;
    }

    /**
     * 主线程池
     */
    private EventLoopGroup mainGroup;

    /**
     * 从线程池
     */
    private EventLoopGroup subGroup;

    /**
     * 服务器驱动类
     */
    private ServerBootstrap serverBootstrap;

    /**
     * ChannelFuture
     */
    private ChannelFuture future;

    /**
     * 端口
     */
    private static final int PORT = 8088;

    /**
     * 服务器私网IP
     */
    //private static final String HOST = "172.17.218.21";// 服务器内网IP
    private static final String HOST = "192.168.1.6";// 本机IP

    public WebSocketServer() {
        // 主线程组:用于接收客户端的请求链接,不作任何处理
        mainGroup = new NioEventLoopGroup();
        // 从线程组:主线程组会把任务交给从线程组,让其做任务
        subGroup = new NioEventLoopGroup();
        // 定义服务器驱动类
        serverBootstrap = new ServerBootstrap();
        // 服务器驱动类绑定主从线程组
        serverBootstrap.group(mainGroup, subGroup)
                // 服务器驱动类绑定nio双向通道
                .channel(NioServerSocketChannel.class)
                // 服务器驱动类初始化子处理器(用于处理从线程池交给的任务)
                .childHandler(new WebSocketServerInitialzer());
    }

    public void start() {
        this.future = serverBootstrap.bind(PORT);//  服务器驱动类绑定端口和IP
        //this.future = serverBootstrap.bind(HOST,PORT);//  服务器驱动类绑定端口和IP
        if (future.isSuccess()) {
            System.out.println("启动 Netty 成功...");
        }
    }
}
WebSocketServerInitialzer 服务初始化器
/**
 * @Auther: csp1999
 * @Date: 2020/09/23/9:59
 * @Description: WebSocket + Netty服务初始化器
 * Netty服务器启动类绑定Channel,并在channel子处理器中绑定该初始化器后,就会执行该初始化器的初始化方法
 */
public class WebSocketServerInitialzer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        // 获取客户端与服务端建立的管道(pipeline)
        ChannelPipeline channelPipeline = socketChannel.pipeline();

        // websocket 基于http 协议所需要的 http编解码器
        channelPipeline.addLast(new HttpServerCodec());

        /*
         * 在http 上有一些数据流产生(数据流有大有小) 我们需要对其进行处理
         * 因此 我们需要使用netty 对该大数据流的读写提供支持
         * 这个类是:ChunkedWriteHandler
         */
        channelPipeline.addLast(new ChunkedWriteHandler());
        // 对httpMessage 进行聚合处理 , 聚合成 response 或者 request
        channelPipeline.addLast(new HttpObjectAggregator(1024 * 64));

        // ===========================增加心跳支持==============================
        /*
         * netty 自带的读写handler
         * 针对客户端,如果在1分钟时间内没有向服务端发送读写心跳(ALL),则主动断开连接
         * 如果有读空闲和写空闲,则不做任何处理
         * 参数1:读的空闲时间 单位s
         * 参数1:写的空闲时间 单位s
         * 参数1:读写的空闲时间 单位s
         */
        channelPipeline.addLast(new IdleStateHandler(8,16,32));

        // ===================================================================

        // 自定义的空闲状态监测的handler
        channelPipeline.addLast(new HeartBeatHandler());

        /*
         * 基于http websocket 添加 路由地址
         * WebSocketServerProtocolHandler 会帮助处理一些繁重复杂的事情
         * 比如处理握手动作:handshaking(close、ping、ping):ping+ping=心跳
         * 对于websocket来说 都是以frams 进行传输的 不同的数据类型对应的frams也不同
         */
        channelPipeline.addLast(new WebSocketServerProtocolHandler("/websocket"));

        // 自定义的handler
        channelPipeline.addLast(new ChatHandler());
    }
}
每个客户端的用户id对应一个Channel
/**
 * @Auther: csp1999
 * @Date: 2020/09/25/12:30
 * @Description: 用户与Channel管道关系实体类 (用于将客户端获取的管道Channel和用户的userid关联)
 */
public class UserChannelRelation {

    /**
     * 使用hashmap集合来存储 ---> 用户id与Channel管道关系
     * key: 用户id
     * value: Channel管道
     */
    private static HashMap<String, Channel> manage = new HashMap<>();

    public static void put(String senderId, Channel channel) {
        manage.put(senderId, channel);
    }

    public static Channel get(String senderId) {
        return manage.get(senderId);
    }

    // 用于打印输出测试
    public static void output() {
        for (Map.Entry<String, Channel> entry : manage.entrySet()) {
            System.out.println("UserId: " + entry.getKey()
                    + ",ChannelId: " + entry.getValue().id().asLongText());
        }
    }
}
ChatHandler 自定义消息处理助手类
/**
 * @Auther: csp1999
 * @Date: 2020/09/23/10:49
 * @Description: 自定义助手类handler:用于处理聊天消息的handler
 * 需要继承SimpleChannelInboundHandler<TextWebSocketFrame>抽象类,并重写相应方法!
 * 由于传输数据的载体是 frame,这个 frame在 netty中是专门用于为websocket处理文本对象的,
 * frame就是消息的载体,这个类名为:TextWebSocketFrame
 */
public class ChatHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    /**
     * 用于记录和管理所有客户端(Client)的管道组ChannelGroup
     * 一个客户端(Client) 对应一个 Channel 通道~
     */
    public static ChannelGroup userClients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

    /**
     * 从channel中数据读取和输出数据
     *
     * @param ctx ctx 上下文:可以通过上下文获取channel,也可以通过上下文,将响应结果渲染回客户端
     * @param msg 客户端传输的消息:消息类型为TextWebSocketFrame
     * @throws Exception
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        // 获取客户端所传输的消息
        String content = msg.text();

        // 1.获取客户端发来的消息,并将其传换成DataContent对象类型
        // 方式一:
        DataContent dataContent = JSON.parseObject(content, DataContent.class);
        // 方式二:DataContent dataContent = JsonUtils.jsonToPojo(content, DataContent.class);
        Integer action = dataContent.getAction();// 消息行为
        Channel channel = ctx.channel();// 客户端请求过来之后生成channel通道,通过ctx 上下文对象获取该通道

        // 2.判断消息类型(行为),根据不同的类型来处理不同的业务
        // MsgActionEnum.CONNECT.type == 1, "第一次(或重连)初始化连接" 行为
        if (action.equals(MsgActionEnum.CONNECT.type)) {
            // 2.1 当websocket 第一次open的时候 初始化channel,把channel 和发送者的userid关联起来,并存入UserChannelRelation
            String senderId = dataContent.getUserChatMsg().getSenderId();
            UserChannelRelation.put(senderId, channel);// hashMap集合

            //UserChannelRelation.output();// 测试

        } else if (action.equals(MsgActionEnum.CHAT.type)) {// 聊天消息
            // MsgActionEnum.CONNECT.type == 2, "聊天消息" 行为

            // 2.2收到聊天类型的消息 把聊天记录保存到数据库 同时标记消息的签收状态(未签收)
            UserChatMsg userChatMsg = dataContent.getUserChatMsg();
            String msgContent = userChatMsg.getMsg();
            String senderId = userChatMsg.getSenderId();
            String receiverId = userChatMsg.getReceiverId();
            // 调用userService 保存消息到数据库 并且消息标记为未签收
            // 注意:chatHandler 并未交由spring容器接管 因此无法使用注入方式获取userService
            // 因此:这里采用SpringUtils 工具类手动注入
            UserService userService = (UserService) SpringUtil.getBean("userServiceImpl");
            String msgId = userService.saveMsg(userChatMsg);// 聊天消息保存数据库
            userChatMsg.setMsgId(msgId);

            DataContent dataContentMsg = new DataContent();
            dataContentMsg.setUserChatMsg(userChatMsg);// 将用户聊天消息内容保存到完整消息内容实体类中

            // 发送消息
            // 从全局用户的channel关系中获取接收方的channel
            Channel receiveChannel = UserChannelRelation.get(receiverId);
            if (receiveChannel == null) {
                // 离线用户
            } else {
                // 当 receiveChannel 不为空的时候 从channelGroup 中查找对应的channel是否存在
                Channel findChannel = userClients.find(receiveChannel.id());
                if (findChannel != null) {
                    // 用户在线
                    // 方式一: 将消息内容写入通道并刷新
                    JSONObject jsonObject = new JSONObject();
                    String jsonString = jsonObject.toJSONString(dataContentMsg);
                    receiveChannel.writeAndFlush(
                            new TextWebSocketFrame(
                                    jsonString
                                    //方式二:JsonUtils.objectToJson(dataContent)
                            )
                    );
                } else {
                    // 离线用户
                }
            }
        } else if (action.equals(MsgActionEnum.SIGNED.type)) {// 消息签收
            // MsgActionEnum.CONNECT.type == 3, "消息签收" 行为

            // 2.3签收消息类型,针对具体的消息进行签收 修改数据库中对应消息的签收状态(已签收)
            UserService userService = (UserService) SpringUtil.getBean("userServiceImpl");
            // 扩展字段在 signed 类型消息中 代表需要去签收的消息id 逗号间隔
            String msgIdStr = dataContent.getExtend();
            String[] msgsId = msgIdStr.split(",");

            List<String> msgIdList = new ArrayList<>();
            for (String msgId : msgsId) {
                if (!StringUtils.isEmpty(msgId)) {
                    msgIdList.add(msgId);
                }
            }
            // 控制台打印输出查看
            msgIdList.forEach(msgId -> {
                //System.out.println(msgId);
            });

            if (!msgIdList.isEmpty()) {
                // 消息批量签收,更新标记
                userService.updateMsgSigned(msgIdList);
            }
        } else if (action.equals(MsgActionEnum.KEEPALIVE.type)) {// 客户端保持心跳
            // MsgActionEnum.CONNECT.type == 4, "客户端保持心跳" 行为

            // 2.4收到心跳类型的消息
            System.out.println("收到来自channel 为【" + channel + "】的心跳包");
        }
    }

    /**
     * (客户端创建完成就会触发该方法):
     * 客户端与服务器端建立连接的时候就在管道组ChannelGroup 中添加该channel通道
     *
     * @param: ctx 上下文:可以通过上下文获取channel,也可以通过上下文,将响应结果渲染回客户端
     * @return: void
     * @create: 2020/9/23 11:01
     * @author: csp1999
     */
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        // 获取channel
        Channel channel = ctx.channel();
        // 在通道管理组中添加 该channel
        userClients.add(channel);// 将存在心跳链接的channel 放入userClients统一录和管理
    }

    /**
     * (浏览器关闭(用户离开客户端)就会触发该方法):
     * 客户端与服务器端断开连接的时候就从管道组ChannelGroup中移除该channel通道
     *
     * @param: ctx 上下文:可以通过上下文获取channel,也可以通过上下文,将响应结果渲染回客户端
     * @return: void
     * @create: 2020/9/23 11:01
     * @author: csp1999
     */
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        // 获取channelId
        String chanelId = ctx.channel().id().asShortText();
        System.out.println("客户端被移除:channel id 为:" + chanelId);

        // 从userClients移出channel
        userClients.remove(ctx.channel());
        /*
         * 在通道管理组中移除 该channel
         * 如果用户把相应的浏览器关闭 也会自动移除该channel通道,
         * 所以即使不调用remove方法 channel仍被移除,
         * 所以可以不写 clients.remove(channel); 这行代码
             System.out.println("客户端断开,channel 对应的长id为:" + ctx.channel().id().asLongText());
             System.out.println("客户端断开,channel 对应的短id为:" + ctx.channel().id().asShortText());
         */
    }

    /**
     * 如果客户端与netty服务器连接发生异常,则将其从channelGroup 移除
     *
     * @param ctx   上下文:可以通过上下文获取channel,也可以通过上下文,将响应结果渲染回客户端
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        // 发生了异常后关闭连接,同时从channelGroup 移除
        ctx.channel().close();
        userClients.remove(ctx.channel());
    }
}
让Netty + WebSocket服务器伴随SpringBoot 主函数一起启动
/**
 * 在IOC的容器的启动过程,当所有的bean都已经处理完成之后,spring ioc容器会有一个发布事件的动作。
 * 让我们的bean实现ApplicationListener接口,这样当发布事件时,[spring]的ioc容器就会以容器的实例对象作为事件源类,
 * 并从中找到事件的监听者,此时ApplicationListener接口实例中的onApplicationEvent(E event)方法就会被调用,
 */
@Component
public class NettyBooter implements ApplicationListener<ContextRefreshedEvent> {

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        if (event.getApplicationContext().getParent() == null) {
            try {
                WebSocketServer.getInstance().start();// 跟随springboot主函数启动的同时启动netty服务端
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}