购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.1 I/O的定义

Go语言对I/O接口的定义是其最成功的设计之一,其接口设计简洁而经典。Go语言的I/O接口定义在标准库io包中(代码位置src/io/io.go)。io包是Go语言中最核心的I/O库,其中主要定义了通用的I/O交互接口,概括了最基本的交互功能,但并不涉及具体的I/O实现。

io包的接口通常可以分为三类:基础类型、组合类型、进阶类型。这些核心的I/O接口类型能够满足大多数I/O场景的需求。

3.1.1 基础类型

Go语言遵循最小化接口的原则,这一点在I/O接口的定义中尤为明显。Go的I/O接口定义了读写操作的最基本的方法。任何实现了这些接口的类型都可以无缝地整合进Go的I/O操作体系中。

常见的I/O核心接口包括Reader、Writer、Closer、ReaderAt、WriterAt、Seeker、Byte-Reader、ByteWriter、RunReader、StringWriter等。

下面我们将分别查看几个核心接口的定义。

1.核心接口

读数据的操作主要由Reader和ReaderAt这两个接口定义,如代码清单3-1所示。

代码清单3-1 Reader和ReaderAt的定义

写数据的操作同样由Writer和WriterAt这两个接口定义,如代码清单3-2所示。

代码清单3-2 Writer和WriterAt的定义

句柄关闭操作由Closer接口定义,如代码清单3-3所示。

代码清单3-3 Closer的定义

定位读写位置的操作由Seeker接口定义,如代码清单3-4所示。

代码清单3-4 Seeker的定义

这些核心接口明确了Go语言中的I/O操作方法。通过这些最小化的接口,可以组合出更加丰富和多样化的接口,实现代码复用。需要注意的是,Go语言对这些接口有详细的约束和规范。如果开发者没有遵守这些约定,可能会导致I/O操作错误。

2.Reader和ReaderAt的语义区别

Go语言往往把I/O操作接口的约束和规范,以注释的形式和接口定义放在一起。因此如果Go语言的接口有注释,建议读者一定要仔细阅读。

接下来深入探讨Reader和ReaderAt接口的约束和规范,这两个接口的注释原文如下所示:

Go语言的官方源码注释中精确阐述了Reader和ReaderAt接口的参数规范及返回值的约定。

Reader接口的实现者必须遵循以下约束和规范。

❑ 读取数据量:Read方法从当前偏移位置起,最多可读取len(p)个字节的数据,返回值为(n, err)。其中n表示实际读取的数据量,其范围为0≤n≤len(p),而err表示遇到的错误。

❑ 偏移位置变化:每次Read方法的调用都会改变当前偏移量,影响后续的读取操作。

❑ 部分读取:Read方法允许在没有读完切片p时(n<len(p))结束调用,并返回部分内容,返回值为(n, nil),其中0<n<len(p)。调用者需要妥善处理此类情况。

❑ 遇到EOF的处理:如果在读取了部分数据(n > 0)之后遇到EOF,Read方法可以返回n>0、err==EOF或err==nil,但之后的调用应该确保返回(0, EOF)。

❑ 返回值处理:调用者必须优先考虑n>0的场景(甚至在判断err!=nil之前)。必须处理读到部分数据,然后遇到错误的场景。

❑ 无操作处理:调用者遇到返回值(n=0, err=nil)时,应理解成“什么都没发生、无任何副作用”。可以重试读取。不能等价当作EOF的场景。通常,除非len(p)==0,否则Read方法不建议返回(n=0, error=nil)。

ReaderAt接口的实现者需要遵循如下规范和约束。

❑ 读取数据量:ReadAt方法从指定的off偏移量开始,最多读取len(p)个字节到p中,并返回值为(n, err)。其中,n表示读取的数据量(范围0<=n<=len(p)),err表示遇到的错误。

❑ 无状态变化:ReadAt调用不会改变实例的内部偏移状态,不会影响当前的偏移,不会影响后续读取操作。

❑ 必须解释未读满的原因:ReadAt方法不允许在n<len(p)且err==nil的情况下返回。如果数据未读满,则必须返回一个非nil的error来解释原因。如果确实是因为数据还未就绪,那么需要阻塞等待直到数据就绪。ReaderAt接口此处比Reader接口的协议更严格。

❑ 遇到EOF的处理:若ReadAt方法读满p之后遇到EOF,则可以返回n==len(p)、err==EOF或err==nil。

❑ 并发读取:ReadAt方法支持并发读取,即可以在一个数据流上同时进行多个ReadAt调用,它们之间不会相互影响。

上面详细描述了Reader和ReaderAt两个接口的语义差异,它们之间有着非常大的区别。例如,Reader的接口允许数据没有填满切片p的时候不报错,而ReaderAt则不允许。接下来将通过一个例子来深入展示这一关键的区别。

