Golang标准库源码学习-encoding/binary
binary包,位于golang标准库父包encoding下,主要实现了各种数据类型和字节序列之间的简单转换以及变量的编码和解码,注重简单性,但是性能可能不佳。
总结一下其作用:
- go各种原生类型与二进制数据之间的序列化和反序列化(定长解编码)
- 64位整形的变长编码算法varint的解编码
binary
binary.go文件主要提供go中各种数据类型与二进制数据进行序列化和反序列化或者说定长解编码的功能
主要接口
我们先来看一下该包的binary.go文件下的两个主要接口,ByteOrder
和AppendByteOrder
// A ByteOrder specifies how to convert byte slices into
// 16-, 32-, or 64-bit unsigned integers.
type ByteOrder interface {
Uint16([]byte) uint16
Uint32([]byte) uint32
Uint64([]byte) uint64
PutUint16([]byte, uint16)
PutUint32([]byte, uint32)
PutUint64([]byte, uint64)
String() string
}
// AppendByteOrder specifies how to append 16-, 32-, or 64-bit unsigned integers
// into a byte slice.
type AppendByteOrder interface {
AppendUint16([]byte, uint16) []byte
AppendUint32([]byte, uint32) []byte
AppendUint64([]byte, uint64) []byte
String() string
}
这两个接口是binary包包含了进行二进制序列化的主要方法,ByteOrder的Uint64系列方法主要将字节数组转换成无符号的16,32,64位数PutUint64系列方法主要将无符号数转换成字节数组添加进传进去的字符数组中,AppendByteOrder主要将无符号的16,32,64位数添加进字节数组。
同时,该包提供了针对这两个接口的littleEndian和bigEndian的大端字节序和小端字节序这两种实现。
我们以小端字节序中的Uint64,PutUint64,AppendUint64这三个方法的实现来简单了解下起实现原理:
首先是Uint64:
func (littleEndian) Uint64(b []byte) uint64 {
_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
}
显然,首先该函数通过_=b[7]
进行了边界检查,确保传入的b数组的长度>=8,这样做的好处是编译器当发现其数组下标越界后会直接panic,不会执行下面的移位操作。
但是这样也有一个明显的缺点,那就是当我们传入的数组长度小于8时,实际上也是ok的,因为假如我们需要转换成一个uint64数1,实际上只需要一个字节就ok了,其余7个字节都是0,完全是多余的,因此,这种边界检查实际上是多余的,这也是为什么后面跟了一个关于issue的注释。这个issue就是讨论encoding/binary里面的边界检查是否多余。
其中也给出了一个不进行边界检查的例子:
func (littleEndian) Uint64(b []byte) uint64 {
switch len(b) {
case 0:
return 0
case 1:
return uint64(b[0])
case 2:
return uint64(b[0]) | uint64(b[1])<<8
case 3:
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16
case 4:
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24
case 5:
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
uint64(b[4])<<32
case 6:
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
uint64(b[4])<<32 | uint64(b[5])<<40
case 7:
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48
default:
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
}
}
从直觉和使用的体验上来说,不进行边界检查的这个方案显然是更合理的,但是截至最新版的go 1.21.0,encoding/binary仍然没有移除边界检查。那为什么这个提案没有被接受呢?其实,如果不进行边界检查,根据字节数组的字节数进行动态地转换,那么考虑到这些函数的主要作用是序列化和反序列化,其难度会大大增加,如果固定其字节数组长度,在对字节数组进行反序列化时就可以根据其长度判断数据是否存在损坏。而动态的话反序列化难度实在太大,因为如果两个Uint64连在一起,如何判断每个Uint64的边界呢,因此也只能采取定长的方式。
然后,边界检查完了之后就是常规的位运算了通过左移操作将各个字节移动到uint64的各位上,最后进行|
操作将所有数整合到一个uint64上。
接着是PutUint64和AppendUint64:
func (littleEndian) PutUint64(b []byte, v uint64) {
_ = b[7] // early bounds check to guarantee safety of writes below
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
b[4] = byte(v >> 32)
b[5] = byte(v >> 40)
b[6] = byte(v >> 48)
b[7] = byte(v >> 56)
}
func (littleEndian) AppendUint64(b []byte, v uint64) []byte {
return append(b,
byte(v),
byte(v>>8),
byte(v>>16),
byte(v>>24),
byte(v>>32),
byte(v>>40),
byte(v>>48),
byte(v>>56),
)
}
很简单,和Uint64原理差不多,就不多介绍了。
Write
Write方法是该包中非常核心的一个方法,主要用于数据的序列化,将data的数据按照order的规则转换成字节数组写入到w中去。
其中布尔类型的true转成1,false转成0
结构体类型_
空白字段的值会被忽略并写成零值。
另外,data必须是固定大小的,切片要求值必须是固定大小的
func Write(w io.Writer, order ByteOrder, data any) error {
// Fast path for basic types and slices.
if n := intDataSize(data); n != 0 {
bs := make([]byte, n)
switch v := data.(type) {
case *bool:
if *v {
bs[0] = 1
} else {
bs[0] = 0
}
case bool:
if v {
bs[0] = 1
} else {
bs[0] = 0
}
case ...
...//中间内容大体就是列举各种类型,然后进行转换,因为篇幅太长,这里省略。
case []float64:
for i, x := range v {
order.PutUint64(bs[8*i:], math.Float64bits(x))
}
}
_, err := w.Write(bs)
return err
}
// Fallback to reflect-based encoding.
v := reflect.Indirect(reflect.ValueOf(data))
size := dataSize(v)
if size < 0 {
return errors.New("binary.Write: invalid type " + reflect.TypeOf(data).String())
}
buf := make([]byte, size)
e := &encoder{order: order, buf: buf}
e.value(v)
_, err := w.Write(buf)
return err
}
从起源代码来说,整个函数的逻辑其实主要分为两部分,和syncz中的Mutex逻辑一样,官方的说法是分成fast path
和slow path
,fast path部分是开头通过switch data.(type)
类型断言配合switch case通过指针操作进行快速写入,其中intDataSize
函数返回data的字节数,但对于不能通过fastpath快速写入的类型会返回0,比如data是结构体类型,则会返回0.
接着是进行slow path的处理逻辑,slow path的处理是从// Fallback to reflect-based encoding.
注释处开始的,就是通过反射的方式进行数据的写入,其slowpath的核心处理逻辑主要集中在e.Value()
函数调用中,我们截取一下其中的部分代码:
func (e *encoder) value(v reflect.Value) {
switch v.Kind() {
case reflect.Array:
l := v.Len()
for i := 0; i < l; i++ {
e.value(v.Index(i))
}
case reflect.Struct:
t := v.Type()
l := v.NumField()
for i := 0; i < l; i++ {
// see comment for corresponding code in decoder.value()
if v := v.Field(i); v.CanSet() || t.Field(i).Name != "_" {
e.value(v)
} else {
e.skip(v)
}
}
case reflect.Slice:
l := v.Len()
for i := 0; i < l; i++ {
e.value(v.Index(i))
}
case reflect.Bool:
e.bool(v.Bool())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
switch v.Type().Kind() {
case reflect.Int8:
e.int8(int8(v.Int()))
case reflect.Int16:
e.int16(int16(v.Int()))
case reflect.Int32:
e.int32(int32(v.Int()))
case reflect.Int64:
e.int64(v.Int())
}
....
//其余逻辑也多是case列举其他类型
}
我们可以看到,实际上e.value中的case也是有对fastpath中一些int基本类型的处理的,这是怎么回事呢,之前在fastpath不是已经处理过了吗?而且既然slow path有对所有类型的反射处理,为什么要分成两部分,不能一开始就进行反射处理呢?
关于第一个问题,实际上仔细看value的处理逻辑,我们发现对于结构体,切片类型实际上里面都是递归调用,因为结构体和切片里面的字段有可能嵌套基本类型也可能继续嵌套结构体等,因此value包含了对所有类型的case,为了保证递归的正常调用。
关于第二个问题,分成fast path和slow path主要是为了程序性能考虑,使用反射需要在运行时进行类型检查和动态分派,这比直接调用静态类型的方法要慢。反射操作通常需要进行额外的内存分配和复制,以及类型转换和函数调用的开销。这些额外的开销会导致反射操作相对于直接操作更加耗时和消耗资源。另外,由于反射是在运行时进行的,编译器无法对其进行静态优化。这意味着一些编译器优化技术,如内联函数等,可能无法应用于反射操作。因此,在性能敏感的场景中,尽量避免过度使用反射操作。如果可以,应优先使用静态类型和编译时确定的代码来提高性能。
Read
Read方法是与Write方法对应的,Write将数据转成字节数组,Read将字节数组转成具体的数据类型,二者必须配合使用。不然很大可能会报错。
关于Read方法,结构体的空白字段会被skip
跳过,非空白字段必须是可以导出的(首字母大写的),不然就会panic
布尔类型,0为false,非零值为true
data必须是指针类型
同样地,data代表类型必须是固定大小的,切片中元素的值必须是固定大小的。
func Read(r io.Reader, order ByteOrder, data any) error
其具体事项逻辑和Write基本一致,分为fast path和slow path,只是处理过程相反,一个序列化,一个反序列化。这里不作介绍。
varint
该包下的另一个文件varint.go实现了一些变长编码的方法。
AppendUvarint
该函数主要将x通过变长编码添加进buf返回一个新的[]byte
func AppendUvarint(buf []byte, x uint64) []byte {
for x >= 0x80 {
buf = append(buf, byte(x)|0x80)
x >>= 7
}
return append(buf, byte(x))
}
其主要处理逻辑是将x与0x80也就是1000 0000
比较,如果比它小,那说明第八位必不可能是1.
然后它每次取7位,并且,将第八位置1作为一个新的字节,加入到buf数组,每次处理完后向右移7位,一值到处理完所有位为止。
可见,该方法转成的字节数组中,么个字节只有低7位才表示的真正的数组,第八位(msb)用于标识是否还存在更高位,也叫持续位(continuation bit),根据该算法,如果需要用一个字节数组表示一个uint64数,则最多需要10个字节,因为如果每个字节能表示7个位,那么9个字节才能表示63个位,如果想表示64个位的话,那么还差一个位,这个时候我们也许可以默认该算法最高可表示uint64,那么可以把第9个字节的最高位(msb)也当做数据位,这样设计的话就最大只需要用9个字节表示一个uint64的数,省下了一个位,但是在这样的话,也就无法表示更大的位比如uint128,无法向后面兼容了,同时也违背了varint变长算法本来的设计。因此一共是需要10个字节,自然可以继续推断,uint32和uint16只需要额外多1个字节即可,因此他们用于标识的位的数量分别是4和2,都不会满一字节。一个字节完全足够。
正好对应varint.go中给出的三个常量的定义:
const (
MaxVarintLen16 = 3
MaxVarintLen32 = 5
MaxVarintLen64 = 10
)
下面是一个uint64的例子
// 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111
// 1111 111 1,1111 111 1,1111 111 1,1111 111 1,1111 111 1,1111 111 1,1111 111 1,1111 111 1,1111 111 1,1 000 0000
PutUvarint
该方法将x通过变长编码转换成字节数组写入buf,并返回写入字节的数量,需要注意的是,该方法写入是从索引0开始的,会覆盖掉buf中原有的数据,并且在buf不够长时panic
// PutUvarint encodes a uint64 into buf and returns the number of bytes written.
// If the buffer is too small, PutUvarint will panic.
func PutUvarint(buf []byte, x uint64) int {
i := 0
for x >= 0x80 {
buf[i] = byte(x) | 0x80
x >>= 7
i++
}
buf[i] = byte(x)
return i + 1
}
Uvarint
该方法将通过前面方法得到的变长编码buf转换成uint64,并返回读取的字节数量n,其中n>0表示正常读取,n==0表示buf太小,n<0表示数据溢出了(此时的-n表示读取的字节数)。
// Uvarint decodes a uint64 from buf and returns that value and the
// number of bytes read (> 0). If an error occurred, the value is 0
// and the number of bytes n is <= 0 meaning:
//
// n == 0: buf too small
// n < 0: value larger than 64 bits (overflow)
// and -n is the number of bytes read
func Uvarint(buf []byte) (uint64, int) {
var x uint64
var s uint
for i, b := range buf {
if i == MaxVarintLen64 {
// Catch byte reads past MaxVarintLen64.
// See issue https://golang.org/issues/41185
return 0, -(i + 1) // overflow
}
if b < 0x80 {
if i == MaxVarintLen64-1 && b > 1 {
return 0, -(i + 1) // overflow
}
return x | uint64(b)<<s, i + 1
}
x |= uint64(b&0x7f) << s
s += 7
}
return 0, 0
}
其主要逻辑就是x |= uint64(b&0x7f) << s
通过遍历字节数组,每次取低7位,转成uint64并且进行左移到合适的位置上,最后|
合在一起。对位操作不熟悉的可以去看Golang基础那篇文章中的玩转位运算。
另外其中的:
if i == MaxVarintLen64 {
// Catch byte reads past MaxVarintLen64.
// See issue https://golang.org/issues/41185
return 0, -(i + 1) // overflow
}
主要是为了安全考虑,防止溢出,它还提到了一个issues,我看了下,这个issues似乎就是讲的原本的该方法会无限制读下去,没有进行溢出检查,现在被解决了。
AppendVarint
这个函数是AppendUVarint的有符号实现,主要就是多了补码的处理逻辑,主要目的是因为一个负数的绝对值如果比较小,那么它的前面会有比较多的1,这段多出来的逻辑就是为了去掉前面的1,值保留其核心的部分。
// AppendVarint appends the varint-encoded form of x,
// as generated by PutVarint, to buf and returns the extended buffer.
func AppendVarint(buf []byte, x int64) []byte {
ux := uint64(x) << 1
if x < 0 {
ux = ^ux
}
return AppendUvarint(buf, ux)
}
我们知道有符号数最高位是符号位,ux := uint64(x) << 1
相当于去掉符号位,然后在x时负数的情况下,执行ux = ^ux
,也就是按位取反,由于原来左移了一位,最右边一位补了0,取反后变成了1,所以末尾应该是有一个固定的1的,但是在正数的情况下,不会有取反操作,所以末尾没有1,因此末尾的这个1用来标记这个数是个负数,而按位取反会使得前面一连串的1变成0,然后讲这个新的数交给AppendUvarint处理
以-3为例,我们解释下这段补码处理逻辑
uint64(-3) -> 1111111111111111111111111111111111111111111111111111111111111101
uint64(-3)<<1 -> 1111111111111111111111111111111111111111111111111111111111111010
^(uint64(-3)<<1) -> 101
PutVarint
PutUvarint的有符号版本,和AppendVarint多了通用的补码处理逻辑。
// PutVarint encodes an int64 into buf and returns the number of bytes written.
// If the buffer is too small, PutVarint will panic.
func PutVarint(buf []byte, x int64) int {
ux := uint64(x) << 1
if x < 0 {
ux = ^ux
}
return PutUvarint(buf, ux)
}
Varint
该函数用于有符号数的解码操作,将buf转成int64
// Varint decodes an int64 from buf and returns that value and the
// number of bytes read (> 0). If an error occurred, the value is 0
// and the number of bytes n is <= 0 with the following meaning:
//
// n == 0: buf too small
// n < 0: value larger than 64 bits (overflow)
// and -n is the number of bytes read
func Varint(buf []byte) (int64, int) {
ux, n := Uvarint(buf) // ok to continue in presence of error
x := int64(ux >> 1)
if ux&1 != 0 {
x = ^x
}
return x, n
}
该函数操作正好和AppendVarint的补码操作相反,先解析出uint64的ux,然后左移一位,如果原先x就是正数,此时左移后已经得到了原来的数,但是如果原来的数是负数,就通过ux&1判断末尾的标记位是不是0,不是0的话说明是个负数,还要执行一步按位取反。
ReadUvarint和ReadVarint
这两个函数和之前的Uvarint和Varint功能基本一致,区别就是这两个从ByteReader中读取,实现原理除了字节的读取部分外也基本一致,不作多的介绍。
// ReadUvarint reads an encoded unsigned integer from r and returns it as a uint64.
// The error is EOF only if no bytes were read.
// If an EOF happens after reading some but not all the bytes,
// ReadUvarint returns io.ErrUnexpectedEOF.
func ReadUvarint(r io.ByteReader) (uint64, error) {
var x uint64
var s uint
for i := 0; i < MaxVarintLen64; i++ {
b, err := r.ReadByte()
if err != nil {
if i > 0 && err == io.EOF {
err = io.ErrUnexpectedEOF
}
return x, err
}
if b < 0x80 {
if i == MaxVarintLen64-1 && b > 1 {
return x, overflow
}
return x | uint64(b)<<s, nil
}
x |= uint64(b&0x7f) << s
s += 7
}
return x, overflow
}
// ReadVarint reads an encoded signed integer from r and returns it as an int64.
// The error is EOF only if no bytes were read.
// If an EOF happens after reading some but not all the bytes,
// ReadVarint returns io.ErrUnexpectedEOF.
func ReadVarint(r io.ByteReader) (int64, error) {
ux, err := ReadUvarint(r) // ok to continue in presence of error
x := int64(ux >> 1)
if ux&1 != 0 {
x = ^x
}
return x, err
}