返回资讯中心
外部精选
软件工程
#开发者实时流

SwiftNIO 的 ByteBuffer 为何用读写下标两个游标

来源标题:SwiftNIO: Why ByteBuffer Has Two Cursors

SwiftNIO 的 ByteBuffer 通过 readerIndex 和 writerIndex 两个游标管理缓冲区,两者之间的字节即为可读数据,写入移动 writerIndex、读取移动 readerIndex。这种设计便于解码器记录已到达和已消费的字节,避免每次读取后重建数组。文章用读取整数、长度前缀消息和复制缓冲区等示例说明了该设计的便利与局限。

Hacker News 最新frizlab2026/11/12 · 今天18 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

网络数据很少会以刚好适合应用处理的完整消息形式到达。一个socket可能只送来半个header、一个完整的帧,或者一次送来好几个帧。解码器必须记住哪些字节已经到达、哪些已经被消费掉。如果每次读取后都重新构建一个新的数组,这种记录工作会变得更加困难。

SwiftNIO在NIOCore中提供了ByteBuffer。这个类型并不把已分配的内存直接当作可读数据,而是跟踪一个reader index和一个writer index。两者之间的字节就是可读的数据。我们来看几个场景:一次成功的读取、一个不完整的整数、一个带长度前缀的消息,以及一个被复制的buffer。这些简单的例子既揭示了这一设计的便利之处,也暴露了它的局限。

一个新的buffer可以已经分配了存储空间,却不包含任何可读字节。写入会移动writer index,读取会移动reader index。 import NIOCore var buffer = ByteBufferAllocator().buffer(capacity: 16) print("start:", buffer.readerIndex, buffer.writerIndex, buffer.readableBytes) buffer.writeString("HELLO") print("written:", buffer.readerIndex, buffer.writerIndex, buffer.readableBytes) print("read:", buffer.readString(length: 2)!) print("remaining:", buffer.readerIndex, buffer.writerIndex, buffer.readableBytes) print("next:", buffer.readString(length: 3)!) start: 0 0 0 written: 0 5 5 read: HE remaining: 2 5 3 next: LLO 初始请求的容量是16,但此时没有任何可读字节。写入HELLO这五个ASCII字节后,writer index移动到5。读取HE会把reader index推进到2,剩下LLO可读。第二次读取消费掉这三个字节。读取只是推进了一个位置,并不意味着每次操作后原始分配的内存会缩小。

解码器在判断一个完整字段是否已经到达时,应该检查readableBytes,而不是已分配的容量。

考虑一个网络字节序的两字节无符号整数。只有一个字节不足以解码它。 import NIOCore var header = ByteBufferAllocator().buffer(capacity: 8) header.writeInteger(UInt8(0x01)) let incomplete: UInt16? = header.readInteger() print("incomplete:", incomplete == nil, header.readerIndex, header.readableBytes) header.writeInteger(UInt8(0x02)) let complete: UInt16? = header.readInteger() print("complete:", complete!, header.readerIndex, header.readableBytes) incomplete: true 0 1 complete: 258 2 0 第一次读取返回nil,reader index保持在0。等0x02到达后,这两个字节0x01 0x02按照该API默认的大端序解码为258。只有成功的读取才会把reader index推进到2。对于增量解析来说,这是一个很有用的约定:数据不足时不会被悄悄丢弃。

这个例子并没有区分协议字段是格式错误还是仅仅不完整。这种区分属于应用层解码器的职责。

一种常见的线格式会在payload之前存放payload的长度。解码器可能需要查看这个长度而不消费它。getInteger(at:)在指定索引处读取,不移动reader index;readInteger()则在成功时消费数据。 import NIOCore var frame = ByteBufferAllocator().buffer(capacity: 8) frame.writeInteger(UInt16(3)) frame.writeString("cat") let length: UInt16? = frame.getInteger(at: frame.readerIndex) print("peek:", length!, frame.readerIndex, frame.readableBytes) let consumedLength: UInt16? = frame.readInteger() let payload = frame.readString(length: Int(consumedLength!)) print("payload:", payload!, frame.readerIndex, frame.readableBytes) peek: 3 0 5 payload: cat 5 0 两个字节编码长度3,接下来的三个字节编码cat。Peek保留了全部五个可读字节。消费长度会把reader index推进到2;消费payload则把它推进到5。

这故意是一个完整帧的例子。在真实的解码器中,要在消费之前确认长度头以及整个声明的payload都已经可用,或者显式保存部分解码的状态。这些强制解包对于这些固定的示例输入是安全的,但绝不能用于不可信的socket数据。恶意的长度值还必须受到应用协议上限的约束。

ByteBuffer-core.swift中的实现把可见的索引和切片与底层存储分离开来。ByteBuffer使用copy-on-write来提供值语义,而不必在每次赋值时急切地复制所有字节。 import NIOCore var original = ByteBufferAllocator().buffer(capacity: 8) original.writeString("ok") var changed = original changed.writeString("!") print("original:", original.getString(at: 0, length: 2)!) print("changed:", changed.getString(at: 0, length: 3)!) original: ok changed: ok! 两个值最初都表示ok。向changed追加!并不会改变original。这展示的是可观察到的值行为,而不是实际执行了多少次内存分配。默认的allocator目前委托给libc的分配函数。长期持有一个大buffer或一个切片,也可能保留比小payload所暗示的更多的存储空间。

对于带长度前缀的网络帧,cat这个例子展示了基本结构。生产代码还必须处理每次读取包含多个帧、跨多次读取的不完整帧,以及非法的长度。buffer提供的是字节操作,而不是分帧策略。

对于二进制协议头,0x01 0x02这个例子展示了固定宽度读取失败时如何保留数据以便后续重试。整数的默认端序是大端;如果线格式不同,必须显式请求它所需的端序。

对于pipeline中的传递,ok和ok!这个例子说明了为什么在stage之间传递buffer值并不一定意味着每个stage都在修改同一个逻辑值。但这并不意味着保留大payload是没有代价的。

当应用只需要一个完整的内存字符串时,String或Data可能更简单。当字节偏移和部分消费成为问题的一部分时,ByteBuffer的优势就体现出来了。

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →