WebSocket+Netty構建web聊天程式

賜我白日夢發表於2019-07-14

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已經同意了,重新載入好友列表

相關文章