Netty实现简单聊天Demo

客户端

 通过AI生成前端代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>WebSocket 客户端测试</title>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        .container { max-width: 800px; margin: 0 auto; }
        .status { padding: 10px; margin: 10px 0; border-radius: 5px; }
        .connected { background: #d4edda; color: #155724; }
        .disconnected { background: #f8d7da; color: #721c24; }
        .message-area {
            height: 300px;
            border: 1px solid #ddd;
            padding: 10px;
            overflow-y: auto;
            margin: 10px 0;
        }
        input[type="text"] { width: 200px; padding: 5px; }
        button { padding: 5px 15px; margin-left: 5px; cursor: pointer; }
    </style>
</head>
<body>
<div class="container">
    <h2>WebSocket 客户端测试</h2>

    <!-- 连接控制 -->
    <div>
        <input type="text" id="serverUrl" placeholder="ws://localhost:8090/ws" style="width: 300px;" />
        <button onclick="connect()" id="connectBtn">连接</button>
        <button onclick="disconnect()" id="disconnBtn" disabled>断开</button>
    </div>

    <!-- 状态显示 -->
    <div id="status" class="status disconnected">未连接</div>

    <!-- 消息发送 -->
    <div>
        <input type="text" id="messageInput" placeholder="输入消息..." style="width: 400px;" />
        <button onclick="sendMessage()" id="sendBtn" disabled>发送</button>
    </div>

    <!-- 消息记录 -->
    <div class="message-area" id="messageArea"></div>
</div>

<script>
    let ws = null;
    let heartbeatInterval = null;

    const statusEl = document.getElementById('status');
    const serverUrlEl = document.getElementById('serverUrl');
    const messageArea = document.getElementById('messageArea');
    const connectBtn = document.getElementById('connectBtn');
    const disconnBtn = document.getElementById('disconnBtn');
    const sendBtn = document.getElementById('sendBtn');

    // 初始化默认地址
    serverUrlEl.value = 'ws://localhost:8090/ws?token=';

    // 连接WebSocket
    function connect() {
        const url = serverUrlEl.value.trim();

        if (!url) {
            alert('请输入有效的WebSocket地址');
            return;
        }

        ws = new WebSocket(url);

        // 成功连接
        ws.onopen = () => {
            updateStatus(true, '连接成功');
            sendBtn.disabled = false;
            disconnBtn.disabled = false;
            connectBtn.disabled = true;

            // 启动心跳
            startHeartbeat();
        };

        // 接收消息
        ws.onmessage = (event) => {
            addMessage('收到消息: ' + event.data);
        };

        // 错误处理
        ws.onerror = (error) => {
            addMessage('连接错误: ' + error.message);
            cleanup();
        };

        // 关闭处理
        ws.onclose = () => {
            addMessage('连接已关闭');
            cleanup();
        };
    }

    // 断开连接
    function disconnect() {
        if (ws) {
            ws.close();
            cleanup();
        }
    }

    // 发送消息
    function sendMessage() {
        const input = document.getElementById('messageInput');
        const msg = input.value.trim();

        if (!msg) {
            alert('请输入消息内容');
            return;
        }

        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(msg);
            addMessage('已发送: ' + msg);
            input.value = '';
        } else {
            alert('连接未就绪');
        }
    }

    // 状态更新
    function updateStatus(connected, text) {
        statusEl.textContent = text;
        statusEl.className = 'status ' + (connected ? 'connected' : 'disconnected');
    }

    // 消息记录
    function addMessage(text) {
        const item = document.createElement('div');
        item.textContent = `${new Date().toLocaleTimeString()}: ${text}`;
        messageArea.appendChild(item);
        // 自动滚动到底部
        messageArea.scrollTop = messageArea.scrollHeight;
    }

    // 清理资源
    function cleanup() {
        updateStatus(false, '未连接');
        sendBtn.disabled = true;
        disconnBtn.disabled = true;
        connectBtn.disabled = false;

        if (ws) {
            ws = null;
        }

        stopHeartbeat(); // 停止心跳
    }

    // 回车发送消息
    document.getElementById('messageInput').addEventListener('keypress', (e) => {
        if (e.key === 'Enter') sendMessage();
    });

    // 心跳包相关函数
    function startHeartbeat() {
        if (heartbeatInterval) return;

        heartbeatInterval = setInterval(() => {
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.send('heartbeat'); // 发送心跳包
                console.log('发送心跳包: ping');
            }
        }, 10000); // 每10秒发送一次
    }

    function stopHeartbeat() {
        if (heartbeatInterval) {
            clearInterval(heartbeatInterval);
            heartbeatInterval = null;
        }
    }
