基於 Netty 的可插拔業務通訊協議的實現「3」業務註冊及實際工作流程

雪中亮發表於2018-04-27

本文為該系列的第三篇文章,設計需求為:服務端程式和眾多客戶端程式通過 TCP 協議進行通訊,通訊雙方需通訊的訊息種類眾多。上一篇文章以一個具體的需求為例,探討了指定的 Java 訊息物件與其相應的二進位制資料幀相互轉換的方法。本文仍以該例項為例,探討該自定義通訊協議的具體工作流程,以及如何以註冊的形式靈活插拔通訊訊息物件。

1. 以註冊的形式實現通訊訊息物件的統一管理

通過該系列的第二篇文章可知,各個訊息物件的編解碼器類均擁有一個靜態工廠方法,用於手動傳入功能位及功能文字描述,進而生成包含這些引數的編解碼器。如此設計,使得所有訊息的功能位和文字描述均能夠統一管理,降低維護成本。

根據上述需求,可通過 Map 容器管理所有的編解碼器,有如下優點:

  1. 進行訊息物件生成操作時,可直接使用相應編解碼器的訊息物件靜態建立方法。
  2. 進行訊息物件的編碼操作時,已擁有該 Java 訊息物件,即可知道訊息物件的功能位,據此可獲取相應的編解碼器;或者,每個 Java 訊息物件均內含相應編解碼器的引用,故可直接對該訊息物件進行編碼操作。
  3. 進行二進位制資料幀的解碼操作時,資料幀中已包含了訊息的功能位,據此可獲取相應的編解碼器,而後可以對該資料幀進行解析,生成相應的 Java 訊息物件。

通訊訊息物件註冊方法如下所示:

/**
 * 訊息物件的註冊
 *
 * @param toolkit 訊息物件編解碼器容器的工具類
 */
private void initialMsg() {
    saveNormalMsgCodec(new MsgCodecDeviceUnlock(0x10, 0x11, "客戶端解鎖"));
    saveNormalMsgCodec(new MsgCodecDeviceClear(0x10, 0x13, "客戶端初始化"));
    saveNormalMsgCodec(new MsgCodecDeviceId(0x10, 0x1B, "客戶端ID設定"));
    saveNormalMsgCodec(new MsgCodecEmployeeName(0x10, 0x1C, "客戶端別名設定"));
    ... ...
}

/**
 * 將普通訊息物件及其回覆訊息物件的編解碼器均儲存到 HashMap 中
 *
 * @param baseMsgCodec 特定的訊息物件編解碼器
 */
private void saveNormalMsgCodec(BaseMsgCodec baseMsgCodec) {
    saveSpecialMsgCodec(baseMsgCodec);
    baseMsgCodec = new MsgCodecReplyNormal(baseMsgCodec.getMajorMsgId() + 0x10, baseMsgCodec.getSubMsgId(), baseMsgCodec.getDetail());
    saveSpecialMsgCodec(baseMsgCodec);
}

/**
 * 將訊息物件的編解碼器儲存到 HashMap 中
 *
 * @param baseMsgCodec 特定的編解碼器
 */
private void saveSpecialMsgCodec(BaseMsgCodec baseMsgCodec) {
    HASH_MAP.put(figureFrameId(baseMsgCodec.getMajorMsgId(), baseMsgCodec.getSubMsgId()), baseMsgCodec);
}
複製程式碼

上述程式碼表明,如果有新的業務需求,需要增刪「插拔」業務訊息物件,只需在 initialMsg() 方法中,對相應編解碼器的註冊語句進行增刪即可。

saveNormalMsgCodec(BaseMsgCodec) 方法可以同時註冊特定業務訊息物件及其通用回覆訊息物件,操作方法清晰、簡潔。

所以,在啟動該 Java 程式時,只需要在啟動過程中,執行上述 initialMsg() 方法,即可完成所有業務訊息物件的註冊。

2. 多個訊息物件自由組合進同一個資料幀的實現原理

由該系列的第一篇文章可知,如果某二進位制資料幀所要傳輸的資料體部分內容很少,導致一個幀的大部分容量均被幀頭佔據,導致有效資料的佔比很小,這就產生了巨大的浪費,故資料幀的資料體部分由子幀組成,同一類子幀均可被組裝進同一個資料幀。如此做法,整個通訊鏈路的資料量會明顯減少,IO 負擔也會因此減輕。

該需求的實現原理如下所示:

