Skip to content

Files

Latest commit

8d041bb · Oct 24, 2021

History

History
492 lines (376 loc) · 21.8 KB

File metadata and controls

492 lines (376 loc) · 21.8 KB

五、处理流

本章讨论数据流,将输入和输出接口扩展到文件系统之外,以及如何实现自定义读写器以满足任何目的。

它还关注输入和输出实用程序中缺失的部分,这些部分以几种不同的方式将它们组合在一起,目标是完全控制输入和输出数据。

本章将介绍以下主题:

  • 溪流
  • 定制阅读器
  • 定制作家
  • 公用事业

技术要求

本章要求安装 Go 并设置您最喜爱的编辑器。更多信息请参见第三章Go概述。

溪流

作家和读者不仅仅是为了文件;它们是从一个方向或另一个方向抽象数据流的接口。这些流通常被称为,是大多数应用程序的重要组成部分。

输入和读取器

如果应用程序无法控制数据流,则传入的数据流将被视为io.Reader接口,并将等待错误结束处理,在最佳情况下接收io.EOF值,这是一个特殊错误,表示没有更多内容可读取,或者是另一个错误。另一个选项是读取器也能够终止流。在这种情况下,正确的表示是io.ReadCloser接口。

除了os.File之外,在标准包中还分布了几个阅读器的实现。

字节读取器

bytes包包含一个有用的结构,将字节片视为io.Reader接口,它实现了更多的 I/O 接口:

  • io.Reader:这可以作为普通读者
  • io.ReaderAt:这使得可以从某个位置开始读取
  • io.WriterTo:这样就可以用偏移量写入内容
  • io.Seeker:可自由移动读卡器光标
  • io.ByteScanner:可以分别对每个字节执行读取操作
  • io.RuneScanner:对于由更多字节组成的字符也可以这样做

符文和字节之间的区别可以通过这个例子来澄清,我们有一个由一个符文组成的字符串,它由三个字节e28c98表示:

func main() {
    const a = `⌘`

    fmt.Printf("plain string: %s\n", a)
    fmt.Printf("quoted string: %q\n",a)

    fmt.Printf("hex bytes: ")
    for i := 0; i < len(a); i++ {
        fmt.Printf("%x ", a[i])
    }
    fmt.Printf("\n")
}

完整示例见https://play.golang.org/p/gVZOufSmlq1

还有bytes.Buffer,它在bytes.Reader的基础上增加了写入功能,可以访问底层切片或以字符串的形式获取内容。

Buffer.String方法将字节转换为字符串,这种类型的 Go 转换是通过复制字节来完成的,因为字符串是不可变的。这意味着在副本不会传播到字符串之后对缓冲区进行最终更改。

字符串读取器

strings包包含另一个与io.Reader接口非常相似的结构,称为strings.Reader。这与第一个完全相同,但基础值是字符串,而不是字节片。

在处理需要读取的字符串时,使用字符串而不是字节读取器的主要优点之一是在初始化数据时避免复制数据。这种细微的差异对性能和内存使用都有帮助,因为它分配的内存更少,并且需要垃圾收集器GC清理副本。

定义读者

任何 Go 应用程序都可以定义io.Reader接口的自定义实现。在实现接口时,一个好的通用规则是接受接口并返回具体类型,避免不必要的抽象。

让我们看一个实际的例子。我们想要实现一个定制阅读器,从另一个阅读器获取内容并将其转换为大写;我们可以称之为AngryReader,例如:

func NewAngryReader(r io.Reader) *AngryReader {
    return &AngryReader{r: r}
}

type AngryReader struct {
    r io.Reader
}

func (a *AngryReader) Read(b []byte) (int, error) {
    n, err := a.r.Read(b)
    for r, i, w := rune(0), 0, 0; i < n; i += w {
        // read a rune
        r, w = utf8.DecodeRune(b[i:])
        // skip if not a letter
        if !unicode.IsLetter(r) {
            continue
        }
        // uppercase version of the rune
        ru := unicode.ToUpper(r)
        // encode the rune and expect same length
        if wu := utf8.EncodeRune(b[i:], ru); w != wu {
            return n, fmt.Errorf("%c->%c, size mismatch %d->%d", r, ru, w, wu)
        }
    }
    return n, err
}

