原创

Go ReadSlice 遇到 ErrBufferFull:拼接的是片段,限制的是完整记录

ReadSlice返回ErrBufferFull,不一定是输入坏了;它可能只是还没读到换行,内部缓冲区已经装满。若把每次返回的片段都当一行,长记录就会被拆成多条;若无限append,又把内存上限交给了输入方。

需要的是两层独立预算:bufio.Reader的工作缓冲区,和协议允许的单条记录长度。前者可以很小,后者稍大,但不能因为二者都以字节计数,就共用一个没有说明的数字。

下面用Go 1.24.1实现有界LF记录读取器。返回值保留末尾LF;上限计算实际收到的整条记录字节,包含LF。最后一条没有换行也允许,空输入则直接结束。这里不做UTF-8校验,也不删除CR,这些应由上层格式决定。调用者应使用同一上限解释成功返回值,不能在另一层再换成字符计数。

缓冲片段先复制,完整记录再交付

保存main.go,在独立模块运行go run .。

package main

import (
    "bufio"
    "errors"
    "fmt"
    "io"
    "strings"
)

var ErrLineTooLong = errors.New("wire record exceeds byte limit")

func readRecord(reader *bufio.Reader, limit int) ([]byte, error) {
    if limit < 1 { return nil, errors.New("positive limit required") }
    var record []byte
    for {
        part, err := reader.ReadSlice('\n')
        if len(part) > limit-len(record) { return nil, ErrLineTooLong }
        record = append(record, part...)
        switch err {
        case nil:
            return record, nil
        case bufio.ErrBufferFull:
            continue
        case io.EOF:
            if len(record) > 0 { return record, nil }
            return nil, io.EOF
        default:
            return nil, err
        }
    }
}

func run(text string, limit int) ([]string, error) {
    reader := bufio.NewReaderSize(strings.NewReader(text), 16)
    var result []string
    for {
        record, err := readRecord(reader, limit)
        if err == io.EOF { return result, nil }
        if err != nil { return nil, err }
        result = append(result, string(record))
    }
}

func main() {
    text := "ok\n" + strings.Repeat("A", 40) + "\n" + "tail"
    records, err := run(text, 48)
    if err != nil { panic(err) }
    fmt.Printf("records: %q\n", records)
    if len(records) != 3 || len(records[1]) != 41 || records[2] != "tail" {
        panic("record assembly failed")
    }
    for _, item := range []struct { name, text string; accepted bool }{
        {"exact with LF", strings.Repeat("x", 47) + "\n", true},
        {"one over", strings.Repeat("x", 48) + "\n", false},
        {"exact final tail", strings.Repeat("x", 48), true},
        {"empty record", "\n", true},
    } {
        _, err := run(item.text, 48)
        if (err == nil) != item.accepted { panic(item.name) }
        if !item.accepted && !errors.Is(err, ErrLineTooLong) { panic(err) }
        fmt.Println(item.name, "accepted:", err == nil)
    }
    empty, err := run("", 48)
    if err != nil || len(empty) != 0 { panic("empty input") }
    fmt.Println("empty stream records:", len(empty))
}

40个A加LF占41字节,超过16字节工作缓冲区,却小于48字节记录上限,应该完整返回一条。47个x加LF刚好48字节;48个x再加LF是49,必须拒绝。最后48个x没有LF,则按本协议允许的末尾记录接收。

这样安排边界,能发现只对“正文字符数”计数却忘记终止符的差一错误。若协议规定上限不含换行,就应修改计数位置并重写对应断言,不能只在说明里换个说法。

长度检查写成len(part)大于剩余额度,而不是先把长度相加再比较,是为了把算术也限制在已建立的范围内。limit必须为正,累计record始终不超过limit,才能让下一轮剩余额度有清楚意义。这里检查的是逻辑字节长度,切片容量和分配器仍可能保留额外空间,不能把它解释成整个进程只分配恰好limit个字节。

ErrBufferFull 是继续当前记录,不是开始下一条

ReadSlice没有遇到分隔符却用完内部缓冲区时,返回当前片段与ErrBufferFull。消费者应把它接到当前记录后面,再继续读,直到遇到LF、EOF或真正失败。

part指向Reader内部缓冲区,后续读取可能覆盖它,所以程序当场append复制。若把每份part存进[][]byte,等最后统一Join,旧片段可能已经变化。这一点与是否使用多线程无关,下一次读取就足以改变有效期。

返回的record拥有自己的切片存储,可以交给后续流程使用;这里再转成string收集只是为了打印断言。真实处理可以立即解析或交给有容量限制的队列,不必把整个输入全部收集到result。

没有必要让Reader缓冲区等于最大记录长度。小缓冲区加有界拼接能处理超过工作块的合法记录,但会引入复制与分配,是否合适取决于实际记录分布。本文没有做吞吐基准,不把这份实现宣称成比其他读取工具更快。

超限后停止,不能把半行后缀当新消息

程序一旦超限就返回错误,run也整体失败。此刻输入已经消费到某个片段,可能还没有跨过那一行的LF;直接再次调用readRecord,会把剩余后缀当成新记录起点。

本例明确选择停止这份输入,不提供恢复。若日志工具允许丢弃坏行继续,必须再设计有界丢弃到下一个分隔符的流程,同时控制丢弃总量和读取时间,避免为寻找永远不来的LF持续占用资源。不能只在错误处continue。

同样,网络慢源可能在ReadSlice内部等待。记录字节限额不会提供时间截止,应由底层连接设置读取预算或取消能力。字节、时间、连接数量是不同资源,不能让一个limit参数暗中承诺全部限制。

末尾记录与空记录,需要明确区分

只有LF的一行是有意义的空记录,函数返回一个字节;完全没有输入则返回EOF,不产生记录。尾部没有LF但已经收到内容时,按本协议交付最后一条,下次调用再结束。

如果业务格式必须每条都有终止符,那么尾部内容不能被接收为成功,应返回格式不完整错误。这类选择在命令行文本与消息协议之间可能不同,不能因为多数编辑器会自动补换行,就从未测试过缺终止符。

CRLF输入也要按合同处理。本例保留CR,它会计入字节上限;若要兼容CRLF,应在完整记录形成后识别末尾序列,再决定正文和限额各自采用什么单位。不要对每个片段TrimSpace,那可能删掉正文中有意义的空格,甚至让跨片段边界处理不一致。

从字节到文字,再到业务记录

UTF-8字符可以跨越内部片段,因此不应把每次ReadSlice片段单独解码后拼文本。这里先合成完整字节记录,上层再验证编码与格式,避免把工作块边界误当字符边界。

对JSON Lines等格式,遇到换行只说明分帧完成,尚未说明JSON合法;对CSV,带引号字段还能包含换行,根本不能使用这份LF分帧器替代CSV解析。工具选型应从实际协议开始,而不是看到文本就寻找一套通用逐行循环。

可以把16改成32,再重复边界测试。合法记录内容与拒绝结果不应改变,改变的只应是内部拆成几块。若结果随工作缓冲大小变化,就说明代码仍然把实现片段泄露成了业务边界。

正文到此结束
评论插件初始化中...
Loading...