基于 Netty 的 Websocket 实现

Netty 是一个广泛使用的 Java 网络编程框架,它提供了一个易于使用的 API 客户端和服务器,具有并发高、传输快、封装好等优点。

Netty的传输快其实也是依赖了 NIO 的零拷贝特性,当他需要接收数据的时候,他会在堆内存之外开辟一块内存,数据就直接从 IO 读到了那块内存中去,在 netty 里面通过 ByteBuf 可以直接对这些数据进行直接操作,从而加快了传输速度。Netty 对 NIO 进行了封装,代码简洁,远远优于传统Socket编程。

本篇博客基于上一篇博客的 demo 代码进行改造,所实现的功能一摸一样,看不出显著的效果。由于采用 netty 的 api 实现比较复杂,因此我们采用第三方开源依赖包 netty-websocket-spring-boot-starter 实现 websocket 和 netty 的集成,大大简化了代码的实现。


一、代码实现细节

新建一个名称为 springboot_netty_websocket 的 springboot 工程,结构如下:

image

在 pom 文件中引入 netty-websocket-spring-boot-starter 依赖包,具体如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.jobs</groupId>
    <artifactId>springboot_netty_websocket</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.5</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
        </dependency>
        <!--引入第三方提供的 netty WebSocket 依赖包-->
        <dependency>
            <groupId>org.yeauty</groupId>
            <artifactId>netty-websocket-spring-boot-starter</artifactId>
            <version>0.12.0</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.4.5</version>
            </plugin>
        </plugins>
    </build>
</project>

由于 netty 服务需要额外的启动端口,因此在 application.yml 中配置了 netty 的启动端口,整体内容如下:

server:
  port: 8086

# 集成了 netty 的 websocket 需要使用额外的端口
ws-netty:
  port: 18086

前端页面连接与 netty 集成的 websocket 服务,需要连接 netty 的端口(即上面配置的 18086 端口),整体前端内容如下所示:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>websocket</title>
    <script src="./jquery-3.7.1.min.js"></script>
    <script>
        //获取浏览器地址栏 get 请求参数
        function GetQueryString(name) {
            var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
            var r = window.location.search.substr(1).match(reg);
            if (r != null) return unescape(r[2]);
            return null;
        }

        var socket;
        if (typeof (WebSocket) == "undefined") {
            console.log("您的浏览器不支持WebSocket");
        } else {
            var host = window.location.hostname;
            var username = GetQueryString("username");
            //实现化 WebSocket 对象,与服务器建立连接
            //这里连接的是在 application.yml 中配置的 netty 的端口
            socket = new WebSocket("ws://" + host + ":18086/socket/" + username);
            //打开事件
            socket.onopen = function () {
                console.log("Socket 已连接");
            };
            //获得消息事件
            socket.onmessage = function (msg) {
                console.log(msg.data);
                $("#msg").append("接收到消息:" + msg.data + "</br>");
            };
            //关闭事件
            socket.onclose = function () {
                console.log("Socket已关闭");
            };
            //发生了错误事件
            socket.onerror = function () {
                alert("Socket发生了错误");
            }

            //关闭连接
            function closeWebSocket() {
                socket.close();
            }

            //发送消息
            function send() {
                var message = $('#text').val();
                socket.send(message);
            }
        }
    </script>
</head>
<body>
<div id="msg"></div>
<input id="text" type="text"/>
<button type="button" onclick="send()">发送消息测试</button>
</body>
</html>

springboot 的启动内中,需要添加 websocket 的支持,需要注意的是:ServerEndpointExporter 对象实例是第三方依赖包下面的对象。

package com.jobs;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

// 注意这里的 ServerEndpointExporter 是第三方包下面的
import org.yeauty.standard.ServerEndpointExporter;

@Slf4j
@SpringBootApplication
public class MainApp {
    public static void main(String[] args) {
        SpringApplication.run(MainApp.class, args);
        log.info("项目已经启动...");
    }

    //让 spring 容器管理 WebSocket,将服务服务暴露出去
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

然后编写基于 netty 的 websocket 服务类 NettyWebSocketServer

需要注意的是:这些注解 @ServerEndpoint、@OnOpen、@OnClose、@OnError、@OnMessage 以及 Session 对象都是使用的是第三方依赖包的。

package com.jobs.websocket;

import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import org.springframework.stereotype.Component;
import org.yeauty.annotation.*;
import org.yeauty.pojo.Session;

//注意:
//这里的 @ServerEndpoint、@OnOpen、@OnClose、@OnError、@OnMessage,使用的是 org.yeauty.annotation 包
//Session 也是 org.yeauty.annotation 包
//集成了 netty 的 websocket 服务,需要使用额外的端口,这里从 application.yml 中的配置读取端口

//配置 WebSocket 服务的连接地址,每次连接都会实例化一个对象
@ServerEndpoint(value = "/socket/{username}", port = "${ws-netty.port}")
@Slf4j
@Component
public class NettyWebSocketServer {