I/O接口把“实现者”和“调用者”分开,遵守接口约定对双方都极其重要。任何一方不遵守约定都可能导致数据错乱。例如,有一个“实现者”A,在实现ReaderAt方法时,明明没有读满数据,但是返回了err=nil。“调用者”B可能会错误地假设所有请求的数据都已经被读取,以为切片p里的全都是正确的用户数据,从而导致它使用了不完整或不正确的数据。这种不一致的语义理解可能导致程序的逻辑混乱,甚至数据损坏。接口语义不一致的示意如图3-1所示。

图3-1 接口语义不一致示意

因此,无论是在设计新的接口,还是实现现有的接口,双方保持一致性和清晰的契约极为关键,这有助于确保软件组件之间的稳定交互和预期行为。

3.1.2 组合类型

Go语言在遵循接口最小化设计原则的同时,还提供了接口嵌入和组合的能力。这使得用户可以将简单的接口组合成功能更丰富的复杂类型。这样的设计在Go的标准库随处可见,体现在如ReadCloser、WriteCloser、ReadWriter、WriteSeeker等众多接口的设计上。这些接口展现了Go语言在接口设计上的灵活性。接下来将通过几个实际场景来展示常见的接口组合的方式。

1.接口间的组合

以ReadWriter接口为例,它由Reader和Writer接口组合而成,其内部包含两个方法——Read、Write。ReadWriter的定义如代码清单3-5所示。

代码清单3-5 ReadWriter的定义

ReadCloser接口是Reader和Closer的组合,其定义如代码清单3-6所示。

代码清单3-6 ReadCloser的定义

WriteCloser接口是Writer和Closer的组合,其定义如代码清单3-7所示。

代码清单3-7 WriteCloser的定义

以os包的File结构类型为例,它实现了Read和Write方法以及Close方法,因此它可以同时作为ReadWriter、WriteCloser和ReadCloser接口的具体类型来使用。

2.接口与方法组合

Go语言不仅支持接口与接口直接组合,也支持接口与方法的灵活组合来构造新的接口。例如,我们定义了一个名为MyWriter的接口,该接口通过io.Writer接口和一个Name()方法组合得到,它的定义如代码清单3-8所示。

代码清单3-8 MyWriter接口的定义

任何想要赋值给MyWriter接口的类型,都必须实现Write和Name这两个方法函数。

3.接口与结构体组合

接口还可以与结构体进行嵌入的组合,这是Go语言提供的一种语法糖,常用于实现类似继承的行为。当在结构体中嵌入接口时,该结构体表面上继承了接口的所有方法,这可能会引起一些混淆,使开发者误以为结构体已经具备了接口定义的所有实现。然而,实际情况可能并非如此,这一点要格外留心。

为了更加清晰地阐述这点,我们一个实际的例子来探讨在结构体内嵌入接口的情形,如代码清单3-9所示。

代码清单3-9 在结构体内嵌入接口

虽然代码清单3-9可以顺利编译,且Tester结构体看似拥有了TestAPI接口的全部方法,但当实际调用t.Method2( )时会发生异常。这是因为TestAPI接口尚未初始化,默认值为nil。因此,尝试执行t.Method2( )实际上等同于在一个nil接口的调用方法。

接下来对比一下t.Method1( )和t.Method2( )调用之间的差异。

❑ t.Method1( ):Tester通过内嵌TestAPI接口,表面上具备了TestAPI所有的方法。因为Tester实现了Method1方法,所以这个方法会覆盖TestAPI接口的同名方法。这意味着当我们调用t.Method1( )的时候,它实际上是在执行Tester结构体自身具体实现的Method1方法,所以这是一个有效的方法调用。

❑ t.Method2( ):因为Tester没有实现Method2方法,所以调用t.Method2( )事实上转换成了对t.TestAPI.Method2( )的调用。而因为TestAPI接口没有被赋值,所以这样的调用会触发异常。

回顾2.6节对接口类型的分析。我们了解到接口变量在Go语言中对应着iface类型。因此,对t.Method2( )的调用等同于对iface.itab.fun[1]( )的调用。但是由于接口变量未初始化,iface.itab和iface.data均为nil,这会导致在尝试访问itab.fun字段时触发空指针异常,从而引发panic错误。因此,在结构体中嵌入接口时,我们必须确保对其进行适当的初始化,以防止运行时发生错误。

3.1.3 进阶类型

在深入研究Go语言的标准库的接口类型时,我们会发现一系列具备特殊功能的接口,这些接口构建在基础接口之上,并融入了巧妙的逻辑处理。在存储编程的实践中,我们常常面临以下问题。

❑ 如何将单一数据流复制成多个相同的数据流?

❑ 如何将多个数据流合并成一个数据流?

❑ 能否定义一个有长度边界的数据流?

为了解决这些问题,Go的标准库中精心提供了一系列Reader和Writer的实现。接下来将深入探究它们的内部机制。

1.特殊的Reader和Writer

Go语言的标准库有很多设计巧妙、功能特殊的Reader、Writer,这些特殊的Reader和Writer不仅在实际应用中表现出强大的实用性,同时也能展现标准库是如何基于接口来实现特殊功能的。接下来来看几个典型的Reader、Writer实现。

1)TeeReader:TeeReader的设计初衷是实现数据分流,它能够将一个Reader中读取的数据流复制成多份。这种功能特别适用于需要创建多个数据副本的场景,或者并行进行数据校验(如计算CRC、MD5)的场景。

2)LimitReader:在流式的数据处理中,EOF通常用于标识数据流的结束。而LimitReader则允许用户显式指定数据流的长度,当读取到指定数据量之后,LimitReader会主动返回EOF,有效防止数据溢出或其他异常情况。

3)MultiReader:通过组合多个Reader数据流,MultiReader创建出一个单一的、连续的Reader视图。这样,用户无须手动在多个Reader之间切换,便能连续读取数据。

4)MultiWriter:MultiWriter将数据写入到多个Writer,实现数据流的复制。与TeeReader相比,MultiWriter专注于Writer的复制。

5)SectionReader:SectionReader提供了对数据流特定部分进行读取的能力。它是对ReaderAt接口的封装,同时还要求实现Seeker的接口。使用SectionReader可以很方便地重复读取数据流的特定区域。

6)PipeReader和PipeWriter:这两个是配合使用的,分别作为管道的Reader和管道的Writer。它们通过io.Pipe()创建,实现了同步传输的内存数据流管道,适用于生产者-消费者模型。

接下来分析TeeReader的实现原理和使用方式。在io包中,提供了一个名为TeeReader的工具函数,该函数返回Reader接口,其实际的类型为teeReader,代码清单3-10展示了TeeReader函数的实现。

代码清单3-10 TeeReader函数的实现

TeeReader函数的核心是创建了teeReader结构体,定义如代码清单3-11所示。

代码清单3-11 teeReader结构体定义

teeReader的Read方法实现逻辑相当直接,每次Read操作中,它将读取到的数据写入关联的Writer中,从而实现数据流的分流和复制。

2.数据I/O流的实践

我们将通过两个具体的例子来深入理解这些特殊Reader、Writer的使用。第一个示例将介绍如何复制Reader(即将一份数据源复制成多份数据源),并展示如何进行多副本的并发写操作和并发计算CRC的。第二个示例展示如何复制Writer(即将一份数据源写到多个目标副本),实现一份数据向多个地方的写入操作。

(1)实践1:复制Reader

让我们先来看一个基础的示例。此示例涉及读取字符串数据到内存中,然后将其写到标准输出(控制台),如代码清单3-12所示。

代码清单3-12 复制Reader的示例

接下来对代码进行两项改进。

1)主Goroutine在执行数据I/O的同时,分出一股数据流做CRC32的计算。

2)将数据流进一步分流,并在另一个Goroutine中进行并发执行I/O操作,模拟多副本数据的写入过程。

为了实现从一个读的数据流中分流,我们需要使用TeeReader。如果要改成多副本并发写,还需要用到Pipe结构。MyReader、MyWriter的定义与代码清单3-12中一致,但main函数有所改动,如代码清单3-13所示(示例中简化了异常处理以突出主要逻辑)。

代码清单3-13 多副本并发写和并发计算CRC的示例

在上述代码的例子中,我们把Reader用TeeReader分流出两个数据流:一个用来做CRC32的计算,一个用来模拟多副本的写入操作。为了实现副本的并发写入,我们首先用Pipe将TeeReader分流出来的Writer转成Reader,随后通过一个并发运行的Goroutine来处理这个Reader,从而达到数据的并发写入的效果。

I/O数据流通常涉及多次I/O操作,为了模拟这一行为,我们故意把copy的缓冲区大小设置为1个字节。这导致每次Read和Write都只处理1个字节,使得程序必须执行多次I/O操作。

最后,编译并运行代码清单3-13的代码,我们可以得到以下结果:

结果显示,由于两个Goroutine是并发执行,每一次I/O操作只处理1个字节,因此输出到控制台的结果是重复且交错在一起的,反映了多次的I/O操作。此外,主Goroutine比子Goroutine多做了一次CRC32的计算。执行的时间线如图3-2所示。

图3-2 I/O并发执行

图3-2展示了I/O并发执行的场景。在实际的应用中,写入磁盘的操作往往耗时最长(我们用MyWriter和os.Stderr来模拟磁盘的I/O写入)。与此相比,内存复制和网络传输的延迟相对较小,因此,从总体时间上看,数据是以并发的方式写入磁盘的。

(2)实践二:复制Writer

在存储实践中,我们经常需要将一个数据流写到多个目的地。为了实现这一需求,MultiWriter提供了一个便捷的解决方案。代码清单3-14展示了如何将单一的数据流复制到多个Writer。

代码清单3-14 一份数据写到多个Writer的示例

编译并执行上述代码后,输出结果如下:

在这个过程中,每一次的I/O操作都将数据写到不同目的地:一处是MyWriter(代表标准输出),另一处则是标准错误输出。值得注意的是,这里的数据复制过程是串行执行的,如图3-3所示。

图3-3 I/O串行执行 c65qZ0mdZqESDgNjbYvrtvM9k/AI3N4FvldPsl0l3Lkt2mB8Wty310O0UHdS05Xk

点击中间区域
呼出菜单
上一章
目录
下一章
×