在传统的网页客服系统开发中,很多团队习惯使用Tomcat自带的WebSocket来实现双向通信。然而,当面对高并发、海量长连接的客服场景(如电商大促、在线教育答疑)时,Tomcat的线程模型往往会成为性能瓶颈。此时,引入Netty作为底层通信网关,结合Spring Boot的业务处理能力以及Vue.js的前端渲染效率,便成为了当前主流且极具实战价值的架构选型。本文将直接从源码层面出发,深入拆解这一经典架构的技术实现细节,带你探究其背后的设计哲学与代码精妙之处。
系统架构与工程结构概览
在正式dive into源码之前,我们先来理清这套系统的宏观架构。整个项目通常采用前后端完全分离的单体分层架构:
• 源码及演示:zxkfym.top
•前端(Vue.js):基于Vue CLI脚手架,配合Vuex管理全局状态(WebSocket实例、未读消息数),通过原生WebSocket或STOMP客户端与后端建立长连接。
•接入层(Netty):独立运行于Spring Boot容器内(或作为单独模块),负责接管所有WebSocket长连接,处理心跳、消息编解码与转发。
•业务层(Spring Boot):处理HTTP短连接请求(如用户登录、客服排班、历史会话查询),并通过Spring Event或自定义工具类与Netty层进行数据交互。
•数据层(Redis+MySQL):Redis存储热点数据(如用户在线状态、WebSocket Channel映射关系),MySQL持久化历史消息、操作日志等。
对应的后端Java工程结构通常如下所示:
src/main/java/com/example/demo
├──config#Netty启动配置类、Bean配置
├──handler#Netty各种入站/出站处理器(AuthHandler,MessageHandler)
├
──server#Netty服务端Bootstrap启动类
├──controller#Spring Boot MVC接口
├──service#业务逻辑层
├──dao#数据库操作层
└──entity#实体类/消息协议POJO
Netty服务端源码深度拆解
Netty是整个客服系统的通信心脏。我们需要让它独立于Spring Boot的内嵌Tomcat运行,同时又能顺利注入Spring容器中的Bean。
1.Netty服务启动与端口绑定
首先,我们看看如何使用Spring的 Component和 PostConstruct注解,让Netty随Spring Boot应用一同启动。
@Component
@Slf4j
public class WebSocketServer {
// 主从多线程Reactor模型
private EventLoopGroup bossGroup = new NioEventLoopGroup();
private EventLoopGroup workerGroup = new NioEventLoopGroup();
@Value("${netty.websocket.port}")
private int port;
// 注入 Spring 容器上下文,用于在非 Spring 管理的类中获取 Bean
@Autowired
private ApplicationContext applicationContext;
@PostConstruct
public void start() throws InterruptedException {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new WebSocketChannelInitializer(applicationContext)); // 传入上下文
ChannelFuture future = bootstrap.bind(port).sync();
log.info("Netty WebSocket 服务器启动成功,监听端口: {}", port);
future.channel().closeFuture().addListener((ChannelFutureListener) f -> {
shutdown(); // 优雅关闭
});
}
public void shutdown() {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
log.info("Netty WebSocket 服务器已关闭");
}
}
2.自定义ChannelInitializer配置流水线
在Netty中,数据的进出需要经过一个个ChannelHandler。我们需要在此配置HTTP编解码器、WebSocket握手处理器,以及最核心的自定义业务处理器。
public class WebSocketChannelInitializer extends ChannelInitializer<SocketChannel> {
private static final String WEBSOCKET_PATH = "/ws";
private final ApplicationContext applicationContext;
public WebSocketChannelInitializer(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
// 1. HTTP请求的解码和编码(因为WS握手是基于HTTP的)
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536)); // 聚合HTTP消息
// 2. 支持大文件分块传输
pipeline.addLast(new ChunkedWriteHandler());
// 3. WebSocket 握手、心跳检测、关闭帧处理
pipeline.addLast(new WebSocketServerProtocolHandler(WEBSOCKET_PATH, null, true));
// 4. 自定义鉴权处理器 (在正式处理消息前拦截)
pipeline.addLast(applicationContext.getBean(AuthHandler.class));
// 5. 自定义消息处理器 (处理业务指令)
pipeline.addLast(applicationContext.getBean(MessageHandler.class));
}
}
核心业务Handler与WebSocket鉴权机制
网页客服系统对安全性要求极高。我们不能让未登录的访客或没有权限的客服建立长连接。由于Netty的Handler不是由Spring托管的,我们需要利用前面传递的ApplicationContext来手动注入Bean。
1.握手阶段的JWT鉴权(AuthHandler)
@ChannelHandler.Sharable
@Component
@Slf4j
public class AuthHandler extends ChannelInboundHandlerAdapter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof FullHttpRequest) {
FullHttpRequest request = (FullHttpRequest) msg;
// 获取 URL 中的 Token 参数
QueryStringDecoder decoder = new QueryStringDecoder(request.uri());
List<String> tokenList = decoder.parameters().get("token");
if (tokenList == null || tokenList.isEmpty()) {
sendError(ctx, HttpResponseStatus.UNAUTHORIZED, "缺少Token参数");
return;
}
String token = tokenList.get(0);
// 校验 JWT 合法性及是否过期
if (!jwtUtil.validateToken(token)) {
sendError(ctx, HttpResponseStatus.UNAUTHORIZED, "Token无效或已过期");
return;
}
// 校验 Redis 中是否存在该 Token (实现单点登录控制)
String userId = jwtUtil.getUserIdFromToken(token);
if (!redisTemplate.hasKey("user:token:" + userId)) {
sendError(ctx, HttpResponseStatus.UNAUTHORIZED, "用户已在其他地方登录");
return;
}
log.info("用户 {} 建立WebSocket连接成功", userId);
// 鉴权成功,移除当前鉴权处理器,避免重复校验
ctx.pipeline().remove(this);
}
super.channelRead(ctx, msg);
}
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status, String message) {
DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=UTF-8");
ctx.writeAndFlush(response);
ctx.close();
}
}
2.消息分发与业务处理(MessageHandler)
当鉴权通过后,所有的WebSocket文本帧都会在这里被处理。我们通常会定义一个统一的消息协议(包含type,toUserId,content),然后根据类型进行分发。
@ChannelHandler.Sharable
@Component
@Slf4j
public class MessageHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Autowired
private MessageDispatcher dispatcher; // 自定义的消息分发器
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
String text = frame.text();
// 1. 解析通用消息协议
BaseMessage message = JSON.parseObject(text, BaseMessage.class);
// 2. 根据消息类型分发处理
switch (message.getType()) {
case HEARTBEAT:
handleHeartbeat(ctx, message);
break;
case SINGLE_CHAT:
dispatcher.dispatchSingleChat(ctx, message);
break;
case GROUP_CHAT:
dispatcher.dispatchGroupChat(ctx, message);
break;
default:
log.warn("未知的消息类型: {}", message.getType());
}
}
private void handleHeartbeat(ChannelHandlerContext ctx, BaseMessage msg) {
// 简单的心跳回应,同时可以更新 Redis 中的在线状态时间戳
ctx.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(msg)));
}
}
Vue.js前端WebSocket源码架构
前端的核心挑战在于如何优雅地管理WebSocket生命周期,以及如何与Vuex无缝集成以实现跨组件的实时数据响应。
1.Vuex中封装WebSocket连接与重连机制
我们在Vuex的actions中封装WebSocket的创建逻辑,利用commit来触发状态变更(如连接状态、未读消息数)。
// store/modules/websocket.js
import { getToken } from '@/utils/auth'; // 获取JWT Token
const websocket = {
state: {
socket: null,
isConnected: false,
reconnectCount: 0,
maxReconnect: 5
},
mutations: {
SET_SOCKET(state, socket) {
state.socket = socket;
},
SET_CONNECTED(state, status) {
state.isConnected = status;
}
},
actions: {
// 初始化 WebSocket 连接
initWebSocket({ commit, state, dispatch }) {
const token = getToken();
if (!token) return;
const wsUrl = `ws://localhost:8080/ws?token=${token}`;
const socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log('WebSocket 连接成功');
commit('SET_SOCKET', socket);
commit('SET_CONNECTED', true);
state.reconnectCount = 0; // 重置重连计数
// 启动心跳检测
dispatch('startHeartbeat');
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
// 统一分发处理收到的消息
dispatch('handleMessage', message);
};
socket.onerror = (error) => {
console.error('WebSocket 发生错误:', error);
commit('SET_CONNECTED', false);
};
socket.onclose = () => {
console.log('WebSocket 连接关闭');
commit('SET_CONNECTED', false);
// 触发重连机制
dispatch('reconnect');
};
},
// 断线重连逻辑
reconnect({ state, dispatch }) {
if (state.reconnectCount < state.maxReconnect) {
state.reconnectCount++;
console.log(`尝试第 ${state.reconnectCount} 次重连...`);
setTimeout(() => {
dispatch('initWebSocket');
}, 3000); // 3秒后重试
} else {
console.error('WebSocket 重连失败,已达到最大重试次数');
}
}
}
};
2.心跳机制与消息分发
为了防止Netty服务端的空闲检测机制断开连接,前端必须定期发送心跳包。同时,收到消息后需要根据类型更新对应的Vuex状态,从而驱动页面重新渲染。
actions: {
// 启动心跳
startHeartbeat({ state }) {
setInterval(() => {
if (state.socket && state.socket.readyState === WebSocket.OPEN) {
const heartbeatMsg = JSON.stringify({ type: 'HEARTBEAT', timestamp: Date.now() });
state.socket.send(heartbeatMsg);
}
}, 30000); // 每30秒发送一次心跳
},
// 处理接收到的消息
handleMessage({ commit }, message) {
switch (message.type) {
case 'SINGLE_CHAT':
// 触发 Vuex mutation 更新会话列表或聊天记录
commit('UPDATE_CHAT_LIST', message);
// 播放提示音或桌面通知
this.dispatch('playNotificationSound');
break;
case 'SYSTEM_NOTIFICATION':
// 触发系统级提示
this.dispatch('showSystemAlert', message.content);
break;
}
}
}
Spring Boot业务层与Netty的数据交互
在客服系统中,Spring Boot负责处理诸如“查看历史会话”、“设置客服排班”等HTTP接口。那么,当Spring Boot需要将一条系统通知推送到特定的Netty Channel时,该如何实现呢?
我们可以通过自定义工具类,利用ChannelId或UserId从全局缓存中获取Channel并写入数据。
@Component
public class NettyChannelManager {
// 使用 ConcurrentHashMap 缓存用户ID与Channel的映射关系
private static final Map<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
/**
* 绑定用户ID与Channel
*/
public static void bind(String userId, Channel channel) {
USER_CHANNEL_MAP.put(userId, channel);
// 同时存入 Redis,用于分布式多节点场景下的用户状态同步
// redisTemplate.opsForValue().set("online:user:" + userId, channel.id().asLongText());
}
/**
* 向指定用户推送消息
*/
public static void sendToUser(String userId, Object message) {
Channel channel = USER_CHANNEL_MAP.get(userId);
if (channel != null && channel.isActive()) {
channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(message)));
}
}
/**
* 广播消息给所有在线用户
*/
public static void broadcast(Object message) {
USER_CHANNEL_MAP.values().forEach(channel -> {
if (channel.isActive()) {
channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(message)));
}
});
}
}
总结
通过对 Spring Boot、Vue.js 与 Netty 的深度整合,我们不仅仅是在构建一个网页客服系统,更是在搭建一套高可用、低延迟的实时通信基础设施。从 Netty 的 EventLoopGroup线程模型优化,到自定义协议解决粘包拆包,再到 Vuex 驱动的响应式数据流转,每一个环节的代码都凝聚着对性能与用户体验的追求。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70050074/viewspace-3117727/ ,如需转载,请注明出处,否则将追究法律责任。