1. 引入依赖
<!-- https://mvnrepository.com/artifact/io.netty/netty-all -->
		<dependency>
		   <groupId>io.netty</groupId>
		   <artifactId>netty-all</artifactId>
		   <version>4.1.66.Final</version>
		</dependency>
		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
  1. 添加properties配置
netty.port= 10000
netty.ws= ws://192.168.1.204:${netty.port}${server.servlet.context-path}/ws
  1. 关闭SpringSecurity 安全检验
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //允许所有的请求通过
        http.authorizeRequests().antMatchers("/").permitAll();
        //关闭CSRF安全校验
        http.cors().and().csrf().disable();
    }

}
@Configuration
public class SpringWebMvcConfig implements WebMvcConfigurer {

    @Resource(name = "thymeleafViewResolver")
    private ThymeleafViewResolver thymeleafViewResolver;

    @Value("${server.servlet.context-path}")
    String contextPath;

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        if (thymeleafViewResolver != null) {
            Map<String, Object> vars = new HashMap<>(1);
            vars.put("contextPath", contextPath);
            thymeleafViewResolver.setStaticVariables(vars);
        }
        WebMvcConfigurer.super.configureViewResolvers(registry);
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedHeaders("*")
                //.exposedHeaders(HttpHeaders.ACCEPT)
                .allowCredentials(true)
                .allowedMethods("GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS")
                .maxAge(3600);
    }

}
  1. 定义NettyInitRunner 项目启动后运行
@Component
public class NettyInitRunner implements CommandLineRunner {

    @Value("${netty.port}")
    Integer nettyPort;

    @Value("${server.servlet.context-path}")
    String contextPath;

    @Override
    public void run(String... args){
        try {
            System.out.println("nettyServer starting ..." + nettyPort);
            new NettyServerConfig(nettyPort, contextPath).start();
        } catch (Exception e) {
            System.out.println("NettyServerError:" + e.getMessage());
        }
    }
}
  1. netty配置
import fangrong.com.cn.im.handler.MessageHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
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;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class NettyServerConfig {
    private final int port;
    private final String contextPath;

    public NettyServerConfig(int port, String contextPath) {
        this.port = port;
        this.contextPath = contextPath;
    }

    public void start() throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup();

        EventLoopGroup group = new NioEventLoopGroup();
        try {
            ServerBootstrap sb = new ServerBootstrap();
            sb.option(ChannelOption.SO_BACKLOG, 1024);
            // 绑定线程池
            sb.group(group, bossGroup)
                    // 指定使用的channel
                    .channel(NioServerSocketChannel.class)
                    // 绑定监听端口
                    .localAddress(this.port)
                    // 绑定客户端连接时候触发操作
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            log.info("收到新的客户端连接");
                            //websocket协议本身是基于http协议的,所以这边也要使用http解编码器
                            ch.pipeline().addLast(new HttpServerCodec());
                            //以块的方式来写的处理器(添加对于读写大数据流的支持)
                            ch.pipeline().addLast(new ChunkedWriteHandler());
                            //对httpMessage进行聚合
                            ch.pipeline().addLast(new HttpObjectAggregator(8192));
                            // ================= 上述是用于支持http协议的 =============

                            // 添加自己的handler  添加测试的聊天消息处理类
                            ch.pipeline().addLast(new MessageHandler());
                            /*websocket 服务器处理的协议,用于给指定的客户端进行连接访问的路由地址
                             * 本handler会帮你处理一些繁重的复杂的事
                             * 会帮你处理握手动作: handshaking(close, ping, pong) ping + pong = 心跳
                             * 对于websocket来讲,都是以frames进行传输的,不同的数据类型对应的frames也不同
                             */
                            ch.pipeline().addLast(new WebSocketServerProtocolHandler(contextPath + "/ws", null, true, 65536 * 10));
                        }
                    });
            // 服务器异步创建绑定
            ChannelFuture cf = sb.bind().sync();
            System.out.println(NettyServerConfig.class + " 启动正在监听: " + cf.channel().localAddress());
            // 关闭服务器通道
            cf.channel().closeFuture().sync();
        } finally {
            // 释放线程池资源
            group.shutdownGracefully().sync();
            bossGroup.shutdownGracefully().sync();
        }
    }
}
  1. 自定义MessageHandler
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import fangrong.com.cn.entity.MessageEntity;
import fangrong.com.cn.im.dto.SocketMessageDTO;
import fangrong.com.cn.service.IMessageService;
import fangrong.com.cn.utils.SpringUtil;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelId;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author 7788
 * @version 1.0
 * @date 2021/7/29 下午 3:27
 * @location wuhan
 */
@Component
public class MessageHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    private final Logger logger = LoggerFactory.getLogger(MessageHandler.class);
    /**
     * 存储已经登录用户的channel对象
     */
    public static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    /**
     * 存储用户id和用户的channelId绑定
     */
    public static ConcurrentHashMap<Integer, ChannelId> userMap = new ConcurrentHashMap<>();
    /**
     * 用于存储 群聊房间号 和 群聊成员的channel信息
     */
    public static ConcurrentHashMap<Integer, ChannelGroup> groupMap = new ConcurrentHashMap<>();

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        logger.info("与客户端建立连接,通道开启!");
        //添加到channelGroup通道组
        channelGroup.add(ctx.channel());
        ctx.channel().id();
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        //移除channelGroup 通道组
        for (Map.Entry<Integer, ChannelId> next : userMap.entrySet()) {
            if (next.getValue().equals(ctx.channel().id())) {
                logger.info(next.getKey() + " 与服务端断开连接,通道关闭!");
                userMap.remove(next.getKey());
            }
        }
        channelGroup.remove(ctx.channel());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        //首次连接是FullHttpRequest,把用户id和对应的channel对象存储起来
        if (msg instanceof FullHttpRequest) {
            FullHttpRequest request = (FullHttpRequest) msg;
            String uri = request.uri();
            Integer userId = getUrlParams(uri);
            userMap.put(getUrlParams(uri), ctx.channel().id());
            logger.info("登录的用户id是:{}", userId);
            //第1次登录,需要查询下当前用户是否加入过群,加入过群,则放入对应的群聊里
            //UserGroupRepositor userGroupRepositor = (UserGroupRepositor)SpringUtil.getBean("userGroupRepositor");
            //List<Integer> groupIds = userGroupRepositor.findGroupIdByUserId(userId);
            List<Integer> groupIds = new ArrayList<>();
            ChannelGroup cGroup = null;
            //查询用户拥有的组是否已经创建了
            for (Integer groupId : groupIds) {
                cGroup = groupMap.get(groupId);
                //如果群聊管理对象没有创建
                if (cGroup == null) {
                    //构建一个channelGroup群聊管理对象然后放入groupMap中
                    cGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
                    groupMap.put(groupId, cGroup);
                }
                //把用户放到群聊管理对象里去
                cGroup.add(ctx.channel());
            }
            //如果url包含参数,需要处理
            if (uri.contains("?")) {
                String newUri = uri.substring(0, uri.indexOf("?"));
                request.setUri(newUri);
            }
        } else if (msg instanceof TextWebSocketFrame) {
            IMessageService messageService = (IMessageService) SpringUtil.getBean("messageServiceImpl");
            //正常的TEXT消息类型
            TextWebSocketFrame frame = (TextWebSocketFrame) msg;
            SocketMessageDTO socketMessageDTO = JSON.parseObject(frame.text(), SocketMessageDTO.class);
            String messageType = socketMessageDTO.getMessageType();
            switch (messageType) {
                //====================================处理群聊任务=============
                case "group":
                    logger.info("客户端收到服务器群聊数据:{}", frame.text());
                    //推送群聊信息
                    groupMap.get(socketMessageDTO.getChatId()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessageDTO)));
                    MessageEntity groupEntity = new MessageEntity(socketMessageDTO.getMessageType(), socketMessageDTO.getUserId().toString(), socketMessageDTO.getChatId().toString(), socketMessageDTO.getMessage(), 1, new Date());
                    //保持到数据库
                    messageService.add(groupEntity);
                    break;
                //====================================处理私聊任务=============
                case "chat":
                    //消息类 默认为已读
                    MessageEntity chatEntity = new MessageEntity(socketMessageDTO.getMessageType(), socketMessageDTO.getUserId().toString(), socketMessageDTO.getChatId().toString(), socketMessageDTO.getMessage(), 1, new Date());
                    //处理私聊的任务,如果对方也在线,则发送消息
                    ChannelId channelId = userMap.get(socketMessageDTO.getChatId());
                    if (channelId != null) {
                        Channel ct = channelGroup.find(channelId);
                        if (ct != null) {
                            ct.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessageDTO)));
                            logger.info("对方在线 收到私聊数据:{}", frame.text());
                        } else {// 将记录保存在数据库中
                            chatEntity.setRead(0);
                            logger.info(socketMessageDTO.getChatId() + " 登录信息丢失,请重新登录");
                        }
                    } else {//处理私聊的任务,如果对方不在线, 则发推送 并将记录保存在数据库中
                        chatEntity.setRead(0);
                        logger.info("对方不在线 收到私聊数据:{}", frame.text());
                    }
                    //保持到数据库
                    messageService.add(chatEntity);
                    break;
                //====================================处理群发任务=============
                case "all":
                    // 1.1 在线的直接发送消息
                    channelGroup.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessageDTO)));
                    // 1.2 消息保存数据库
                    channelGroup.forEach(channel -> {
                        for (Map.Entry<Integer, ChannelId> next : userMap.entrySet()) {
                            if (next.getValue().equals(channel.id())) {
                                MessageEntity onlineEntity = new MessageEntity(socketMessageDTO.getMessageType(), socketMessageDTO.getUserId().toString(), next.getKey().toString(), socketMessageDTO.getMessage(), 1, new Date());
                                messageService.add(onlineEntity);
                            }
                        }
                    });
                    // 2.1 TODO 不在线的 数据库保存一条未读的消息
                    //MessageEntity offlineEntity = new MessageEntity();
                    //messageService.add(offlineEntity);
                    break;
                default:
                    logger.info("未知类型:{}", frame.text());
                    break;
            }
        }
        super.channelRead(ctx, msg);
    }

    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) {

    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        // 发生异常之后关闭连接(关闭channel),随后从ChannelGroup中移除
        ctx.channel().close();
        for (Map.Entry<Integer, ChannelId> next : userMap.entrySet()) {
            if (next.getValue().equals(ctx.channel().id())) {
                logger.info(next.getKey() + " 发生异常,通道关闭!");
                userMap.remove(next.getKey());
            }
        }
        channelGroup.remove(ctx.channel());
        logger.info(" netty 异常...... ");
    }

    private static Integer getUrlParams(String url) {
        if (!url.contains("=")) {
            return null;
        }
        String userId = url.substring(url.indexOf("=") + 1);
        return Integer.parseInt(userId);
    }
}
@Data
public class SocketMessageDTO {