这是一个非常简单的例子,使用unicodeunicode/utf8来实现其目标:

  • utf8.DecodeRune用于获取第一个符文,其宽度是读取的片段的一部分
  • unicode.IsLetter确定符文是否为字母
  • unicode.ToUpper将文本转换为大写
  • ut8.EncodeLetter将新字母写入必要的字节
  • 字母及其大写版本的宽度应相同

完整示例见https://play.golang.org/p/PhdSsbzXcbE

输出和写入程序

适用于传入流的推理也适用于传出流。我们有io.Writer接口,应用程序只能在其中发送数据;还有io.WriteCloser接口,应用程序也可以在其中关闭连接。

字节写入器

我们已经看到,bytes包提供了Buffer,它具有读写功能。这实现了ByteReader接口的所有方法,加上一个以上的Writer接口:

  • io.Writer:可以作为普通作家
  • io.WriterAt:这使得可以从某个位置开始书写
  • io.ByteWriter:这样就可以写入单个字节

bytes.Buffer是一个非常灵活的结构,考虑到它同时适用于WriterByteWriter,并且由于ResetTruncate方法,如果重复使用,效果最好。与其让 GC 回收一个使用过的缓冲区并创建一个新的缓冲区,不如重置现有的缓冲区,保留缓冲区的底层数组,并将切片长度设置为0

在上一章中,我们看到了一个很好的缓冲区使用示例:

    bookList := []book{
        {Author: grr, Title: "A Game of Thrones", Year: 1996},
        {Author: grr, Title: "A Clash of Kings", Year: 1998},
        {Author: grr, Title: "A Storm of Swords", Year: 2000},
        {Author: grr, Title: "A Feast for Crows", Year: 2005},
        {Author: grr, Title: "A Dance with Dragons", Year: 2011},
        {Author: grr, Title: "The Winds of Winter"},
        {Author: grr, Title: "A Dream of Spring"},
    }
    b := bytes.NewBuffer(make([]byte, 0, 16))
    for _, v := range bookList {
        // prints a msg formatted with arguments to writer
        fmt.Fprintf(b, "%s - %s", v.Title, v.Author)
        if v.Year > 0 { // we do not print the year if it's not there
            fmt.Fprintf(b, " (%d)", v.Year)
        }
        b.WriteRune('\n')
        if _, err := b.WriteTo(dst); true { // copies bytes, drains buffer
            fmt.Println("Error:", err)
            return
        }
    }

缓冲区不是用来合成字符串值的。因此,当调用String方法时,字节被转换成字符串,与切片不同,字符串是不可变的。以这种方式创建的新字符串是使用当前切片的副本创建的,对切片的更改不会触及该字符串。这既不是限制,也不是特征;如果使用不当,该属性可能导致错误。以下是重置缓冲区并使用String方法的效果示例:

package main

import (
    "bytes"
    "fmt"
)

func main() {
    b := bytes.NewBuffer(nil)
    b.WriteString("One")
    s1 := b.String()
    b.WriteString("Two")
    s2 := b.String()
    b.Reset()
    b.WriteString("Hey!")    // does not change s1 or s2
    s3 := b.String()
    fmt.Println(s1, s2, s3)  // prints "One OneTwo Hey!"
}

完整示例见https://play.golang.org/p/zBjGPMC4sfF

弦乐编剧

字节缓冲区执行字节的副本以生成字符串。这就是为什么在 1.10 版中,strings.Builder首次亮相的原因。它共享缓冲区中所有与写相关的方法,不允许通过Bytes方法访问底层切片。获取最终字符串的唯一方法是使用String方法,该方法使用引擎盖下的unsafe包将切片转换为字符串,而无需复制底层数据。

这样做的主要结果是,此结构强烈反对复制,这是因为复制切片的基础切片指向同一数组,在副本中写入会影响另一个数组。由此产生的操作将导致死机:

package main

import (
    "strings"
)

func main() {
    b := strings.Builder{}
    b.WriteString("One")
    c := b
    c.WriteString("Hey!") // panic: strings: illegal use of non-zero Builder copied by value
}

定义作家