    //存储 username 与 Session 的对应关系,key 是 username
    private static Map<String, Session> sessionMap = new HashMap<String, Session>();

    //存储 Session 的 id 和 username 的对应关系
    private static Map<String, String> idnameMap = new HashMap<String, String>();

    //WebSocket 连接建立后调用该方法
    //注意:当前 Socket Session 属于长连接类型(有状态),因此不能持久化对象到数据库中
    @OnOpen
    public void onOpen(@PathVariable("username") String username, Session session) {
        //根据 username 获取 Socket Session,如果一个用户重复连接,只保留该用户最后连接的 Socket Session
        Session userSession = sessionMap.get(username);
        if (userSession != null) {
            idnameMap.remove(userSession.channel().id().toString());
            sessionMap.remove(username);
        }

        //存储 username 和 Socket Session 的对应关系
        sessionMap.put(username, session);
        //存储 Socket Session 的 ID 和 username 的对应关系
        idnameMap.put(session.channel().id().toString(), username);
    }

    //关闭链接
    @OnClose
    public void onClose(Session session) {
        //根据 Scocket Session 的 ID 获取 username
        String username = idnameMap.get(session.channel().id().toString());
        //移除用户信息
        sessionMap.remove(username);
        idnameMap.remove(session.channel().id().toString());
    }

    //异常处理
    @OnError
    public void onError(Session session, Throwable throwable) {
        String username = idnameMap.get(session.channel().id().toString());
        log.error("用户 " + username + " 的 WebSocket 通信发生了异常:" + throwable.getMessage());
    }

    //接收客户端发送过来的消息
    @OnMessage
    public void onMessage(Session session, String message) throws IOException {
        String username = idnameMap.get(session.channel().id().toString());
        log.info("用户 " + username + " 接收到客户端发来的消息是:" + message);
        //同步给客户端发送消息
        session.sendText("服务端收到消息:" + message);
    }

    //封住的消息发送方法,用于其它地方的服务端代码进行调用,给客户端发送消息
    public void sendMessage(String username, String message) throws IOException {
        //获取用户的 Socket Session 对象
        Session session = sessionMap.get(username);

        if (session != null) {
            //给指定会话发送消息
            session.sendText(message);
        }
    }
}

为了便于通过后端服务,向前端发送消息,需要在后端编写一个接口,内容如下:

package com.jobs.controller;

import com.jobs.dto.SendMsgDTO;
import com.jobs.websocket.NettyWebSocketServer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private NettyWebSocketServer nettyWebSocketServer;

    //通过后端代码,向指定用户发送信息,测试指定用户的前端页面是否可以收到信息
    @PostMapping("/sendmsg")
    public String send(@RequestBody SendMsgDTO sendMsgDTO) throws IOException {
        nettyWebSocketServer.sendMessage(sendMsgDTO.getUsername(), sendMsgDTO.getMsg());
        return "send success";
    }
}

接口传入的参数 SendMsgDTO 内容如下:

package com.jobs.dto;

import lombok.Data;

@Data
public class SendMsgDTO {

    //用户名
    private String username;

    //发送的消息内容
    private String msg;
}

二、测试验证效果

启动 springboot 项目,由于我是本地启动,所以访问的域名地址是 localhost,使用 2 个不同的浏览器访问,代表 2 个不同的用户:

使用谷歌浏览器访问 http://localhost:8086?username=lisi 表示用户李四

使用 Edge 浏览器访问 http://localhost:8086?username=zhangsan表示用户张三

image

浏览器打开后,其实已经跟服务器建立了 websocket 建立了连接,此时通过文本框想服务器发送消息,能够得到服务器的实时响应:

image

我们使用 apipost 工具,通过 http://localhost:8086/test/sendmsg接口向 lisi 发送消息,lisi 的前端页面能够实时接收到数据,zhangsan 的前端页码则不会收到发给 lisi 的数据,如下图:

image

然后我们再使用 apipost 工具,通过 http://localhost:8086/test/sendmsg接口向 zhangsan 发送消息,zhangsan 的前端页面能够实时接收到数据,lisi 的前端页码则不会收到发给 zhangsan 的数据,如下图:

image


本篇博客的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/springboot_netty_websocket.zip

posted @ 2025-04-14 22:21  乔京飞  阅读(1687)  评论(0)    收藏  举报