netty源碼分析之拆包器的奧秘

為什么要粘包拆包

為什么要粘包

首先你得了解一下TCP/IP協議,在用戶數據量非常小的情況下,極端情況下,一個字節,該TCP數據包的有效載荷非常低,傳遞100字節的數據,需要100次TCP傳送,100次ACK,在應用及時性要求不高的情況下,將這100個有效數據拼接成一個數據包,那會縮短到一個TCP數據包,以及一個ack,有效載荷提高了,帶寬也節省了

非極端情況,有可能兩個數據包拼接成一個數據包,也有可能一個半的數據包拼接成一個數據包,也有可能兩個半的數據包拼接成一個數據包

為什么要拆包

拆包和粘包是相對的,一端粘了包,另外一端就需要將粘過的包拆開,舉個栗子,發送端將三個數據包粘成兩個TCP數據包發送到接收端,接收端就需要根據應用協議將兩個數據包重新組裝成三個數據包

還有一種情況就是用戶數據包超過了mss(最大報文長度),那么這個數據包在發送的時候必須拆分成幾個數據包,接收端收到之后需要將這些數據包粘合起來之后,再拆開

拆包的原理

在沒有netty的情況下,用戶如果自己需要拆包,基本原理就是不斷從TCP緩沖區中讀取數據,每次讀取完都需要判斷是否是一個完整的數據包

1.如果當前讀取的數據不足以拼接成一個完整的業務數據包,那就保留該數據,繼續從tcp緩沖區中讀取,直到得到一個完整的數據包
2.如果當前讀到的數據加上已經讀取的數據足夠拼接成一個數據包,那就將已經讀取的數據拼接上本次讀取的數據,夠成一個完整的業務數據包傳遞到業務邏輯,多余的數據仍然保留,以便和下次讀到的數據嘗試拼接

netty中拆包的基類

netty 中的拆包也是如上這個原理,內部會有一個累加器,每次讀取到數據都會不斷累加,然后嘗試對累加到的數據進行拆包,拆成一個完整的業務數據包,這個基類叫做 ByteToMessageDecoder,下面我們先詳細分析下這個類

累加器

ByteToMessageDecoder 中定義了兩個累加器

public static final Cumulator MERGE_CUMULATOR = ...;
public static final Cumulator COMPOSITE_CUMULATOR = ...;

默認情況下,會使用 MERGE_CUMULATOR

private Cumulator cumulator = MERGE_CUMULATOR;

MERGE_CUMULATOR 的原理是每次都將讀取到的數據通過內存拷貝的方式,拼接到一個大的字節容器中,這個字節容器在 ByteToMessageDecoder中叫做 cumulation

ByteBuf cumulation;

下面我們看一下 MERGE_CUMULATOR 是如何將新讀取到的數據累加到字節容器里的

public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
        ByteBuf buffer;
        if (cumulation.writerIndex() > cumulation.maxCapacity() - in.readableBytes()
                || cumulation.refCnt() > 1) {
            buffer = expandCumulation(alloc, cumulation, in.readableBytes());
        } else {
            buffer = cumulation;
        }
        buffer.writeBytes(in);
        in.release();
        return buffer;
}

netty 中ByteBuf的抽象,使得累加非常簡單,通過一個簡單的api調用 buffer.writeBytes(in); 便將新數據累加到字節容器中,為了防止字節容器大小不夠,在累加之前還進行了擴容處理

static ByteBuf expandCumulation(ByteBufAllocator alloc, ByteBuf cumulation, int readable) {
        ByteBuf oldCumulation = cumulation;
        cumulation = alloc.buffer(oldCumulation.readableBytes() + readable);
        cumulation.writeBytes(oldCumulation);
        oldCumulation.release();
        return cumulation;
}

擴容也是一個內存拷貝操作,新增的大小即是新讀取數據的大小

拆包抽象