    /**
     * 消息类型
     */
    private String messageType;
    /**
     * 消息发送者id
     */
    private Integer userId;
    /**
     * 消息接受者id或群聊id
     */
    private Integer chatId;
    /**
     * 消息内容
     */
    private String message;
}
  1. 页面接口跳转
@Controller
public class TestIMController {

    @Value("${netty.ws}")
    private String ws;


    /**
     * 登录页面
     */
    @RequestMapping("/login")
    public String login() {
        return "/login";
    }

    /**
     * 登录后跳转到测试主页
     */
    @PostMapping("/login.do")
    public String login(@RequestParam Integer userId, HttpSession session, Model model) {
        model.addAttribute("ws", ws);
        session.setAttribute("userId", userId);
        model.addAttribute("groupList", new ArrayList<>());
        return "/index";
    }
}
  1. 登录和聊天首页 页面
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form th:action= "${contextPath} + '/login.do'" method="post">
    登录(默认的4个用户id:[1,2,3,4])
    用户Id:<input type="number" name="userId"/>
    <input type="submit" value="登录"/>
</form>
</body>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<style type="text/css">
    .flexBox {display: flex;width: 100%;}
    .flexBox div {width: 50%;background-color: pink;}
    #messageBox ul {border: solid 1px #ccc;width: 600px;height: 400px}
</style>
<body>

<div class="flexBox">
    <div style="text-align: left;" th:text="'当前登录的用户:'+${session.userId}"></div>