任何编写器的任何自定义实现都可以在应用程序中定义。一个非常常见的例子是 decorator,它是一个包装另一个作者并改变或扩展原始作者所做的工作的作者。对于读者来说,一个好习惯是让构造函数接受另一个 writer 并可能对其进行包装,以使其与许多标准库结构兼容,例如:

  • *os.File
  • *bytes.Buffer
  • *strings.Builder

让我们来看一个真实世界的用例,我们希望生成一些文本,每个单词中都有加扰的字母,以便在人类开始无法阅读时进行测试。我们将创建一个可配置的 writer,它将在将字母写入目标 writer 之前对字母进行置乱,我们将创建一个二进制文件,该二进制文件接受一个文件并创建其置乱版本。我们将使用math/rand包对加扰进行随机化。

让我们定义结构及其构造函数。这将接受另一个写入程序、随机数生成器和加扰chance

func NewScrambleWriter(w io.Writer, r *rand.Rand, chance float64) *ScrambleWriter {
    return &ScrambleWriter{w: w, r: r, c: chance}
}

type ScrambleWriter struct {
    w io.Writer
    r *rand.Rand
    c float64
}

Write方法需要按原样执行不带字母的字节,并扰乱字母序列。它将使用我们前面看到的ut8.DecodeRune函数迭代符文,打印任何不是字母的内容,并堆叠它能找到的所有字母序列:

func (s *ScrambleWriter) Write(b []byte) (n int, err error) {
    var runes = make([]rune, 0, 10)
    for r, i, w := rune(0), 0, 0; i < len(b); i += w {
        r, w = utf8.DecodeRune(b[i:])
        if unicode.IsLetter(r) {
            runes = append(runes, r)
            continue
        }
        v, err := s.shambleWrite(runes, r)
        if err != nil {
            return n, err
        }
        n += v
        runes = runes[:0]
    }
    if len(runes) != 0 {
        v, err := s.shambleWrite(runes, 0)
        if err != nil {
            return n, err
        }
        n += v
    }
    return
}

当序列结束时,将由shambleWrite方法处理,该方法将有效地执行一个乱码并写入乱码符文:

func (s *ScrambleWriter) shambleWrite(runes []rune, sep rune) (n int, err error) {
    //scramble after first letter
    for i := 1; i < len(runes)-1; i++ {
        if s.r.Float64() > s.c {
            continue
        }
        j := s.r.Intn(len(runes)-1) + 1
        runes[i], runes[j] = runes[j], runes[i]
    }
    if sep!= 0 {
        runes = append(runes, sep)
    }
    var b = make([]byte, 10)
    for _, r := range runes {
        v, err := s.w.Write(b[:utf8.EncodeRune(b, r)])
        if err != nil {
            return n, err
        }
        n += v
    }
    return
}

完整示例见https://play.golang.org/p/0Xez--6P7nj

内置公用设施

ioio/ioutil软件包中还有许多其他功能,可以帮助管理读者、作者等。了解所有可用的工具将有助于您避免编写不必要的代码,并将指导您为工作使用最佳工具。

从一个流复制到另一个流

io包中有三个主要功能,可以将数据从写入程序传输到读取程序。这是一种非常常见的情况;例如,您可以将打开读取的文件中的内容写入另一个打开写入的文件,或者耗尽缓冲区并将其内容作为标准输出写入。

我们已经看到了如何在文件上使用io.Copy函数来模拟第 4 章cp命令的行为,并使用文件系统。这种行为可以扩展到任何类型的读写器实现,从缓冲区到网络连接。

如果 writer 也是一个io.WriterTo接口,则副本调用WriteTo方法。否则,它将使用固定大小(32KB)的缓冲区执行一系列写入操作。如果操作以io.EOF值结束,则不会返回错误。一个常见的场景是bytes.Buffer结构,它能够将其内容写入另一个编写器,并相应地进行操作。或者,如果目的地是io.ReaderFrom接口,则执行ReadFrom方法。

如果该接口是一个简单的io.Writer接口,则该方法使用一个临时缓冲区,该缓冲区将在之后被清理。为了避免在垃圾收集上浪费计算能力,并可能重用相同的缓冲区,还有另一个函数io.CopyBuffer函数。这有一个额外的参数,只有当这个额外的参数是nil时,才会分配一个新的缓冲区。

