Go语言自定义二进制文件的读写操作 图片看不了?点击切换HTTP 返回上层
虽然 Go语言的 encoding/gob 包非常易用,而且使用时所需代码量也非常少,我们仍有可能需要创建自定义的二进制格式。自定义的二进制格式有可能做到最紧凑的数据表示,并且读写速度可以非常快。
不过,在实际使用中,我们发现以 Go语言二进制格式的读写通常比自定义格式要快非常多,而且创建的文件也不会大很多。但如果我们必须通过满足 gob.GobEncoder 和 gob.GobDecoder 接口来处理一些不可被 gob 编码的数据,这些优势就有可能会失去。
在有些情况下我们可能需要与一些使用自定义二进制格式的软件交互,因此了解如何处理二进制文件就非常有用。
下图给出了 .inv 自定义二进制格式如何表示一个发票文件的概要。整数值表示成固定大小的无符号整数。布尔值中的 true 表示成一个 int8 类型的值 1,false 表示成 0。字符串表示成一个字节数(类型为 int32)后跟一个它们的 UTF-8 编码的字节切片 []byte。
对于日期,我们采取稍微非常规的做法,将一个 ISO-8601 格式的日期(不含连字符)当成一个数字,并将其表示成 int32 值。例如,我们将日期 2006-01-02 表示成数字 20 060 102。每一个发票项表示成一个发票项的总数后跟各个发票项。

