Go 存儲基礎 — 內存結構體怎麼寫入文件?

堅持思考,就會很酷

概述

講了那麼多存儲的通用知識,從 Linux 的文件系統,塊層,再到磁盤,都做了一些深入的分享。今天分享一個 Go 編程的使用技巧:怎麼把內存的結構體寫入到磁盤?又怎麼讀出來?

大家可以先思考下。其實把這裏本質就是 IO 操作,關鍵看數據是怎麼來的。我們常見的數據要麼來源於文件,來源於網絡的包,而常常忽略到其實內存的結構體本身就是數據。不就是字節數組嘛,不就是 010101 嘛。本質都一樣。

數據在磁盤上,是感知不到什麼結構體的,就是 0101010 的數據,文件就只是字節數組而已(計算機的最小數據存儲單元是 Byte ,字節)。想清楚這點就很清晰了,**劃重點,寫入的時候關鍵在於怎麼把結構體轉變成字節數組,讀取的時候關鍵在於怎麼把字節數組轉化成結構體。**怎麼做到這點?

說白了,就是結構體到字節數組的轉化嘛,專業一點的術語叫做序列化與反序列化

其實不光是內存到磁盤,內存到網絡,只要是涉及到跨平臺,跨介質,都是如此,你必須把數據轉化成一個計算機體系通用的形態:字節數組。

字節是最小的存儲單位,所有計算機的理解是一致的,這纔是宇宙通用,沒有歧義的最小單位。

序列化

序列化有複雜的,有簡單的,但本質都是一樣,因爲目標一致就是字節數組。不同的方式誕生了不同的協議,最出名的大概是 json,pb 。

舉個栗子,以下面的 Test 爲例:

// 按照對齊原則,Test 變量佔用 16 字節的內存
type Test struct {
 F1 uint64
 F2 uint32
 F3 byte
}

 1   json 協議

如下,是一個把結構體按照 json 的協議格式轉化成字節數組(序列化)的調用。

package main

import (
    "encoding/json"
    "fmt"
)

// 按照對齊原則,Test 變量佔用 16 字節的內存
type Test struct {
    F1 uint64
    F2 uint32
    F3 byte
}


func main() {
    t := Test{F1: 0x1234, F2: 0x4567, F3: 12,}

    // 測試序列化
    bs, err := json.Marshal(&t)
    if err != nil {
        panic("")
    }
    fmt.Printf("t -> []byte\t: %v\n", bs)

    // 測試反序列化
    t1 := Test{}
    err = json.Unmarshal(bs, &t1)
    if err != nil {
        panic("")
    }
    fmt.Printf("[]byte -> t1\t: %v\n", t1)
}

輸出:

$ go build -gcflags "-N -l" ./test_json_1.go 
$ ./test_json_1 
t -> []byte     : [123 34 70 49 34 58 52 54 54 48 44 34 70 50 34 58 49 55 55 54 55 44 34 70 51 34 58 49 50 125]
[]byte -> t1    : {4660 17767 12}

這很簡單,是的,這個也是我們經常用的姿勢,但是我們的需求有各種各樣的。

比如,Test 內存佔用只有 16 個字節,但是 json 序列化完之後佔用 30 個字節?我要求你這個 Test  結構體序列化成 16 字節的數組。你怎麼做?你不能用 json 去做這個事情,因爲你無法顯式的控制序列化之後的樣子,所以你只能自定義序列化。

 2   自定義

自定義序列化規則怎麼做呢?還是以 Test 舉個例子:

package main

import (
 "encoding/binary"
 "errors"
 "fmt"
)

// 按照對齊原則,Test 變量佔用 16 字節的內存
type Test struct {
 F1 uint64
 F2 uint32
 F3 byte
}

func (t *Test) Marshal() ([]byte, error) {
 // 創建一個 16 字節的 buffer
 buf := make([]byte, 16)
 // 序列化
 binary.BigEndian.PutUint64(buf[0:8], t.F1)
 binary.BigEndian.PutUint32(buf[8:12], t.F2)
 buf[12] = t.F3

 return buf, nil
}

