购买
下载掌阅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串行执行 Lqv8IKkaQPG+Ig91Y5FPBRmGIpSQTP3r2aJaC0atTidOaiFzT8cQnnzCvaLEs3yO



3.2 通用I/O函数

在Go语言中,首先在io包中定义了一系列基本的I/O接口,然后面向这些接口还提供了众多便利的I/O函数。这些函数具有很强的实用性。接下来将详细探索这些通用函数的用法。

3.2.1 面向I/O接口的操作

Go语言的I/O操作可以很容易被抽象为流式数据处理,这就得益于标准库封装的各种的Copy和Read类函数。这些函数都是基于Reader、Writer来实现的。

❑ Copy、CopyN、CopyBuffer:这些函数负责将数据从Reader传输到Writer。它们底层使用相同的copyBuffer函数进行处理,该函数处理了I/O错误码、内部循环条件判断、EOF检测以及长度大小等问题。

❑ ReadFull、ReadAtLeast、ReadAll:这些函数专注于对Reader的操作,提供了完全读取、指定读取和读取全部等封装逻辑。

Reader接口定义了单次Read方法调用的行为,Writer接口定义了单次Write方法调用的行为,流式的I/O操作则是这些Read和Write调用组成的序列构成。其中最为经典的函数便是io.Copy,它对外提供了一个数据流的复制过程。该函数返回的是整个数据流复制的结果,而不仅仅是单次I/O操作的结果。

1.Copy系列函数

在Go语言的I/O库中,Copy系列函数扮演着数据复制的关键角色。以下是Copy系列函数的实现原理,如代码清单3-15所示。

代码清单3-15 Copy系列函数的实现原理

Copy系列函数都依赖于copyBuffer这一核心内部函数。copyBuffer的核心逻辑很简单:内部是一个大的for循环,每一次循环都执行一次Read,然后执行一次Write,直到所有数据被处理完毕或者遇到异常。因此,数据流的复制过程实际上就是由一系列的Read和Write构成的。

在copyBuffer的基础上,还衍生出了CopyN、CopyBuffer这两个复制函数。Copy函数处理的是无边界的数据流,而CopyN提供了数据量的限制功能,CopyBuffer则允许使用自定义的缓冲区来优化每次的I/O传输。

通过上述解析,我们可以了解到Copy系列函数是如何面向I/O接口编程的:这些函数专注于复制逻辑,从一个Reader里读取数据,然后将数据写到Writer里面,而不用关注Reader和Writer的具体类型。这种设计允许开发者将这些函数应用于多种Reader和Writer,提高了代码的复用性。

2.Read系列函数

Go语言处理数据流读取主要包括三个函数:ReadAll、ReadFull和ReadAtLeast。尽管它们都能读取数据流,但终止条件有所区别。

1)ReadAll函数持续读取数据直至遇到EOF或者发生错误。当ReadAll函数在正常情况下读到EOF时,并不会认为是异常,因此返回错误值仍为nil。该函数专注于数据流的整体读取状态。

2)ReadFull函数的目的是读取足够的数据以填满给定的缓冲区(即n==len(buf))。如果未能填满缓冲区(即n<len(buf)),则会返回错误。例如,如果在填满缓冲区之前遇到数据流的EOF,则会返回UnexpectedEOF错误。只要填满缓冲区,则返回值err是nil。该函数关注的是缓冲区的填充状态。

以下是ReadFull函数的实现,如代码清单3-16所示。

代码清单3-16 ReadFull函数的实现

3)ReadAtLeast函数用于确保至少读取了指定量的数据。它是ReadFull的底层实现。如果读取的数据量未达到指定的最小值(即n<min),则会返回报错。例如,在读取足够数据之前遇到数据流的EOF,则会返回UnexpectedEOF错误。一旦读取了指定的最小数据量,则返回值err是nil。这个函数关注的是读取数据量的大小。

ReadAtLeast函数的实现如代码清单3-17所示。

代码清单3-17 ReadAtLeast函数的实现

总的来说,若要简单读取整个数据流,ReadAll是合适的选择;若目标是填满一个缓冲区,那ReadFull会更适用;而ReadAtLeast则适合需要读取至少一定数量数据的场景。这些工具函数大大提高了存储编程的效率,简化了数据读取过程中的条件判断和错误处理。

3.2.2 文件I/O的操作函数