</div>
<!-- 聊天息 -->
<div class="flexBox" id="messageBox">
    <ul th:id="${groupId}" th:each="groupId,iterObj : ${groupList}">
        <li th:text="房间号+${groupId}"></li>
    </ul>
    <ul id="chat">
        <li style="color: #0c31ec">聊天记录</li>
    </ul>
</div>
<div style="width:100%;border: solid 1px #ccc;">
    <form style="width: 40%;border: solid 1px red;margin: 0px auto">
        <h3>给好友发送数据</h3>
        <div>
            测试数据: 好友编号为 1-4<br/><br/>
            请输入好友编号 <input type="number" id="chatId" value="1"><br/><br/>
            <textarea id="message" style="width: 96%">呼叫呼叫</textarea>
        </div>
        <div>
            消息类型<input name="messageType" type="radio" value="chat" checked>私聊
            <a href="#" id="send">发送</a>
        </div>
    </form>
</div>
</body>
<!--在js脚本中获取作用域的值-->
<script th:inline="javascript">
    //获取session中的user
    var userId = [[${session.userId}]];
    //获取ws服务地址
    var ws = [[${ws}]]
</script>

<script type="text/javascript">
    var websocket;
    if (!window.WebSocket) {
        window.WebSocket = window.MozWebSocket;
    }
    if (window.WebSocket) {
        websocket = new WebSocket(ws + "?userId=" + userId);
        websocket.onmessage = function (event) {
            var json = JSON.parse(event.data);
            console.log(json)
            chat.onmessage(json);
        };
        websocket.onopen = function (event) {
            console.log("Netty-WebSocket服务器。。。。。。连接");
        };
        websocket.onclose = function (event) {
            console.log("Netty-WebSocket服务器。。。。。。关闭");
        };
    } else {
        alert("您的浏览器不支持WebSocket协议!");
    }
    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function () {
        if (websocket != null) {
            websocket.close();
        }
    };
</script>


<script>
    /**
     * sendMessage    发送消息推送给websocket对象
     * onmessage      接受来自服务端推送的消息,并显示在页面
     * */
    var chat = {
        sendMessage: function () {
            var message = document.getElementById("message").value; //发送的内容
            if (message == "") {
                alert('不能发送空消息');
                return;
            }
            if (!window.WebSocket) {
                return;
            }
            var chatId = document.getElementById("chatId").value; //好友Id

            var radio=document.getElementsByName("messageType");
            var messageType=null;   //  聊天类型
            for(var i=0;i<radio.length;i++){
                if(radio[i].checked==true) {
                    messageType=radio[i].value;
                    break;
                }
            }
            if (messageType == "chat") {
                if (chatId == userId) {
                    alert("不能给自己发私聊信息,请换个好友吧");
                    return;
                }
                var li = document.createElement("li");
                li.innerHTML = "自己: " + message
                var ul = document.getElementById("chat");
                ul.appendChild(li);
            }
            if (websocket.readyState == WebSocket.OPEN) {
                var data = {};
                data.chatId = chatId;
                data.message = message;
                data.userId = userId;
                data.messageType = messageType;
                websocket.send(JSON.stringify(data));
            } else {
                alert("和服务器连接异常!" + websocket.readyState);
            }
        },
        onmessage: function (jsonData) {
            var id;
            if (jsonData.messageType == "chat") {
                id = "chat";
            } else {
                id = jsonData.chatId;
            }
            console.log(id);
            var li = document.createElement("li");
            li.innerHTML = "好友 " + jsonData.userId + " 发来消息: " + jsonData.message;
            var ul = document.getElementById(id);
            ul.appendChild(li);
        }
    }

    document.onkeydown = keyDownSearch;

    function keyDownSearch(e) {
        // 兼容FF和IE和Opera
        var theEvent = e || window.event;
        var code = theEvent.keyCode || theEvent.which || theEvent.charCode;
        // 13 代表 回车键
        if (code == 13) {
            // 要执行的函数 或者点击事件
            chat.sendMessage();
            return false;
        }
        return true;
    }

    document.getElementById("send").onclick = function () {
        chat.sendMessage();
    }
</script>
</html>