func (t *Test) Unmarshal(buf []byte) error {
 if len(buf) != 16 {
  return errors.New("length not match")
 }
 // 反序列化
 t.F1 = binary.BigEndian.Uint64(buf[0:8])
 t.F2 = binary.BigEndian.Uint32(buf[8:12])
 t.F3 = buf[12]
 return nil
}

func main() {
 t := Test{F1: 0x1234, F2: 0x4567, F3: 12,}

 // 測試序列化
 bs, err := t.Marshal()
 if err != nil {
  panic("")
 }
 fmt.Printf("t -> []byte\t: %v\n", bs)

 // 測試反序列化
 t1 := Test{}
 err = t1.Unmarshal(bs)
 if err != nil {
     panic("")
    }
    fmt.Printf("[]byte -> t1\t: %v\n", t1)
}

這樣我們就能嚴格控制我們想要輸出的序列化數組了。運行這個程序,輸入如下:

$ go build -gcflags "-N -l" ./test_json.go 
$ ./test_json 

t -> []byte     : [0 0 0 0 0 0 18 52 0 0 69 103 12 0 0 0]
[]byte -> t1    : {4660 17767 12}

千萬不要覺得疑惑,你把這個輸出轉成 16 進制的輸出,你就會發現其實是:

t -> []byte     : [0 0 0 0 0 0 0x12 0x34 0 0 0x45 0x67 12 0 0 0]
[]byte -> t1    : {0x1234 0x4567 12}

這就是序列化和反序列化的本來樣貌,其他各種序列化協議的都和這個是一樣的。

但注意了,無論是哪種,這種相互轉化一定是無損的纔行,不能說 Test  這個結構體裏面是 0x1234 ,經過你的序列化,再反序列化出來裏面的值變成 0x3333 了,這是不行的。

字節序:大小端

細心的小夥伴肯定注意到了,我使用了 binary.BigEndian.Uint64 這個函數,這個函數是把一個 64 位的整形轉化成一個大端序的字節數組。這裏就引出了一個概念,什麼是大端序?小端序又是什麼?一般有什麼遵守慣例呢?

劃重點:字節的大小端是隻針對多字節的基礎類型才需要考慮的。

舉個栗子,現在有一個 uint32 的整數 0x11 22 33 44,計算機怎麼存儲?磁盤怎麼存儲呢?

我們之前說過,計算機的存儲單元是字節。一個 uint32 是 4 字節,你怎麼擺放這 4 個字節呢?

直觀的想法,有兩種方式嘍(左到右,低地址 -> 高地址)。

第一種就是所謂的大端序,第二種就是所謂的小端序。

劃重點:最高有效位 在 低地址,那就是大端序,最低有效位 在 低地址那就是小端序。

現實世界中,大部分機器處理器體系是小端序,比如 x86,有的機器是大端序,比如 PowerPC 970 等。

關於大小端的其實是有一些慣例

大端序更符合人的習慣,這個很容易理解吧,比如你把 0x11223344,按照大端序打印出來的每個字節是 0x11 0x22 0x33 0x44 ,小端序則是 0x44 0x33 0x22 0x11 ,你說哪個好看些?

所以呢,自定義序列化的時候,千萬注意大小端。因爲這裏可能引入不兼容的坑。比如如果你不顯式把一個整型序列化成一個大端序,那按照默認的方式存儲到磁盤,到時候你讀取上來的時候,到底是按照大端還是還是小端反序列化呢?這就是坑。

最原始的辦法

結構體轉化成字節數組一定要序列化這麼高級姿勢嗎?

不一定呀。結構體本身就是內存塊,本身就是字節數組,所以把結構體地址強轉成 []byte 的地址是簡單也最原始的 "序列化" 方式

 1   強轉類型

package main

import (
 "fmt"
 "unsafe"
)

// 按照對齊原則,Test 變量佔用 16 字節的內存
type Test struct {
 F1 uint64
 F2 uint32
 F3 byte
}

func Struct2Bytes(p unsafe.Pointer, n int) []byte {
 return ((*[4096]byte)(p))[:n]
}

