【SpringBoot框架篇】18.使用Netty加websocket实现在线聊天功能

简介

  • Netty
    Netty 是一个基于NIO的客户、服务器端的编程框架,使用Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户、服务端应用。Netty相当于简化和流线化了网络应用的编程开发过程,例如:基于TCP和UDP的socket服务开发。
  • WebSocket
    WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
    WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

最终功能实现的效果图

在线客服功能包含2个页面,客服回复页面和游客页面,目前都支持pc端和移动端使用。

pc端

在这里插入图片描述

移动端

在这里插入图片描述

实战应用

引入依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--netty核心组件-->
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.36.Final</version>
        </dependency>

        <!--json转换器-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.14</version>
        </dependency>

        <!--模版引擎-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

配置文件

server:
  port: 8018

netty:
  #监听websocket连接的端口
  port: 11111
  #websocket连接地址  (此处要用电脑的ip,不然手机访问会出现问题)
  ws: ws://192.168.3.175:${
    
    netty.port}/ws

测试demo

消息内容实体类

public class SocketMessage {
    
    

    /**
     * 消息类型
     */
    private String messageType;
    /**
     * 消息发送者id
     */
    private Integer userId;
    /**
     * 消息接受者id或群聊id
     */
    private Integer chatId;
    /**
     * 消息内容
     */
    private String message;
	//....省略get set方法
}

处理请求的Handler类

public class TestWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    
    

    private final Logger logger = LoggerFactory.getLogger(TestWebSocketHandler.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<>();

    /**
     * 获取用户拥有的群聊id号
     */
    UserGroupRepository userGroupRepositor = SpringUtil.getBean(UserGroupRepository.class);

    @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 {
    
    
        logger.info("与客户端断开连接,通道关闭!");
        //添加到channelGroup 通道组
        channelGroup.remove(ctx.channel());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    
    
        //首次连接是FullHttpRequest,把用户id和对应的channel对象存储起来
        if (null != msg && 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次登录,需要查询下当前用户是否加入过群,加入过群,则放入对应的群聊里
            List<Integer> groupIds = userGroupRepositor.findGroupIdByUserId(userId);
            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) {
    
    
            //正常的TEXT消息类型
            TextWebSocketFrame frame = (TextWebSocketFrame) msg;
            logger.info("客户端收到服务器数据:{}", frame.text());
            SocketMessage socketMessage = JSON.parseObject(frame.text(), SocketMessage.class);
            //处理群聊任务
            if ("group".equals(socketMessage.getMessageType())) {
    
    
                //推送群聊信息
                groupMap.get(socketMessage.getChatId()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessage)));
            } else {
    
    
                //处理私聊的任务,如果对方也在线,则推送消息
                ChannelId channelId = userMap.get(socketMessage.getChatId());
                if (channelId != null) {
    
    
                    Channel ct = channelGroup.find(channelId);
                    if (ct != null) {
    
    
                        ct.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessage)));
                    }
                }
            }
        }
        super.channelRead(ctx, msg);
    }

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

    }

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

Netty服务启动类

public class NettyServer {
    
    
    private final int port;

    public NettyServer(int port) {
    
    
        this.port = port;
    }

    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 {
    
    
                            System.out.println("收到新连接");
                            //websocket协议本身是基于http协议的,所以这边也要使用http解编码器
                            ch.pipeline().addLast(new HttpServerCodec());
                            //以块的方式来写的处理器
                            ch.pipeline().addLast(new ChunkedWriteHandler());
                            ch.pipeline().addLast(new HttpObjectAggregator(8192));
                            //ch.pipeline().addLast(new OnlineWebSocketHandler());//添加在线客服聊天消息处理类
                            ch.pipeline().addLast(new TestWebSocketHandler());//添加测试的聊天消息处理类
                            ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", null, true, 65536 * 10));
                        }
                    });
            // 服务器异步创建绑定
            ChannelFuture cf = sb.bind().sync();
            System.out.println(NettyServer.class + " 启动正在监听: " + cf.channel().localAddress());
            // 关闭服务器通道
            cf.channel().closeFuture().sync();
        } finally {
    
    
            // 释放线程池资源
            group.shutdownGracefully().sync();
            bossGroup.shutdownGracefully().sync();
        }
    }
}

容器启动后加载Netty服务类

@Component
public class NettyInitListen implements CommandLineRunner {
    
    

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

    @Override
    public void run(String... args) throws Exception {
    
    
        try {
    
    
            System.out.println("nettyServer starting ...");
            System.out.println("http://127.0.0.1:" + serverPort + "/login");
            new NettyServer(nettyPort).start();
        } catch (Exception e) {
    
    
            System.out.println("NettyServerError:" + e.getMessage());
        }
    }
}

初始化用户群聊信息

public interface UserGroupRepository {
    
    
    List<Integer> findGroupIdByUserId(Integer userId);
}
@Component
public class UserGroupRepositoryImpl implements UserGroupRepository {
    
    
    /**
     * 组装假数据,真实环境应该重数据库获取
     */
    HashMap<Integer, List<Integer>> userGroup = new HashMap<>(4);

    {
    
    
        List<Integer> list = Arrays.asList(1, 2);
        userGroup.put(1, list);
        userGroup.put(2, list);
        userGroup.put(3, list);
        userGroup.put(4, list);
    }