最后一个函数是io.CopyN,它的工作原理与io.Copy完全相同,但可以指定要写入额外参数的字节数限制。如果读卡器也是io.Seeker,写入部分内容可能会很有用。搜索者首先将光标移动到正确的偏移量,然后写入一定数量的字节。

让我们举一个一次复制n字节的例子:

func CopyNOffset(dst io.Writer, src io.ReadSeeker, offset, length int64) (int64, error) {
  if _, err := src.Seek(offset, io.SeekStart); err != nil {
    return 0, err
  }
  return io.CopyN(dst, src, length)
}

完整示例见https://play.golang.org/p/8wCqGXp5mSZ

联系读者和作家

io.Pipe函数创建一对相互连接的读写器。这意味着发送给作者的任何东西都将从读者那里接收。如果仍有数据挂起,写入操作将被阻止;只有当读卡器完成对已发送内容的消费后,新操作才会结束。

对于非并发应用程序来说,这不是一个重要的工具,因为非并发应用程序更可能使用并发工具(如通道),但当读写器在不同的 goroutine 上执行时,这可能是一个很好的同步机制,如以下程序所示:

    pr, pw := io.Pipe()
    go func(w io.WriteCloser) {
        for _, s := range []string{"a string", "another string", 
           "last one"} {
                fmt.Printf("-> writing %q\n", s)
                fmt.Fprint(w, s)
        }
        w.Close()
    }(pw)
    var err error
    for n, b := 0, make([]byte, 100); err == nil; {
        fmt.Println("<- waiting...")
        n, err = pr.Read(b)
        if err == nil {
            fmt.Printf("<- received %q\n", string(b[:n]))
        }
    }
    if err != nil && err != io.EOF {
        fmt.Println("error:", err)
    }

完整示例见https://play.golang.org/p/0YpRK25wFw_c

扩展读者

当涉及到传入流时,标准库中有许多函数可用于提高读卡器的功能。其中一个最简单的例子是ioutil.NopCloser,它接受一个读卡器并返回io.ReadCloser,而它什么也不做。如果函数负责释放资源,但所使用的读取器不是io.Closer(如bytes.Buffer中的读取器),则这非常有用。

有两种工具限制读取的字节数。ReadAtLeast函数定义要读取的最小字节数。只有当没有字节可读取时,结果才会为EOF;否则,如果在EOF之前读取的字节数较少,则返回ErrUnexpectedEOF。如果字节缓冲区短于请求的字节(这是没有意义的),则会出现一个ErrShortBuffer。在发生读取错误的情况下,函数会设法读取至少所需的字节数,并且会删除该错误。

然后有ReadFull,预计将填充缓冲区,否则将返回ErrUnexpectedEOF

另一个约束函数是LimitReader。此函数是一个修饰符,它获取一个读卡器并返回另一个读卡器,该读卡器将在读取所需字节后返回EOF。这可用于实际阅读器内容的预览,如以下示例所示:

s := strings.NewReader(`Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.`)
    io.Copy(os.Stdout, io.LimitReader(s, 25)) // will print "Lorem Ipsum is simply dum"

完整示例见https://play.golang.org/p/LllOdWg9uyU

可以按顺序组合多个读卡器,MultiReader功能将按顺序读取每个部件,直到到达EOF,然后跳到下一个部件。

一个读卡器和一个写卡器可以连接在一起,这样,来自读卡器的任何内容都会复制到写卡器上,与io.Pipe的情况相反。这是通过io.TeeReader完成的。

让我们尝试使用它来创建一个 writer,它在文件系统中充当搜索引擎,只打印与请求的查询匹配的行。我们需要一个可执行以下操作的程序:

  • 从参数中读取要搜索的目录路径和字符串
  • 获取选定路径中的文件列表
  • 读取每个文件并将包含选定字符串的行传递给另一个编写器
  • 另一个编写器将注入颜色字符以突出显示字符串,并将其内容复制到标准输出