图:.inv 自定义二进制格式
该方法将所有发票项写入给定的 io.Writer 中。它开始时创建了一个便捷的 write() 函数,该函数能够捕获我们要使用的 io.Writer 和字节序。正如处理上 .txt 格式所做的那样,我们将 write() 函数定义为一个特定的类型 (invWriterFunc),并且为该 write() 函数创建了一些方法(例如 invWriterFunc.Writeinvoices()),以便后续使用。
需注意的是,读和写二进制数据时其字节序必须一致。(我们不能将 byteOrder 定义为一个常量,因为 binary.LittleEndian 或者 binary.BigEndian 不是像字符串或者整数这样的简单值。)
这里,写数据的方式与我们之前在看到写其他格式数据的方式类似。一个非常重要的不同在于,将幻数和文件版本写入后,我们写入了一个表示发票数量的数字。(也可以跳过而不写该数字,而只是简单地将发票写入。然后,读数据的时候,持续地依次读入发票直到遇到 io.EOF。)
对于每—个发票数据,writeInvoice() 方法都会被调用一遍。它接受一个指向被写发票数据的指针,并使用作为接收器的 write() 函数来写数据。
该方法开始处以 int32 写入了发票 ID 及客户 ID。当然,以纯 int 型写入数据是合法的,但底层机器以及所使用的 Go语言版本的改变都可能导致 int 的大小改变,因此写入时非常重要的一点是确定整型的符号和大小,如 uintf32 和 int32 等。
接下来,我们使用自定义的 writeDate() 方法写入创建和过期时间,然后写入表示是否支付的布尔值和注释字符串。最后,我们写入了一个代表发票中有多少发票项的数字,随后再使用 writeItem() 方法写入发票项。
前文中我们讨论了 time.Time.Format() 函数以及为何必须在格式字符串中使用特定的日期 2006-01-02。这里,我们使用了类 ISO-8601 格式,并去除连字符以便得到一个八个数字的字符串,其中如果月份和天数为单一数字则在其前面加上 0。
然后,将该字符串转换成数字。例如,如果日期是 2012-08-05,则将其转换成一个等价的数字,即 20120805,然后以 int32 的形式将该数字写入。
值得一提的是,如果我们想存储日期/时间值而非仅仅是日期值,或者只想得到一个更快的计算,我们可以将对该方法的调用替换成调用 write(int64 (date.Unix ())),以存储一个 Unix 新纪元以来的秒数。相应的读取数据的方法则类似于
encoding/binary 包不支持读写布尔值,因此我们创建了该简单方法来处理它们。顺便提一下,我们不必使用类型转换(如 int8(v)),因为变量 v 已经是一个有符号并且固定大小的类型了。
字符串必须以它们底层的 UTF-8 编码字节的形式来写入。这里,我们首先写入了所需写入的字节总数,然后再写入所有字节。(如果数据是固定宽度的,就不需要写入字节数。当然,前提是,读取数据时,我们创建了一个存储与写入的数据大小相同的空切片 []byte。)
该方法用于写入一个发票项。对于字符串 ID 和注释文本,我们使用 invWriterFunc.writeString() 方法,对于物品数量,我们使用无符号的大小固定的整数。但是对于价格,我们就以它原始的形式写入,因为它本来就是个固定大小的类型 (float64)。
往文件中写入二进制数据并不难,只要我们小心地将可变长度数据的大小在数据本身前面写入,以便读数据时知道该读多少。当然,使用 gob 格式非常方便,但是使用一个自定义的二进制格式所产生的文件更小。
该方法首先检查所给定版本的发票文件能否被处理,然后使用自定义的 readIntFromInt32() 函数从文件中读取所需处理的发票数量。我们将 invoices 切片的长度设为 0 (即当前还没有发票),但其容量正好是我们所需要的。然后,轮流读取每一个发票并将其存储在 invoices 切片中。
另一种可选的方法是使用 make([] *Invoice, count) 代替 make(),使用 invoices[i]=invoice 代替 append()。不管怎样,我们倾向于使用所需的容量来创建切片,因为与实时增长切片相比,这样做更有潜在的性能优势。
毕竟,如果我们再往一个其长度与容量相等的切片中追加数据,切片会在背后创建一个新的容量更大的切片,并将起原始切片数据复制至新切片中。然而,如果其容量一开始就足够,后面就没必要进行复制。
该函数试图从文件中读取其幻数及版本号。如果该文件格式可接受,则返回 nil;否则返回非空错误值。
其中的 binary.Read() 函数与 binary.Write() 函数相对应,它接受一个从中读取数据的 io.Reader、一个字节序以及一个指向特定类型的用于保存所读数据的指针。
该辅助函数用于从二进制文件中读取一个 int32 值,并以 int 类型返回。
每次读取发票文件的时候,该函数都会被调用。函数开始处创建了一个初始化为零值的 Invoice 值,并将指向它的指针保存在 invoice 变量中。
发票 ID 和客户 ID 使用自定义的 readIntFromInt32() 函数读取。这段代码的微妙之处在于,我们迭代那些指向发票 ID 和客户 ID 的指针,并将返回的整数赋值给指针 (pId) 所指的值。
一个可选的方案是单独处理每一个 ID。例如,
读取创建及过期日期的流程与读取 ID 的流程完全一样,只是这次我们使用的是自定义的 readInvDate() 函数。
正如读取 ID 一样,我们也可以以更加简单的方式单独处理日期。例如,
稍后将看到,我们使用一些辅助函数读取是否支付的标志和注释文本。发票数据读完之后,我们再读取有多少个发票项,然后调用 readInvIterns() 函数读取全部发票项,传递给该函数一个用于读取的 io.Reader 值和一个表示需要读多少项的数字。
该函数用于读取表示日期的 int32 值(如 20130501),并将该数字解析成字符串表示的日期值,然后返回对应的 time.Time 值(如 2013-05-01)。
该简单的辅助函数读取一个 int8 数字,如果该数字为 1 则返回 true,否则返回 false。
该函数读取一个 []byte 切片,但它的原理适用于任何类型的切片,只要写入切片之前写明了切片中包含多少项元素。
函数首先将切片项的个数读到一个 length 变量中。然后创建一个长度与此相同的切片。给 binary.Read() 函数传入一个指向切片的指针之后,它就会往该切片中尽可能地读入该类型的项(如果失败则返回一个非空的错误值)。需注意的是,这里重要的是切片的长度,而非其容量(其容量可能等于或者大于长度)。
在本例中,该 []byte 切片保存了 UTF-8 编码的字节,我们将其转换成字符串后将其返回。
该函数读入发票的所有发票项。由于传入了一个计数值,因此它知道应该读入多少项。
该函数读取单个发票项。从结构上看,它与 readInvInvoice() 函数类似,首先创建一个初始化为零值的 Item 值,并将指向它的指针存储在变量 item 中,然后填充该 item 变量的字段。价格可以直接读入,因为它是以 float64 类型写入文件的,是一个固定大小的类型。
Item.Price 字段的类型也一样。(我们省略了 readIntFromInt16() 函数,因为它与我们前文所描述的 readIntFromInt32() 函数基本相同。)
至此,我们完成了对自定义二进制数据的读和写。只要小心选择表示长度的整数符号和大小,并将该长度值写在变长值(如切片)的内容之前,那么使用二进制数据进行工作并不难。
Go语言对二进制文件的支持还包括随机访问。这种情况下,我们必须使用 os.OpenFile() 函数来打开文件(而非 os.Open()),并给它传入合理的权限标志和模式(例如,os.O_RDWR 表示可读写)参数。
然后,就可以使用 os.File.Seek() 方法来在文件中定位并读写,或者使用 os.File.ReadAt() 和 os.File.WriteAt() 方法来从特定的字节偏移中读取或者写入数据。
Go语言还提供了其他常用的方法,包括 os.File.Stat() 方法,它返回的 os.FileInfo 包含了文件大小、权限以及日期时间等细节信息。
不过,在实际使用中,我们发现以 Go语言二进制格式的读写通常比自定义格式要快非常多,而且创建的文件也不会大很多。但如果我们必须通过满足 gob.GobEncoder 和 gob.GobDecoder 接口来处理一些不可被 gob 编码的数据,这些优势就有可能会失去。
在有些情况下我们可能需要与一些使用自定义二进制格式的软件交互,因此了解如何处理二进制文件就非常有用。
下图给出了 .inv 自定义二进制格式如何表示一个发票文件的概要。整数值表示成固定大小的无符号整数。布尔值中的 true 表示成一个 int8 类型的值 1,false 表示成 0。字符串表示成一个字节数(类型为 int32)后跟一个它们的 UTF-8 编码的字节切片 []byte。
对于日期,我们采取稍微非常规的做法,将一个 ISO-8601 格式的日期(不含连字符)当成一个数字,并将其表示成 int32 值。例如,我们将日期 2006-01-02 表示成数字 20 060 102。每一个发票项表示成一个发票项的总数后跟各个发票项。