在存储编程实践中,文件I/O操作是常见且关键的一环。Go语言对此提供了丰富的工具函数,旨在帮助开发者快速实现文件的操作,减少了重复代码。值得注意的是,文件操作强依赖于操作系统,Go语言通过对os包中File类型的抽象操作来实现对系统文件的操作。这些文件操作的工具函数存放在os包中(曾位于io/ioutil包,自Go 1.16版本之后进行了重新整理)。

1)ReadFile:通过传入文件路径,可以直接把文件内容读到内存。这省去了开发者对Open、Read、Close的调用。

2)WriteFile:允许用户将数据从内存一次性写入文件中。这省去了开发者对Open、Write、Close的调用。

3)ReadDir:通过传入目录路径,可以快速获取该路径下所有文件的列表。

这些函数简化了开发者对文件的操作。接下来深入分析ReadFile函数的实现原理,如代码清单3-18所示。

代码清单3-18 ReadFile函数的实现原理

使用ReadFile函数可以轻松地将整个文件内容读到内存,数据被存放在一个字节切片中。然而,这种方式需要注意文件的大小,以防文件过大而超出内存容量。

在处理配置文件(即文件大小可控的场景)时,ReadFile和WriteFile显得尤为便捷,它们提供了一个简洁的方法来实施配置数据的读取和存储。这两个函数的使用可以简化配置管理过程,让文件的读写变得直接而高效。 Lito/S16I02yVMX8U/cUZdMzqx4/4Y0cfwDQpcTfUip+5KgDLUO6Mqaswq3jH/EA



3.3 文件系统

Go语言在其设计中提供了一个独特的“文件系统”接口,这是由语言层面做的抽象,与操作系统的文件系统并不等价。这样设计的初衷在于解耦Go语言中文件处理与操作系统之间的直接依赖。

深入研究Go的文件操作机制,我们可以发现,文件操作并未以接口形式定义。相反,它直接与os.File结构体相关联。例如,os.Open函数用于打开文件,它返回一个os.File结构体的指针,而后续的文件I/O操作都是通过这个os.File结构体实现来进行的。os.Open函数的定义如代码清单3-19所示。

代码清单3-19 os.Open函数的定义

Open返回的是一个具体的类型,而非接口类型。这个选择令Go的开发者颇感困惑,因为这会导致业务代码和os.File类型紧密耦合。例如,业务函数执行了文件I/O操作,那么在单元测试时就必须真实地创建一个文件。如果os.Open返回的是接口类型,那么在测试时可以传入满足该接口的任何类型,大大简化了测试过程。

Go在1.16版本中引入io.FS接口,以应对与操作系统的文件系统不同的I/O需求。例如,embed文件系统——一种将文件嵌入二进制程序的机制。通过在io/fs为文件系统提供抽象定义,使Go中的文件系统抽象与操作系统的文件系统得以解耦。

现在,业务程序就可以用标准库io/fs定义的FS接口进行I/O操作,而无须直接依赖于os包,这为编写更加灵活和可移植的代码铺平了道路。

3.3.1 FS接口的定义

接下来介绍Go语言的FS接口定义。这些接口定义在io/fs包中,它们同样遵循最小接口的设计原则,旨在通过组合较小的接口来实现更强大的功能。

1.基础接口

fs.FS接口代表了文件系统,按照Go语言的理解,最基本的文件系统应至少包含一个Open方法。io.FS接口的定义如代码清单3-20所示。

代码清单3-20 io.FS接口的定义

在Open方法中,返回的是fs.File接口类型。fs.File接口代表一个文件,fs.File接口的定义如代码清单3-21所示。

代码清单3-21 fs.File接口的定义

通过fs.File接口定义可以看到,一个文件系统的文件至少需要提供Stat、Read、Close三种方法。对于File.Stat方法返回的fs.FileInfo也是接口类型,它代表文件的元数据信息。fs.FileInfo接口的定义如代码清单3-22所示。

代码清单3-22 fs.FileInfo接口的定义

以上定义的三个接口构成了一个极简的只读文件系统的框架。文件系统至少要有一个Open方法,返回一个可读的文件。我们可以在此基础之上扩展FS的接口,来丰富文件系统的功能。

2.扩展文件系统

Go语言的文件系统允许通过扩展来满足更丰富的应用场景。接下来看几个io/fs中基于fs.FS的扩展的文件系统。

(1)ReadDirFS

在io/fs包内定义一个名为ReadDirFS的接口,该文件系统基于FS增加了一个ReadDir方法,代表一个能够读取目录内容的文件系统。ReadDirFS接口的定义如代码清单3-23所示。

代码清单3-23 ReadDirFS接口的定义

(2)GlobFS

