WebSocket+Netty构建web聊天程序
WebSocket
传统的浏览器和服务器之间的交互模式是基于请求/响应的模式,虽然可以使用js发送定时任务让浏览器在服务器中拉取但是弊端很明显,首先就是不能避免的延迟,其次就是频繁的请求,让服务器的压力骤然提升
WebSocket是H5新增的协议,用于构建浏览器和服务器之间的不受限的长连接的通信模式,不再局限于请求/响应式的模型,服务端可以主动推送消息给客户端,(游戏有某个玩家得奖了的弹幕)基于这个特性我们可以构建我们的实时的通信程序
协议详情:
websocket建立连接时,是通过浏览器发送的HTTP请求,报文如下:
GET ws://localhost:3000/ws/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Origin: http://localhost:3000
Sec-WebSocket-Key: client-random-string
Sec-WebSocket-Version: 13
- 首先GET请求是以
ws
开头的 - 其中请求头中的
Upgrade: websocket Connection: Upgrade
表示尝试建立WebSocket连接
对于服务端的相应数据
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: server-random-string
其中的101
,表示服务端支持WebSocket协议, 双方基于Http请求,成功建立起WebSocket连接,双方之间的通信也不再通过HTTP
JS对WebSocket的封装对象
对于JS的WebSocket对象来说,它常用 4个回调方法,以及两个主动方法
方法名 | 作用 |
---|---|
onopen() | 和服务端成功建立连接后回调 |
onmessage(e) | 收到服务端的的消息后回调,e为消息对象 |
onerror() | 链接出现异常回调,如服务端关闭 |
onclose() | 客户端单方面断开连接时回调 |
send(e) | 主动向服务端推送消息 |
close() | 主动关闭通道 |
再次对WebSocket进行封装
知道了回调函数回调时机,我们接下来要做的就是在他的整个生命周期的不同回调函数中,添加我们指定的动作就ok了,下面是通过Window定义一个全局的聊天对象CHAT
window.CHAT={
var socket = null;
// 初始化socket
init:function(){
// 判断当前的浏览器是否支持WebSocket
if(window.WebSocket){
// 检验当前的webSocket是否存在,以及连接的状态,如已经连接,直接返回
if(CHAT.socket!=null&&CHAT.socket!=undefined&&CHAT.socket.readyState==WebSocket.OPEN){
return false;
}else{// 实例化 , 第二个ws是我们可以自定义的, 根据后端的路由来
CHAT.socket=new WebSocket("ws://192.168.43.10:9999/ws");
// 初始化WebSocket原生的方法
CHAT.socket.onopen=CHAT.myopen();
CHAT.socket.onmessage=CHAT.mymessage();
CHAT.socket.onerror=CHAT.myerror();
CHAT.socket.onclose=CHAT.myclose();
}
}else{
alert("当前设备不支持WebSocket");
}
}
// 发送聊天消息
chat:function(msg){
// 如果的当前的WebSocket是连接的状态,直接发送 否则从新连接
if(CHAT.socket.readyState==WebSocket.OPEN&&CHAT.socket!=null&&CHAT.socket!=undefined){
socket.send(msg);
}else{
// 重新连接
CHAT.init();
// 延迟一会,从新发送
setTimeout(1000);
CHAT.send(msg);
}
}
// 当连接建立完成后对调
myopen:function(){
// 拉取连接建立之前的未签收的消息记录
// 发送心跳包
}
mymessage:function(msg){
// 因为服务端可以主动的推送消息,我们提前定义和后端统一msg的类型, 如,拉取好友信息的消息,或 聊天的消息
if(msg==聊天内容){
// 发送请求签收消息,改变请求的状态
// 将消息缓存到本地
// 将msg 转换成消息对象, 植入html进行渲染
}else if(msg==拉取好友列表){
// 发送请求更新好友列表
}
}
myerror:function(){
console.log("连接出现异常...");
}
myclose:function(){
console.log("连接关闭...");
}
keepalive: function() {
// 构建对象
var dataContent = new app.DataContent(app.KEEPALIVE, null, null);
// 发送心跳
CHAT.chat(JSON.stringify(dataContent));
// 定时执行函数, 其他操作
// 拉取未读消息
// 拉取好友信息
}
}
对消息类型的约定
WebSocket对象通过send(msg)
;方法向后端提交数据,常见的数据如下:
- 客户端发送聊天消息
- 客户端签收消息
- 客户端发送心跳包
- 客户端请求建立连接
为了使后端接收到不同的类型的数据做出不同的动作, 于是我们约定发送的msg的类型;
// 消息action的枚举,这个枚举和后端约定好,统一值
CONNECT: 1, // 第一次(或重连)初始化连接
CHAT: 2, // 聊天消息
SIGNED: 3, // 消息签收
KEEPALIVE: 4, // 客户端保持心跳
PULL_FRIEND:5, // 重新拉取好友
// 消息模型的构造函数
ChatMsg: function(senderId, receiverId, msg, msgId){
this.senderId = senderId;
this.receiverId = receiverId;
this.msg = msg;
this.msgId = msgId;
}
// 进一步封装两个得到最终版消息模型的构造函数
DataContent: function(action, chatMsg, extand){
this.action = action;
this.chatMsg = chatMsg;
this.extand = extand;
}
如何发送数据?
我们使用js,给发送按钮绑定点击事件,一经触发,从缓存中获取出我们需要的参数,调用
CHAT.chat(Json.stringify(dataContent));
后端netty会解析dataContent的类型,进一步处理
如何签收未与服务器连接时好友发送的消息?
-
消息的签收时机:
之所以会有未签收的信息,是因为客户端未与服务端建立WebSocket连接, 当服务端判断他维护的channel组中没有接受者的channel时,不会发送数据,而是把数据持久化到数据库,并且标记flag=未读, 所以我们签收信息自然放在客户端和服务端建立起连接时的回调函数中执行 -
步骤:
- 客户端通过js请求,拉取全部的和自己相关的flag=未读的消息实体列表
- 从回调函数数中,把列表中的数据取出,缓存在本地
- 将列表中的数据回显在html页面中
- 和后端约定,将该列表中所有的实例的id取出,用逗号分隔拼接成字符串, 以
action=SIGNED
的方式发送给后端,让其进行签收
Netty对WebSocket的支持
首先每一个Netty服务端的程序都是形似的,想创建不同的服务端,就得给Netty装配的pipeline不同的Handler
针对聊天程序,处理String类型的Json信息,我们选取SimpleChannelInboundHandler
, 他是个典型的入站处理器,并且如果我们没有出来数据,她会帮我们回收 重写它里面未实现抽象方法,这些抽象方法同样是回调方法, 当一个新的Channel进来, 它注册进Selector上的过程中,会回调不同的抽象方法
方法名 | 回调时机 |
---|---|
handlerAdded(ChannelHandlerContext ctx) | Pepiline中的Handler添加完成回调 |
channelRegistered(ChannelHandlerContext ctx) | channel注册进Selector后回调 |
channelActive(ChannelHandlerContext ctx) | channel处于活动状态回调 |
channelReadComplete(ChannelHandlerContext ctx) | channel, read结束后回调 |
userEventTriggered(ChannelHandlerContext ctx, Object evt) | 当出现用户事件时回调,如 读/写 |
channelInactive(ChannelHandlerContext ctx) | 客户端断开连接时回调 |
channelUnregistered(ChannelHandlerContext ctx) | 客户端断开连接后,取消channel的注册时回调 |
handlerRemoved(ChannelHandlerContext ctx) | 取消channel的注册后,将channel移除ChannelGroup后回调 |
exceptionCaught(ChannelHandlerContext ctx, Throwable cause) | 出现异常时回调 |
handler的设计编码
要做到点对点的聊天,前提是服务端拥有全部的channel因为所有数据的读写都依赖于它,而 netty为我们提供了ChannelGroup
用来保存所有新添加进来的channel, 此外点对点的聊天,我们需要将用户信息和它所属的channel进行一对一的绑定,才可以精准的匹配出两个channel进而数据交互, 因此添加UserChannel映射类
public class UserChanelRelationship {
private static HashMap<String, Channel> manager = new HashMap<>();
public static void put(String sendId,Channel channel){
manager.put(sendId,channel);
}
public static Channel get(String sendId){
return manager.get(sendId);
}
public static void outPut(){
for (HashMap.Entry<String,Channel> entry:manager.entrySet()){
System.out.println("UserId: "+entry.getKey() + "channelId: "+entry.getValue().id().asLongText());
}
}
}
我们把User和Channel之间的关系以键值对的形式存放进Map中,服务端启动后,程序就会维护这个map, 那么问题来了? 什么时候添加两者之间的映射关系呢? 看上handler的回调函数,我们选择 channelRead0()
当我们判断出 客户端发送过来的信息是 CONNECT
类型时,添加映射关系
下面是handler的处理编码
public class MyHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// 用于管理整个客户端的 组
public static ChannelGroup users = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame frame) throws Exception {
Channel currentChanenl = channelHandlerContext.channel();
// 1. 获取客户端发送的消息
String content = frame.text();
System.out.println(" content: "+content);
// 2. 判断不同的消息的类型, 根据不同的类型进行不同的处理
// 当建立连接时, 第一次open , 初始化channel,将channel和数据库中的用户做一个唯一的关联
DataContent dataContent = JsonUtils.jsonToPojo(content,DataContent.class);
Integer action = dataContent.getAction();
if (action == MsgActionEnum.CHAT.type) {
// 3. 把聊天记录保存到数据库
// 4. 同时标记消息的签收状态 [未签收]
// 5. 从我们的映射中获取接受方的chanel 发送消息
// 6. 从 chanelGroup中查找 当前的channel是否存在于 group, 只有存在,我们才进行下一步发送
// 6.1 如果没有接受者用户channel就不writeAndFlush, 等着用户上线后,通过js发起请求拉取未接受的信息
// 6.2 如果没有接受者用户channel就不writeAndFlush, 可以选择推送
}else if (action == MsgActionEnum.CONNECT.type){
// 当建立连接时, 第一次open , 初始化channel,将channel和数据库中的用户做一个唯一的关联
String sendId = dataContent.getChatMsg().getSenderId();
UserChanelRelationship.put(sendId,currentChanenl);
}else if(action == MsgActionEnum.SINGNED.type){
// 7. 当用户没有上线时,发送消息的人把要发送的消息持久化在数据库,但是却没有把信息写回到接受者的channel, 把这种消息称为未签收的消息
// 8. 签收消息, 就是修改数据库中消息的签收状态, 我们和前端约定,前端如何签收消息在上面有提到
String extend = dataContent.getExtand();
// 扩展字段在 signed类型代表 需要被签收的消息的id, 用逗号分隔
String[] msgIdList = extend.split(",");
List<String> msgIds = new ArrayList<>();
Arrays.asList(msgIdList).forEach(s->{
if (null!=s){
msgIds.add(s);
}
});
if (!msgIds.isEmpty()&&null!=msgIds&&msgIds.size()>0){
// 批量签收
}
}else if (action == MsgActionEnum.KEEPALIVE.type){
// 6. 心跳类型
System.out.println("收到来自channel 为" +currentChanenl+" 的心跳包... ");
}
}
// handler 添加完成后回调
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
// 获取链接, 并且若想要群发的话,就得往每一个channel中写数据, 因此我们得在创建连接时, 把channel保存起来
System.err.println("handlerAdded");
users .add(ctx.channel());
}
// 用户关闭了浏览器回调
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
// 断开连接后, channel会自动移除group
// 我们主动的关闭进行, channel会被移除, 但是我们如果是开启的飞行模式,不会被移除
System.err.println("客户端channel被移出: "+ctx.channel().id().asShortText());
users.remove(ctx.channel());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
// 发生异常关闭channel, 并从ChannelGroup中移除Channel
ctx.channel().close();
users.remove(ctx.channel());
}
... 其他方法
前后端的心跳维持
双方建立起WebSocket连接后,服务端需要明确的知道,自己维护的诸多channel中,谁已经挂掉了, 为了提高性能,需要及早把废弃的channel移除ChanelGroup
客户端杀掉了进程,或者开启了飞行模式, 这时服务端是感知不到它维护的channel中已经有一个不能使用了,首先来说,维护一个不能使用的channel会影响性能,而且当这个channel的好友给他发送消息时,服务端认为用户在线,于是向一个不存在的channel写入刷新数据,会带来额外的麻烦
这时我们就需要添加心跳机制,客户端设置定时任务,每个一段时间就往服务端发送心跳包,心跳包的内容是什么不是重点,它的作用就是告诉服务端自己还active, N多个客户端都要向服务端发送心跳,这并不会增加服务端的请求,因为这个请求是通过WebSocket的send方法发送过去的,只不过dataContent的类型是 KEEPALIVE , 同样这是我们提前约定好的(此外,服务端向客户端发送心跳看起来是没有必要的)
于是对于后端来说,我们发送的心跳包,会使得当前客户端对应的channel的channelRead0()方法回调, netty为我们提供了心跳相关的handler, 每一次的chanelRead0()的回调,都是read/write事件, 下面是netty对心跳的支持的实现
/**
* @Author: Changwu
* @Date: 2019/7/2 9:33
* 我们的心跳handler不需要实现handler0方法,我们选择,直接继承SimpleInboundHandler的父类
*/
public class HeartHandler extends ChannelInboundHandlerAdapter {
// 我们重写 EventTrigger 方法
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
// 当出现read/write 读写写空闲时触发
if(evt instanceof IdleStateEvent){
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state()== IdleState.READER_IDLE){ // 读空闲
System.out.println(ctx.channel().id().asShortText()+" 读空闲... ");
}else if (event.state()==IdleState.WRITER_IDLE){
System.out.println(ctx.channel().id().asShortText()+" 写空闲... ");
}else if (event.state()==IdleState.ALL_IDLE){
System.out.println("channel 读写空闲, 准备关闭当前channel , 当前UsersChanel的数量: "+MyHandler.users.size());
Channel channel = ctx.channel();
channel.close();
System.out.println("channel 关闭后, UsersChanel的数量: "+MyHandler.users.size());
}
}
}
Handler我们不再使用SimpleChannelInboundHandler了,因为它当中的方法都是抽象方法,而我们需要回调的函数时机是,每次当有用户事件时回调, 比如read,write事件, 这些事件可以证明channel还活着,对应的方法是userEventTriggered()
此外, ChannelInboundHandlerAdapter是netty中,适配器模式的体现, 它实现了全都抽象方法,然后他的实现方法中并不是在干活,而是把这个事件往下传播下去了,现在我们重写userEventTriggered()
执行的就是我们的逻辑
另外,我们需要在pipeline中添加handler
...
/ 添加netty为我们提供的 检测空闲的处理器, 每 20 40 60 秒, 会触发userEventTriggered事件的回调
pipeline.addLast(new IdleStateHandler(10,20,30));
// todo 添加心跳的支持
pipeline.addLast("heartHandler",new HeartHandler());
服务端主动向客户端推送数据
如, 添加好友的操作中, A向B发送添加好友请求的过程,会经过如下几步
- A向服务端发送ajax请求,将自己的id, 目标朋友的id持久化到 数据库,请求friend_request表
- 用户B上线,通过js,向后端拉取friend_request表中有没有关于自己的信息,于是服务端把A的请求给B推送过去
- 在B的前端回显A的请求, B进一步处理这个信息, 此时两种情况
- B拒绝了A的请求: 后端把friend_request表关于AB的信息清除
- B同意了A的请求: 后端在firend_List表中,将AB双方的信息都持久化进去, 这时我们可以顺势在后端的方法中,给B推送最新的联系人信息, 但是这不属于主动推送,因为这次会话是客户端主动发起的
但是A却不知道,B已经同意了,于是需要给A主动的推送数据, 怎么推送呢? 我们需要在上面的UserChannel的关系中,拿出发送者的channel, 然后往回writeAndFlush
内容,这时A就得知B已经同意了,重新加载好友列表