累加器原理清楚之后,下面我們回到主流程,目光集中在 channelRead 方法,channelRead方法是每次從TCP緩沖區讀到數據都會調用的方法,觸發點在AbstractNioByteChannelread方法中,里面有個while循環不斷讀取,讀取到一次就觸發一次channelRead

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    if (msg instanceof ByteBuf) {
        CodecOutputList out = CodecOutputList.newInstance();
        try {
            ByteBuf data = (ByteBuf) msg;
            first = cumulation == null;
            if (first) {
                cumulation = data;
            } else {
                cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
            }
            callDecode(ctx, cumulation, out);
        } catch (DecoderException e) {
            throw e;
        } catch (Throwable t) {
            throw new DecoderException(t);
        } finally {
            if (cumulation != null && !cumulation.isReadable()) {
                numReads = 0;
                cumulation.release();
                cumulation = null;
            } else if (++ numReads >= discardAfterReads) {
                numReads = 0;
                discardSomeReadBytes();
            }

            int size = out.size();
            decodeWasNull = !out.insertSinceRecycled();
            fireChannelRead(ctx, out, size);
            out.recycle();
        }
    } else {
        ctx.fireChannelRead(msg);
    }
}

方法體不長不短,可以分為以下幾個邏輯步驟

1.累加數據
2.將累加到的數據傳遞給業務進行業務拆包
3.清理字節容器
4.傳遞業務數據包給業務解碼器處理

1 累加數據

如果當前累加器沒有數據,就直接跳過內存拷貝,直接將字節容器的指針指向新讀取的數據,否則,調用累加器累加數據至字節容器

ByteBuf data = (ByteBuf) msg;
first = cumulation == null;
if (first) {
    cumulation = data;
} else {
    cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}

2 將累加到的數據傳遞給業務進行拆包

到這一步,字節容器里的數據已是目前未拆包部分的所有的數據了

CodecOutputList out = CodecOutputList.newInstance();
callDecode(ctx, cumulation, out);

callDecode 將嘗試將字節容器的數據拆分成業務數據包塞到業務數據容器out

protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    while (in.isReadable()) {
        // 記錄一下字節容器中有多少字節待拆
        int oldInputLength = in.readableBytes();
        decode(ctx, in, out);
        if (out.size() == 0) {
            // 拆包器未讀取任何數據
            if (oldInputLength == in.readableBytes()) {
                break;
            } else {
             // 拆包器已讀取部分數據,還需要繼續
                continue;
            }
        }

        if (oldInputLength == in.readableBytes()) {
            throw new DecoderException(
                    StringUtil.simpleClassName(getClass()) +
                    ".decode() did not read anything but decoded a message.");
        }

        if (isSingleDecode()) {
            break;
        }
    }
}

我將原始代碼做了一些精簡,在解碼之前,先記錄一下字節容器中有多少字節待拆,然后調用抽象函數 decode 進行拆包

protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception;

netty中對各種用戶協議的支持就體現在這個抽象函數中,傳進去的是當前讀取到的未被消費的所有的數據,以及業務協議包容器,所有的拆包器最終都實現了該抽象方法

業務拆包完成之后,如果發現并沒有拆到一個完整的數據包,這個時候又分兩種情況

1.一個是拆包器什么數據也沒讀取,可能數據還不夠業務拆包器處理,直接break等待新的數據
2.拆包器已讀取部分數據,說明解碼器仍然在工作,繼續解碼

業務拆包完成之后,如果發現已經解到了數據包,但是,發現并沒有讀取任何數據,這個時候就會拋出一個Runtime異常 DecoderException,告訴你,你什么數據都沒讀取,卻解析出一個業務數據包,這是有問題的

3 清理字節容器

業務拆包完成之后,只是從字節容器中取走了數據,但是這部分空間對于字節容器來說依然保留著,而字節容器每次累加字節數據的時候都是將字節數據追加到尾部,如果不對字節容器做清理,那么時間一長就會OOM

正常情況下,其實每次讀取完數據,netty都會在下面這個方法中將字節容器清理,只不過,當發送端發送數據過快,channelReadComplete可能會很久才被調用一次

public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
    numReads = 0;
    discardSomeReadBytes();
    if (decodeWasNull) {
        decodeWasNull = false;
        if (!ctx.channel().config().isAutoRead()) {
            ctx.read();
        }
    }
    ctx.fireChannelReadComplete();
}