    @Override
    public List<Integer> findGroupIdByUserId(Integer userId) {
    
    
        return this.userGroup.get(userId);
    }
}
```·

### web控制器层
@Controller
public class TestController {
    
    

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

    @Autowired
    UserGroupRepository userGroupRepository;

    /**
     * 登录页面
     */
    @RequestMapping("/login")
    public String login() {
    
    
        return "test/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", userGroupRepository.findGroupIdByUserId(userId));
        return "test/index";
    }
}

页面代码

登录页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/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: right;" 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>好友消息</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 ,房间号 1-2 )<br/>
            请输出好友编号或房间号 <input type="number" id="chatId" value="1"><br/>
            <textarea id="message" style="width: 100%">在不?</textarea>
        </div>
        <div>
            消息类型<input name="messageType" type="radio" checked value="group">群聊<input name="messageType" type="radio" value="chat">私聊
            <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或房间号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("不能给自己发私聊信息,请换个好友吧");
                }
                var li = document.createElement("li");
                li.innerHTML = "My:" + 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("和服务器连接异常!");
            }
        },
        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 = "用户id=" + 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>

测试功能

访问登录页面: http://localhost:8018/login
分别打开2个浏览器,一个用 id=1登录,另外一个用id=2登录
在这里插入图片描述
选择消息类型为群聊,然后输入房间号就可以发送群聊消息了
在这里插入图片描述

选择消息类型为私聊,然后输入好友Id就可以发送私聊信息
在这里插入图片描述

在线客服功能实现

消息内容实体类

public class OnlineMessage {
    
    

    /**
     * 消息发送者id
     */
    private String sendId;
    /**
     * 消息接受者id
     */
    private String acceptId;
    /**
     * 消息内容
     */
    private String message;

    /**
     * 头像
     */
    private String headImg;
    //省略get set方法
}

处理请求的Handler类

public class OnlineWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    
    
    private final Logger logger = LoggerFactory.getLogger(TestWebSocketHandler.class);

    /**
     * 存储已经登录用户的channel对象
     */
    public static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

    /**
     * 存储用户id和用户的channelId绑定
     */
    public static ConcurrentHashMap<String, ChannelId> userMap = 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 {
    
    
        logger.info("与客户端断开连接,通道关闭!");
        //添加到channelGroup 通道组
        channelGroup.remove(ctx.channel());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    
    
        //首次连接是FullHttpRequest,把用户id和对应的channel对象存储起来
        if (null != msg && msg instanceof FullHttpRequest) {
    
    
            FullHttpRequest request = (FullHttpRequest) msg;
            String uri = request.uri();
            String userId = getUrlParams(uri);
            //登录后把用户id和channel关联上
            userMap.put(userId, ctx.channel().id());
            logger.info("登录的用户id是:{}", userId);
            //如果url包含参数,需要处理
            if (uri.contains("?")) {
    
    
                String newUri = uri.substring(0, uri.indexOf("?"));
                request.setUri(newUri);
            }

        } else if (msg instanceof TextWebSocketFrame) {
    
    
            //正常的TEXT消息类型
            TextWebSocketFrame frame = (TextWebSocketFrame) msg;
            logger.info("客户端收到服务器数据:{}", frame.text());
            OnlineMessage onlineMessage = JSON.parseObject(frame.text(), OnlineMessage.class);
            //处理私聊的任务,如果对方也在线,则推送消息
            ChannelId channelId = userMap.get(onlineMessage.getAcceptId());
            if (channelId != null) {
    
    
                Channel ct = channelGroup.find(channelId);
                if (ct != null) {
    
    
                    ct.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(onlineMessage)));
                }
            }
        }
        super.channelRead(ctx, msg);
    }

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

    }

    /**
     * 解析url中的参数
     * @return 获取用户的id
     */
    private String getUrlParams(String url) {
    
    
        if (!url.contains("=")) {
    
    
            return null;
        }
        String userId = url.substring(url.indexOf("=") + 1);
        return userId;
    }
}

Netty服务启动类

和测试demo用的一致,只是handler处理类不一致,把测试的注释掉,在线聊天的handler放开,加载netty服务类用同一个即可
在这里插入图片描述

web控制器

@Controller
public class OnlineController {
    
    

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

    /**
     * 客服界面
     */
    @GetMapping(value = {
    
    "/index", "/customer","/"})
    public String index(Model model) {
    
    
        model.addAttribute("ws", ws);
        return "customer";
    }

    /**
     * 游客页面
     */
    @GetMapping("/tourist")
    public String tourist(Model model) {
    
    
        model.addAttribute("ws", ws);
        return "tourist";
    }
}

测试

在线客户的html代码和js代码请参考本博客配套代码

访问游客页面: http://localhost:8018/tourist

在这里插入图片描述

访问客服页面: http://localhost:8018/customer
在这里插入图片描述

项目配套代码

github地址

要是觉得我写的对你有点帮助的话,麻烦在github上帮我点 Star

【SpringBoot框架篇】其它文章如下,后续会继续更新。

猜你喜欢

转载自blog.csdn.net/ming19951224/article/details/108555917