這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
今天這個小貼士主要介紹協議解析的一些知識,Go語言作為服務端程式設計語言,免不了要涉及到通訊協議解析,即便不是做網路通訊,也難免會涉及到檔案解析,其實它們的知識點都是一樣的。現實應用情境中,通訊協議按通常可以分為兩類:二進位協議和文本協議。Go語言內建的gob格式就是一種二進位協議,而JSON、XML等則是文本協議。
假設我們要發送123這個數值,用二進位協議只需要一個位元組,因為一個位元組(byte)有8個二進位位(bit),2的8次方是256,一個位元組可以表達0-255之間的任意值,共256種可能性。
如果我們用文本協議發送123這個數值,則需要至少三個位元組,因為123這個數字需要轉換成字元'1'、'2'、'3'這三個ASCII字元,存入三個位元組中。
所以同樣一個資料,用二進位協議表達的體積通常會小於用文本協議表達的體積。這個特性體現到網路應用中,可能就是網路頻寬需求的差異。
換個角度看,當我們用二進位協議把123這個數值寫入一個檔案以後,我們用文字編輯器開啟它,看到的會'{'這個字元,因為這個字元的ASCII值正好是123。而當我們用文本協議儲存資料時,我們可以用文字編輯器直接讀到123這個數值。
所以通常二進位協議比較不利於閱讀,而文本協議方便閱讀。這個特性體現到開發中的時候,可能就是調試難易度的差異。
位元據和文本資料還有個差異是執行效率差異。以123這個值為例,二進位序列化時候只需要直接對一個位元組進行賦值,而用用文字格式設定的時候,則需要計算出‘個’、‘十’、‘百’位上的值,並轉成ASCII碼,再賦值給三個位元組,還原序列化的時候也是如此。
以上分析了二進位協議和文本協議的一些特性,並沒有說哪個是最優方案,因為不同的應用情境會需要不同的技術方案。比如TCP/IP協議是二進位協議,在TCP/IP之上構建的HTTP協議則是文本協議,它們各有各的應用情境,所以會出現技術上的差異。
再說回協議解析,當我們要解析位元據的時候,通常會需要用到Go語言內建的encoding/binary這個包,這個包內建了大端序和小端序的位元據操作。
什麼是大端序和小端序呢?以數值256為例,上面我們有提到,一個位元組可以表達0-255之間的任意值,但是當我們要表達256這個值的時候怎麼表達呢?
255用二進位表達就是1111 1111,再加1就是1 0000 0000,多了一個1出來,顯然我們需要再用額外的一個位元組來存放這個1,但是這個1要存放在第一個位元組還是第二個位元組呢?這時候因為人們選擇的不同,就出現了大端序和小端序的差異。
當我們把這個1放在第一個位元組的時候,就稱之為大端序格式。當我們把1放在第二個位元組的時候,就稱之為小端序格式。
這兩種格式顯然沒辦法說誰更好,所以兩個格式一直都各自的支援者,如果是按標準實現一個通訊協議,那就得嚴格按照標準上說的位元組序來實現。如果是自訂的二進位協議,選擇哪個格式按自己喜好就可以了。
encoding/binary包中的全域變數BigEndian用於操作大端序資料,LittleEndian用於操作小端序資料,這兩個變數所對應的資料類型都實行了ByteOrder介面:
type ByteOrder interface { Uint16([]byte) uint16 Uint32([]byte) uint32 Uint64([]byte) uint64 PutUint16([]byte, uint16) PutUint32([]byte, uint32) PutUint64([]byte, uint64) String() string}
其中,前三個方法用於讀取資料,後三個方法用於寫入資料。
大家可能會注意到,上面的方法操作的都是無符號整型,如果我們要操作有符號整型的時候怎麼辦呢?很簡單,強制轉換就可以了,比如這樣:
func PutInt32(b []byte, v int32) { binary.BigEndian.PutUint32(b, uint32(v))}
大家可能還注意到,上面提供的方法都是操作整型值。原因是浮點數的實現在不同程式設計語言中可能會不一樣,沒辦法在執行階段程式庫中給出一個普適的標準。如果我們要寫入和讀取浮點數怎麼辦呢?
項目實踐上有兩種做法,一種是在協議上約定好一個取整精度,比如小數點後多少位,然後把浮點數轉成對應精度的整數,這樣的做法跨語言相容性最好。
如果是Go語言開發的應用之間的位元據交換,或者是符合IEEE 754浮點數標準的程式設計語言,則可以用math包裡的這幾個函數:
func Float32bits(f float32) uint32func Float32frombits(b uint32) float32func Float64bits(f float64) uint64func Float64frombits(b uint64) float64
上面說到的都是數實值型別的操作,但是實際應用情境中文本,列表,字典等各種複雜資料要怎麼在二進位協議中實現呢?
複雜的位元據表達,最主要的一個問題就是資料分割問題,比如我們要將下面這個Go結構體進行二進位序列化:
type MyStruct struct { Field1 int32 Field2 string Field3 []int16}
首先我們會遇到的問題就是怎麼區別各個欄位?
首先我們對結構體進行分析,其中第一個欄位是int32類型的,這種資料類型固定都會被表達成4個位元組,所以我們稱之為定長類型。第二個欄位是string類型的,字串裡面內容多上是不一定的,所以我們稱之為變長資料類型。第三個欄位是[]int16類型的,列表中元素個數是不一定的,但是每個元素的位元組長度是固定的。
對於字串,我們可以在字串開始前用兩個位元組來存放它的長度,這樣我們的字串可以存放65536個字元(2的16次方)。對於數組,一樣可以用兩個位元組來存放它的元素個數。
這樣我們就可以得到以下的序列化和還原序列化代碼:
package mainimport ( "fmt" "encoding/binary")func main() { var s1 = MyStruct {123, "456", []int16{1,2,3}} var s2 MyStruct s2.Unmarshal(s1.Marshal()) fmt.Println(s1, s2)}type MyStruct struct { Field1 int32 Field2 string Field3 []int16}func (s *MyStruct) binarySize() int { return 4 + // Field1 2 + len(s.Field2) + // Len + Field2 2 + 2 * len(s.Field3) // Len + Field3}func (s *MyStruct) Marshal() []byte { b := make([]byte, s.binarySize()) n := 0 binary.BigEndian.PutUint32(b[n:], uint32(s.Field1)) n += 4 binary.BigEndian.PutUint16(b[n:], uint16(len(s.Field2))) n += 2 copy(b[n:], s.Field2) n += len(s.Field2) binary.BigEndian.PutUint16(b[n:], uint16(len(s.Field3))) n += 2 for i := 0; i < len(s.Field3); i ++ { binary.BigEndian.PutUint16(b[n:], uint16(s.Field3[i])) n += 2 } return b}func (s *MyStruct) Unmarshal(b []byte) { n := 0 s.Field1 = int32(binary.BigEndian.Uint32(b[n:])) n += 4 x := int(binary.BigEndian.Uint16(b[n:])) n += 2 s.Field2 = string(b[n : n + x]) n += x s.Field3 = make([]int16, binary.BigEndian.Uint16(b[n:])) n += 2 for i := 0; i < len(s.Field3); i ++ { s.Field3[i] = int16(binary.BigEndian.Uint16(b[n:])) n += 2 }}
上面用到的協議設計技巧,同樣適用於不定長的訊息包的發送。在很多應用情境中,訊息包的長度是不固定的,就像上面的字串欄位一樣。我們一樣可以用開頭固定的幾個位元組來存放訊息長度,在解析通訊協議的時候就可以從位元組流中截出一個個的訊息包了,這樣的操作通常叫做協議分包或者粘包處理。
貼個從Socket讀取訊息包的虛擬碼(沒編譯):
func ReadPacket(conn net.Conn) ([]byte, error) { var head [2]byte if _, err := io.ReadFull(conn, head[:]); err != nil { return err } size := binary.BigEndian.Uint16(head) packet := make([]byte, size) if _, err := io.ReadFull(conn, packet); err != nil { return err } return packet}
上面的代碼就用到了前一個小貼士中說到的io.ReadFull來確保一次讀取完整資料。
要注意,這段代碼不是安全執行緒的,如果有兩個線程同時對一個net.Conn進行ReadPacket操作,很可能會發生嚴重錯誤,具體邏輯請自行分析。
從上面結構體序列化和還原序列化的代碼中,大家不難看出,實現一個二進位協議是挺繁瑣和容易出BUG的,只要稍微有一個數值計算錯就解析出錯了。
所以在工程實踐中,不推薦大家手寫二進位協議的解析代碼,項目中通常會用自動化的工具來輔助產生代碼。
因為篇幅限制,這篇文章沒辦法進一步介紹文本協議相關的知識,因為之後有準備講bufio和json,這兩者都會涉及到文本協議相關的知識,所以就放到以後的文章中再介紹。
Go語言小貼士全面徵集大家的建議和反饋,歡迎留言。