本篇是遊戲開發系列第三篇,如若你有興趣,請持續關注,後期會持續更新。其餘文章列表以下:java
遊戲開發—協議-protobufjson
若是你有看過上一篇《遊戲開發-協議設計-protobuf》,就會了解到prtobuf的what和how,那麼這一篇主要分析一下why的問題,protobuf爲什麼解析速度快,佔用空間小,以及兼容性好,它是如何作到的,咱們將從 佔用空間,解析速度,兼容三個問題着手進行分析。ui
上一篇發出後也有同窗留言,說直接二進制read和write比protobuf會節省空間,某種程度上他說的對,可是須要在極端條件下才成立,通常狀況下protobuf 仍是比二進制序列化節省空間的,具體爲什麼,如下會詳細介紹。google
一條消息數據,用protobuf序列化後的大小是json的10分之一,xml格式的20分之一,是二進制序列化的10分之一(極端狀況下,會大於等於直接序列化),整體看來ProtoBuf的優點仍是很明顯的。那麼protobuf是如何作到的,咱們主要從如下4個方面進行分析。編碼
相對於json或者xml,protobuf沒有定義標籤,直接生成的二進制,令消息很是緊湊,這個和咱們直接定義消息,而後順序解析二進制數據類似。期間沒有多餘的數據。spa
一個message的信息結構以下:.net
每一個field由一個tag和value組成,每一個field之間在字節流中緊密相連,這意味着消息的信息沒用冗餘,保持最緊湊的樣子。設計
剔除無值字段通常json和xml這種標籤式結構數據在序列化的時候也會處理,一樣protobuf也作了處理。咱們先看一下一個常規定義的的二進制信息:
此種常規定義,能夠對數據順序寫入,而後再順序讀取,也支持數據的序列化。但會帶來一個問題,某些字段沒有賦值的狀況下,不得不傳一個默認值。好比field3的值是一個int,佔位4個字節。若是client的field2沒有賦值,而不寫入一個默認值(好比0),那麼server解包就會偏移量就會出錯,最終整個包的數據讀不出。
protobuf 是如何解決這個問題,由於它引入了tag。
咱們看下tag的組成。
static int makeTag(final int fieldNumber, final int wireType) { return (fieldNumber << 3) | wireType; }
tag是由:fieldNumber和wireType組成,fieldNumber定義字段的標識位,以此來處理寫入和讀取順序,wireType定義字段類型,以此來定義單個field的佔用字節大小。
wireType 能夠支持的類型以下:
咱們看下每一個的field解析方式:
boolean done = false; while (!done) { //讀取tag int tag = input.readTag(); switch (tag) { case 0: done = true; break; default: { if (!input.skipField(tag)) { done = true; } break; } case tag1: { //讀取value a_ = input.readFloat(); break; } }
讀取field的時候,先讀取tag,而後基於tag知道value的數據類型,獲取value。write也同樣,單個field的寫入也是先寫入tag再寫入value。
由於每一個field都定義了tag,如若field沒有賦值,編碼的時候它的tag不會被寫入流中,相應也不會有它的value,如此解析期間由於沒有此字段的tag,能夠直接無視,讀取其餘field。如此去除無效字段以後,能夠有效的節省空間。
好比上述的常規定義的的二進制信息,在field2沒有賦值的狀況下,protobuf能夠如此處理。
咱們在上面的wireType中也看到有一種Varint類型的field定義,這是要說第三個特色。
Varint 是一種緊湊的表示數字的方法。它用一個或多個字節來表示一個數字,值越小的數字使用越少的字節數。這能減小用來表示數字的字節數。咱們看下它的算法:
public final void writeUInt32NoTag(int value) throws IOException { if (HAS_UNSAFE_ARRAY_OPERATIONS && spaceLeft() >= MAX_VARINT_SIZE) { long pos = ARRAY_BASE_OFFSET + position; while (true) { if ((value & ~0x7F) == 0) { UnsafeUtil.putByte(buffer, pos++, (byte) value); position++; return; } else { UnsafeUtil.putByte(buffer, pos++, (byte) ((value & 0x7F) | 0x80)); position++; value >>>= 7; } } } else { try { while (true) { if ((value & ~0x7F) == 0) { buffer[position++] = (byte) value; return; } else { buffer[position++] = (byte) ((value & 0x7F) | 0x80); value >>>= 7; } } } catch (IndexOutOfBoundsException e) { throw new OutOfSpaceException( String.format("Pos: %d, limit: %d, len: %d", position, limit, 1), e); } } }
咱們知道, int32數據類型 ,通常須要4 個 字節 來表示,採用 Varint編碼以後,對於很小的 int32 類型的數字,好比小於127的,則能夠用 1 個 字節 來存儲,小於255的2個字節來存儲,依次類推,最優佔用字節的大小。固然這也有很差的一面,採用 Varint 表示法,太大的數字則須要 5 個 byte 來表示。不過通常不會全部的消息中的數字都是大數,並且大數的機率比較低,因此大多數狀況下,採用 Varint 後,能夠用更少的字節數來表示數字信息。
咱們知道有符號的整型數值,由於採用的是補碼,因此一個負數會比正數佔用的字節多,好比-1,二進制結構是11111111 11111111 11111111 11111111,若是咱們仍是採用 Varint 表示一個負數,那麼須要 5 個 byte。爲此 protobuf 定義了 sint32 ,採用 zigzag 編碼。
咱們看下zigzag的算法:
public static int encodeZigZag32(final int n) { // Note: the right-shift must be arithmetic return (n << 1) ^ (n >> 31); }
Zigzag 編碼用無符號數來表示有符號數字,正數和負數交錯,不管正負均可以採用較少的 byte 來表示。
leg 表示字符串數據的長度,value是字符串真實數據,protobuf裏面這個leg 採用Varint定義,通常狀況下一個字節足夠了。
解析速度快,主要歸功於protobuf對message 沒有動態解析,沒有了動態解析的處理序列化速度天然快了。就好比xml ,獲取文件以後,還須要解析標籤、節點、字段,每個都須要遍歷,而protobuf不須要,直接將field裝入流。
咱們知道.proto文件定義了整個message的結構,但這只是一個定義的配置文件,結合compiler的使用,單個message的read和write代碼已經被生成,無需再基於配置文件解析,直接操做field到二進制流裏面,這個速度就比如你直接操做IO同樣快,沒有其餘代價。
咱們看下生成的message代碼(仍是LoginMsg)
write
public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { if (!getUseranmeBytes().isEmpty()) { com.google.protobuf.GeneratedMessageV3.writeString(output, 1, useranme_); } if (pwd_ != 0) { output.writeInt32(2, pwd_); } }
read
boolean done = false; while (!done) { int tag = input.readTag(); switch (tag) { case 0: done = true; break; default: { if (!input.skipField(tag)) { done = true; } break; } case 10: { java.lang.String s = input.readStringRequireUtf8(); useranme_ = s; break; } case 16: { pwd_ = input.readInt32(); break; } } }
咱們看到,read的代碼中tag的生成(轉換爲int)也已經幫你處理,因此都是基於流直接操做。
兼容性什麼意思,就是說message須要支持向上兼容,不能說單個message的升級,就會致使old message解析出錯,這是咱們不能忍受的,開發過程當中,需求總變,誰都沒法保證協議不會有變動。
好比這種場景下:message 須要增長一個字段,如若client沒有升級,sever升級了,此時client 請求的message格式一定是old 格式,server 採用的new message來解析,此時會出現找不到新字段的問題,流數據錯亂以後,後續的數據都會亂。
那麼protobuf是如何處理?這時候fieldNumber就派上用途了,看似可又可無的設計,其實包含很大用處。
fieldNumber 爲每一個field定義一個編號,其一保證不重複,其二保證其在流中的位置。如若當前數據流中有某個字段,而解析方沒有相關的解析代碼,解析放會直接skip 吊這個field,並且讀數據的position也會後移,保證後續讀取不出問題。
boolean done = false; while (!done) { int tag = input.readTag(); switch (tag) { case 0: done = true; break; // 解析方沒有這個field的解析方法,skip掉 default: { if (!input.skipField(tag)) { done = true; } break; } } }
如上,線讀取tag,某個字段沒有被賦值,就沒有這個字段的tag,解析方不處理,如如有某個字段,而沒有解析方法,就skip了,不影響消息的處理。老數據依然被獲取。
---------------------------------------------------end---------------------------------------------------
掃描關注更多,關注我的成長和技術學習,期待用本身的一點點改變,帶給你一些啓發及感悟。