基本操作
bytes包和strings包非常相似,单从它们提供的函数的数量和功能上看,差别微乎其微。
strings包主要是面向Unicode字符和经过UTF-8编码的字符串,而bytes包主要是面对字节和字节切片。
bytes.Buffer类型
Buffer类型的用途主要是作为字节序列的缓冲区。
bytes.Buffer是开箱即用的。可以进行拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序的读取其中的子序列。所以是集读、写功能与一身的数据类型,这些也是作为缓冲区应该拥有的功能。
在内部,bytes.Buffer类型使用字节切片(bootstrap字段)作为内容容器。还有一个int类型(off字段)作为已读字节的计数器,简称为已读计数。不过这里的已读计数是不无获取也无法计算得到的。bytes.Buffer类型具体如下:
type Buffer struct {
buf []byte // contents are the bytes buf[off : len(buf)]
off int // read at &buf[off], write at &buf[len(buf)]
bootstrap [64]byte // memory to hold first slice; helps small buffers avoid allocation.
lastRead readOp // last read operation, so that Unread* can work correctly.
}
长度和容量
先看下示例:
package main
import (
"fmt"
"bytes"
)
func main() {
var b1 bytes.Buffer
contents := "Make the plan."
b1.WriteString(contents)
fmt.Println(b1.Len(), b1.Cap())
p1 := make([]byte, 5)
n, _ := b1.Read(p1) // 忽略错误
fmt.Println(n, string(p1))
fmt.Println(b1.Len(), b1.Cap())
}
/* 执行结果
PS G:\Steed\Documents\Go\src\Go36\article38\example01> go run main.go
Lan: 14 Cap: 64
5 Make
Lan: 9 Cap: 64
PS G:\Steed\Documents\Go\src\Go36\article38\example01>
*/
先声明了一个byte.Buffer类型的变量,并写入一个字符串。然后打印了这个Buffer值的长度和容量。之后进行了一次读取,读取之后,再输出一个长度和容量。这里容量没有变,因为没有再写入任何内容。而长度变小了,这里的长度是未读内容的长度,一开始和存放的字节序列的长度一样,在读取操作之后,会随之变小,同样的,在写入操作之后,也会增大。
已读计数
没有办法可以直接得到Buffer值的已读计数,并且也很难估算它。但是为了用好bytes.Buffer,依然需要去源码里了解一下已读计数的作用。
bytes.Buffer中的已读计数的大致的功用如下:
- 读取内容时,相应方法会依据已读计数找到未读部分,并在读取后更新计数
- 写入内容时,如需扩容,相应方法会根据已读计数实现扩容策略
- 截断内容时,相应方法截掉的是已读计数代表的索引之后的未读部分
- 读回退时,相应方法需要用已读计数记录回退点
- 重置内容时,相应方法会把已读计数置为0
- 导出内容时,相应方法会导出已读计数代表的索引之后的未读部分
- 获取长度时,相应方法会依据已读计数和内容容器的长度,计算未读部分的长度并返回
通过以上功能的介绍,就能够体会到已读计数的重要性了。在bytes.Buffer的大多数的方法都用到了已读计数,而且都是非用不可的。
读取内容
在读取内容的时候,相应方法会先根据已读计数,判断一下内容容器中是否还有未读内容。如果有,那就会以已读计数为索引开始读取。读完之后,还会及时的更新已读计数。
读取内容的方法:
- 所有名称以Read开头的方法
- Next方法
- WriteTo方法
写入内容
在写入内容的时候,绝大多数的响应方法都会先检查当前的内容容器,看看是否有足够的容量容纳新内容。如果没有,就会进行扩容。在扩容的时候,方法会在必要时,依据已读计数找到未读部分,并把其中的内容拷贝到扩容后的内容容器的头部位置。然后,方法将会把已读计数的值置为0,这样下一次读取的时候就会从新的内容容器的第一个字节开始了。
由于扩容后,已读的内容不会拷贝,所以就真正的丢弃了。不过Buffer本身也不支持对已读内容的再次操作,只是出于效率和值不可变的考虑,不会进行删除,而是等到扩容的时候忽略该部分内容不做拷贝,最后等着被回收掉。
写入内容的方法:
- 所有名称以Write开头的方法
- ReadFrom方法
示例:
func main() {
var contents string
b1 := bytes.NewBufferString(contents)
fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
contents = "一二三四五"
b1.WriteString(contents)
fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
contents = "67"
b1.WriteString(contents)
fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
}
截断内容
截断内容的方法:Truncate
该方法会接受一个int类型的参数,表示在截断时需要保留头部的多个个字节。注意这里所说的头部指的是未读部分的头部。这个头部的起始索引正是已读计数的值。
还是因为已读部分逻辑上就是不存在的,所以这里截断操作是从未读部分开始的。
读回退
读回退有2个方法:
- UnreadByte : 回退一个一节
- UnreadRune : 回退一个Unicode字符
调用它们一般是为了退回到上一次被读取内容末尾的那个分隔符,或者为了重新读取前一个字节或字符做准备。回退是有前提的,在调用之前的哪一个操作必须是读取内容,并且是成功读取的。否则这写方法就会忽略后续操作并返回一个非nil的错误值。
UnreadByte方法比较简单,直接已读计数减1即可。
而UnreadRune方法需要从已读计数中减去的,是上一次被读取的Unicode字符所占用的字节数。这个字节数存在bytes.Buffer的lastRead字段里。只有在执行ReadRune方法中才会把这个字段设置为1至4的值,其他一些读写的方法中会在这个字段设置为0或-1。所以只有紧接在ReadRune方法之后,才能成功调用UnreadRune方法。这个方法明显比UnreadByte方法的适用面更小。
重置内容
重置内容的方法:Reset
不多解释了,直接看源码:
func (b *Buffer) Reset() {
b.buf = b.buf[:0]
b.off = 0
b.lastRead = opInvalid
}
没有重置内容容器,这样避免了一次内存分配。
导出内容
导出内容的方法:
- Bytes方法
- String方法
访问未读部分的中的内容,并返回相应的结果。已读的部分可以认为是逻辑丢弃了,如果有过扩容,在垃圾清理后就是真正的物理丢弃了,所以也不应该获取到。
获取长度
获取长度的方法:Lan方法
返回内容容器中未读部分的长度。而不是其中已存内容的总长度,即:内容长度。
小结
已读计数器索引之前的那些内容,永远都是已经被读过的,几乎没有机会再次被读取到。
不过,这些已读内容所在的内存空间可能会被存入新的内容。这一般都是由于重置或者扩容内容容器导致的。重置或扩容后,已读计数一定会被置0,从而再次指向内容容器中的第一个字节。这有时候也是为了避免内存分配和重用内存空间,这句意思大概是:重用一次内容空间的话,就避免了一次内存分配的操作。直接把之前分配过的但是内容已经不需要的内存再用起来。否则的话,就是一次新的内存分配和一次对已分配内存的清理。
扩展知识
主要讲两个问题:
- 扩容策略
- 内容泄露
扩容策略
Buffer值既可以被手动扩容,也可以进行自动扩容。并且这种扩容方式的策略是基本一致的。所以,在完全确定后续内容所需的字节数的时候手动扩容,否则让Buffer值自动扩容就好了。
在扩容的时候,是会先判断内容容器(bootstrap)的剩余容量是否够用,如果可以,会在当前的内容容器上,进行长度扩容。在源码中就是下面这几句体现的:
func (b *Buffer) grow(n int) int {
m := b.Len()
// 省略中间的若干代码
b.buf = b.buf[:m+n] // 当前内容的长度+需要的长度
return m
}
若干内容容器的剩余容量不够了,那么扩容就会用新的内容容器去替代原有的内容容器,从而实现扩容。这里会有一步优化,如果当前内容容器的容量的一半仍然大于或等于现有长度在加上需要的字节数,那么扩容代码会复用现有的内容容器,并把容器中未读内容拷贝到它的头部位置。这样就是把已读内容都覆盖掉了,整体内容在内存里往前移。这样的复用可以省掉一次后续的扩容所带来的内存分配,以及若干字节的拷贝。
若上面的优化条件不满足,那么扩容代码就要再创建一个新的内容容器,并把原有容器中的未读内容拷贝进去,最后再用新的容器替换掉原有的容器。这个新容器的容量讲会等于原有容量的两倍再加上需要的字节数。这个策略和之前strings扩容的策略是一样的。
下面是一个扩容的示例代码:
func main() {
contents := "Good Year!"
b1 := bytes.NewBufferString(contents)
fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap()) // 10, 16
n := 10
b1.Grow(n)
fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap()) // 10, 42
}
如果对处于零值状态的Buffer值来说,如果第一次扩容时需要的字节数不大于64,那么该值就会基于一个预先定义好的、长度为64的数组([64]byte)来作为内容容器。这样做的目的是为了让Buffer值在刚被真正使用的时候就可以快速的做好准备。
完成上面的步骤,对内容容器的扩容就基本完成了。不过,为了内部数据的一致性,以及避免原有的已读内容可能造成的数据混乱,扩容代码还会把已读计数置为0,并再对内容容器做一下切片操作,以掩盖掉原有的已读内容。
注意内容泄露
内容泄露:这里说的内容泄露是指,使用Buffer值的一个方法通过某种非标准的(或者说不正式的)方法得到了不该得到的内容。
比如,通过调用Buffer值的某个用于读取内容的方法,得到了一部分未读内容。但是这个Buffer值又有了一些新内容后,却可以通过当时得到的结果值,直接获得新的内容,而不需要再次调用相应的读去内容的方法。这就是典型的非标准读取方式。这种读取方式是不应该存在的,即使存在,也不应该使用。因为它是在无意中(或者说不小心)暴露出来的,其行为很可能是不稳定的。
在bytes.Buffer中,Bytes方法和Next方法都可能会造成内容的泄露。原因在于,它们都把基于内容容器的切片直接返回给了方法的调用方。通过切片,就可以直接访问和操作它的底层数组。不论这个切片是基于某个数组得来的,还是通过对另一个切片做切片操作获得的。这里的Bytes方法和Next方法返回的字节切片,都是通过对内容容器做切片操作得到的。也就是说,它们与内容容器公用了同一个底层数组,起码在一段时期之内是这样的。
以Bytes方法为例,下面是演示内容泄露的示例:
func main() {
b1 := bytes.NewBufferString("abc")
fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
s1 := b1.Bytes()
fmt.Printf("%[1]v, %[1]s\n", s1)
b1.WriteString("123")
fmt.Printf("Lan: %d, Cap: %d.\n", b1.Len(), b1.Cap())
fmt.Printf("%[1]v, %[1]s\n", s1)
// 这里只要扩充一下切片,就读到后续内容了
s1 = s1[:cap(s1)]
fmt.Printf("%[1]v, %[1]s\n", s1)
// 只是读到还不算,还能改
s1[len(s1)-3] = ‘X‘
fmt.Printf("%[1]v, %[1]s\n", s1)
}
这里要避免扩容,写入内容后都输出了一下容量,容量不变就是没有扩容过。那么Bytes方法返回的结果值与内容容器在此时还共用着同一个底层数组。之后就简单的做了再切片,就通过这个结果值把后面的未读内容都拿到了。这还没完,如果当时把这个值传到了外界,那么外界就可以通过该值修改里面的内容了。这个后果就很严重了,另一个Next方法,也存在相同的问题。
不过,如果经过扩容,Buffer值的内容容器或者它的底层数组就被重新设定了,那么之前的内容泄露问题就无法再进一步发展了。
这里是一个很严重的数据安全问题。一定要避免这种情况的发生。泄露的包里的方法本身的特性,无法避免,但是可以小心操作。会造成严重后果的途径是有意或无意的把这些返回的结果值传到了外界,这个问题可以避免。要在传出切片这类值之前,做好隔离。不如,先对它们进行深拷贝,然后再把副本传出去。
原文地址:http://blog.51cto.com/steed/2348657