在io/fs包内定义一个名为GlobFS的接口,该文件系统基于FS增加了Glob方法,代表一个具备路径通配符查询的文件系统。GlobFS接口的定义如代码清单3-24所示。

代码清单3-24 GlobFS接口的定义

(3)StatFS

在io/fs定义一个名为StatFS的接口,该文件系统基于FS之上增加了Stat方法,代表一个路径查询的文件系统。StatFS接口的定义如代码清单3-25所示。

代码清单3-25 StatFS接口的定义

(4)ReadFileFS

在io/fs定义一个名为ReadFileFS的接口,该文件系统基于FS之上增加了ReadFile方法,代表一个可以通过路径读取文件内容的文件系统。ReadFileFS接口的定义如代码清单3-26所示。

代码清单3-26 ReadFileFS接口的定义

图3-4形象地展示了Go语言的扩展文件系统的拓扑关系。

图3-4 Go语言的FS拓扑关系

3.3.2 FS接口的实现和扩展

在本节内容中,我们将通过两个具体实例深入探讨Go语言是如何实现文件系统的:一种是代表操作系统的Dir文件系统,另一种是embed文件系统。这两种文件系统的类型都是对fs.FS接口的具体实现。

1.Dir文件系统

io/fs中定义了文件系统的fs.FS接口之后,Go语言紧接着就在os包里实现了这个接口,让os相关的文件操作兼容到fs.FS的接口上。首先,os包提供了一个DirFS函数,返回一个dirFS类型的结构体对象。DirFS函数的实现如代码清单3-27所示。

代码清单3-27 DirFS函数的实现

dirFS类型实现了fs.FS的Open方法,dirFS类型的定义和Open方法的实现如代码清单3-28所示。

代码清单3-28 dirFS类型的定义和Open方法的实现

在dirFS.Open方法中,调用了os.Open方法,获取到os.File的结构体的指针。而os.File类型是实现了fs.File接口的所有方法。这样就可以让os的文件操作兼容fs.FS的接口。

举一个实际读取并打印文件内容到控制台的例子。首先,我们需要创建一个名为hello.txt的文件,其内容为hello world。如果用传统的方式来读该文件,则如代码清单3-29所示。

代码清单3-29 使用os.Open函数读文件示例

如果针对上述代码中的PrintFile函数做单测,就需要实际创建出一个*os.File实例并传入。现在,我们可以改用接口进行重构,如代码清单3-30所示。

代码清单3-30 Dir文件系统读文件示例

通过类似的方法就可以比较平滑地重构代码了。把一些依赖于*os.File的参数替换掉。之后对PrintFile函数做单测就非常灵活了。

2.embed文件系统

Go语言为了更便捷地部署,提供了一个内嵌文件的功能。该功能允许开发者将静态文件直接打包进编译后的二进制程序中。这项特性意味着应用程序和它所需要的数据整合到单个二进制文件中,极大地简化了发布和部署过程。

Go标准库的embed包提供了一个内嵌文件系统(简称embed FS),通过它我们可以在运行时像操作普通文件一样读取这些嵌入二进制的静态文件。

首先,在embed包,我们定义一个名为FS的结构体,它具体实现了fs.FS接口的所有方法。embed的FS结构体定义如代码清单3-31所示。

代码清单3-31 embed的FS结构体定义

根据fs.FS的接口的定义,embed包的FS结构体仅需实现Open方法。代码清单3-32展示了embed的FS结构体的Open方法实现细节。

代码清单3-32 embed的FS结构体的Open方法

在embed的FS.Open方法中,首先通过lookup方法在files数组中根据名字查找是否存在内嵌文件,如果找到,就返回一个openFile的结构体实例。进一步地,openFile结构则要实现fs.File的接口,openFile的定义和实现如代码清单3-33所示。

代码清单3-33 openFile的定义和实现

openFile结构体实现了Read、Stat、Close方法,这就是一个完整的Go语言FS实现。

下面通过一个实际的示例来展示embed的FS使用技巧。首先,创建一个名为hello.txt的文件,其内容为“hello world”。然后,把这个文件在编译时内嵌到二进制中,如代码清单3-34所示。

代码清单3-34 embed文件系统的使用示例

编译之后hello.txt文件的内容就被嵌入二进制文件中。在程序运行时我们可以像读取普通文件一样读取hello.txt文件,而实际上,读取的是内嵌在二进制文件的数据,而非磁盘上的实体文件。 Lito/S16I02yVMX8U/cUZdMzqx4/4Y0cfwDQpcTfUip+5KgDLUO6Mqaswq3jH/EA

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