遊戲開發-協議-protobuf原理詳解

本篇是遊戲開發系列第三篇,如若你有興趣,請持續關注,後期會持續更新。其餘文章列表以下:java

遊戲開發—協議設計算法

遊戲開發—協議-protobufjson

遊戲開發-協議-protobuf原理詳解學習

若是你有看過上一篇《遊戲開發-協議設計-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能夠如此處理。

三、Varints & Zigzag

Varints

咱們在上面的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 後,能夠用更少的字節數來表示數字信息。

ZigZag 

咱們知道有符號的整型數值,由於採用的是補碼,因此一個負數會比正數佔用的字節多,好比-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 ,以下圖所示:

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---------------------------------------------------

掃描關注更多,關注我的成長和技術學習,期待用本身的一點點改變,帶給你一些啓發及感悟。

相關文章
相關標籤/搜索