Go 1.26 新增 `bytes.Buffer.Peek`:只看数据,不移动读取位置

在处理协议头、文件魔数或消息类型时,我们经常遇到这样的需求:

我想先看看接下来是什么数据,再决定使用哪种方式处理,但暂时不想把这些数据读走。

从 Go 1.26 开始,bytes.Buffer 提供了一个专门解决这一问题的方法:

go 复制代码
func (b *Buffer) Peek(n int) ([]byte, error)

Peek 可以查看接下来的 n 个字节,但不会移动 Buffer 的读取位置。

本文将介绍:

  • Buffer.Peek 是如何加入 Go 标准库的
  • 它解决了什么实际问题
  • PeekRead 的核心区别
  • 为什么 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 个字节。


三、PeekRead 有什么区别?

两者最重要的区别是:

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.Bufferbufio.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 场景中的额外包装。

使用时需要特别记住:

  1. 数据不足时会返回已有数据和 io.EOF
  2. 返回切片引用 Buffer 的内部存储。
  3. 下一次读写后,不应继续使用此前返回的切片。
  4. 修改返回切片可能改变 Buffer 中的数据。
  5. 只有 Go 1.26 及以上版本才提供该方法。

一个看似简单的 Peek,背后其实涉及 API 一致性、零拷贝、接口适配、错误语义以及标准库抽象边界等多个问题。这也正是阅读 Go 标准库演进过程最有意思的地方。


参考资料

相关推荐
羑悻1 小时前
我受够了 Coding Agent 的“假聪明,真串行”,于是做了 Meta-Orchestrator!
后端
神奇小汤圆1 小时前
字节二面挂了!被问“订单超时自动取消”
后端
饼干哥哥1 小时前
每晚300+个爆款疯狂跑,0人值守TikTok工厂开张!!
后端·操作系统·产品
65岁退休Coder1 小时前
LangChain v1.3.4 笔记 - 02 工具、结构化输出
后端
颜进强1 小时前
Claude Code - 18 从 Figma 到 React:一套让 AI 生成代码"不瞎编"的实战工作流
前端·后端·ai编程
geovindu2 小时前
CSharp: Bridge Pattern
开发语言·后端·桥接模式·结构型模式
霸道流氓气质2 小时前
SpringBoot中设计模式组合使用示例-策略、模板、观察者、门面、工厂、单例。
spring boot·后端·设计模式
货拉拉技术2 小时前
复杂业务规则太多?我们搭了一个规则可视化配置平台
后端
爱勇宝3 小时前
AI 都能写代码了,还要学计算机吗?
前端·后端·程序员