让我们从颜色注入开始。在 Unix shell 中,按以下顺序获得彩色输出:

  • \xbb1:转义字符
  • [:一个开口支架
  • 39:一个数字
  • m:字母m

这个数字决定了背景色和前景色。对于本例,我们将使用31(红色)和39(默认)。

我们正在创建一个 writer,它将打印匹配的行并突出显示文本:

type queryWriter struct {
    Query []byte
    io.Writer
}

func (q queryWriter) Write(b []byte) (n int, err error) {
    lines := bytes.Split(b, []byte{'\n'})
    l := len(q.Query)
    for _, b := range lines {
        i := bytes.Index(b, q.Query)
        if i == -1 {
            continue
        }
        for _, s := range [][]byte{
            b[:i], // what's before the match
            []byte("\x1b[31m"), //star red color
            b[i : i+l], // match
            []byte("\x1b[39m"), // default color
            b[i+l:], // whatever is left
        } {
            v, err := q.Writer.Write(s)
            n += v
            if err != nil {
                return 0, err
            }
        }
        fmt.Fprintln(q.Writer)
    }
    return len(b), nil
}

这将与打开文件的TeeReader一起使用,因此读取文件将写入queryWriter

func main() {
    if len(os.Args) < 3 {
        fmt.Println("Please specify a path and a search string.")
        return
    }
    root, err := filepath.Abs(os.Args[1]) // get absolute path
    if err != nil {
        fmt.Println("Cannot get absolute path:", err)
        return
    }
    q := []byte(strings.Join(os.Args[2:], " "))
    fmt.Printf("Searching for %q in %s...\n", query, root)
    err = filepath.Walk(root, func(path string, info os.FileInfo,   
        err error) error {
            if info.IsDir() {
                return nil
            }
            fmt.Println(path)
            f, err := os.Open(path)
            if err != nil {
                return err
            }
        defer f.Close()

        _, err = ioutil.ReadAll(io.TeeReader(f, queryWriter{q, os.Stdout}))
        return err
    })
    if err != nil {
        fmt.Println(err)
    }
}

正如你所见,没有必要写作;从文件读取会自动写入连接到标准输出的查询编写器。

作家和装饰家

有太多的工具来增强、装饰和使用读者,但同样的东西不适用于作家。

还有一个io.WriteString函数,它可以防止从字符串到字节的不必要的转换。首先,它检查写入程序是否支持字符串写入,尝试转换为io.stringWriter,一个仅使用WriteString方法的未报告接口,如果成功,则写入字符串,否则将其转换为字节。

还有io.MultiWriter函数,它创建一个 writer,将信息复制到一系列其他 writer,并在创建时接收这些 writer。一个实际示例是在标准输出上显示内容的同时编写一些内容,如下例所示:

    r := strings.NewReader("let's read this message\n")
    b := bytes.NewBuffer(nil)
    w := io.MultiWriter(b, os.Stdout)
    io.Copy(w, r) // prints to the standard output
    fmt.Println(b.String()) // buffer also contains string now

完整示例见https://play.golang.org/p/ZWDF2vCDfsM

还有一个有用的变量ioutil.Discard,它是一个写入到/dev/null(一个空设备)的写入器。这意味着写入此变量会忽略数据。

总结

在本章中,我们介绍了用于描述数据传入和传出流的流的概念。我们看到读卡器接口代表接收的数据,而写卡器代表发送的数据。

我们比较了标准包中提供的不同阅读器。我们在上一章中查看了文件,在本章中,我们在列表中添加了字节和字符串读取器。我们通过一个示例学习了如何实现自定义读卡器,并发现在另一个读卡器的基础上设计一个读卡器总是很好的。

然后,我们关注作家。我们发现,如果正确打开,文件也是写入程序,并且标准包中有几个写入程序,包括字节缓冲区和字符串生成器。我们还实现了一个自定义编写器,并了解了如何使用utf8包处理字节和符文。

最后,我们探讨了ioioutil中的剩余功能,分析了复制数据以及连接读者和作者的各种工具。我们还了解了哪些装饰器可用于改进或更改读者和作者的能力。

在下一章中,我们将讨论伪终端应用程序,并将使用所有这些知识构建其中的一些应用程序。

问题

  1. 什么是小溪?
  2. 什么接口抽象传入流?
  3. 哪些接口代表传出流?
  4. 什么时候应该使用字节读取器?什么时候应该使用字符串读取器?
  5. 字符串生成器和字节缓冲区之间有什么区别?
  6. 为什么读写器实现应该接受接口作为输入?
  7. 管道与TeeReader有何不同?