func main() {
 t := Test{F1: 0x1234, F2: 0x4567, F3: 12}
 bytes := Struct2Bytes(unsafe.Pointer(&t), 16)
 fmt.Printf("t -> []byte\t: %v\n", bytes)
}

輸出:

$ ./test_direct 
t -> []byte     : [52 18 0 0 0 0 0 0 103 69 0 0 12 0 0 0], len:16

發現沒,這個是小端序的輸出(前面說過了,機器默認的字節序是小端序)。

文件讀寫

到這裏已經非常簡單了,因爲前面我們已經把結構體和字節數組互轉的方法詳細介紹了。寫入文件的是字節數組,從文件讀出來的是文件數組。就是這樣簡單。

寫一個結構體的步驟如下:

  1. 先把結構體搞成字節數組的形態;

  2. 然後寫入

讀一個結構體的步驟如下:

  1. 先從文件中讀數據,讀到一個字節 buffer 中;

  2. 強轉成結構體

舉個栗子:

package main

import (
 "fmt"
    "log"
    "os"
    "unsafe"
)

// 按照對齊原則,Test 變量佔用 16 字節的內存
type Test struct {
 F1 uint64
 F2 uint32
 F3 byte
}

func Struct2Bytes(p unsafe.Pointer, n int) []byte {
 return ((*[4096]byte)(p))[:n]
}

func main() {
 t := Test{F1: 0x1234, F2: 0x4567, F3: 12}
    // 強轉類型
 bytes := Struct2Bytes(unsafe.Pointer(&t), 16)

 fmt.Printf("t -> []byte\t: %v\n", bytes)

    fd, err := os.OpenFile("test_bytes.txt", os.O_RDWR|os.O_CREATE, 0666)
    if err != nil {
        log.Fatalf("create failed, err:%v\n",err)
    }

    // 結構體寫入文件
    _, err = fd.Write(bytes)
    if err != nil {
        log.Fatalf("write failed, err:%v\n", err)
    }

    t1 := Test{}
    // 強轉出一個 16 字節的內存 buffer 來 
    t1Bytes := Struct2Bytes(unsafe.Pointer(&t1), 16)
    // 從文件中把數據讀出來
    _, err = fd.ReadAt(t1Bytes, 0)
    if err != nil {
        log.Fatalf("read failed, err:%v\n", err)
    }

    fmt.Printf("t1 -> []byte\t: %v\n", t1Bytes)
}

輸出如下:

$ ./test_direct 
t -> []byte     : [52 18 0 0 0 0 0 0 103 69 0 0 12 0 0 0]
t1 -> []byte    : [52 18 0 0 0 0 0 0 103 69 0 0 12 0 0 0]

變量 t 和 變量 t1 完全一致。完美撒花。最後再看一眼文件的樣子:

$ hexdump -C test_bytes.txt 
00000000  34 12 00 00 00 00 00 00  67 45 00 00 0c 00 00 00  |4.......gE......|
00000010

發現了嗎?其實就是你結構體直接強轉成 Byte 數組的樣子,小端序的樣子。

總結

好啦,我們簡單總結下知識點,你學會了嗎:

  1. 文件,從某個存儲組成來看,本質是字節數組;

  2. 內存結構體在磁盤文件中的讀寫本質要經過字節數組的轉化;

  3. 結構體變成字節數組我們叫做序列化,字節數組轉化成結構體我們叫做反序列化;

  4. 序列化有複雜的,有簡單的,本質都是一樣,因爲目標一致就是字節數組。不同的方式誕生了不同的協議,最出名的大概是 json,pb;

  5. 結構體本身就是內存塊,本身就是字節數組,所以把結構體地址強轉成 []byte 的地址是簡單也最原始的序列化方式;

  6. 結構體強轉類型是要注意的,在遇到多字節類型(比如整型)必須要字節序大小端的兼容性問題;

  7. 考慮數據大小端兼容性問題的時候,又不想用 json 這種複雜的序列化,那麼最好的就是自定義序列化規則啦;

  8. hexdump 這個工具用過沒?非常好用,查看文件二進制數據;

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mfNz7r76vZOOgiMSmuVeJA