</script>
</body>
</html>

服务端

依赖

    <dependencies>
        <!-- Logback 日志框架 -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.10</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-core</artifactId>
            <version>1.2.10</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
        </dependency>
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <version>2.9.3</version>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.119.Final</version>
        </dependency>
    </dependencies>

WebSocket服务端

package com.gyforum.im.websocket;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
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 io.netty.handler.timeout.IdleStateHandler;
import io.netty.util.NettyRuntime;
import io.netty.util.concurrent.Future;
import lombok.extern.slf4j.Slf4j;
//import org.springframework.beans.factory.annotation.Value;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
//import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

/**
 * <p>
 * 描述: 基于Netty实现websocket主类
 * </p>
 *
 * @Author huhongyuan
 * @Date 2025/5/7 9:05
 */
//@Configuration
//@ConditionalOnProperty(name = "ws.enabled", havingValue = "true")
@Slf4j
public class NettyWebSocketServer {
//    @Value("${ws.port:8090}")
    private int WEB_SOCKET_PORT = 8090;
//    @Value("${ws.path}")
    private String WEB_SOCKET_PATH = "/ws";
    public static final NettyWebSocketServerHandler NETTY_WEB_SOCKET_SERVER_HANDLER = new NettyWebSocketServerHandler();
    public static final HeartBeatHandler HEARTBEAT_HANDLER = new HeartBeatHandler();
    // boss只处理连接事件
    private final NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
    // workers来处理读写事件
    private final NioEventLoopGroup workerGroup = new NioEventLoopGroup(NettyRuntime.availableProcessors());

    @PostConstruct
    public void start() throws InterruptedException {
        run();
    }

    @PreDestroy
    public void destroy() {
        Future<?> future = bossGroup.shutdownGracefully();
        Future<?> future1 = workerGroup.shutdownGracefully();
        future.syncUninterruptibly();
        future1.syncUninterruptibly();
        log.info("WebSocket服务关闭成功");
    }
    public void run() throws InterruptedException {
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        serverBootstrap.group(bossGroup, workerGroup)
                // 因为是服务器,所以是ServerSocketChannel
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        ChannelPipeline pipeline = socketChannel.pipeline();
                        // 因为使用http协议,所以需要使用http的编码器,解码器
                        pipeline.addLast(new HttpServerCodec());
                        // 以块方式写,添加 chunkedWriter 处理器
                        pipeline.addLast(new ChunkedWriteHandler());
                        // 30秒客户端没有向服务器发送心跳则关闭连接,填0表示不关注
                        // 读空闲时间必须比心跳发送间隔大
                        pipeline.addLast(new IdleStateHandler(30, 0, 0));
                        pipeline.addLast(HEARTBEAT_HANDLER);
                        // http数据在传输过程中是分段的,HttpObjectAggregator可以把多个段聚合起来
                        pipeline.addLast(new HttpObjectAggregator(64 * 1024));
                        /**
                         * 对应构造函数
                         * public WebSocketServerProtocolHandler(
                         *        String websocketPath, // WebSocket路径
                         *        String subprotocols, // 支持的子协议,默认null表示不限制
                         *        boolean allowExtensions, // 允许扩展
                         *        int maxFrameSize, // 最大 WebSocket帧 大小
                         *        boolean allowMaskMismatch, // 是否允许发送未掩码的帧,防止代理服务器误判为 HTTP 请求
                         *        boolean checkStartsWith) // 允许路径前缀匹配,即只要以 ${WEB_SOCKET_PATH} 开头的路径都可以
                         */
                        pipeline.addLast(new WebSocketServerProtocolHandler(
                                WEB_SOCKET_PATH,
                                null,
                                true,
                                64 * 1024,
                                false,
                                true
                        ));
                        // 自定义handler, 处理业务逻辑
                        pipeline.addLast(NETTY_WEB_SOCKET_SERVER_HANDLER);
                    }
                });
        serverBootstrap.bind(WEB_SOCKET_PORT).addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                if (future.isSuccess()) {
                    log.info("WebSocket服务运行在: ws://localhost:{}{}", WEB_SOCKET_PORT, WEB_SOCKET_PATH);
                } else {
                    log.info("WebSocket服务启动失败!");
                }
            }
        });
    }

}

