在编程世界里,有时我们需要将数据紧凑地存储起来,或者高效地在网络间传输,这时二进制格式就成了我们的不二之选。今天,就让我们一起探索Go语言中二进制文件操作的奥秘!
在我们深入技术细节之前,先来理解一个基本问题:为什么有时候我们要选择二进制格式而不是简单的文本格式?
想象一下,如果你需要存储一个简单的整数12345。在文本格式中,它会存储为字符"1"、"2"、"3"、"4"、"5",占用5个字节。而在二进制格式中,同样的整数只需要4个字节(int32)。当数据量庞大时,这种差异会非常显著!
二进制格式的优势:
更紧凑:占用空间更少读写更快:无需复杂的解析过程类型保持:保持数据的原始类型信息但要注意,二进制格式也有缺点,比如可读性差——用普通文本编辑器打开二进制文件,你看到的只是一堆乱码。这就是为什么我们需要专门学习如何读写它们!
Go语言提供了多种处理二进制数据的方式,主要分为两类:
使用标准库的
encoding/gob包自定义二进制格式使用
encoding/binary包
encoding/gob是Go语言的一个特色包,它能够自动编码和解码Go语言中的数据类型。就像是一个专业的快递员,他知道如何将你的数据结构完好无损地打包、运送和拆包。
让我们看一个简单的例子,演示如何使用gob包将数据编码为二进制格式:
package main
import (
"encoding/gob"
"fmt"
"os"
)
func main() {
info := "http://jb51.net/golang/"
file, err := os.Create("./output.gob")
if err != nil {
fmt.Println("文件创建失败", err.Error())
return
}
defer file.Close()
encoder := gob.NewEncoder(file)
err = encoder.Encode(info)
if err != nil {
fmt.Println("编码错误", err.Error())
return
} else {
fmt.Println("编码成功")
}
}
这段代码创建了一个名为
output.gob的二进制文件,并将字符串写入其中。生成的文件内容会是这样的二进制序列:
210c 001e 6874 7470 3a2f 2f63 2e62 6961
6e63 6865 6e67 2e6e 6574 2f67 6f6c 616e
672f
那么,如何读取这个二进制文件呢?同样简单:
package main
import (
"encoding/gob"
"fmt"
"os"
)
func main() {
file, err := os.Open("./output.gob")
if err != nil {
fmt.Println("文件打开失败", err.Error())
return
}
defer file.Close()
decoder := gob.NewDecoder(file)
info := ""
err = decoder.Decode(&info)
if err != nil {
fmt.Println("解码失败", err.Error())
} else {
fmt.Println("解码成功")
fmt.Println(info)
}
}
运行这段代码,你会看到输出:"解码成功http://jb51.net/golang/"。
gob格式的强大之处在于它能处理复杂的数据结构。例如,你可以序列化一个结构体:
package main
import (
"bytes"
"encoding/gob"
"fmt"
"io/ioutil"
)
type Article struct {
Id int
Title string
Content string
Author string
}
// 写入二进制数据到磁盘文件
func write(data interface{}, filename string) {
buffer := new(bytes.Buffer)
encoder := gob.NewEncoder(buffer)
err := encoder.Encode(data)
if err != nil {
panic(err)
}
err = ioutil.WriteFile(filename, buffer.Bytes(), 0600)
if err != nil {
panic(err)
}
}
// 从磁盘文件加载二进制数据
func read(data interface{}, filename string) {
raw, err := ioutil.ReadFile(filename)
if err != nil {
panic(err)
}
buffer := bytes.NewBuffer(raw)
dec := gob.NewDecoder(buffer)
err = dec.Decode(data)
if err != nil {
panic(err)
}
}
func main() {
article := Article{
Id: 1,
Title: "基于 Gob 包编解码二进制数据",
Content: "通过 Gob 包序列化二进制数据以便通过网络传输",
Author: "学院君",
}
write(article, "article_data")
var articleData Article
read(&articleData, "article_data")
fmt.Printf("%#v
", articleData)
}
这个例子展示了如何将结构体序列化为二进制数据,然后再反序列化。当你运行它时,会看到结构体被完美地还原了。
尽管gob包非常方便,但它有一些限制:
主要是为Go语言设计的,与其他语言交互可能不兼容虽然高效,但可能不是最紧凑的格式有时需要处理一些不可被gob编码的数据正因为这些限制,当我们需要与其他语言交互、追求极致的性能或需要特定的数据布局时,就需要自定义二进制格式。
自定义二进制格式就像是自己设计家具而不是购买现成的——你可以完全控制每个细节,但这需要更多工作。
Go语言的
encoding/binary包提供了基本的二进制编码解码功能。其中最常用的是
binary.Write()和
binary.Read()函数。
binary.Write()函数的原型如下:
func Write(w io.Writer, order ByteOrder, data interface{}) error
这个函数将数据的二进制编码格式写入io.Writer,数据必须是定长值、定长值的切片或定长值的指针。order参数指定写入数据的字节序(后面会详细解释)。
在深入了解代码之前,我们需要理解一个关键概念:字节序(Endianness)。这指的是数据在内存中的存储顺序:
大端序:最高有效字节存储在最低内存地址(就像我们书写数字一样)小端序:最低有效字节存储在最低内存地址例如,数字0x12345678在大端序和小端序中的存储方式不同:
选择哪种字节序取决于目标平台和协议要求。网络协议通常使用大端序,而Intel处理器使用小端序。
让我们看一个完整的例子,演示如何创建自定义二进制文件:
package main
import (
"bytes"
"encoding/binary"
"fmt"
"os"
)
type Website struct {
Url int32
}
func main() {
file, err := os.Create("output.bin")
for i := 1; i <= 10; i++ {
info := Website{
int32(i),
}
if err != nil {
fmt.Println("文件创建失败 ", err.Error())
return
}
defer file.Close()
var bin_buf bytes.Buffer
binary.Write(&bin_buf, binary.LittleEndian, info)
b := bin_buf.Bytes()
_, err = file.Write(b)
if err != nil {
fmt.Println("编码失败", err.Error())
return
}
}
fmt.Println("编码成功")
}
运行这个程序会在当前目录下生成output.bin文件,内容如下:
0100 0000 0200 0000 0300 0000 0400 0000
0500 0000 0600 0000 0700 0000 0800 0000
0900 0000 0a00 0000
读取自定义二进制数据同样简单:使用与写入时相同的字节顺序将数据读进相同类型的值中。
package main
import (
"bytes"
"encoding/binary"
"fmt"
"os"
)
type Website struct {
Url int32
}
func main() {
file, err := os.Open("output.bin")
defer file.Close()
if err != nil {
fmt.Println("文件打开失败", err.Error())
return
}
m := Website{}
for i := 1; i <= 10; i++ {
data := readNextBytes(file, 4)
buffer := bytes.NewBuffer(data)
err = binary.Read(buffer, binary.LittleEndian, &m)
if err != nil {
fmt.Println("二进制文件读取失败", err)
return
}
fmt.Println("第", i, "个值为:", m)
}
}
func readNextBytes(file *os.File, number int) []byte {
bytes := make([]byte, number)
_, err := file.Read(bytes)
if err != nil {
fmt.Println("解码失败", err)
}
return bytes
}
运行这个程序,你会看到输出:
第 1 个值为: {1}
第 2 个值为: {2}
// ... 省略部分输出
第 10 个值为: {10}
至此,我们完成了对自定义二进制数据的读和写操作。
让我们来看一个更实际的例子:为搜索引擎构建倒排索引文档ID存储。
在搜索引擎中,倒排索引是非常关键的数据结构。文档ID可以使用大文件来保存,记录每个关键词对应的文档ID列表和偏移量。
以下是实现这一功能的完整代码:
package main
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
)
type FileHandler struct {
filePath string
file *os.File
}
const FILEDIR = "/data/index/"
// 创建一个新的文件处理器
func NewFileHandler(field string) *FileHandler {
root := GetPath()
filePath := root + FILEDIR + field + ".bin"
var fp *os.File
var err error
if FileExist(filePath) {
fp, err = os.OpenFile(filePath, os.O_RDWR, 0666)
if err != nil {
fmt.Println("open file:", err)
}
} else {
fp, err = os.Create(filePath)
if err != nil {
fmt.Println("create file:", err)
}
}
fileHandler := new(FileHandler)
fileHandler.filePath = filePath
fileHandler.file = fp
return fileHandler
}
// 从指定的位置读取一个int64
func (fh *FileHandler) ReadInt64(start int64) int64 {
buf := make([]byte, 8)
_, err := fh.file.ReadAt(buf, start)
if err != nil {
if err == io.EOF {
return -1
}
}
return bytetoint(buf) // 把读取的字节转为int64
}
// 在指定的地方写入int64,不传start就追加到文件末尾
func (fh *FileHandler) WriteInt64(value, start int64) int64 {
if start < 1 {
start, _ = fh.file.Seek(0, io.SeekEnd) // 表示0到文件end的偏移量
}
b := inttobyte(value)
_, err := fh.file.WriteAt(b, start)
if err != nil {
fmt.Println(err)
}
return start
}
// 从start下标读取len个int64
func (fh *FileHandler) ReadDocIdsArry(start, len int64) []int64 {
var i int64 = 0
res := make([]int64, 0, len)
for ; i < len; i++ {
start = start + i*8
num := fh.ReadInt64(start)
if num <= 0 { // 越界了就直接返回
break
}
res = append(res, num)
}
return res
}
// 检查文件是否存在
func FileExist(filePath string) bool {
_, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
// []byte 转化 int64
func bytetoint(by []byte) int64 {
var num int64
b_buf := bytes.NewBuffer(by)
binary.Read(b_buf, binary.BigEndian, &num)
return num
}
// int64 转 []byte
func inttobyte(num int64) []byte {
b_buf := new(bytes.Buffer)
binary.Write(b_buf, binary.BigEndian, &num)
return b_buf.Bytes()
}
// 获取当前程序目录
func GetPath() string {
path, _ := os.Getwd()
return path
}
func main() {
h := NewFileHandler("test") // 会创建test.bin文件
index := h.WriteInt64(998, 0) // 写入998,返回偏移量
num := h.ReadInt64(index) // 传入偏移量,读取一个int64
s := h.ReadDocIdsArry(index, 100) // 传入偏移量,读取100个int64
fmt.Println(index, num, s)
}
这个例子展示了如何在二进制文件中随机访问数据,这是许多高性能应用的基础。
当需要存储复杂的数据结构时,有几种策略:
序列化嵌套结构:将每个字段依次写入使用长度前缀:对于变长数据(如字符串),先写入长度再写入数据索引表:在文件开头创建索引表,记录各个数据块的位置Go语言对二进制文件的支持还包括随机访问。这时我们需要使用
os.OpenFile()函数来打开文件(而不是
os.Open()),并传入合理的权限标志和模式(例如
os.O_RDWR表示可读写)。
然后,可以使用
os.File.Seek()方法在文件中定位,或者使用
os.File.ReadAt()和
os.File.WriteAt()方法从特定的字节偏移中读取或写入数据。
使用binary包时,必须使用长度确定的类型。可以用int32、int64但别用int,因为int的长度在不同平台上可能不同!
在生产环境中,二进制文件的读写需要有健壮的错误处理:
检查文件头和魔数以确保文件格式正确使用版本字段以便格式升级添加校验和以检测数据损坏在编程文化中,开发者有时会在二进制文件中隐藏一些彩蛋。例如,在百度的一个RPC协议中,tech lead在magic number字段中放置了"0xfb709394",这是粤语数字梗"出来搞三搞四"的谐音。
后来这个协议变成了百度内部很通行的通讯协议,几乎全百度的服务器都在使用这个包含彩蛋的协议握手。
虽然添加彩蛋很有趣,但在生产环境中需要谨慎,确保这些彩蛋不会影响系统的稳定性和可维护性。
通过本文的学习,我们了解了Go语言中二进制文件操作的方方面面:
标准gob格式适合Go语言内部使用,简单方便自定义二进制格式提供最大控制和性能优化字节序是关键决策点,影响数据兼容性错误处理和版本控制是生产环境必备二进制文件操作是Go语言程序员工具箱中一个重要工具。无论是构建高性能系统、处理网络协议,还是优化存储效率,掌握二进制文件操作都将让你在Go语言开发中游刃有余。
记住,能力越大责任越大——在使用二进制格式时,务必做好文档记录和错误处理,以免给未来的维护者(或者未来的你)留下难以理解的"天书"!
推荐阅读与工具:
Go官方文档:encoding/binary包Go官方文档:encoding/gob包十六进制查看工具:Hex Fiend、HxD等