1. 前言
先看实现效果:
由于篇幅有限,而且代码量还是有的,所以下面只给出重要部分的代码,详细大家可以去项目里面看,完整代码已经完全开源。(仓库链接在文章末尾)
到这里我们可以看到接下来要实现的内容包括:
- 聊天主页面收到新消息后提示并刷新最新消息。
- 消息已读/未读状态实时变更。
- 消息实时收与发。
- 聊天页面收到新消息或者发送了消息,聊天主页面消息展示同步更新。(视频未展示)
只要思路正确,最后实现出效果所产生的代码量并不是很多。
寥寥的四条实现效果,可能并不多,但是你要学习的东西却与之成反比例。
通过本篇文章您大至能够了解到或者学习到:
- Netty的基础知识
- websocket基础知识
- 小程序开发基础知识(uni-app vscode开发版)
- vue、vuex的一些内容
2. 前置知识准备
这里说一下实现这些功能需要具备的一些基础,读者可以进一步的深入了解,更有利于自身的学习与成长,仅仅介绍技术的基础,认识到这个是做什么的,不深入讨论某些功能。
2.1 简单介绍一下Netty
Netty是什么?Netty 是一个基于 Java 的高性能网络应用框架,主要用于开发客户端-服务器 (Client-Server) 的通信程序,支持多种传输协议(如 TCP、UDP、HTTP 等)。它是一个异步事件驱动的网络框架,能够大幅简化网络编程,特别是在高并发场景下。
- 异步和事件驱动
基于事件驱动机制,通过
Channel、EventLoop、Future等模型实现高效的异步通信。 - 多协议支持 支持 TCP、UDP、HTTP/2、WebSocket 等协议,也可以自定义协议。
- 高性能
- 线程模型优化:采用 Reactor 模式,使用少量线程处理大量连接。
- 内存优化:通过对象池和零拷贝技术优化内存使用。
- 可扩展性
通过
ChannelHandler和Pipeline实现灵活的业务逻辑扩展。(重要,本文介绍的内容基于该特性实现)
它的一些应用场景:
- 即时通讯:IM 系统、聊天服务(如微信、QQ)。
- 网关系统:微服务网关、API 网关。
- 高性能 HTTP 服务:如 HTTP 反向代理、流媒体服务。
- 游戏服务器:需要长连接和低延迟的实时通信场景。
- 分布式系统:消息队列、RPC 框架的底层实现。
选择使用Netty,它有这比较丰富的API和工具,简化socket编程,在了解它的前提下可以基于它快速构建自己的应用。
我有一篇文章介绍了Netty的一部分源码内容,感兴趣的可以去研究,看源码是一项有挑战性的事情。Netty部分源码
2.2 WebSocket
WebSocket表示一种网络传输协议,位于OSI模型的应用层,并且依赖于传输层的TCP协议。它是一种不同于http的协议,但是RFC 6455中:it is designed to work over HTTP ports 80 and 443 as well as to support HTTP proxies and intermediaries(WebSocket通过HTTP端口80和443进行工作,并支持HTTP代理和中介),为了实现兼容性,WebSocket握手使用HTTP Upgrade头从HTTP协议更改为WebSocket协议。
WebSocket协议支持Web浏览器(或其他客户端应用程序)与Web服务器之间的交互,具有较低的开销,便于实现客户端与服务器的实时数据传输。
2.3 开发工具
使用的是uniapp跨端开发语言,实际使用和vue一样,只是有一些新组件,看官方文档就知道如何使用。
开发工具使用的是vscode,至于如何在vscode上面开发uniapp,要先调教一下vscode,笔者是参考的这篇文章。
小程序开发工具下载
可以去微信开发者平台注册一个账号,拿到appid后面方便自己调试使用。
3. 数据库设计
看到这里,相信你已经对用到的技术和工具有了大致的了解,现在下面开始动手如何去实现。
首先我们需要一张消息表来记录各自收发的信息,不能离开聊天界面把消息都丢了,当然如果你不需要存储消息,只是即时聊天,也可以不用搞一个表去存储消息。
create table tbl_message
(
id varchar(32) primary key,
message_type smallint not null comment '消息类型:待办通知:0 申请结果通知:1',
read_state smallint not null default 0 comment '阅读状态:0未读,1已读',
content varchar(1024) not null comment '消息内容',
message_receiver_id varchar(32) not null comment '消息接收者id',
message_sender_id varchar(32) not null comment '消息发送者id',
create_time bigint not null comment '创建时间',
update_time bigint not null comment '修改时间',
constraint message_receiver_fk foreign key (message_receiver_id) references tbl_user (id),
constraint message_sender_fk foreign key (message_sender_id) references tbl_user (id)
) comment '消息表';
避免篇幅过长这里的用户表就不贴出了。
位置在这里:src/main/resources/db/migration/V1.0__init.sql
3. 后端
netty的依赖要引入到项目中:
<!-- https://mvnrepository.com/artifact/io.netty/netty-all -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.110.Final</version>
</dependency>
创建Netty启动类:
@Component
@Slf4j
public class NettyBootstrapRunner implements ApplicationRunner, ApplicationListener<ContextClosedEvent>, ApplicationContextAware {
@Value("${netty.websocket.port}")
private int port;
@Value("${netty.websocket.pemFile}")
private String pemFile;
@Value("${netty.websocket.keyFile}")
private String keyFile;
private Channel serverChannel;
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void run(ApplicationArguments args) throws Exception {
// 使用自签名证书进行 SSL 配置
ApplyRoomRecordConfig globalConfig = applicationContext.getBean(ApplyRoomRecordConfig.class);
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.localAddress(new InetSocketAddress(port));
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
addSslHandler(pipeline, socketChannel, globalConfig);
pipeline.addLast(new HttpServerCodec());//请求解码器
pipeline.addLast(new HttpObjectAggregator(65536));//将多个消息转换成单一的消息对象
pipeline.addLast(new ChunkedWriteHandler());//支持异步发送大的码流,一般用于发送文件流
pipeline.addLast(new WebSocketServerCompressionHandler());//压缩处理
pipeline.addLast(applicationContext.getBean(UserAuthHandler.class));// 用户认证处理
// 参数配置请百度
pipeline.addLast(new WebSocketServerProtocolHandler("/websocket", null, true, 16384, false, true, 60000L));//websocket协议处理
pipeline.addLast(applicationContext.getBean(SocketConnectedHandler.class)); // 自定义处理器,处理消息发送与在线统计
}
});
serverChannel = serverBootstrap.bind().sync().channel();
log.info("websocket 服务启动,port={}", this.port);
serverChannel.closeFuture().sync();
} catch (Exception e) {
log.error(e.getMessage());
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
@Override
public void onApplicationEvent(ContextClosedEvent event) {
if (this.serverChannel != null) {
this.serverChannel.close();
}
log.info("websocket 服务停止");
}
private void addSslHandler(ChannelPipeline pipeline, SocketChannel socketChannel, ApplyRoomRecordConfig globalConfig) {
// 取决于你的配置,如果配置了是,那么请您同时配置pem和key文件
// 这两个文件请放在resource目录下
if (globalConfig.getUseWebsocketSSL()) {
try {
ClassPathResource pem = new ClassPathResource(pemFile);
ClassPathResource key = new ClassPathResource(keyFile);
SslContext sslCtx = SslContextBuilder.forServer(pem.getStream(), key.getStream()).build();
pipeline.addLast(applicationContext.getBean(HttpRequestCheckHandler.class));
pipeline.addLast(sslCtx.newHandler(socketChannel.alloc())); // 添加 SSL 处理
} catch (SSLException e) {
throw new RuntimeException(e);
}
}
}
}
这样随着项目的启动而运行,项目的停止而停止。
下面就是Netty启动后我的事件驱动重要的几个自定义部分,UserAuthHandler负责身份的认证,SocketConnectedHandler负责消息的转发。
UserAuthHandler是根据url中拼接的token参数来验证身份的,项目中使用的权限框架时sa-token,所以我们根据token就能拿到用户的登录id。StpUtil.getLoginIdByToken(token)根据获取的id是否为空判断用户是否登录,然后将获取的userId存放到channel的attr中,因为后面的handler会用到。
@ChannelHandler.Sharable
@Component
@Slf4j
public class UserAuthHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) {
String token = String.valueOf(UrlQuery.of(msg.uri(), Charset.defaultCharset()).get("token"));
String platform = String.valueOf(UrlQuery.of(msg.uri(), Charset.defaultCharset()).get("platform"));
// 是否是授权访问socket
String userId = (String) StpUtil.getLoginIdByToken(token);
if (StrUtil.isEmpty(token) || StrUtil.isEmpty(userId)) {
errorResponse(ctx);
ctx.channel().close();
return;
}
if (StrUtil.isEmpty(platform)) {
log.warn("Platform param is invalid. Param is {}.", platform);
errorResponse(ctx);
ctx.channel().close();
return;
}
// 信息记录流转到业务逻辑中
ctx.channel().attr(AttributeKey.valueOf("userId")).set(userId);
ctx.channel().attr(AttributeKey.valueOf("platform")).set(platform);
ctx.fireChannelRead(msg.retain());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
super.exceptionCaught(ctx, cause);
}
private void errorResponse(ChannelHandlerContext ctx) {
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
}
下面时重要的消息处理逻辑SocketConnectedHandler:
我们约定前后端的消息交互格式:
{"content":"Chrome131.0(Windows)","type":"device","toUserId":""}
会有不同类型的消息。
@ChannelHandler.Sharable
@Component
@Slf4j
public class SocketConnectedHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) {
// 心跳检测信息
if (msg.text().equalsIgnoreCase("ping")) {
ctx.writeAndFlush(new TextWebSocketFrame("pong"));
}
// 这里可以考虑使用不同的manager处理不同type的消息,将功能解耦
if (StrUtil.isNotEmpty(msg.text()) && !"ping".equalsIgnoreCase(msg.text())) {
JSONObject jsonObject = JSON.parseObject(msg.text());
String type = jsonObject.getString("type");
// 用户第一次进入app,发送的设备信息,通知其他监听的用户
if (type.equals(SocketMsgType.DEVICE.getType())) {
// 告知当前设备消息发送成功
ctx.writeAndFlush(new TextWebSocketFrame("ok"));
UserOnlineManager.addChannel(ctx.channel(), msg.text());
UserOnlineManager.broadCastToOnlineUser();
} else {
// 用户发送的消息,通知对应的用户,如果在线的话
UserOnlineManager.sendMessage(msg.text(), jsonObject.getString("toUserId"));
}
}
}
@Override
public void channelUnregistered(ChannelHandlerContext ctx) {
// log.info("用户断开连接:{}", ctx.channel().attr(AttributeKey.valueOf("userId")).get());
UserOnlineManager.removeChannel(ctx.channel());
UserOnlineManager.broadCastToOnlineUser();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof NotSslRecordException) {
// 处理 SSL 相关的异常
log.error("SSL 请求异常,非https请求, 异常请求channel: {}, message is {}", ctx.channel(), cause.getMessage());
} else if (cause instanceof IOException) {
// 处理 I/O 异常
log.error("I/O 异常:{}", cause.getMessage());
} else if (cause instanceof DecoderException) {
log.error("请求解码异常, 异常请求channel: {},已关闭. message is {}", ctx.channel(), cause.getMessage());
} else {
// 处理其他异常
log.error("未知异常:", cause);
}
// 关闭连接
UserOnlineManager.removeChannel(ctx.channel());
UserOnlineManager.broadCastToOnlineUser();
}
}
UserOnlineManager负责控制和存储各个channel,这里也会存储用户在线设备信息,推送给管理端实时用户在线信息。
@Component
@Slf4j
public class UserOnlineManager {
private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
private static final ConcurrentMap<Channel, String> channelToSource = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, List<String>> userIdToSources = new ConcurrentHashMap<>();
private static final ConcurrentMap<Channel, String> channelToPlatform = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, Channel> userIdToChannel = new ConcurrentHashMap<>();
public static void addChannel(Channel channel, String source) {
String userId = String.valueOf(channel.attr(AttributeKey.valueOf("userId")).get());
String platform = String.valueOf(channel.attr(AttributeKey.valueOf("platform")).get());
if (platform.equals("wx") ) {
userIdToChannel.put(userId, channel);
}
channelToPlatform.put(channel, platform);
channelToSource.put(channel, source);
List<String> sources = userIdToSources.getOrDefault(userId, new ArrayList<>());
sources.add(source);
userIdToSources.put(userId, sources);
}
public static void removeChannel(Channel channel) {
try {
lock.writeLock().lock();
channel.close();
String source = channelToSource.getOrDefault(channel, null);
channelToPlatform.remove(channel); // 移除连接平台信息
if (StrUtil.isNotEmpty(source)) {
String userId = String.valueOf(channel.attr(AttributeKey.valueOf("userId")).get());
// log.info("移除用户连接{}", userId);
List<String> sources = userIdToSources.getOrDefault(userId, new ArrayList<>());
// 删除
sources.remove(source);
channelToSource.remove(channel);
userIdToChannel.remove(userId);
// 完全下线
if (sources.isEmpty()) {
userIdToSources.remove(userId);
}
}
} finally {
lock.writeLock().unlock();
}
}
public static ConcurrentMap<String, List<String>> getOnlineUserIdToSources() {
return userIdToSources;
}
public static void broadCastToOnlineUser() {
try {
lock.readLock().lock();
Set<Channel> channels = channelToSource.keySet();
for (Channel channel : channels) {
String platform = channelToPlatform.get(channel);
// web管理的需要通知有新用户并展示
if ("web".equals(platform)) {
if (channel.isActive() && channel.isOpen() && channel.isWritable() && channel.isRegistered()) {
// log.info("转发给用户{}更新信息", channel.attr(AttributeKey.valueOf("userId")).get());
channel.writeAndFlush(new TextWebSocketFrame(IdUtil.fastSimpleUUID()));
} else {
//log.info("remove channel: {}", channel.id());
removeChannel(channel);
}
}
}
} finally {
lock.readLock().unlock();
}
}
public static void sendMessage(String message, String toUserId) {
Channel channel = userIdToChannel.get(toUserId);
if (channel != null && StrUtil.isNotEmpty(toUserId) && StrUtil.isNotEmpty(message)) {
if (channel.isActive() && channel.isOpen() && channel.isWritable() && channel.isRegistered()) {
// 通知接收者
channel.writeAndFlush(new TextWebSocketFrame(message));
} else {
removeChannel(channel);
}
}
}
}
所有代码都在netty包下。
4. 前端
从演示的视频可以看到,我们需要在聊天列表页面和聊天页面使用到socket接收和发送信息,所以使用vuex创建一个全局websocket,结合vue的数据变更监听来实现实时显示。
全局websocket:
socket的链接实在应用启动的时候,写在了App.vue中。
import indexConfig from "@/config/index.config.js";
import { getToken } from "@/utils/auth.js";
const MAX_RECONNECT_ATTEMPTS = 5; // 最大重连次数
const socketData = {
state: {
socketTask: null, //socket实例
websocketData: '', // 存放从后端接收到的websocket数据
webSocketPingTimer: null, // 心跳定时器
webSocketPingTime: 5000, // 心跳的间隔,当前为 10秒,
webSocketReconnectCount: 1, // 重连次数
webSocketIsReconnect: true, // 是否重连
webSocketIsOpen: true, //链接是否在打开
},
getters: {
websocketData(state) {
return state.websocketData;
},
},
mutations: {
setSockTask(state, data) {
state.socketTask = data;
},
setWebsocketData(state, data) {
state.websocketData = data;
},
setReconnectCount(state, count) {
state.webSocketReconnectCount = count;
},
setIsOpen(state, data) {
state.webSocketIsOpen = data;
},
},
actions: {
websocketInit({ state, dispatch, commit }) {
let socketTask = uni.connectSocket({
// url, // url是websocket连接ip
url: `${indexConfig.socketUrl}?token=${getToken()}&platform=wx`,
// 我只关心我的socket连接成功还是失败,你给我socketTask对象我自己调用其中的方法进行处理即可,不用关心uni接口调用成功还是失败。
complete: () => {}, // 回调函数
});
//检测链接打开
socketTask.onOpen(() => dispatch("websocketOnOpen"));
//接收服务器消息
socketTask.onMessage((res) => dispatch("websocketOnMessage", res));
// 链接关闭事件
socketTask.onClose((e) => dispatch("websocketOnClose"));
//链接错误
socketTask.onError((e) => dispatch("websocketOnError"));
commit("setSockTask", socketTask);
},
// 连接打开时
websocketOnOpen({ dispatch, commit }) {
console.log("WebSocket连接正常打开!");
commit("setIsOpen", true);
commit("setReconnectCount", 1); // 重置重连计数
// 发送在线设备信息
uni.getSystemInfo({
success: (e) => {
const device = e.deviceBrand + "(" + e.deviceType + ")";
let msgBody = {
content: device,
type: "device",
toUserId: "",
};
// console.log('发送设备信息中');
dispatch("websocketSend", JSON.stringify(msgBody));
},
});
//开始心跳检测
dispatch("webSocketPing");
},
// 发送数据
websocketSend({ state, dispatch }, data) {
state.socketTask.send({
data,
fail: (e) => {
console.log("uni send接口调用失败", e);
dispatch("webSocketClose");
},
});
},
// 收到数据
websocketOnMessage({ commit }, res) {
// 修改状态为未连接
//接到推送的消息--显示全局弹窗
// 存储消息
// console.log('收到服务器内容', res)
if (res.data !== "pong" && res.data !== 'ok') {
// 心跳不记录
// console.log("收到服务器内容:" + res.data);
commit("setWebsocketData", res.data);
}
},
websocketOnClose({ state, commit, dispatch }) {
//服务端连接关闭,或者遇到error时触发
commit("setIsOpen", false);
clearTimeout(state.webSocketPingTimer); // 清理心跳定时器
state.socketTask = null;
console.log("websocketOnClose连接关闭");
},
// socket连接异常
websocketOnError({ commit, dispatch }) {
//链接关闭执行
console.log("websocketOnError连接错误");
dispatch("webSocketClose");
},
// 定时心跳告诉服务器自己还活着,防止丢包
webSocketPing({ state, dispatch }) {
if (getToken() && state.webSocketIsOpen) {
state.webSocketPingTimer = setTimeout(() => {
// console.log("发送心跳,ping");
dispatch("websocketSend", "ping");
clearTimeout(state.webSocketPingTimer); // 清理当前心跳定时器
dispatch("webSocketPing"); // 重新开始心跳
}, state.webSocketPingTime);
}
},
// 连接断开做的处理
webSocketClose({ state, dispatch, commit }) {
if (getToken()) {
commit("setIsOpen", false);
clearTimeout(state.webSocketPingTimer); // 清理心跳定时器
state.socketTask = null;
if (
state.webSocketIsReconnect &&
state.webSocketReconnectCount <= MAX_RECONNECT_ATTEMPTS
) {
dispatch("webSocketReconnect");
}
}
},
// WebSocket 重连
webSocketReconnect({ state, dispatch, commit }) {
if (
getToken() &&
!state.webSocketIsOpen &&
state.webSocketReconnectCount <= MAX_RECONNECT_ATTEMPTS
) {
console.log(`第 ${state.webSocketReconnectCount} 次重连`);
commit("setReconnectCount", state.webSocketReconnectCount + 1);
dispatch("websocketInit"); // 尝试重连
// 5秒后检查是否重连成功,否则继续尝试
setTimeout(() => {
if (
!state.webSocketIsOpen &&
state.webSocketReconnectCount <= MAX_RECONNECT_ATTEMPTS
) {
dispatch("webSocketReconnect");
}
}, 5000);
}
},
//手动关闭websocket
websocketCloseGuanBi({ state }) {
if (!state.socketTask) return;
state.socketTask.close({
complete: () => {},
});
},
},
};
export default socketData;
这里会将接收到的socket消息存储下来,我们不同的页面根据不同的消息type来做不同的逻辑。
在首页实现消息提醒。
export default {
computed: {
...mapGetters(["websocketData"]),
},
watch: {
websocketData: {
handler(newValue, oldValue) {
// 只有在当前页面的时候执行此逻辑
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage.route === "pages/index") {
console.log("home收到新消息");
let newMsgBody = JSON.parse(newValue);
if (newMsgBody.type === "user_message") {
// 收到新消息
let newMsg = newMsgBody.content;
this.$refs.tips.show({
msg: "收到新消息,已为您刷新",
backgroundColor: "rgba(0, 0, 0, 0.4)",
fontColor: "#FFFFFF",
duration: 3000,
});
// 刷新对应tab
this.getMessageList(newMsg.messageType);
}
}
},
deep: true, // 深度监听对象内的属性变化
},
},
}
完整代码位置:pages/component/home.vue
消息聊天界面:
export default {
computed: {
...mapGetters(["websocketData"]),
},
watch: {
websocketData: {
handler(newValue, oldValue) {
// 在这里处理websocketData变化后的逻辑
// console.log("socket 数据发送变化", newValue);
let newMsgBody = JSON.parse(newValue);
if (newMsgBody.type === "user_message") {
// 收到新消息
let newMsg = newMsgBody.content;
if (
newMsg.messageSenderId === this.senderId &&
newMsg.messageReceiverId === this.senderId
) {
newMsg.type = 1;
} else {
newMsg.type = newMsg.messageSenderId === this.curUserId ? 1 : 0;
}
this.talkList.push(newMsg);
this.$nextTick(() => {
uni.pageScrollTo({
scrollTop: 999999, // 设置一个超大值,以保证滚动条滚动到底部
duration: 0,
});
});
setMessageToReadApi([newMsg.id]).then(() => {
this.notifyMsgToRead([newMsg.id]);
newMsg.readState = 1;
});
uni.$emit("receiveNewMsg", { newMsg, index: this.curMsgIndex });
} else if (newMsgBody.type === "user_message_read") {
// 消息列表中id相同的置为已读
let msgIds = newMsgBody.content;
// talkList中id于msgIds相同的
this.talkList.forEach((item) => {
if (msgIds.includes(item.id)) {
item.readState = 1;
}
});
}
},
deep: true, // 深度监听对象内的属性变化
},
},
}
这里也是实现了其他功能,如通知消息列表页面刷新消息动态,消息内容。
位置:pages/message/index.vue
5. 占位
管理端项目有演示版本可查看
登录账号:202300001,密码:123456