客户端
通过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();
}));
}
}