「这是我参与2022首次更文挑战的第18天,活动详情查看:2022首次更文挑战」
一、前言
简易网页聊天室有如下需求:
- 支持简单的文本发送
- 消息实时接收
- 支持消息未读数(总未读和会话未读)
先来回顾下,保证消息实时性的三种常见方式:
- 短轮询:客户端定时去请求服务端拉取消息
- 长轮询:当请求没有获取到新消息时,并不会马上结束返回,而是会在服务端 “悬挂(hang)”
- 长连接:当有新消息产生,服务端直接向客户端推送
前文 已经对网页聊天室有大概了解,现在采用长连接方式(WebSocket),同时会加上相对高级的功能:
- 应用层心跳
ACK机制- 双端
idle超时断连 - 客户端断线后的自动重连
二、实战
技术实现:项目地址
- 数据表
- 接口
- 业务逻辑实现
(1)websocket 使用
前端代码如下:
onopen事件:进行用户信息上绑定onmessage事件:对接收到所有该连接上的数据进行处理onerror事件:错误事件onclose事件:连接关闭
// 1. 初始化:建立 websocket 连接
function init() {
if (window.WebSocket) {
websocket = new WebSocket("ws://127.0.0.1:8080");
websocket.onmessage = function (event) {
// 处理所有 web 端接收到的数据
onmsg(event);
};
websocket.onopen = function () {
bind();
heartBeat.start();
}
websocket.onclose = function () {
// 重连操作
reconnect();
};
websocket.onerror = function () {
// 重连操作
reconnect();
};
} else {
alert("您的浏览器不支持WebSocket协议!");
}
}
// 上线
function bind() {
if (window.WebSocket) {
if (websocket.readyState == WebSocket.OPEN) {
var bindJson = '{ "type": 1, "data": {"uid":' + $("#sender_id").val() + ' }}';
websocket.send(bindJson);
}
} else {
return;
}
}
举个栗子,发送消息格式:
var sendMsgJson = '{ "type": 3, "data": {"senderUid":' + sender_id + ',"recipientUid":' + recipient_id + ', "content":"' + msg_content + '","msgType":1 }}';
websocket.send(sendMsgJson);
后端采用 Netty 实现 WebSocket Server,代码如下:
EventLoopGroup bossGroup =
new EpollEventLoopGroup(serverConfig.bossThreads, new DefaultThreadFactory("WebSocketBossGroup", true));
EventLoopGroup workerGroup =
new EpollEventLoopGroup(serverConfig.workerThreads, new DefaultThreadFactory("WebSocketWorkerGroup", true));
ServerBootstrap serverBootstrap = new ServerBootstrap().group(bossGroup, workerGroup).channel(EpollServerSocketChannel.class);
ChannelInitializer<SocketChannel> initializer = new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
// 先添加WebSocket相关的编解码器和协议处理器
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new LoggingHandler(LogLevel.DEBUG));
pipeline.addLast(new WebSocketServerProtocolHandler("/", null, true));
// 再添加服务端业务消息的总处理器
pipeline.addLast(websocketRouterHandler);
// 服务端添加一个idle处理器,如果一段时间Socket中没有消息传输,服务端会强制断开
pipeline.addLast(new IdleStateHandler(0, 0, serverConfig.getAllIdleSecond()));
pipeline.addLast(closeIdleChannelHandler);
}
}
serverBootstrap.childHandler(initializer);
serverBootstrap.bind(serverConfig.port).sync(
(2)核心消息收发逻辑处理
前端发送消息,通过 websocket.send(),对应代码如下:
function sendMsg(event) {
event.preventDefault();
var sendMsgJson = '{ "type": 3, "data": {"senderUid":' + sender_id + ',"recipientUid":' + recipient_id + ', "content":"' + msg_content + '","msgType":1 }}';
websocket.send(sendMsgJson);
return false;
}
对接服务端接收消息:
- 从
WebSocketFrame格式的数据中,解析出消息(收发者Id、内容) - 通过
messageService.sendNewMsg:处理消息存储、未读数等。 - 通过
writeAndFlush方法把消息发送方的客户端
@ChannelHandler.Sharable
@Component
public class WebsocketRouterHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame)
throws Exception {
if (frame instanceof TextWebSocketFrame) {
String msg = ((TextWebSocketFrame) frame).text();
JSONObject msgJson = JSONObject.parseObject(msg);
int type = msgJson.getIntValue("type");
JSONObject data = msgJson.getJSONObject("data");
switch (type) {
case 0:// 心跳
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":0,\"timeout\":" + timeout + "}"));
break;
case 1:// 上线消息
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":1,\"status\":\"success\"}"));
break;
case 2: // 查询消息
ctx.writeAndFlush(new TextWebSocketFrame(msgs));
break;
case 3: // 发消息
MessageVO messageContent = messageService.sendNewMsg(senderUid, recipientUid, content, msgType);
break;
case 5: // 查总未读
long unreadOwnerUid = data.getLong("uid");
long totalUnread = messageService.queryTotalUnread(unreadOwnerUid);
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":5,\"data\":{\"unread\":" + totalUnread + "}}"));
break;
case 6: // 处理ack
long tid = data.getLong("tid");
ConcurrentHashMap<Long, JSONObject> nonAckedMap = ctx.channel().attr(NON_ACKED_MAP).get();
nonAckedMap.remove(tid);
break;
}
}
}
}
// 推送消息
public void pushMsg(long recipientUid, JSONObject message) {
Channel channel = userChannel.get(recipientUid);
if (channel != null && channel.isActive() && channel.isWritable()) {
AtomicLong generator = channel.attr(TID_GENERATOR).get();
long tid = generator.incrementAndGet();
message.put("tid", tid);
channel.writeAndFlush(new TextWebSocketFrame(message.toJSONString()))
.addListener(future -> {
if (future.isCancelled()) {
logger.warn("future has been cancelled. {}, channel: {}",
message, channel);
} else if (future.isSuccess()) {
// 将消息放到等待 ACK 的队列里
// 服务端会同时创建一个定时器,在一定的时间后,会触发“检查当前消息是否被 ACK”的逻辑。
addMsgToAckBuffer(channel, message);
logger.warn("future has been successfully pushed. {}, channel: {}",
message, channel);
} else {
logger.error("message write fail, {}, channel: {}", message, channel,
future.cause());
}
});
}
}
这里使用 Redis 的发布 / 订阅,实现一个消息推送的发布订阅器:
- 业务层:处理发送消息的逻辑
- 业务层:将消息发布到
Redis里的一个Topic - 网关层:
NewMessageListener监听着Topic,并会将消息传递给WebSocketRouterHandler,最终将消息下推给客户端
@Component
public class NewMessageListener implements MessageListener {
private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class);
@Autowired
private WebsocketRouterHandler websocketRouterHandler;
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
private static final RedisSerializer<String> valueSerializer = new GenericToStringSerializer(Object.class);
@Override
public void onMessage(Message message, byte[] pattern) {
String topic = stringRedisSerializer.deserialize(message.getChannel());
String jsonMsg = valueSerializer.deserialize(message.getBody());
logger.info("Message Received --> pattern: {},topic:{},message: {}", new String(pattern), topic, jsonMsg);
JSONObject msgJson = JSONObject.parseObject(jsonMsg);
long otherUid = msgJson.getLong("otherUid");
JSONObject pushJson = new JSONObject();
pushJson.put("type", 4);
pushJson.put("data", msgJson);
websocketRouterHandler.pushMsg(otherUid, pushJson);
}
}
(3)消息推送的 ACK
消息推送 ACK 如何保证消息的可靠投递:
- 当系统有消息下推后,服务端会依赖客户端响应的
ACK包,来保证消息推送的可靠性。 - 如果消息下推后一段时间,服务端没有收到客户端的
ACK包,那么服务端会认为这条消息没有正常投递下去,就会触发重新下推。 - 如果一直重试当然不行:得关闭清除客户端连接和待
ACK列表
**具体实现如下:**服务端会将当前消息加入到一个 “待 ACK Buffer”
- 服务端会同时创建一个定时器,在一定的时间后,会触发 “检查当前消息是否被
ACK”的逻辑- 如果客户端有回
ACK,那么服务端就会从这个 “待ACK Buffer” 中移除这条消息 - 否则如果这条消息没有被
ACK,那么就会触发消息的重新下推。
- 如果客户端有回
public void addMsgToAckBuffer(Channel channel, JSONObject msgJson) {
channel.attr(NON_ACKED_MAP).get().put(msgJson.getLong("tid"), msgJson);
// 定时器调度:
executorService.schedule(() -> {
if (channel.isActive()) {
checkAndResend(channel, msgJson);
}
}, 5000, TimeUnit.MILLISECONDS);
}
private void checkAndResend(Channel channel, JSONObject msgJson) {
long tid = msgJson.getLong("tid");
int tryTimes = 2;// 重推2次
while (tryTimes > 0) {
if (channel.attr(NON_ACKED_MAP).get().containsKey(tid) && tryTimes > 0) {
channel.writeAndFlush(new TextWebSocketFrame(msgJson.toJSONString()));
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
tryTimes--;
}
}
(4)应用层心跳
应用层心跳的作用:主要是为了解决由于网络的不确定性,而导致的连接不可用的问题。
前端逻辑实现如下:通过定时器每 2 分钟通过长连接给服务端发送一次心跳包
- 如果在 2 分钟内接收到服务端的消息或者响应,那么客户端的下次 2 分钟定时器的计时,会进行清零重置,重新计算
- 如果发送的心跳包在 2 分钟后没有收到服务端的响应,客户端会断开当前连接,然后尝试重连
// 每2分钟发送一次心跳包,接收到消息或者服务端的响应又会重置来重新计时
var heartBeat = {
timeout: 120000,
timeoutObj: null,
serverTimeoutObj: null,
reset: function () {
clearTimeout(this.timeoutObj);
clearTimeout(this.serverTimeoutObj);
this.start();
},
start: function () {
// ... ...
}, self.timeout)
}, this.timeout)
},
}
服务端接收到心跳包的处理逻辑:
@ChannelHandler.Sharable
@Component
public class WebsocketRouterHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame)
throws Exception {
if (frame instanceof TextWebSocketFrame) {
String msg = ((TextWebSocketFrame) frame).text();
JSONObject msgJson = JSONObject.parseObject(msg);
int type = msgJson.getIntValue("type");
JSONObject data = msgJson.getJSONObject("data");
switch (type) {
case 0://心跳
long uid = data.getLong("uid");
long timeout = data.getLong("timeout");
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":0,\"timeout\":"
+ timeout + "}"));
break;
}
}
}
}