這里順帶插一句,如果一次數據讀取完畢之后(可能接收端一邊收,發送端一邊發,這里的讀取完畢指的是接收端在某個時間不再接受到數據為止),發現仍然沒有拆到一個完整的用戶數據包,即使該channel的設置為非自動讀取,也會觸發一次讀取操作 ctx.read(),該操作會重新向selector注冊op_read事件,以便于下一次能讀到數據之后拼接成一個完整的數據包

所以為了防止發送端發送數據過快,netty會在每次讀取到一次數據,業務拆包之后對字節字節容器做清理,清理部分的代碼如下

if (cumulation != null && !cumulation.isReadable()) {
    numReads = 0;
    cumulation.release();
    cumulation = null;
} else if (++ numReads >= discardAfterReads) {
    numReads = 0;
    discardSomeReadBytes();
}

如果字節容器當前已無數據可讀取,直接銷毀字節容器,并且標注一下當前字節容器一次數據也沒讀取

如果連續16次(discardAfterReads的默認值),字節容器中仍然有未被業務拆包器讀取的數據,那就做一次壓縮,有效數據段整體移到容器首部

discardSomeReadBytes之前,字節累加器中的數據分布

+--------------+----------+----------+
|   readed     | unreaded | writable | 
+--------------+----------+----------+

discardSomeReadBytes之后,字節容器中的數據分布

+----------+-------------------------+
| unreaded |      writable           | 
+----------+-------------------------+

這樣字節容器又可以承載更多的數據了

4 傳遞業務數據包給業務解碼器處理

以上三個步驟完成之后,就可以將拆成的包丟到業務解碼器處理了,代碼如下

int size = out.size();
decodeWasNull = !out.insertSinceRecycled();
fireChannelRead(ctx, out, size);
out.recycle();

期間用一個成員變量 decodeWasNull 來標識本次讀取數據是否拆到一個業務數據包,然后調用 fireChannelRead 將拆到的業務數據包都傳遞到后續的handler

static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList msgs, int numElements) {
    for (int i = 0; i < numElements; i ++) {
        ctx.fireChannelRead(msgs.getUnsafe(i));
    }
}

這樣,就可以把一個個完整的業務數據包傳遞到后續的業務解碼器進行解碼,隨后處理業務邏輯

行拆包器

下面,以一個具體的例子來看看業netty自帶的拆包器是如何來拆包的

這個類叫做 LineBasedFrameDecoder,基于行分隔符的拆包器,TA可以同時處理 \n以及\r\n兩種類型的行分隔符,核心方法都在繼承的 decode 方法中

protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
    Object decoded = decode(ctx, in);
    if (decoded != null) {
        out.add(decoded);
    }
}

netty 中自帶的拆包器都是如上這種模板,其實可以加一層,把這這層模板抽取出來的,不知道為什么netty沒有這么做,我們接著跟進去,代碼比較長,我們還是分模塊來剖析

1 找到換行符位置

final int eol = findEndOfLine(buffer);

private static int findEndOfLine(final ByteBuf buffer) {
    int i = buffer.forEachByte(ByteProcessor.FIND_LF);
    if (i > 0 && buffer.getByte(i - 1) == '\r') {
        i--;
    }
    return i;
}

ByteProcessor FIND_LF = new IndexOfProcessor((byte) '\n');

for循環遍歷,找到第一個 \n 的位置,如果\n前面的字符為\r,那就返回\r的位置

2 非discarding模式的處理

接下來,netty會判斷,當前拆包是否屬于丟棄模式,用一個成員變量來標識

private boolean discarding;

第一次拆包不在discarding模式( 后面的分支會講何為非discarding模式),于是進入以下環節

2.1 非discarding模式下找到行分隔符的處理

// 1.計算分隔符和包長度
final ByteBuf frame;
final int length = eol - buffer.readerIndex();
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;

// 丟棄異常數據
if (length > maxLength) {
    buffer.readerIndex(eol + delimLength);
    fail(ctx, length);
    return null;
}

// 取包的時候是否包括分隔符
if (stripDelimiter) {
    frame = buffer.readRetainedSlice(length);
    buffer.skipBytes(delimLength);
} else {
    frame = buffer.readRetainedSlice(length + delimLength);
}
return frame;