重点配置:

由于后续需要进行token的校验,所以 boolean checkStartsWith 的值必须为true。否则日志会报

Discarded inbound message HttpObjectAggregator$AggregatedFullHttpRequest(decodeResult: success, version: HTTP/1.1, content: CompositeByteBuf(ridx: 0, widx: 0, cap: 0, components=0))
GET /ws?token=1 HTTP/1.1
Host: localhost:8090
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
...

HttpObjectAggregator$AggregatedFullHttpRequest 说明被丢弃的是一个 HTTP请求

原因是boolean checkStartsWith 的值默认为 false,所以默认是不认 /ws?token=1 这种带查询参数的请求为WebSocket请求的

空闲事件处理器

package com.gyforum.im.websocket;

import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;
//import org.springframework.stereotype.Component;

/**
 * <p>
 * 描述: 空闲事件处理 
 * </p>
 *
 * @Author huhongyuan
 */
@Slf4j
//@Component
@ChannelHandler.Sharable
public class HeartBeatHandler extends ChannelDuplexHandler {
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent e = (IdleStateEvent) evt;
            if (e.state() == IdleState.READER_IDLE) {
                log.warn("心跳超时,客户端未响应,关闭连接: {}", ctx.channel().remoteAddress());
                ctx.writeAndFlush(new TextWebSocketFrame("长时间未操作,与服务器的连接已断开..."));
                ctx.close();
            } else if (e.state() == IdleState.WRITER_IDLE) {
                super.userEventTriggered(ctx, evt);
                log.debug("发送心跳包给客户端: {}", ctx.channel().remoteAddress());
            }
        }
    }
}

业务逻辑处理器

package com.gyforum.im.websocket;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.gyforum.common.util.StringTools;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.util.concurrent.GlobalEventExecutor;
import lombok.extern.slf4j.Slf4j;

import java.util.Objects;

/**
 * <p>
 * 描述: websocket自定义入站处理器
 * </p>
 *
 * @Author huhongyuan
 * @Date 2025/5/7 10:15
 */