图:.inv 自定义二进制格式
写自定义二进制文件
encoding/binary 包中的 binary.Write() 函数使得以二进制格式写数据非常简单。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | type InvMarshaler struct{} var byteOrder = binary .LittleEndian func (InvMarshaler) MarshalInvoices(writer io.Writer, invoices []*Invoice) error { var write invWriterFunc = func(x interface{}) error { return binary .Write(writer, byteOrder, x) } if err := write(uint32(magicNumber)); err != nil { return err } if err := write(uintl6(fileVersion)); err != nil { return err } if err := write(int32(len(invoices))); err != nil { return err } for _, invoice := range invoices { if err := write.writeInvoice(invoice); err != nil { return err } } return nil } |
需注意的是,读和写二进制数据时其字节序必须一致。(我们不能将 byteOrder 定义为一个常量,因为 binary.LittleEndian 或者 binary.BigEndian 不是像字符串或者整数这样的简单值。)
这里,写数据的方式与我们之前在看到写其他格式数据的方式类似。一个非常重要的不同在于,将幻数和文件版本写入后,我们写入了一个表示发票数量的数字。(也可以跳过而不写该数字,而只是简单地将发票写入。然后,读数据的时候,持续地依次读入发票直到遇到 io.EOF。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | type invWriterFunc func(interface{}) error func (write invWriterFunc) writeinvoice(invoice *Invoice) error { for _, i := range [] int {invoice.Id, invoice.Customerld} { if err := write(int32(i)); err != nil { return err } } for date := range [] time . Time {invoice.Raised, invoice.Due} { if err := write.writeDate( date ); err != nil { return err } } if err := write.writeBool(invoice.Paid); err != nil { return err } if err := write.writeString(invoice.Note); err != nil { return err } if err := write(int32(len(invoice.Items))); err != nil { return err } for item := range invoice.Items { if err := write.writeitem(item); err != nil { return err } } return nil } |
该方法开始处以 int32 写入了发票 ID 及客户 ID。当然,以纯 int 型写入数据是合法的,但底层机器以及所使用的 Go语言版本的改变都可能导致 int 的大小改变,因此写入时非常重要的一点是确定整型的符号和大小,如 uintf32 和 int32 等。
接下来,我们使用自定义的 writeDate() 方法写入创建和过期时间,然后写入表示是否支付的布尔值和注释字符串。最后,我们写入了一个代表发票中有多少发票项的数字,随后再使用 writeItem() 方法写入发票项。
1 2 3 4 5 6 7 8 9 | const invDateFormat = ”20060102” // 必须总是使用该日期值 func (write invWriterFunc) writeDate( date time . Time ) error { i, err := strconv.Atoi( date .Format(invDateFormat)) if err != nil { return err } return write(int32(i)) } |
然后,将该字符串转换成数字。例如,如果日期是 2012-08-05,则将其转换成一个等价的数字,即 20120805,然后以 int32 的形式将该数字写入。
值得一提的是,如果我们想存储日期/时间值而非仅仅是日期值,或者只想得到一个更快的计算,我们可以将对该方法的调用替换成调用 write(int64 (date.Unix ())),以存储一个 Unix 新纪元以来的秒数。相应的读取数据的方法则类似于
var d int64; if err := binary.Read(reader, byteOrder, &d); err != nil { return err }; date := time.Unix(d, 0)
。1 2 3 4 5 6 7 | func (write invWriterFunc) writeBool(b bool) error { var v int8 if b { v = 1 } return write(v) } |
1 2 3 4 5 6 | func (write invWriterFunc) writeString(s string) error { if err := write(int32(len(s))); err != nil { return err } return write([]byte(s)) } |
1 2 3 4 5 6 7 8 9 10 11 12 | func (write invWriterFunc) writeitem(item *Item) error { if err := write.writeString(item.Id); err != nil { return err } if err := write(item.Price); err != nil { return err } if err := write(intl6(item.Quantity)); err != nil { return err } return write.writeString(item.Note) } |
往文件中写入二进制数据并不难,只要我们小心地将可变长度数据的大小在数据本身前面写入,以便读数据时知道该读多少。当然,使用 gob 格式非常方便,但是使用一个自定义的二进制格式所产生的文件更小。
读自定义二进制文件
读取自定义的二进制数据与写自定义二进制数据一样简单。我们无需解析这类数据,只需使用与写数据时相同的字节顺序将数据读进相同类型的值中。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | func (InvMarshaler) Unmarshallnvoices(reader io.Reader) ([]*Invoice, error){ if err := checkInvVersion(reader); err != nil { return nil, err } count , err := readIntFromInt32(reader) if err != nil { return nil, err } invoices := make([]*Invoice, 0, count ) for i := 0; i < count ; i++ { invoice, err := readInvInvoice(reader) if err != nil { return nil, err } invoices = append(invoices, invoice) } return invoices, nil } |
另一种可选的方法是使用 make([] *Invoice, count) 代替 make(),使用 invoices[i]=invoice 代替 append()。不管怎样,我们倾向于使用所需的容量来创建切片,因为与实时增长切片相比,这样做更有潜在的性能优势。
毕竟,如果我们再往一个其长度与容量相等的切片中追加数据,切片会在背后创建一个新的容量更大的切片,并将起原始切片数据复制至新切片中。然而,如果其容量一开始就足够,后面就没必要进行复制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | func checklnvVersion(reader io.Reader) error { var magic uint32 if err := binary . Read (reader, byteOrder, &magic); err != nil { return err } if magic ! = magicNuntber { return errors.New( "cannot read non-invoices inv file" ) } var version uintl6 if err := binary . Read (reader, byteOrder, &version); err != nil { return err } if version > fileVerson { return fmt.Errorf( "version %d is too new to read" , version) } return nil } |
其中的 binary.Read() 函数与 binary.Write() 函数相对应,它接受一个从中读取数据的 io.Reader、一个字节序以及一个指向特定类型的用于保存所读数据的指针。
1 2 3 4 5 | func readIntFromInt32(reader io.Reader) ( int , error) { var i32 int32 err := binary . Read (reader, byteOrder, &i32) return int (i32), err } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | func readInvInvoice(reader io.Reader) (invoice *Invoice, err error) { invoice = &Invoice{} for _, pId := range []* int {&invoice.Id, &invoice.Customerld} { if *pId, err = readIntFromInt32(reader); err != nil { return nil, err } } for _, pDate := range []* time . Time {&invoice.Raised, &invoice.Due} { if *pDate, err = readInvDate(reader); err != nil { return nil, err } } if invoice.Paid, err = readBoolFromInt8(reader); err != nil { return nil, err } if invoice.Note, err = readlnvString(reader); err != nil { return nil, err } var count int if count , err = readIntFromInt32(reader); err != nil { return nil, err } invoice.Items, err = readInvItems(reader, count ) return invoice, err } |
发票 ID 和客户 ID 使用自定义的 readIntFromInt32() 函数读取。这段代码的微妙之处在于,我们迭代那些指向发票 ID 和客户 ID 的指针,并将返回的整数赋值给指针 (pId) 所指的值。
一个可选的方案是单独处理每一个 ID。例如,
if invoice.Id, err = readIntFromInt32(reader) ; err ! = nil { return err}
等。读取创建及过期日期的流程与读取 ID 的流程完全一样,只是这次我们使用的是自定义的 readInvDate() 函数。
正如读取 ID 一样,我们也可以以更加简单的方式单独处理日期。例如,
if invoice.Due, err = readlnvDate(reader); err != nil { return err }
等。稍后将看到,我们使用一些辅助函数读取是否支付的标志和注释文本。发票数据读完之后,我们再读取有多少个发票项,然后调用 readInvIterns() 函数读取全部发票项,传递给该函数一个用于读取的 io.Reader 值和一个表示需要读多少项的数字。
1 2 3 4 5 6 7 | func readlnvDate(reader io.Reader) ( time . Time , error) { var n int32 if err := binary . Read (reader, byteOrder, &n); err != nil { return time . Time {}, err } return time .Parse(invDateFormat, fmt.Sprint(n)) } |
1 2 3 4 5 | func readBoolFromInt8(reader io.Reader) (bool, error) { var i8 int8 err := binary . Read (reader, byteOrder, &i8) return i8 == 1, err } |
1 2 3 4 5 6 7 8 9 10 11 | func readInvString(reader io.Reader) (string, error) { var length int32 if err := binary . Read (reader, byteOrder, &length); err != nil { return "" , nil } raw := make([]byte, length) if err := binary . Read (reader, byteOrder, &raw); err != nil { return "" , err } return string(raw), nil } |
函数首先将切片项的个数读到一个 length 变量中。然后创建一个长度与此相同的切片。给 binary.Read() 函数传入一个指向切片的指针之后,它就会往该切片中尽可能地读入该类型的项(如果失败则返回一个非空的错误值)。需注意的是,这里重要的是切片的长度,而非其容量(其容量可能等于或者大于长度)。
在本例中,该 []byte 切片保存了 UTF-8 编码的字节,我们将其转换成字符串后将其返回。
1 2 3 4 5 6 7 8 9 10 11 | func readInvItems(reader io.Reader, count int ) ([]*Item, error) { items := make([]*Item, 0, count ) for i := 0; i < count ; i++ { item, err := readInvItem(reader) if err != nil { return nil, err } items = append(items, item) } return items, nil } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | func readlnvltem(reader io.Reader) (item Ttem, err error) { item = &Item{} if item.Id, err = readInvString(reader); err != nil { return nil, err } if err = binary . Read (reader, byteOrder, &item.Price); err != nil { return nil, err } if item.Quantity, err = readIntFromInt16(reader); err != nil { return nil, err } item.Note, err = readInvString(reader) return item, nil } |
Item.Price 字段的类型也一样。(我们省略了 readIntFromInt16() 函数,因为它与我们前文所描述的 readIntFromInt32() 函数基本相同。)
至此,我们完成了对自定义二进制数据的读和写。只要小心选择表示长度的整数符号和大小,并将该长度值写在变长值(如切片)的内容之前,那么使用二进制数据进行工作并不难。
Go语言对二进制文件的支持还包括随机访问。这种情况下,我们必须使用 os.OpenFile() 函数来打开文件(而非 os.Open()),并给它传入合理的权限标志和模式(例如,os.O_RDWR 表示可读写)参数。
然后,就可以使用 os.File.Seek() 方法来在文件中定位并读写,或者使用 os.File.ReadAt() 和 os.File.WriteAt() 方法来从特定的字节偏移中读取或者写入数据。
Go语言还提供了其他常用的方法,包括 os.File.Stat() 方法,它返回的 os.FileInfo 包含了文件大小、权限以及日期时间等细节信息。