/**
 * 啟動一個Channel的定時任務,用於間隔指定的時間對訊息佇列進行輪詢,併傳送指定資料幀
 *
 * @param deque     指定的訊息傳送佇列
 * @param channelId 指定 Channel 的序號
 */
private void startMessageQueueTask(LinkedBlockingDeque<BaseMsg> deque, Integer channelId) {
    executorService.scheduleWithFixedDelay(() -> {
        try {
            BaseMsg baseMsg = deque.take();         // 從佇列中取出一個訊息物件,佇列為空時阻塞
            Thread.sleep(AWAKE_TO_PROCESS_INTERVAL);// 等待極短的時間,保證佇列中快取儘可能多的物件
            Channel channel = touchChannel(channelId); // 獲取指定的待傳送的 Channel
            List<ByteBuf> dataList = new ArrayList<>();// 子幀容器
            ByteBuf data = baseMsg.subFrameEncode(channel.alloc().buffer());// 編碼一個子幀
            dataList.add(data);
            touchNeedReplyMsg(baseMsg);            // 對該子幀設定檢錯重發任務
            int length = data.readableBytes();
            int flag = baseMsg.combineFrameFlag(); // 獲取訊息物件標識
            while (true) {
                BaseMsg subMsg = deque.peek();     // 檢視佇列中的第一個訊息物件
                if (subMsg == null || subMsg.combineFrameFlag() != flag) {
                    break; // 訊息物件標識不同,即欲生成的主幀幀頭不同,不能組合進同一主幀
                }
                data = subMsg.subFrameEncode(channel.alloc().buffer());
                if (length + data.readableBytes() > FrameSetting.MAX_DATA_LENGTH) {
                    break;
                }
                length += data.readableBytes();
                dataList.add(data);                // 組合進了同一主幀
                deque.poll();                      // 從佇列中移除該訊息物件
                touchNeedReplyMsg(subMsg);
            }
            FrameMajorHeader frameHeader = new FrameMajorHeader(
                    baseMsg.getMajorMsgId(),
                    baseMsg.getGroupId(),
                    baseMsg.getDeviceId(),
                    length);                       // 生成主幀幀頭訊息物件
            channel.writeAndFlush(new SendableMsgContainer(frameHeader, dataList)); // 送入Channel進行傳送
        } catch (InterruptedException e) {
            logger.warn("訊息佇列定時傳送任務被中斷");
        }
    }, channelId, CommSetting.FRAME_SEND_INTERVAL, TimeUnit.MILLISECONDS);
}
複製程式碼

由程式碼可知,待傳送的訊息物件均被送入指定的傳送佇列進行快取,某客戶端相應的執行緒對佇列進行操作,取出訊息物件並進行編碼、組裝、傳送等。當然,當客戶端數量較多時,上述的執行緒實現方式可採用 Netty 的 NIO 方式進行優化,以降低系統開銷。

由上述描述可知,欲傳送一個訊息物件,只需將該訊息物件送入相應的傳送佇列即可。

3. 實際業務訊息物件的編解碼

3.1 訊息物件的編碼方式

由於每個 Java 訊息物件均內含相應編解碼器的引用,故可直接對該訊息物件進行編碼操作,程式碼如下:

public abstract class BaseMsg implements Cloneable {
    private final BaseMsgCodec msgCodec;
    ... ...

    /**
     * 將 java 訊息物件編碼為 TCP 子幀
     *
     * @param buffer 空白的 TCP 子幀的容器
     * @return 儲存有 TCP 子幀的容器
     */
    public ByteBuf subFrameEncode(ByteBuf buffer) {
        return msgCodec.code(this, buffer);
    }
}
複製程式碼

3.2 訊息物件的解碼方式

首先根據資料幀的幀頭,即可解析出 FrameMajorHeader 物件,然後即可呼叫如下方法完成子幀的解析工作。實現原理文章開頭已指出。

/**
 * TCP 幀解碼為 Java 訊息物件
 *
 * @param head     主幀頭
 * @param subMsgId 子幀功能位
 * @param data     子幀資料
 * @return 已解碼的 Java 物件
 */
public BaseMsg decode(FrameMajorHeader head, int subMsgId, byte[] data) {
    BaseMsgCodec msgCodec = MsgCodecToolkit.getMsgCodec(head.getMsgId(), subMsgId);
    return msgCodec.decode(head.getGroupId(), head.getDeviceId(), data);
}
複製程式碼

相關文章