在处理协议头、文件魔数或消息类型时,我们经常遇到这样的需求:
我想先看看接下来是什么数据,再决定使用哪种方式处理,但暂时不想把这些数据读走。
从 Go 1.26 开始,bytes.Buffer 提供了一个专门解决这一问题的方法:
go
func (b *Buffer) Peek(n int) ([]byte, error)
Peek 可以查看接下来的 n 个字节,但不会移动 Buffer 的读取位置。
本文将介绍:
Buffer.Peek是如何加入 Go 标准库的- 它解决了什么实际问题
Peek与Read的核心区别- 为什么
Read后再次Peek会得到不同结果 - 使用
Peek时容易忽略的几个细节
一、Buffer.Peek 是做什么的?
先看一个完整示例:
go
package main
import (
"bytes"
"fmt"
)
func main() {
var b bytes.Buffer
b.WriteString("Hello, Gophers!")
data, err := b.Peek(5)
if err != nil {
panic(err)
}
fmt.Printf("First peek: %s\n", data)
fmt.Printf("Buffer: %s\n", b.String())
// Advance past "Hello, ".
if _, err := b.Read(make([]byte, 7)); err != nil {
panic(err)
}
data, err = b.Peek(7)
if err != nil {
panic(err)
}
fmt.Printf("Second peek: %s\n", data)
}
输出:
text
First peek: Hello
Buffer: Hello, Gophers!
Second peek: Gophers
第一次执行:
go
data, err := b.Peek(5)
得到前 5 个字节:
text
Hello
但 Buffer 的内容依然是:
text
Hello, Gophers!
这说明 Peek 只是查看数据,并没有把数据消费掉。
随后执行:
go
b.Read(make([]byte, 7))
读取并消费前 7 个字节:
text
Hello,
Buffer 当前的未读部分因此变成:
text
Gophers!
再次调用:
go
b.Peek(7)
得到的就是:
text
Gophers
所以,Peek 并不总是从 Buffer 最初的第一个字节开始,而是从当前读取位置开始。
二、什么是"当前读取位置"?
可以把 bytes.Buffer 想象成一段数据和一个读取游标。
初始状态:
text
Hello, Gophers!
^
当前读取位置
调用:
go
b.Peek(5)
只是查看游标之后的 5 个字节:
text
Hello
游标不会移动:
text
Hello, Gophers!
^
当前位置仍然不变
调用:
go
b.Read(make([]byte, 7))
则会读取 "Hello, ",同时移动游标:
text
Hello, Gophers!
^
新的读取位置
此时再执行:
go
b.Peek(7)
自然会从新位置开始,返回:
text
Gophers
这就是示例中注释:
go
// Advance past "Hello, ".
所表达的含义:消费掉 "Hello, ",将读取位置向后移动 7 个字节。
三、Peek 和 Read 有什么区别?
两者最重要的区别是:
Peek只查看数据,Read会消费数据。
| 对比项 | Peek(n) |
Read(p) |
|---|---|---|
| 是否返回数据 | 是 | 是 |
| 是否移动读取位置 | 否 | 是 |
| 是否消费数据 | 否 | 是 |
| 是否复制数据 | 通常不复制,返回内部切片 | 将数据复制到调用者提供的切片 |
| 数据不足时 | 返回全部未读数据和 io.EOF |
有数据时返回实际读取长度和 nil;没有数据时返回 io.EOF |
| 返回切片是否引用 Buffer 内存 | 是 | 否,数据写入调用者的切片 |
| 典型用途 | 预览、判断协议或数据类型 | 正式读取和消费数据 |
使用 Peek
go
data, err := b.Peek(5)
如果 Buffer 中有 "Hello, Gophers!",那么 data 是:
text
Hello
但下一次读取仍然会从 H 开始。
使用 Read
go
data := make([]byte, 5)
n, err := b.Read(data)
同样可以得到:
text
Hello
但之后 Buffer 的未读部分只剩:
text
, Gophers!
下一次读取会从逗号开始。
四、Buffer.Peek 是什么时候加入 Go 的?
Buffer.Peek 在 Go 1.26 中正式加入标准库。Go 1.26 发布说明将它描述为:返回 Buffer 接下来的 n 个字节,但不推进读取位置。
这个 API 来自 2025 年 5 月提交的标准库提案:golang/go#73794。
提案的直接动机与 image.Decode 有关。
Go 的 image 包需要先查看输入开头的魔数,例如 PNG、JPEG 或 GIF 的文件头,以判断图片格式。但是查看格式之后,真正的解码器仍然需要从数据开头读取,所以格式探测过程不能消费这些字节。
image 包内部定义了一个类似下面的接口:
go
type reader interface {
io.Reader
Peek(int) ([]byte, error)
}
当传入的 io.Reader 没有实现 Peek 时,image.Decode 需要使用 bufio.Reader 再包装一层:
go
func asReader(r io.Reader) reader {
if rr, ok := r.(reader); ok {
return rr
}
return bufio.NewReader(r)
}
以前,即便传入的是已经把数据保存在内存中的 *bytes.Buffer,因为它没有 Peek 方法,也仍然需要被包装成 bufio.Reader。
加入:
go
func (b *Buffer) Peek(n int) ([]byte, error)
后,*bytes.Buffer 会自动满足这个内部接口,image.Decode 可以直接使用它,不再需要额外包装。
这个改动最终通过 CL 674415 合入 Go 标准库,并解决了 proposal #73794。
五、为什么以前没有这个方法?
有人可能会问:
bytes.Buffer不是已经有Bytes()吗?直接取前几个字节不就行了?
确实可以这样写:
go
data := b.Bytes()
if len(data) >= 5 {
data = data[:5]
}
从实现能力上看,Peek 更像是一个便利方法,而不是过去完全无法实现的功能。
但 Peek 仍然有几个重要价值。
1. 统一语义
调用者不再需要每次手动判断长度和切片:
go
data, err := b.Peek(5)
它明确表达了:
查看接下来的 5 个字节,但不要消费。
2. 与 bufio.Reader.Peek 保持一致
标准库中已经有:
go
func (b *bufio.Reader) Peek(n int) ([]byte, error)
bytes.Buffer 和 bufio.Reader 都具有"缓存并读取数据"的用途。让二者提供相似的 Peek 能力,可以使它们更容易满足相同的小接口。
3. 支持基于接口的零额外包装
Bytes() 和 Peek() 的最大区别之一,是方法名和签名本身可以用来满足接口。
例如:
go
type readPeeker interface {
io.Reader
Peek(int) ([]byte, error)
}
Bytes() 无法让 bytes.Buffer 满足这个接口,而 Peek() 可以。
六、为什么没有直接增加一个通用的 io.Peeker?
在 Buffer.Peek 之前,Go 社区还讨论过更广泛的方案:proposal #63548,包括定义统一的 Peek 接口以及为更多 Reader 类型增加 Peek 能力。
但一个完全通用的 Peek 接口并不容易定义。
例如,bufio.Reader.Peek(n) 受到内部缓冲区大小限制。当请求的 n 大于内部缓冲区容量时,它可能返回:
go
bufio.ErrBufferFull
而 bytes.Buffer 已经持有全部数据,不存在相同的固定缓冲区限制。如果未读数据少于 n,它会返回现有的全部未读数据以及:
go
io.EOF
不同类型对"最多能向前看多远"的能力不同,所以通用接口很难给出对所有实现都自然的约束。
最终,Go 团队选择了一个更保守的方案:
先只为具体的
bytes.Buffer增加Peek,解决明确存在的使用场景,而不是立即定义一个适用于所有 Reader 的公共接口。
这也是 Go 标准库演进中很典型的做法:先解决具体、确定的问题,再观察是否有足够多的一致实践值得进一步抽象。
七、数据不足时会发生什么?
如果 Buffer 中只有:
text
Hello
却请求:
go
data, err := b.Peek(10)
Peek 会返回当前所有未读数据:
text
Hello
同时返回:
go
io.EOF
示例:
go
package main
import (
"bytes"
"errors"
"fmt"
"io"
)
func main() {
b := bytes.NewBufferString("Hello")
data, err := b.Peek(10)
fmt.Printf("data: %s\n", data)
fmt.Printf("EOF: %v\n", errors.Is(err, io.EOF))
}
输出:
text
data: Hello
EOF: true
因此,不要因为 err != nil 就假设 data 一定为空。
正确处理方式取决于业务需求。
如果必须获得完整的 n 个字节:
go
data, err := b.Peek(n)
if err != nil {
return err
}
如果允许处理不足 n 个字节的数据,则应该同时检查返回的切片。
八、返回的切片不是独立副本
这是 Peek 最容易被忽略的一点。
Peek 返回的 []byte 直接引用 Buffer 的内部存储,并没有复制数据。它的实现非常简单:
go
func (b *Buffer) Peek(n int) ([]byte, error) {
if b.Len() < n {
return b.buf[b.off:], io.EOF
}
return b.buf[b.off : b.off+n], nil
}
因此它的性能很好,但也带来两个限制。
1. 不要长期保存返回值
官方文档说明,返回的切片只保证在下一次读或写操作之前有效。
下面这种写法存在风险:
go
data, _ := b.Peek(5)
b.WriteString("more data")
fmt.Println(string(data))
写入可能导致 Buffer 扩容或内部存储发生变化,之前的 data 不应再继续使用。
如果需要长期保存,应该复制:
go
data, err := b.Peek(5)
if err != nil {
return err
}
saved := bytes.Clone(data)
2. 不要随意修改返回值
因为返回切片与 Buffer 的内部数据共享存储,修改它可能直接改变后续读取到的内容:
go
b := bytes.NewBufferString("Hello")
data, _ := b.Peek(5)
data[0] = 'h'
fmt.Println(b.String())
可能输出:
text
hello
因此,虽然类型是可修改的 []byte,实际使用时最好把它当作只读数据。
九、Peek 的典型使用场景
场景一:检查协议头
go
header, err := b.Peek(4)
if err != nil {
return err
}
switch string(header) {
case "HTTP":
handleHTTP(&b)
case "POST":
handlePost(&b)
default:
return fmt.Errorf("unknown protocol")
}
由于 Peek 没有消费数据,后面的处理函数仍然可以从完整协议头开始读取。
场景二:识别文件类型
go
magic, err := b.Peek(4)
if err != nil {
return err
}
if bytes.Equal(magic, []byte{0x89, 'P', 'N', 'G'}) {
fmt.Println("PNG image")
}
这与 image.Decode 需要先检查图片魔数的场景非常相似。
场景三:查看消息类型
假设一条消息的第一个字节表示消息类型:
go
kind, err := b.Peek(1)
if err != nil {
return err
}
switch kind[0] {
case 1:
decodeLoginMessage(&b)
case 2:
decodeDataMessage(&b)
}
选择解析器时没有消费类型字节,解析器仍然可以读取完整消息。
场景四:先验证长度,再决定是否读取
go
header, err := b.Peek(4)
if err != nil {
return err
}
length := binary.BigEndian.Uint32(header)
if length > maxMessageSize {
return fmt.Errorf("message too large")
}
// 验证通过后再正式读取。
十、什么时候应该用 Peek,什么时候用 Read?
可以用一句话判断:
如果只是想根据接下来的数据做决定,用
Peek;如果已经决定处理并消费数据,用Read。
适合 Peek:
- 判断协议类型
- 检查文件魔数
- 预览消息头
- 验证长度字段
- 根据数据选择不同解析器
适合 Read:
- 正式解析数据
- 将数据复制到目标缓冲区
- 推进处理进度
- 消费已经确认过的消息内容
两者经常配合使用:
go
header, err := b.Peek(headerSize)
if err != nil {
return err
}
if !validHeader(header) {
return errors.New("invalid header")
}
data := make([]byte, headerSize)
if _, err := io.ReadFull(&b, data); err != nil {
return err
}
先用 Peek 验证,再用 Read 正式消费,是非常典型的使用模式。
总结
bytes.Buffer.Peek 是 Go 1.26 中一个不大但很实用的标准库改进。
它的核心语义是:
text
查看接下来的数据,但不移动读取位置。
与 Read 相比:
text
Peek:只看,不消费,不移动读取位置。
Read:读取并消费,同时移动读取位置。
它不仅减少了手动调用 Bytes() 和边界检查的样板代码,更重要的是让 bytes.Buffer 能够满足需要 Peek 方法的接口,从而避免类似 image.Decode 场景中的额外包装。
使用时需要特别记住:
- 数据不足时会返回已有数据和
io.EOF。 - 返回切片引用 Buffer 的内部存储。
- 下一次读写后,不应继续使用此前返回的切片。
- 修改返回切片可能改变 Buffer 中的数据。
- 只有 Go 1.26 及以上版本才提供该方法。
一个看似简单的 Peek,背后其实涉及 API 一致性、零拷贝、接口适配、错误语义以及标准库抽象边界等多个问题。这也正是阅读 Go 标准库演进过程最有意思的地方。