1.首先,新建一個幀,計算一下當前包的長度和分隔符的長度(因為有兩種分隔符)
2.然后判斷一下需要拆包的長度是否大于該拆包器允許的最大長度(maxLength),這個參數在構造函數中被傳遞進來,如超出允許的最大長度,就將這段數據拋棄,返回null
3.最后,將一個完整的數據包取出,如果構造本解包器的時候指定 stripDelimiter為false,即解析出來的包包含分隔符,默認為不包含分隔符

2.2 非discarding模式下未找到分隔符的處理

沒有找到對應的行分隔符,說明字節容器沒有足夠的數據拼接成一個完整的業務數據包,進入如下流程處理

final int length = buffer.readableBytes();
if (length > maxLength) {
    discardedBytes = length;
    buffer.readerIndex(buffer.writerIndex());
    discarding = true;
    if (failFast) {
        fail(ctx, "over " + discardedBytes);
    }
}
return null;

首先取得當前字節容器的可讀字節個數,接著,判斷一下是否已經超過可允許的最大長度,如果沒有超過,直接返回null,字節容器中的數據沒有任何改變,否則,就需要進入丟棄模式

使用一個成員變量 discardedBytes 來表示已經丟棄了多少數據,然后將字節容器的讀指針移到寫指針,意味著丟棄這一部分數據,設置成員變量discarding為true表示當前處于丟棄模式。如果設置了failFast,那么直接拋出異常,默認情況下failFast為false,即安靜得丟棄數據

3 discarding模式

如果解包的時候處在discarding模式,也會有兩種情況發生

3.1 discarding模式下找到行分隔符

在discarding模式下,如果找到分隔符,那可以將分隔符之前的都丟棄掉

final int length = discardedBytes + eol - buffer.readerIndex();
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
buffer.readerIndex(eol + delimLength);
discardedBytes = 0;
discarding = false;
if (!failFast) {
    fail(ctx, length);
}

計算出分隔符的長度之后,直接把分隔符之前的數據全部丟棄,當然丟棄的字符也包括分隔符,經過這么一次丟棄,后面就有可能是正常的數據包,下一次解包的時候就會進入正常的解包流程

3.2discarding模式下未找到行分隔符

這種情況比較簡單,因為當前還在丟棄模式,沒有找到行分隔符意味著當前一個完整的數據包還沒丟棄完,當前讀取的數據是丟棄的一部分,所以直接丟棄

discardedBytes += buffer.readableBytes();
buffer.readerIndex(buffer.writerIndex());

特定分隔符拆包

這個類叫做 DelimiterBasedFrameDecoder,可以傳遞給TA一個分隔符列表,數據包會按照分隔符列表進行拆分,讀者可以完全根據行拆包器的思路去分析這個DelimiterBasedFrameDecoder,這里不在贅述,有問題可以留言

總結

netty中的拆包過程其實是和你自己去拆包過程一樣,只不過TA將拆包過程中邏輯比較獨立的部分抽象出來變成幾個不同層次的類,方便各種協議的擴展,我們平時在寫代碼過程中,也必須培養這種抽象能力,這樣你的coding水平才會不斷提高,完。

如果你覺得看的不過癮,想系統學習Netty原理,那么你一定不要錯過我的Netty源碼分析系列視頻:https://coding.imooc.com/class/230.html

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,882評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,208評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,746評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,666評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,477評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,960評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,047評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,200評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,726評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,617評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,807評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,327評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,049評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,425評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,674評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,432評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,769評論 2 372

推薦閱讀更多精彩內容

  • 一、粘包與拆包 1、發送時的粘包與拆包 TCP連接維護了一個發送緩存區。將要發送給對端的數據會由socket AP...
    益文的圈閱讀 4,306評論 6 14
  • 前奏 https://tech.meituan.com/2016/11/04/nio.html 綜述 netty通...
    jiangmo閱讀 5,888評論 0 13
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,781評論 18 139
  • 編解碼處理器作為Netty編程時必備的ChannelHandler,每個應用都必不可少。Netty作為網絡應用框架...
    Hypercube閱讀 3,658評論 7 12
  • BGM:晴天 & worth it 以前跟你聊天大多時候你都在看書,所以你應該是個愛看書的人。 Re So So ...
    歐尼柚閱讀 280評論 1 0