@Slf4j
@ChannelHandler.Sharable
public class NettyWebSocketServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    public static final String PUBLIC_GROUP = "public";

    public static final Cache<String, Channel> USER_CHANNEL_MAP = Caffeine.newBuilder().build();
    public static final Cache<String, ChannelGroup> GROUP_CHANNEL_MAP = Caffeine.newBuilder().build();
    static {
        ChannelGroup groupChannel = GROUP_CHANNEL_MAP.getIfPresent(PUBLIC_GROUP);
        if (Objects.isNull(groupChannel)) {
            groupChannel = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
            GROUP_CHANNEL_MAP.put(PUBLIC_GROUP, groupChannel);
        }
    }

    /**
     * 用户事件触发
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
            WebSocketServerProtocolHandler.HandshakeComplete complete = (WebSocketServerProtocolHandler.HandshakeComplete) evt;
            String uri = complete.requestUri();
            String token = getToken(uri);
            Channel channel = ctx.channel();
            if (token == null) {
                log.warn("请求未携带token,拒绝连接: {}", uri);
                channel.close();
                return;
            }
            NettyAttrUtils.setAttr(channel, NettyAttrUtils.TOKEN, token);
            USER_CHANNEL_MAP.put(token, channel);

            add2PublicGroup(channel);
        }
    }

    private void add2PublicGroup(Channel channel) {
        GROUP_CHANNEL_MAP.asMap().get(PUBLIC_GROUP).add(channel);
    }

    private String getToken(String uri) {
        if (StringTools.isEmpty(uri) || uri.lastIndexOf("?") == -1) {
            return null;
        }
        String[] split = uri.split("\\?");
        if (split.length != 2) {
            return null;
        }
        String[] kv = split[1].split("=");
        if (kv.length != 2) {
            return null;
        }
        return kv[1];
    }

    /**
     * channelRead0在通道接收到数据时自动被调用
     * @param ctx 上下文对象,可用于获取 Channel、发送响应等操作
     * @param textWebSocketFrame 客户端发送的文本消息
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame textWebSocketFrame) throws Exception {
        Channel channel = ctx.channel();
        // 用户发的消息
        String msg = textWebSocketFrame.text();
        // 拿到公频
        ChannelGroup group = GROUP_CHANNEL_MAP.asMap().get(PUBLIC_GROUP);
        // 广播消息
        group.writeAndFlush(new TextWebSocketFrame("用户" + channel.attr(NettyAttrUtils.TOKEN).get() + "群发消息: " + msg));
//        channel.writeAndFlush(new TextWebSocketFrame("服务端回复给用户" + channel.attr(NettyAttrUtils.TOKEN).get() + ": pong"));
    }

    /**
     * 当客户端与服务端建立连接并处于活跃状态时触发
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        log.info("接收到连接,来自{}", ctx.channel().remoteAddress());
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        log.info("远程连接断开,来自{}", channel.remoteAddress());
        String token = NettyAttrUtils.getAttr(channel, NettyAttrUtils.TOKEN);
        if (StringTools.isEmpty(token)){
            return;
        }
        USER_CHANNEL_MAP.invalidate(token);
        GROUP_CHANNEL_MAP.asMap().get(PUBLIC_GROUP).remove(channel);
        channel.close();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        log.error("WebSocket连接异常", cause);
        ctx.close();
    }
}

工具类

package com.gyforum.im.websocket;

import io.netty.channel.Channel;
import io.netty.util.Attribute;
import io.netty.util.AttributeKey;

/**
 * <p>
 * 描述: channel属性工具类
 * </p>
 *
 * @Author huhongyuan
 */
public class NettyAttrUtils {
    public static AttributeKey<String> TOKEN = AttributeKey.valueOf("token");

    public static <T> void setAttr(Channel channel, AttributeKey<T> attributeKey, T data) {
        Attribute<T> attr = channel.attr(attributeKey);
        attr.set(data);
    }

    public static <T> T getAttr(Channel channel, AttributeKey<T> target) {
        return channel.attr(target).get();
    }
}

启动类

package com.gyforum.im.websocket;

//import org.springframework.boot.SpringApplication;
//import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * <p>
 * 描述: 启动类
 * </p>
 *
 * @Author huhongyuan
 */
//@SpringBootApplication
public class NettyWebSocketApplication {
    public static void main(String[] args) throws InterruptedException {
//        SpringApplication.run(NettyWebSocketApplication.class, args);
        NettyWebSocketServer server = new NettyWebSocketServer();
        server.start();
        Runtime.getRuntime().addShutdownHook(new Thread(()->{
            server.destroy();
        }));
    }
}

效果演示

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值