首页 » Go语言程序设计 » Go语言程序设计全文在线阅读

《Go语言程序设计》第8章 文件处理

关灯直达底部

在前面几章中我们看了几个与创建以及读写文件有关的例子。本章我们将深入了解一下Go语言中的文件处理,特别是如何读写标准格式(如 XML和JSON 格式)的文件以及自定义的纯文本和二进制格式文件。

由于前面的内容已覆盖 Go语言的所有特性(下一章将要讲到的使用自定义包和第三方包来创建程序的内容除外),现在我们可以灵活地使用Go语言提供的所有工具。我们会充分利用这种灵活性并利用闭包(参见5.6.3节)来避免重复性的代码,同时在某些情况下充分利用Go语言对面向对象的支持,特别是对为函数添加方法的支持。

本章的重点在于文件而非目录或者通用的文件系统。对于目录,前面章节的findduplicates示例(参见7.2.5节)展示了如何使用filepath.Walk函数来迭代访问目录下的文件及其子目录。此外,标准库 os 包中的os.File 类型提供了用于读取目录下的文件名的方法(os.File.Readdirnames),以及用于获取目录下每一项的os.FileInfo 值的方法(os.File.Readdir)。

本章的第一节讲解了如何使用标准和自定义的文件格式进行文件的读写。第二节讲解了Go语言对处理压缩文件及相应的压缩算法的支持。

8.1 自定义数据文件

对一个程序非常普遍的需求包括维护内部数据结构,为数据交换提供导入导出功能,也支持使用外部工具来处理数据。由于我们这里的关注重点是文件处理,因此我们纯粹只关心如何从程序内部数据结构中读取数据并将其写入标准和自定义格式的文件中,以及如何从标准和自定义格式文件中读取数据并写入程序的内部数据结构中。

本节中,我们会为所有的例子使用相同的数据,以便直接比较不同的文件格式。所有的代码都来自 invoicedate 程序(在 invoicedata 目录中的invoicedata.go、gob.go、inv.go、jsn.go、txt.go和xml.go等文件中)。该程序接受两个文件名作为命令行参数,一个用于读,另一个用于写(它们必须是不同的文件)。程序从第一个文件中读取数据(以其后缀所表示的任何格式),并将数据写入第二个文件(也是以其后缀所表示的任何格式)。

由invoicedata程序创建的文件可跨平台使用,也就是说,无论是什么格式,Windows上创建的文件都可在 Mac OS X 以及 Linux 上读取,反之亦然。Gzip 格式压缩的文件(如invoices.gob.gz)可以无缝读写。压缩相关的内容在8.2节阐述。

这些数据由一个*Invoice组成,也就是说,是一个保存了指向Invoice值的指针的切片。每一个发票数据都保存在一个Invoice类型的值中,同时每一个发票数据都以*Item的形式保存着0个或者多个项。

这两个结构体用于保存数据。表8-1给出了一些非正式的对比,展示了每种格式下读写相同的50000份随机发票数据所需的时间,以及以该格式所存储文件的大小。计时按秒计,并向上舍入到最近的十分之一秒。我们应该把计时结果认为是无绝对单位的,因为不同硬件以及不同负载情况下该值都不尽相同。大小一栏以千字节(KB)算,该值在所有机器上应该都是相同的。对于该数据集,虽然未压缩文件的大小千差万别,但压缩文件的大小都惊人的相似。而代码的函数不包括所有格式通用的代码(例如,那些用于压缩和解压缩以及定义结构体的代码)。

表8-1 各种格式的速度以及大小对比

这些读写时间和文件大小在我们的合理预期范围内,除了纯文本格式的读写异常快之外。这得益于 fmt 包优秀的打印和扫描函数,以及我们设计的易于解析的自定义文本格式。对于JSON和XML 格式,我们只简单地存储了日期部分而非存储默认的time.Time 值(一个ISO-8601 日期/时间字符串),通过牺牲一些速度和增加一些额外代码稍微减小了文件的大小。例如,如果让JSON代码自己来处理time.Time值,它能够运行得更快,并且其代码行数与Go语言二进制编码差不多。

对于二进制数据,Go语言的二进制格式是最便于使用的。它非常快且极端紧凑,所需的代码非常少,并且相对容易适应数据的变化。然而,如果我们使用的自定义类型不原生支持 gob编码,我们必须让该类型满足gob.Encoder和gob.Decoder接口,这样会导致gob格式的读写相当得慢,并且文件大小也会膨胀。

对于可读的数据,XML可能是最好使用的格式,特别是作为一种数据交换格式时非常有用。与处理JSON格式相比,处理XML格式需要更多行代码。这是因为Go 1没有一个xml.Marshaler接口(这个缺失有希望在Go 1.x之后的发行版中得到弥补),也因为我们这里使用了并行的数据类型(XMLInvoice和XMLItem)来帮助映射XML数据和发票数据(Invoice和Item)。使用XML作为外部存储格式的应用程序可能不需要并行的数据类型或者也不需要 invoicedata 程序这样的转换,因此就有可能比invoicedata例子中所给出的更快,并且所需的代码也更少。

除了读写速度和文件大小以及代码行数之外,还有另一个问题值得考虑:格式的稳健性。例如,如果我们为Invoice结构体和Item结构体添加了一个字段,那么就必须再改变文件的格式。我们的代码适应读写新格式并继续支持读旧格式的难易程度如何?如果我们为文件格式定义版本,这样的变化就很容易被适应(会以本章一个练习的形式给出),除了让JSON格式同时适应读写新旧格式稍微复杂一点之外。

除了Invoice和Item结构体之外,所有文件格式都共享以下常量:

const (

fileType     = "INVOICES"     // 用于纯文本格式

magicNumber    = 0x125D       // 用于二进制格式

fileVersion    = 100         // 用于所有的格式

dataFormat    = "2006-01-02"    // 必须总是使用该日期

)

magicNumber用于唯一标记发票文件[1]。fileVersion用于标记发票文件的版本,该标记便于之后修改程序来适应数据格式的改变。dataFormat稍后介绍(参见5.1.1.2节),它表示我们希望数据如何按照可读的格式进行格式化。

同时,我们也创建了一对接口。

type InvoiceMarshaler interface {

MarshalInvoices(writer io.Writer, invoices *Invoice) error

}

type InvoiceUnmarshaler interface {

UnmarshalInvoices(reader io.Reader) (*Invoice, error)

}

这样做的目的是以统一的方式针对特定格式使用 reader和writer。例如,下列函数是invoicedata程序用来从一个打开的文件中读取发票数据的。

func readInvoices(reader io.Reader, suffix string)(*Invoice, error) {

var unmarshaler InvoicesUnmarshaler

switch suffix {

case ".gob":

unmarshaler = GobMarshaler{}

case ".inv":

unmarshaler = InvMarshaler{}

case ".jsn", ".json":

unmarshaler = JSONMarshaler{}

case ".txt":

unmarshaler = TxtMarshaler{}

case ".xml":

unmarshaler = XMLMarshaler{}

}

if unmarshaler != nil {

return unmarshaler.UnmarshalInvoices(reader)

}

return nil, fmt.Errorf("unrecognized input suffix: %s", suffix)

}

其中,reader 是任何能够满足 io.Reader 接口的值,例如,一个打开的文件(其类型为*os.File)、一个gzip解码器(其类型为 *gzip.Reader)或者一个string.Reader。字符串 suffix 是文件的后缀名(从.gz 文件中解压之后)。在接下来的小节中我们将会看到GobMarshaler和InvMarshaler 等自定义的类型,它们提供了 MarshalInvoices和UnmarshalInvoices方法(因此满足InvoicesMarshaler和InvoicesUnmarshaler接口)。

8.1.1 处理JSON文件

根据www.json.org介绍,JSON(JavaScript对象表示法,JavaScript Object Notation)是一种易于人读写并且易于机器解析和生成的轻量级的数据交换格式。JSON 是一种使用 UTF-8编码的纯文本格式。由于写起来比 XML 格式方便,并且(通常)更为紧凑,而所需的处理时间也更少,JSON格式已经越来越流行,特别是在通过网络连接传送数据方面。

这里是一个简单的发票数据的JSON表示,但是它省略了该发票的第二项的大部分字段。

{

"Id": 4461,

"CustomerId": 917,

"Raised": "2012-07-22",

"Due": "2012-08-21",

"Paid": true,

"Note": "Use trade entrance",

"Items": [

{

"Id": "AM2574",

"Price": 415.8,

"Quantity": 5,

"Note": ""

},

{

"Id": "MI7296",

...

}

]

}

通常,encodeing/json 包所写的JSON 数据没有任何不必要的空格,但是这里我们为了更容易看明白数据的结构而使用了缩进和空白来展示它。虽然 encoding/json 包支持time.Times,但是我们通过自己实现自定义的MarshalJSON和UnmarshalJSON Invoice方法来处理发票的开具和到期日期。这样我们就可以存储更短的日期字符串(因为对于我们的数据来说,其时间部分始终为0),例如“2012-09-06”,而非整个日期/时间值,如“2012-09-06T00:00:00Z”。

8.1.1.1 写JSON文件

我们创建了一个基于空结构体的类型,它定义了与 JSON 相关的MarshalInvoices和UnmarshalInvoices方法。

type JSONMarshaler struct{}

该类型满足我们在前文看到的InvoicesMarshaler和InvoicesUnmarshaler接口(见8.1节)。

这里的方法使用encoding/json包中标准的Go到JSON序列化函数将*Invoice项中的所有数据以JSON格式写入一个io.Writer中。该writer可以是os.Create函数返回的*os.File,或者是 gzip.NewWriter函数返回的*gzip.Writer,或者是任何满足io.Writer接口的其他值。

unc (JSONMarshaler) MarshalInvoices(writer io.Writer, invoices *Invoice) error {

encoder := json.NewEncoder(writer)

if err := encoder.Encode(fileType); err != nil {

return err

}

if err := encoder.Encode(fileVersion); err != nil {

return err

}

return encoder.Encode(invoices)

}

JSONMarshaler类型没有数据,因此我们没必要将其值赋值给一个接收器变量。

函数开始处,我们创建了一个 JSON 编码器,它包装了 io.Writer,可以接收我们写入的支持JSON编码的数据。

我们使用json.Encoder.Encode方法来写入数据。该方法能够完美地处理发票切片,其中每个发票都包含一到多个项的切片。该方法返回一个错误值或者空值nil。如果返回的是一个错误值,则立即返回给调用者。

文件的类型和版本不是必须写入的,但在后面一个练习中会看到,这样做是为了以后更容易更改文件格式(例如,为了适应Invoice和Item结构体中额外的字段),以及为了能够同时支持读取新旧格式的数据。

需注意的是,该方法实际上与它所编码的数据类型无关,因此很容易创建类似函数用于写入其他可JSON编码的数据。另外,只要新的文件格式中新增的字段是导出的且支持JSON编码,该JSONMarshaler.MarshalInvoices方法无需做任何更改。

如果这里所给出的代码就是JSON相关代码的全部,这样当然可以很好地工作。然而,由于我们希望更好地控制JSON的输出,特别是对time.Time值的格式化,我们还为Invoice类型提供了一个满足 json.Marshaler 接口的MarshalJSON方法。json.Encode函数足够智能,它会去检查所需编码的值是否支持 json.Marshaler 接口,如果支持,该函数会使用该值的MarshalJSON方法而非内置的编码代码。

type JSONInvoice struct {

Id     int

CustomerId int

Raised   string    // Invoice结构体中的time.Time

Due    string    // Invoice结构体中的time.Time

Paid    bool

Note    string

Items   *Item

}

func (invoice Invoice) MarshalJSON(byte, error) {

jsonInvoice := JSONInvoice {

invoice.Id,

invoice.CustomerId,

invoice.Raised.Format(dateFormat),

invoice.Due.Format(dateFormat),

invoice.Paid,

invoice.Note,

invoice.Items,

}

return json.Marshal(jsonInvoice)

}

该自定义的Invoice.MarshalJSON方法接受一个已有的Invoice 值,返回一个该数据 JSON 编码后的版本。该函数的第一个语句简单地将发票的各个字段复制到自定义的JSONInvoice结构体中,同时将两个time.Time值转换成字符串。由于JSONInvoice结构体的字段都是布尔类型、数字或者字符串,该结构体可以使用 json.Marshal函数进行编码,因此我们使用该函数来完成工作。

为了将日期/时间(即 time.Time 值)以字符串的形式写入,我们必须使用 time.Time.Format方法。该方法接受一个格式字符串,它表示该日期/时间值应该如何写入。该格式字符串非常特殊,必须是一个Unix时间1 136 243 045的字符串表示,即精确的日期/时间值2006-01-02T15:04:05Z07:00,或者跟这里一样,使用该日期/时间值的子集。该特殊的日期/时间值是任意的,但必须是确定的,因为没有其他的值来声明日期、时间以及日期/时间的格式。

如果我们想自定义日期/时间格式,它们必须按照 Go语言的日期/时间格式来写。假如我们要以星期、月、日和年的形式来写日期,我们必须使用“Mon, Jan 02, 2006”这种格式,或者如果我们希望删除前导的0,就必须使用“Mon, Jan _2, 2006”这种格式。time包的文档中有完整的描述,并列出了一些预定义的格式字符串。

8.1.1.2 读JSON文件

读JSON数据与写JSON数据一样简单,特别是当将数据读回与写数据时类型一样的变量时。JSONMarshaler.UnMarshalInvoices方法接受一个 io.Reader 值,该值可以是一个 os.Open函数返回的*os.File 值,或者是一个 gzip.NewReader函数返回的*gzip.Reader值,也可以是任何满足io.Reader接口的值。

func (JSONMarshaler) UnmarshalInvoices(reader io.Reader) (*Invoice, error){

decoder := json.NewDecoder(reader)

var kind string

if err := decoder.Decode(&king); err != nil {

return nil, err

}

if kind != fileType {

return nil, errors.New("Cannot read non-invoices json file")

}

var version int

if err := decoder.Decode(&version); err != nil {

return nil, err

}

if version > fileVersion {

return nil, fmt.Error("version %d is too new to read", version)

}

var invoices *Invoice

err := decoder.Decode(&invoices)

return invoices, err

}

我们需读入3项数据:文件类型、文件版本以及完整的发票数据。json.Decoder.Decode方法接受一个指针,该指针所指向的值用于存储解码后的JSON数据,解码后返回一个错误值或者nil。我们使用前两个变量(kind和version)来保证接受一个JSON格式的发票文件,并且该文件的版本是我们能够处理的。然后程序读取发票数据,在该过程中,随着json.Decoder.Decode方法所读取发票数的增多,它会增加invoices切片的长度,并将相应发票的指针(及其项目)保存在切片中,这些指针是UnmarshalInvoices函数在必要时实时创建的。最后,该方法返回解码后的发票数据和一个nil值。或者,如果解码过程中遇到了问题则返回一个nil值和一个错误值。

如果我们之前纯粹依赖于json包内置的功能把数据的创建及到期日期按照默认的方式序列化,那么这里给出的代码已经足以反序列化一个 JSON 格式的发票文件。然而,由于我们使用自定义的方式来序列化数据的建立和到期日期 time.Times(只存储日期部分),我们必须提供一个自定义的反序列化方法,该方法理解我们的自定义序列化流程。

func (invoice *Invoice) UnmarshalJSON(data byte) (err error) {

var jsonInvoice JSONInvoice

if err = json.Unmarshal(data, &jsonInvoice); err != nil {

return err

}

var raised, due time.Time

if raised, err = time.Parse(dateFormat, jsonInvoice.Raised);

err != nil {

return err

}

if due, err = time.Parse(dateFormat, jsonInvoice.Due); err != nil {

return err

}

*invoice = Invoice {

jsonInvoice.Id,

jsonInvoice.CustomerId,

raised,

due,

jsonInvoice.Paid,

jsonInvoice.Note,

jsonInvoice.Items,

}

return nil

}

该方法使用与前面一样的JSONInvoice结构体,并且依赖于json.Unmarshal函数来填充数据。然后,我们将反序列化后的数据以及转换成 time.Time的日期值赋给新创建的Invoice变量。

json.Decoder.Decode足够智能会检查它需要解码的值是否满足 json.Unmarshaler接口,如果满足则使用该值自己的UnmarshalJSON方法。

如果发票数据因为新添加了导出字段而发生改变,该方法能继续正常工作的前提是我们必须让Invoice.UnmarshalJSON方法也能处理版本变化。另外,如果新添加字段的零值不可被接受,那么当以原始格式读文件的时候,我们必须对数据做一些后期处理,并给它们一个合理的值。(有一个练习需要添加新字段以及进行此类后期处理工作。)

虽然要支持两个或者更多个版本的JSON文件格式有点麻烦,但JSON是一种很容易处理的格式,特别是如果我们创建的结构体的导出字段比较合理时。同时,json.Encoder.Encode函数和json.Decoder.Decode函数也不是完美可逆的,这意味着序列化后得到的数据经过反序列化后不一定能够得到原始的数据。因此,我们必须小心检查,保证它们对我们的数据有效。

顺便提一下,还有一种叫做BSON(Binary JSON)的格式与JSON非常类似,它比JSON更为紧凑,并且读写速度也更快。godashboard.appspot.com/project 网页上有一个支持BSON格式的第三方包(gobson)。(安装和使用第三方包的内容将在第9章阐述。)

8.1.2 处理XML文件

XML(eXtensible Markup Language)格式被广泛用作一种数据交换格式,并且自成一种文件格式。与JSON相比,XML复杂得多,手动写起来也啰嗦而且乏味得多。

encoding/xml包可以用在结构体和XML格式之间进行编解码,其方式跟encoding/json包类似。然而,与encoding/json包相比,XML的编码和解码在功能上更苛刻得多。这部分是由于encoding/xml包要求结构体的字段包含格式合理的标签(然而JSON格式却不需要)。同时, Go 1的encoding/xml包没有xml.Marshaler接口,因此与编解码JSON格式和Go语言的二进制格式相比,我们处理 XML 格式时必须写更多的代码。(该问题有望在 Go 1.x 发行版中得以解决。)

这里有个简单的XML 格式的发票文件。为了适应页面的宽度和容易阅读,我们添加了换行和额外的空白。

<INVOICE Id="2640" CustomerId="968" Raised="2012-08-27" Due="2012-09-26"

Paid="false"><NOTE>See special Terms &amp; Conditions</NOTE>

<ITEM Id="MI2419" Price="342.80" Quantity="1"><NOTE></NOTE></ITEM>

<ITEM Id="OU5941" Price="448.99" Quantity="3"><NOTE>

&quot;Blue&quot; ordered but will accept &quot;Navy&quot;</NOTE> </ITEM>

<ITEM Id="IF9284" Price="475.01" Quantity="1"><NOTE></NOTE></ITEM>

<ITEM Id="TI4394" Price="417.79" Quantity="2"><NOTE></NOTE></ITEM>

<ITEM Id="VG4325" Price="80.67" Quantity="5"><NOTE></NOTE></ITEM>

</INVOICE>

对于xml包中的编码器和解码器而言,标签中如果包含原始字符数据(如invoice和item中的Note字段)处理起来比较麻烦,因此invoicedata示例使用了显式的<NOTE>标签。

8.1.2.1 写XML文件

encoidng/xml包要求我们使用的结构体中的字段包含encoding/xml包中所声明的标签,所以我们不能直接将Invoice和Item结构体用于XML序列化。因此,我们创建了针对XML格式的XMLInvoices、XMLInvoice和XMLItem结构体来解决这个问题。同时,由于invoicedata程序要求我们有并行的结构体集合,因此必须提供一种方式来让它们相互转换。当然,使用XML格式作为主要存储格式的应用程序只需一个结构体(或者一个结构体集合),同时要将必要的encoidng/xml包的标签直接添加到结构体的字段中。

下面是保存整个数据集合的XMLInvoices结构体。

type XMLInvoices struct {

XMLName xml.Name    `xml:"INVOICES"`

Version int       `xml:"version,attr"`

Invoice *XMLInvoice  `xml:"INVOICE"`

}

在Go语言中,结构体的标签本质上没有任何语义,它们只是可以使用Go语言的反射接口获得的字符串(参见9.4.9节)。然而,encoding/xml包要求我们使用该标签来提供如何将结构体的字段映射到XML的信息。xml.Name字段用于为XML中的标签命名,该标签包含了该字段所在的结构体。以`xml:“,attr”`标记的字段将成为该标签的属性,字段名字将成为属性名。我们也可以根据自己的喜好使用另一个名字,只需在所给的名字签名加上一个逗号。这里,我们把Version字段当做一个叫做version的属性,而非默认的名字Version。如果标签只包含一个名字,则该名字用于表示嵌套的标签,如此例中的<INVOICE>标签。有一个非常重要的细节需注意的是,我们把 XMLInvoices的发票字段命名为 Invoice,而非Invoices,这是为了匹配XML格式中的标签名(不区分大小写)。

下面是原始的Invoice结构体,以及与XML格式相对应的XMLInvoice结构体。

在这里,我们为属性提供了默认的名字。例如,字段CustomerId在XML中对应一个属性,其名字与该字段的名字完全一样。这里有两个可嵌套的标签:<NOTE>和<ITEM>,并且如XMLInvoices结构体一样,我们把XMLInvoice的item字段定义成Item(大小写不敏感)而非Items,以匹配标签名。

由于我们希望自己处理创建和到期日期(只存储日期),而非让encoding/xml包来保存完整的日期/时间字符串,我们为它们在XMLInvoice结构体中定义了相应的Raised和Due字段。

下面是原始的Item结构体,以及与XML相对应的XMLItem结构体。

除了作为嵌套的<NOTE>标签的Note字段和用于保存该XML标签名的XMLName字段之外,XMLItem的字段都被打上了标签以作为属性。

正如处理JSON格式时所做的那样,对于XML格式,我们创建了一个空的结构体并关联了XML相关的MarshalInvoices方法和UnmarshalInvoices方法。

type XMLMarshaler struct{}

该类型满足前文所述的InvoicesMarshaler和InvoiceUnmarshaler接口(参见8.1节)。

func (XMLMarshaler) MarshalInvoices(writer io.Writer, invoices *Invoice) error {

if _, err := writer.Writer(byte(xml.Header)); err != nil {

return err

}

xmlInvoices := XMLInvoicesForInvoices(invoices)

encoder := xml.NewEncoder(writer)

return encoder.Encode(xmlInvoices)

}

该方法接受一个io.Writer(也就是说,任何满足io.Writer接口的值如打开的文件或者打开的压缩文件),以用于写入XML数据。该方法从写入标准的XML头部开始(该xml.Header常量的末尾包含一个新行)。然后,它将所有的发票数据及其项写入相应的XML结构体中。这样做虽然看起来会耗费与原始数据相同的内存,但是由于Go语言的字符串是不可变的,因此在底层只将原始数据字符串的引用复制到XML结构体中,因此其代价并不是我们所看到的那么大。而对于直接使用带有XML标签的结构体的应用而言,其数据没必要再次转换。

一旦填充好 xmlInvoices(其类型为 XMLInvoices)后,我们创建了一个新的xml.Encoder,并将我们希望写入数据的io.Writer传给它。然后,我们将数据编码成XML格式,并返回编码器的返回值,该值可能为一个error值也可能为nil。

func XMLInvoicesForInvoices(invoices *Invoice) *XMLInvoices {

xmlInvoices := &XMLInvoices{

Version: fileVersion,

Invoice: make(*XMLInvoice, 0, len(invoices)),

}

for _, invoice := range invoices {

xmlInvoices.Invoice = append(xmlInvoices.Invoice,

XMLInvoiceForInvoice (invoice))

}

return xmlInvoices

}

该函数接受一个*Invoice 值并返回一个 *XMLInvoices 值,其中包含转换成*XMLInvoices(还包含 *XMLItems 而非 *Items)的所有数据。该函数又依赖于XmlInvoiceForInvoice函数来为其完成所有工作。

我们不必手动填充 xml.Name 字段(除非我们想使用名字空间),因此在这里,当创建*XMLInvoices的时候,我们只需填充Version字段以保证我们的标签有一个version属性,例如<INVILES verion=”100”>。同时,我们将 Invoice字段设置成一个空间足够容纳所有的发票数据的空切片。这样做不是严格必须的,但是与将该字段的初始值留空相比,这样做可能更高效,因为这样做意味着调用内置的append函数时无需分配内存和复制数据以扩充切片容量。

func XMLInvoiceForInvoice(invoice *Invoice) *XMLInvoice {

xmlInvoice := &XMLInvoice{

Id:      invoice.id,

CustomerId: invoice.CustomerId,

Raised:   invoice.Raised.Format(dateFormat),

Due:     invoice.Due.Format(dateFormat),

Paid:     invoice.Paid,

Note:     invoice.Note,

Item:     make(*XMLItem, 0, len(invoice.Items)),

}

for _, item := range invoice.Items {

xmlItem := &XMLItem {

Id:    item.Id,

Price:   item.Price,

Quantity: item.Quantity,

Note:   item.Note,

}

xmlInvoice.Item = append(xmlInvoice.Item, xmlItem)

}

return xmlInvoice

}

该函数接受一个Invoice值并返回一个等价的XMLInvoice值。该转换非常直接,只需简单地将Invoice中每个字段的值复制至XMLInvoice字段中。由于我们选择自己来处理创建以及到期日期(因此我们只需存储日期而非完整的日期/时间),我们只需将其转换成字符串。而对于Invoice.Items字段,我们将每一项转换成XMLItem后添加到XMLInvoice.Item切片中。与前面一样,我们使用相同的优化方式,创建 Item 切片时分配了足够多的空间以避免 append时需要分配内存和复制数据。前文阐述 JSON 格式时我们已讨论过 time.Time值的写入(参见8.1.1.1节)。

最后需要注意的是,我们的代码中没有做任何 XML 转义,它是由 xml.Encoder.Encode方法自动完成的。

8.1.2.2 读XML文件

读XML文件比写XML文件稍微复杂,特别是在必须处理一些我们自定义的字段的时候(例如日期)。但是,如果我们使用合理的打上XML标签的结构体,就不会复杂。

func (XMLMarshaler) UnmarshalInvoices(reader io.Reader)(*Invoice, error) {

xmlInvoices := &XMLInvoices{}

decoder := xml.NewDecoder(reader)

if err := decoder.Decode(xmlInvoices); err != nil {

return nil, err

}

if xmlInvoices.Version > fileVersion {

return nil, fmt.Errorf("version %d is too new to read", xmlInvoices.Version)

}

return xmlInvoices.Invoices

}

该方法接受一个 io.Reader(也就是说,任何满足 io.Reader 接口的值如打开的文件或者打开的压缩文件),并从其中读取XML。该方法的开始处创建了一个指向空XMLInvoices结构体的指针,以及一个 xml.Decoder 用于读取 io.Reader。然后,整个 XML 文件由xml.Decoder.Decode方法解析,如果解析成功则将 XML 文件的数据填充到该*XMLInvoices结构体中。如果解析失败(例如,XML文件语法有误,或者该文件不是一个合法的发票文件),那么解码器会立即返回错误值给调用者。如果解析成功,我们再检查其版本,如果该版本是我们能够处理的,就将该XML结构体转换成我们程序内部使用的结构体。当然,如果我们直接使用带XML标签的结构体,该转换步骤就没必要了。

func (xmlInvoices *XMLInvoices) Invoices (invoices *Invoice, err error){

invoices = make(*Invoice, 0, len(xmlInvoices.Invoice))

for _, XMLInvoice := range xmlInvoices.Invoice {

invoice, err := xmlInvoice.Invoice

if err != nil {

return nil, err

}

invoices = append(invoices, invoice)

}

return invoices, nil

}

该XMLInvoices.Invoices方法将一个*XMLInvoices值转换成一个*Invoice值,它是 XmlInvoicesForInvoices函数的逆反操作,并将具体的转换工作交给XMLInvoice.Invoice方法完成。

func (xmlInvoice *XMLInvoice) Invoice (invoice *Invoice, err error) {

invoice = &Invoice{

Id:     xmlInvoice.Id,

CustomerId: xmlInvoice.CustomerId,

Paid:      xmlInvoice.Paid,

Note:      strings.TrimSpace(xmlInvoice.Note),

Items:     make(*Item, 0, len(xmlInvoice.Item)),

}

if invoice.Raised, err = time.Parse(dateFormat, xmlInvoice.Raised);

err != nil {

return nil, err

}

if invoice.Due, err = time.Parse(dateFormat, xmlInvoice.Due);

err != nil{

return nil, err

}

for _, xmlItem := range xmlInvoice.Item {

item := &Item {

Id:    xmlItem.Id,

Price:   xmlItem.Price,

Quantity: xmlItem.Quantity,

Note:   strings.TrimSpace(xmlItem.Note),

}

invoice.Items = append(invoice.Items, item)

}

return invoice, nil

}

该方法用于返回与调用它的*XMLInvoice值相应的*Invoice值。

该方法在开始处创建了一个Invoice值,其大部分字段都由来自XMLInvoice的数据填充,而Items字段则设置成一个容量足够大的空切片。

然后,由于我们选择自己处理这些,因此手动填充两个日期/时间字段。time.Parse函数接受一个日期/时间格式的字符串(如前所述,该字符串必须基于精确的日期/时间值,如2006-01-02T15:04:05Z07:00),以及一个需要解析的字符串,并返回等价的time.Time 值和nil,或者,返回一个nil和一个错误值。

接下来是填充发票的Items 字段,这是通过迭代 XMLInvoice的Item 字段中的*XMLItems并创建相应的*Items来完成的。最后,返回 *Invoice。

正如写 XML 时一样,我们无需关心对所读取的XML 数据进行转义,xml.Decoder.Decode函数会自动处理这些。

xml 包支持比我们这里所需的更为复杂的标签,包括嵌套。例如,标签名为`xml:"Books> Author"`产生的是<Books><Author>content</Author></Books>这样的XML内容。同时,除了`xml:",attr"`之外,该包还支持`xml:",chardata"`这样的标签表示将该字段当做字符数据来写,支持`xml:",innerxml"`这样的标签表示按照字面量来写该字段,以及`xml:",comment"`这样的标签表示将该字段当做 XML 注释。因此,通过使用标签化的结构体,我们可以充分利用好这些方便的编码解码函数,同时合理控制如何读写XML数据。

8.1.3 处理纯文本文件

对于纯文本文件,我们必须创建自定义的格式,理想的格式应该易于解析和扩展。

下面是某单个发票以自定义纯文本格式存储的数据。

INVOICE ID=5441 CUSTOMER=960 RAISED=2012-09-06 DUE=2012-10-06 PAID=true

ITEM ID=BE9066 PRICE=400.89 QUANTITY=7: Keep out of <direct> sunlight

ITEM ID=AM7240 PRICE=183.69 QUANTITY=2

ITEM ID=PT9110 PRICE=105.40 QUANTITY=3: Flammable

在该格式中,每个发票是一个INVOICE行,然后是一个或者多个ITEM行,最后是换页符。每一行(无论是发票还是它们的项)的基本结构都相同:起始处有一个单词表示该行的类型,接下来是一个空格分隔的“键=值”序列,以及可选的跟在一个冒号和一个空格后面的注释文本。

8.1.3.1 写纯文本文件

由于Go语言的fmt包中打印函数强大而灵活(这在前文已有阐述,详见3.5节),写纯文本数据非常简单直接。

type TxtMarshaler struct{}

func (TxtMarshaler) MarshalInvoices(writer io.Writer,

invoices *Invoice) error {

bufferedWriter := bufio.NewWriter(writer)

defer bufferedWriter.Flush

var write writerFunc = func(format string, args…interface{}) error {

_, err := fmt.Fprintf(bufferedWriter, format, args...)

return err

}

if err := write("%s %d/n", fileType, fileVersion); err != nil {

return err

}

for _, invoice := range invoices {

if err := write.WriteInvoice(invoice); err != nil {

return err

}

}

return nil

}

该方法在开始处创建了一个带缓冲区的writer,用于操作所传入的文件。延迟执行刷新缓冲区的操作是必要的,这可以保证我们所写的数据确实能够写入文件(除非发生错误)。

与以if _, err := fmt.Fprintf(bufferedWriter,...); err != nil {return err}的形式来检查每次写操作不同的是,我们创建了一个函数字面量来做两方面的简化。第一,该writer函数会忽略fmt.Fprintf函数报告的所写字节数。其次,该函数处理了bufferedWriter,因此我们不必在自己的代码中显式地提到。

我们本可以将 write函数传给辅助函数的,例如,writeInvoice(write, invoice)。但不同于此做法的是,我们往前更进了一步,将该方法添加到writerFunc类型中。这是通过声明接受一个writerFunc值作为其接收器的方法(即函数)来达到,跟定义任何其他类型一样。这样就允许我们以write.writeInvoice(invoice)这样的形式调用,也就是说,在 write函数自身上调用方法。并且,由于这些方法接受 write函数作为它们的接收器,我们就可以使用write函数。

需注意的是,我们必须显式地声明write函数的类型(writerFunc)。如果不这样做, Go语言就会将其类型定义为func(string,...interface{}) error(当然,它本来就是这种类型),并且不允许我们在其上调用writerFunc方法(除非我们使用类型转换的方法将其转换成writerFunc类型)。

有了方便的write函数(及其方法),我们就可以开始写入文件类型和文件版本(后者使得容易适应数据的改变)。然后,我们迭代每一个发票项,针对每一次迭代,我们调用write函数的writeInvoice方法。

const noteSep = ":"

type writerFunc func(string,..interface{}) error

func (write writerFunc) writeInvoice(invoice *Invoice) error {

note := ""

if invoice.Note != "" {

note = noteSep + " " + invoice.Note

}

if err := write("INVOICE ID=%d CUSTOMER=%d RAISED=%s DUE=%s" +

"PAID=%t%s/n", invoice.Id, invoice.CustomerId,

invoice.Raised.Format(dateFormat),

invoice.Due.Format(dateFormat), invoice.Paid, note); err != nil {

return err

}

if err := write.writeItems(invoice.Items); err != nil {

return err

}

return write("/f/n")

}

该方法用于写每一个发票项。它接受一个要写的发票项,同时使用作为接收器传入的write函数来写数据。

发票数据一次性就可以写入。如果给出了注释文本,我们就在其前面加入冒号以及空格来将其写入。对于日期/时间(即 time.Time 值),我们使用 time.Time.Format方法,跟我们以JSON和XML格式写入数据时一样。而对于布尔值,我们使用%t格式指令,也可以使用%v格式指令或strconv.FormatBool函数。

一旦发票行写好了,就开始写发票项。最后,我们写入分页符和一个换行符,表示发票数据的结束。

func (write writerFunc) writeItems(items *Item) error {

for _, item := range items {

note := ""

if item.Note != "" {

note = noteSep + " " + item.Note

}

if err := write("ITEM ID=%s PRICE=%.2f QUANTITY=%d%s/n", item.Id,

item.Price, item.Quantity, note); err != nil {

return err

}

}

return nil

}

该writeItems方法接受发票的发票项,并使用作为接收器传入的write函数来写数据。它迭代每一个发票项并将其写入,并且也跟写入发票数据一样,如果其注释文档为空则无需写入。

8.1.3.2 读纯文本文件

打开并读取一个纯文本格式的数据跟写入纯文本格式数据一样简单。要解析文本来重建原始数据可能稍微复杂,这需根据格式的复杂性而定。

有4种方法可以使用。前3种方法包括将每行切分,然后针对非字符串的字段使用转换函数如strconv.Atoi和time.Parse。这些方法是:第一,手动解析(例如,一个字母一个字母或者一个字一个字地解析),这样做实现起来烦琐,不够健壮并且也慢;第二,使用fmt.Fields或者 fmt.Split函数来将每行切分;第三,使用正则表达式。对于该invoicedata 程序,我们使用第四种方法。无需将每行切分或者使用转换函数,因为我们所需的功能都能够交由fmt包的扫描函数处理。

func (TxtMarshaler) UnmarshalInvoices(reader io.Reader) (*Invoice, error) {

bufferedReader := bufio.NewReader(reader)

if err := checkTxtVersion(bufferedReader); err != nil {

return nil, err

}

var invoices *Invoice

eof := false

for lino := 2; !eof; lino++ {

line, err := bufferedReader.ReadString('/n)

if err == io.EOF{

err = nil     // io.EOF不是一个真正的错误

eof = true    // 下一次迭代的时候会终止循环

} else if err != nil {

return nil, err  // 遇到真正的错误则立即停止

}

if invoices, err = parseTxtLine(lino, line, invoices); err != nil {

return nil, err

}

}

return invoices, nil

}

针对所传入的io.Reader,该方法创建了一个带缓冲的reader,并将其中的每一行轮流传入解析函数中。通常,对于文本文件,我们会对io.EOF进行特殊处理,以便无论它是否以新行结尾其最后一行都能被读取。(当然,对于这种格式,这样做相当自由。)

按照常规,从行号1开始,该文件被逐行读取。第一行用于检查文件是否有个合法的类型和版本号,因此处理实际数据时,行号(lino)从2开始读起。

由于我们逐行工作,并且每一个发票文件都表示成两行甚至多行(一行 INVOICE 行和一行或者多行ITEM行),我们需跟踪当前发票,以便每读一行就可以将其添加到当前发票数据中。这很容易做到,因为所有的发票数据都被追加到一个发票切片中,因此当前发票永远是处于位置invoices[len(invoices)-1]处的发票。

当parseTxtLine函数解析一个INVOICE行时,它会创建一个新的Invoice值,并将一个指向该值的指针追加到invoices切片中。

如果要在一个函数内部往一个切片中追加数据,有两种技术可以使用。第一种技术是传入一个指向切片的指针,然后在所指向的切片中操作。第二种技术是传入切片值,同时返回(可能被修改过的)切片给调用者,以赋值回原始切片。parseTxtLine函数使用第二种技术。(我们在前文已看过一个使用第一种技术的例子。)

func parseTxtLine(lino int, line string, invoices *Invoice) (*Invoice, error) {

var err error

if strings.HasPrefix(line, "INVOICE") {

var invoice *Invoice

invoice, err = parseTxtInvoice(lino, line)

invoices = append(invoices, invoice)

} else if strings.HasPrefix(line, "ITEM") {

if len(invoices) == 0 {

err = fmt.Errorf("item outside of an invoice line %d", lino)

} else {

var item *Item

item, err = parseTxtItem(lino, line)

items := &invoices[len(invoices)-1].Items  ①

*items = append(*items, item)

}

}

return invoices, err

}

该函数接受一个行号(lino,用于错误报告),需被解析的行,以及我们需要填充的发票切片。

如果该行以文本“INVOICE”开头,我们就调用 parseTxtInvoice函数来解析该行并创建一个 Invoice 值,并返回一个指向它的指针。然后,我们将该*Invoice 值追加到invoices切片中,并在最后返回该invoices切片和nil值或者错误值。需注意的是,这里的发票信息是不完整的,我们只有它的ID、客户 ID、创建和持续时间、是否支付以及注释信息,但是没有任何发票项。

如果该行以“ITEM”开头,我们首先检查当前发票是否存在(即invoices切片不为空)。如果存在,我们调用parseTxtItem函数来解析该行并创建一个Item值,然后返回一个指向该值的指针。然后我们将该项添加到当前发票的项中。这可以通过取得指向当前发票项的指针(见标注①)以及将指针的值设置为追加新*Item后的结果来达到。当然,我们本可以使用invoices[len(invoices)-1].Items = append(invoices[len(invoices)-1].Items, item)来直接添加 *Item。

任何其他的行(例如空和换页行)都被忽略。顺便提一下,理论上而言,如果我们优先处理“ITEM”的情况该函数会更快,因为数据中发票项的行数远比发票和空行的行数多。

func parseTxtInvoice(lino int, line string) (invoice *Invoice, err error) {

invoice = &Invoice{}

var raised, due string

if _, err = fmt.Sscanf(line, "INVOICE ID=%d CUSTOMER=%d" +

"RAISED=%s DUE=%s PAID=%t", &invoice.Id, &invoice.CustomerId,

&raised, &due, &invoice.Paid); err != nil {

return nil, fmt.Errorf("invalid invoice %v line %d", err, lino)

}

if invoice.Raised, err = time.Parse(dateFormat, raised); err != nil {

return nil, fmt.Errorf("invalid raised %v line %d", err, lino)

}

if invoice.Due, err = time.Parse(dateFormat, due); err != nil {

return nil, fmt.Errorf("invalid due %v line %d", err, lino)

}

if i := strings.Index(line, noteSep); i > -1 {

invoice.Note = strings.TrimSpace(line[i+len(noteSep):])

}

return invoice, nil

}

函数开始处,我们创建了一个0值的Invoice值,并将指向它的指针赋值给invoice变量(类型为*Invoice)。扫描函数可以处理字符串、数字以及布尔值,但不能处理time.Time值,因此我们将创建以及持续时间以字符串的形式输入,并单独解析它们。表8-2中列出了扫描函数。

表8-2 fmt中的扫描函数

续表

如果fmt.Sscanf函数不能读入与我们所提供的值相同数量的项,或者如果发生了错误(例如,读取错误),函数就会返回一个非空的错误值。

日期使用time.Parse函数来解析,这在之前的节中已有阐述。如果发票行有冒号,则意味着该行末尾处有注释,那么我们就删除其空白符,并将其返回。我们使用了表达式line[i+1:]而非line[i+len(noteSep):],因为我们知道noteSep的冒号字符占用了一个UTF-8字节,但为了更为健壮,我们选择了对任何字符都有效的方法,无论它占用多少字节。

func parseTxtItem(lino int, line string) (item *Item, err error) {

item = &Item{}

if _, err = fmt.Sscanf(line, "ITEM ID=%s PRICE=%f QUANTITY=%d",

&item.Id, &item.Price, &item.Quantity); err != nil {

return nil, fmt.Errorf("invalid item %v line %d", err, lino)

}

if i := strings.Index(line, noteSep); i > -1 {

item.Note = strings.TrimSpace(line[i+len(noteSep):])

}

return item, nil

}

该函数的功能如我们所见过的parseTxtInvoice函数一样,区别在于除了注释文本之外,所有的发票项值都可以直接扫描。

func checkTxtVersion(bufferReader *buffio.Reader) error {

var version int

if _, err := fmt.Fscanf(bufferedReader, "INVOICES %d/n", &version);

err != nil {

return errors.New("cannot read non-invoices text file")

} else if version > fileVersion {

return fmt.Erroff("version %d is too new to read", version)

}

return nil

}

该函数用于读取发票文本文件的第一行数据。它使用 fmt.Fscanf函数来直接读取bufio.Reader。如果该文件不是一个发票文件或者其版本太新而不能处理,就会报告错误。否则,返回nil值。

使用fmt包的打印函数来写文本文件比较容易。解析文本文件却挑战不小,但是Go语言的regexp包中提供了strings.Fields和strings.Split函数,fmt包中提供了扫描函数,使得我们可以很好的解决该问题。

8.1.4 处理Go语言二进制文件

Go语言的二进制(gob)格式是一个自描述的二进制序列。从其内部表示来看,Go语言的二进制格式由一个0块或者更多块的序列组成,其中的每一块都包含一个字节数,一个由0个或者多个 typeId-typeSpecification 对组成的序列,以及一个 typeId-value 对。如果 typeId-value对的typeId是预先定义好的(例如,bool、int和string等),则这些typeId-typeSpecification对可以省略。否则就用类型对来描述一个自定义类型(如一个自定义的结构体)。类型对和值对之间的typeId没有区别。正如我们将看到的,我们无需了解其内部结构就可以使用gob格式,因为encoding/gob包会在幕后为我们打理好一切底层细节[2]。

encoding/gob包也提供了与encoding/json包一样的编码解码功能,并且容易使用。通常而言,如果对肉眼可读性不做要求,gob格式是Go语言上用于文件存储和网络传输最为方便的格式。

8.1.4.1 写Go语言二进制文件

下面有个方法用于将整个*Invoice项的数据以gob的格式写入一个打开的文件(或者是任何满足io.Writer接口的值)中。

type GobMarshaler struct{}

func (GobMarshaler) MarshalInvoices(writer io.Writer, invoices *Invoice) error {

encoder := gob.NewEncoder(writer)

if err := encoder.Encode(magicNumber); err != nil {

return err

}

if err := encoder.Encode(fileVersion); err != nil {

return err

}

return encoder.Encode(invoices)

}

在方法开始处,我们创建了一个包装了io.Writer的gob编码器,它本身是一个writer,让我们可以写数据。

我们使用gob.Encoder.Encode方法来写数据。该方法能够完美地处理我们的发票切片,其中每个发票切片包含它自身的发票项切片。该方法返回一个空或者非空的错误值。如果发生错误,则立即返回给它的调用者。

往文件写入幻数(magic number)和文件版本并不是必需的,但正如将在练习中所看到的那样,这样做可以在后期更方便地改变文件格式。

需注意的是,该方法并不真正关心它编码数据的类型,因此创建类似的函数来写gob数据区别不大。此外,GobMarshaler.MarshalInvoices方法无需任何改变就可以写新数据格式。

由于Invoice结构体的字段都是布尔值、数字、字符串、time.Time值以及包含布尔值、数字、字符串和time.Time值的结构体(如Item),这里的代码可以正常工作。

如果我们的结构体包含某些不可用gob格式编码的字段,那么就必须更改该结构体以便满足gob.GobEncoder和gob.GobDecoder接口。该gob编码器足够智能来检查它需要编码的值是不是一个 gob.GobEncoder,如果是,那么编码器就使用该值自身的GobEncode方法而非编码器内置的编码方法来编码。相同的规则也作用于解码时,检查该值是否定义了GobDecode方法以满足gob.GobDecoder接口。(该invoicedata例子的源代码gob.go文件中包含了相应的代码,将 Invoice 定义成一个编码器和解码器。因为这些代码不是必须的,因此我们将其注释掉,只是为了演示如何做。)让一个结构体满足这些接口会极大地降低gob的读写速度,也会产生更大的文件。

8.1.4.2 读Go语言二进制文件

读 gob 数据和写一样简单,如果我们的目标数据类型与写时相同。GobMarshaler.UnmarshalInvoices方法接受一个 io.Reader(例如,一个打开的文件),并从中读取gob数据。

func (GobMarshaler) UnmarshalInvoices(reader io.Reader)(*Invoice, error) {

decoder := gob.NewDecoder(reader)

var magic int

if err := decoder.Decode(&magic); err != nil {

return nil, err

}

if magic != magicNumber {

return nil, errors.New("cannot read non-invoices gob file")

}

var version int

if err := decoder.Decode(&version); err != nil {

return nil, err

}

if version > fileVersion {

return nil, fmt.Errorf("version %d is too new to read", version)

}

var invoices *Invoice

err := decoder.Decode(&invoices)

return invoices, err

}

我们有3项数据要读:幻数、文件版本号以及所有发票数据。gob.Decoder.Decode方法接受一个指向目标值的指针,返回一个空或者非空的错误值。我们使用头两个变量(幻数和版本号)来确认我们得到的是一个gob格式的发票文件,并且该文件的版本是我们可以处理的。然后,我们读取发票文件,在此过程中,gob.Decoder.Decode方法会根据所读取的发票数据增加invoices切片的大小,并根据需要来将指向函数实时创建的Invoices数据(及其发票项)的指针保存在invoices切片中。最后,该方法返回invoices切片,以及一个空的错误值,或者如果发生问题则返回非空的错误值。

如果发票数据由于添加了导出字段被更改了,针对布尔值、整数、字符串、time.Time值以及包含这些类型值的结构体,该方法还能继续工作。当然,如果数据包含其他类型,那就必须更新方法以满足gob.GobEncoder和gob.GobDecoder接口。

处理结构体类型时,gob 格式非常灵活,能够无缝地处理一些不同的数据结构。例如,如果一个包含某值的结构体被写成gob格式,那么就必然可以从gob格式中将该值读回到此结构体,甚至也读回到许多其他类似的结构体,比如包含指向该值指针的结构体,或者结构体中的值类型兼容也可(比如int相对于uint,或者类似的情况)。同时,正如invoicedata示例所示,gob格式可以处理嵌套的数据(但是,在本书撰写时,它还不能处理递归的值)。gob的文档中给出了它能处理的格式以及该格式的底层存储结构,但如果我们使用相同的类型来进行读写,正如上例中所做的那样,我们就不必关心这些。

8.1.5 处理自定义的二进制文件

虽然Go语言的encoding/gob包非常易用,而且使用时所需代码量也非常少,我们仍有可能需要创建自定义的二进制格式。自定义的二进制格式有可能做到最紧凑的数据表示,并且读写速度可以非常快。不过,在实际使用中,我们发现以Go语言二进制格式的读写通常比自定义格式要快非常多,而且创建的文件也不会大很多。但如果我们必须通过满足 gob.GobEncoder和gob.GobDecoder接口来处理一些不可被gob编码的数据,这些优势就有可能会失去。在有些情况下我们可能需要与一些使用自定义二进制格式的软件交互,因此了解如何处理二进制文件就非常有用。

图8-1给出了.inv自定义二进制格式如何表示一个发票文件的概要。整数值表示成固定大小的无符号整数。布尔值中的true表示成一个int8类型的值1,false表示成0。字符串表示成一个字节数(类型为int32)后跟一个它们的UTF-8编码的字节切片byte。对于日期,我们采取稍微非常规的做法,将一个ISO-8601格式的日期(不含连字符)当成一个数字,并将其表示成int32值。例如,我们将日期2006-01-02表示成数字20 060 102。每一个发票项表示成一个发票项的总数后跟各个发票项。(回想一下,发票项ID是字符串而非整数,这与发票ID不同,参见8.1节。)

图8-1.inv自定义二进制格式

8.1.5.1 写自定义二进制文件

encoding/binary包中的binary.Write函数使得以二进制格式写数据非常简单。

type InvMarshaler struct{}

var byteOrder = binary.LittleEndian

func (InvMarshaler) MarshalInvoices(writer io.Writer, invoices *Invoice) error {

var write invWriterFunc = func(x interface{}) error {

return binary.Write(writer, byteOrder, x)

}

if err := write(uint32(magicNumber)); err != nil {

return err

}

if err := write(uint16(fileVersion)); err != nil {

return err

}

if err := write(int32(len(invoices))); err != nil {

return err

}

for _, invoice := range invoices {

if err := write.writeInvoice(invoice); err != nil {

return err

}

}

return nil

}

该方法将所有发票项写入给定的io.Writer 中。它开始时创建了一个便捷的write函数,该函数能够捕获我们要使用的io.Writer和字节序。正如处理.txt格式所做的那样,我们将write函数定义为一个特定的类型(invWriterFunc),并且为该write函数创建了一些方法(例如invWriterFunc.WriteInvoices),以便后续使用。

需注意的是,读和写二进制数据时其字节序必须一致。(我们不能将byteOrder定义为一个常量,因为binary.LittleEndian或者binary.BigEndian不是像字符串或者整数这样的简单值。)

这里,写数据的方式与我们之前在看到写其他格式数据的方式类似。一个非常重要的不同在于,将幻数和文件版本写入后,我们写入了一个表示发票数量的数字。(也可以跳过而不写该数字,而只是简单地将发票写入。然后,读数据的时候,持续地依次读入发票直到遇到io.EOF。)

type invWriterFunc func(interface{}) error

func (write invWriterFunc) writeInvoice(invoice *Invoice) error {

for _, i := range int{invoice.Id, invoice.CustomerId} {

if err := write(int32(i)); err != nil {

return err

}

}

for _, date := range time.Time{invoice.Raised, invoice.Due} {

if err := write.writeDate(date); err != nil {

return err

}

}

if err := write.writeBool(invoice.Paid); err != nil {

return err

}

if err := write.writeString(invoice.Note); err != nil {

return err

}

if err := write(int32(len(invoice.Items))); err != nil {

return err

}

for _, item := range invoice.Items {

if err := write.writeItem(item); err != nil {

return err

}

}

return nil

}

对于每一个发票数据,writeInvoice方法都会被调用一遍。它接受一个指向被写发票数据的指针,并使用作为接收器的write函数来写数据。

该方法开始处以int32写入了发票ID及客户ID。当然,以纯int型写入数据是合法的,但底层机器以及所使用的Go语言版本的改变都可能导致int的大小改变,因此写入时非常重要的一点是确定整型的符号和大小,如 uintf32和int32 等。接下来,我们使用自定义的writeDate方法写入创建和过期时间,然后写入表示是否支付的布尔值和注释字符串。最后,我们写入了一个代表发票中有多少发票项的数字,随后再使用writeItem方法写入发票项。

const invDateFormat = "20060102" // 必须总是使用该日期值

func (write invWriterFunc) writeDate(date time.Time) error {

i, err := strconv.Atoi(date.Format(invDateFormat))

if err != nil {

return err

}

return write(int32(i))

}

前文中我们讨论了time.Time.Format函数以及为何必须在格式字符串中使用特定的日期2006-01-02。这里,我们使用了类ISO-8601格式,并去除连字符以便得到一个八个数字的字符串,其中如果月份和天数为单一数字则在其前面加上 0。然后,将该字符串转换成数字。例如,如果日期是 2012-08-05,则将其转换成一个等价的数字,即 20120805,然后以 int32的形式将该数字写入。

值得一提的是,如果我们想存储日期/时间值而非仅仅是日期值,或者只想得到一个更快的计算,我们可以将对该方法的调用替换成调用write(int64(date.Unix)),以存储一个Unix新纪元以来的秒数。相应的读取数据的方法则类似于var d int64;if err:=binary.Read(reader, byteOrder, &d); err != nil { return err }; date := time.Unix(d, 0)。

func (write invWriterFunc) writeBool(b bool) error {

var v int8

if b {

v = 1

}

return write(v)

}

本书撰写时,encoding/binary包还不支持读写布尔值,因此我们创建了该简单方法来处理它们。顺便提一下,我们不必使用类型转换(如int8(v)),因为变量v已经是一个有符号并且固定大小的类型了。

func (write invWriterFunc) writeString(s string) error {

if err := write(int32(len(s))); err != nil {

return err

}

return write(byte(s))

}

字符串必须以它们底层的UTF-8编码字节的形式来写入。这里,我们首先写入了所需写入的字节总数,然后再写入所有字节。(如果数据是固定宽度的,就不需要写入字节数。当然,前提是,读取数据时,我们创建了一个存储与写入的数据大小相同的空切片byte。)

func (write invWriterFunc) writeItem(item *Item) error {

if err := write.writeString(item.Id); err != nil {

return err

}

if err := write(item.Price); err != nil {

return err

}

if err := write(int16(item.Quantity)); err != nil {

return err

}

return write.writeString(item.Note)

}

该方法用于写入一个发票项。对于字符串 ID和注释文本,我们使用 invWriterFunc.writeString方法,对于物品数量,我们使用无符号的大小固定的整数。但是对于价格,我们就以它原始的形式写入,因为它本来就是个固定大小的类型(float64)。

往文件中写入二进制数据并不难,只要我们小心地将可变长度数据的大小在数据本身前面写入,以便读数据时知道该读多少。当然,使用gob格式非常方便,但是使用一个自定义的二进制格式所产生的文件更小。

8.1.5.2 读自定义二进制文件

读取自定义的二进制数据与写自定义二进制数据一样简单。我们无需解析这类数据,只需使用与写数据时相同的字节顺序将数据读进相同类型的值中。

func (InvMarshaler) UnmarshalInvoices(reader io.Reader) (*Invoice, error){

if err := checkInvVersion(reader); err != nil {

return nil, err

}

count, err := readIntFromInt32(reader)

if err != nil {

return nil, err

}

invoices := make(*Invoice, 0, count)

for i := 0; i < count; i++ {

invoice, err := readInvInvoice(reader)

if err != nil {

return nil, err

}

invoices = append(invoices, invoice)

}

return invoices, nil

}

该方法首先检查所给定版本的发票文件能否被处理,然后使用自定义的readIntFromInt32函数从文件中读取所需处理的发票数量。我们将 invoices 切片的长度设为 0(即当前还没有发票),但其容量正好是我们所需要的。然后,轮流读取每一个发票并将其存储在invoices切片中。

另一种可选的方法是使用make(*Invoice, count)代替make,使用invoices[i]= invoice 代替 append。不管怎样,我们倾向于使用所需的容量来创建切片,因为与实时增长切片相比,这样做更有潜在的性能优势。毕竟,如果我们再往一个其长度与容量相等的切片中追加数据,切片会在背后创建一个新的容量更大的切片,并将起原始切片数据复制至新切片中。然而,如果其容量一开始就足够,后面就没必要进行复制。

func checkInvVersion(reader io.Reader) error {

var magic uint32

if err := binary.Read(reader, byteOrder, &magic); err != nil {

return err

}

if magic != magicNumber {

return errors.New("cannot read non-invoices inv file")

}

var version uint16

if err := binary.Read(reader, byteOrder, &version); err != nil {

return err

}

if version > fileVerson {

return fmt.Errorf("version %d is too new to read", version)

}

return nil

}

该函数试图从文件中读取其幻数及版本号。如果该文件格式可接受,则返回nil;否则返回非空错误值。

其中的binary.Read函数与 binary.Write函数相对应,它接受一个从中读取数据的io.Reader、一个字节序以及一个指向特定类型的用于保存所读数据的指针。

func readIntFromInt32(reader io.Reader) (int, error) {

var i32 int32

err := binary.Read(reader, byteOrder, &i32)

return int(i32), err

}

该辅助函数用于从二进制文件中读取一个int32值,并以int类型返回。

func readInvInvoice(reader io.Reader) (invoice *Invoice, err error) {

invoice = &Invoice{}

for _, pId := range *int{&invoice.Id, &invoice.CustomerId} {

if *pId, err = readIntFromInt32(reader); err != nil {

return nil, err

}

}

for _, pDate := range *time.Time{&invoice.Raised, &invoice.Due} {

if *pDate, err = readInvDate(reader); err != nil {

return nil, err

}

}

if invoice.Paid, err = readBoolFromInt8(reader); err != nil {

return nil, err

}

if invoice.Note, err = readInvString(reader); err != nil {

return nil, err

}

var count int

if count, err = readIntFromInt32(reader); err != nil {

return nil, err

}

invoice.Items, err = readInvItems(reader, count)

return invoice, err

}

每次读取发票文件的时候,该函数都会被调用。函数开始处创建了一个初始化为零值的Invoice值,并将指向它的指针保存在invoice变量中。

发票ID和客户ID使用自定义的readIntFromInt32函数读取。这段代码的微妙之处在于,我们迭代那些指向发票ID和客户ID的指针,并将返回的整数赋值给指针(pId)所指的值。

一个可选的方案是单独处理每一个 ID。例如,if invoice.Id, err =readIntFromInt32(reader); err != nil { return err}等。

读取创建及过期日期的流程与读取 ID的流程完全一样,只是这次我们使用的是自定义的readInvDate函数。

正如读取ID一样,我们也可以以更加简单的方式单独处理日期。例如,if invoice.Due, err = readInvDate(reader); err != nil { return err}等。

稍后将看到,我们使用一些辅助函数读取是否支付的标志和注释文本。发票数据读完之后,我们再读取有多少个发票项,然后调用 readInvItems函数读取全部发票项,传递给该函数一个用于读取的io.Reader值和一个表示需要读多少项的数字。

func readInvDate(reader io.Reader) (time.Time, error) {

var n int32

if err := binary.Read(reader, byteOrder, &n); err != nil {

return time.Time{}, err

}

return time.Parse(invDateFormat, fmt.Sprint(n))

}

该函数用于读取表示日期的int32值(如20130501),并将该数字解析成字符串表示的日期值,然后返回对应的time.Time值(如2013-05-01)。

func readBoolFromInt8(reader io.Reader) (bool, error) {

var i8 int8

err := binary.Read(reader, byteOrder, &i8)

return i8 == 1, err

}

该简单的辅助函数读取一个int8数字,如果该数字为1则返回true,否则返回false。

func readInvString(reader io.Reader) (string, error) {

var length int32

if err := binary.Read(reader, byteOrder, &length); err != nil {

return "", nil

}

raw := make(byte, length)

if err := binary.Read(reader, byteOrder, &raw); err != nil {

return "", err

}

return string(raw), nil

}

该函数读取一个byte 切片,但它的原理适用于任何类型的切片,只要写入切片之前写明了切片中包含多少项元素。

函数首先将切片项的个数读到一个length变量中。然后创建一个长度与此相同的切片。给binary.Read函数传入一个指向切片的指针之后,它就会往该切片中尽可能地读入该类型的项(如果失败则返回一个非空的错误值)。需注意的是,这里重要的是切片的长度,而非其容量(其容量可能等于或者大于长度)。

在本例中,该byte切片保存了UTF-8编码的字节,我们将其转换成字符串后将其返回。

func readInvItems(reader io.Reader, count int) (*Item, error) {

items := make(*Item, 0, count)

for i := 0; i < count; i++ {

item, err := readInvItem(reader)

if err != nil {

return nil, err

}

items = append(items, item)

}

return items, nil

}

该函数读入发票的所有发票项。由于传入了一个计数值,因此它知道应该读入多少项。

func readInvItem(reader io.Reader) (item *Item, err error) {

item = &Item{}

if item.Id, err = readInvString(reader); err != nil {

return nil, err

}

if err = binary.Read(reader, byteOrder, &item.Price); err != nil {

return nil, err

}

if item.Quantity, err = readIntFromInt16(reader); err != nil {

return nil, err

}

item.Note, err = readInvString(reader)

return item, nil

}

该函数读取单个发票项。从结构上看,它与 readInvInvoice函数类似,首先创建一个初始化为零值的Item值,并将指向它的指针存储在变量item中,然后填充该item变量的字段。价格可以直接读入,因为它是以float64类型写入文件的,是一个固定大小的类型。Item.Price 字段的类型也一样。(我们省略了 readIntFromInt16函数,因为它与我们前文所描述的readIntFromInt32函数基本相同。)

至此,我们完成了对自定义二进制数据的读和写。只要小心选择表示长度的整数符号和大小,并将该长度值写在变长值(如切片)的内容之前,那么使用二进制数据进行工作并不难。

Go语言对二进制文件的支持还包括随机访问。这种情况下,我们必须使用os.OpenFile函数来打开文件(而非os.Open),并给它传入合理的权限标志和模式(例如,os.O_RDWR表示可读写)参数[3]。然后,就可以使用os.File.Seek方法来在文件中定位并读写,或者使用os.File.ReadAt和os.File.WriteAt方法来从特定的字节偏移中读取或者写入数据。Go语言还提供了其他常用的方法,包括os.File.Stat方法,它返回的os.FileInfo包含了文件大小、权限以及日期时间等细节信息。

8.2 归档文件

Go语言的标准库提供了对几种压缩格式的支持,其中包括gzip,因此Go程序可以无缝地读写.gz 扩展名的gzip 压缩文件或非.gz 扩展名的非压缩文件。此外,标准库也提供了读和写.zip文件、tar包文件(.tar和.tar.gz),以及读.bz2文件(即.tar.bz2文件)的功能。

本节中我们会看一些从两个程序中抽出的代码。第一个是pack程序(在文件pack/pack.go中),它从命令行接受一个归档文件的文件名和需打包的文件列表。它通过检测归档文件的扩展名来判断该使用何种打包格式。第二个是unpack 程序(在文件unpack/unpack.go 中),也从命令行接受一个归档文件的文件名,并从中提取所有打包的文件,如有必要则在提取过程中重建目录结构。

8.2.1 创建zip归档文件

要使用 zip 包来压缩文件,我们首先必须打开一个用于写的文件,然后创建一个*zip.Writer值来往其中写入数据。然后,对于每一个我们希望加入.zip归档文件的文件,我们必须读取该文件并将其内容写入 *zip.Writer中。该pack程序使用了createZip和writeFileToZip两个函数以这种方式来创建一个.zip文件。

func createZip(filename string, files string) error {

file, err := os.Create(filename)

if err != nil {

return err

}

defer file.Close

zipper := zip.NewWriter(file)

defer zipper.Close

for _, name := range files {

if err := writeFileToZip(zipper, name); err != nil {

return err

}

}

return nil

}

该 createZip函数和writeFileToZip函数都比较简短,因此容易让人觉得应该写入一个函数中。这是不明智的,因为在该 for 循环中我们可能打开一个又一个的文件(即files 切片中的所有文件),从而可能超出操作系统允许的文件打开数上限。这点我们在前面章节中已有简短的阐述。当然,我们可以在每次迭代中调用os.File.Close,而非延迟执行它,但这样做还必须保证程序无论是否出错文件都必须关闭。因此,最为简便而干净的解决方案是,像这里所做的那样,总是创建一个独立的函数来处理每个独立的文件。

func writeFileToZip(zipper *zip.Writer, filename string) error {

file, err := os.Open(filename)

if err != nil {

return err

}

defer file.Close

info, err := file.Stat

if err != nil {

return err

}

header, err := zip.FileInfoHeader(info)

if err != nil {

return err

}

header.name = sanitizedName(filename)

writer, err := zipper.CreateHeader(header)

if err != nil {

return err

}

_, err = io.Copy(writer, file)

return err

}

首先我们打开需要归档的文件以供读取,然后延迟关闭它。这是我们处理文件的老套路了。

接下来,我们调用os.File.Stat方法来取得包含时间戳和权限标志的os.FileInfo值。然后,我们将该值传给 zip.FileInfoHeader函数,该函数返回一个zip.FileHeader 值,其中保存了时间戳、权限以及文件名。在压缩文件中,我们无需使用与原始文件名一样的文件名,因此这里我们使用净化过的文件名来覆盖原始文件名(保存在zip.FileHeader.Name字段中)。

头部设置好之后,我们将其作为参数调用zip.CreateHeader函数。这会在.zip压缩文件中创建一个项,其中包含头部的时间戳、权限以及文件名,并返回一个 io.Writer,我们可以往其中写入需要被压缩的文件的内容。为此,我们使用了 io.Copy函数,它能够返回所复制的字节数(我们已将其丢弃),以及一个为空或者非空的错误值。

如果在任何时候发生错误,该函数就会立即返回并由调用者处理错误。如果最终没有错误发生,那么该.zip压缩文件就会包含该给定文件。

func sanitizedName(filename string) string{

if len(filename) > 1 && filename[1] == ':' &&

runtime.GOOS == "windows" {

filename = filename[2:]

}

filename = filepath.ToSlash(filename)

filename = strings.TrimLeft(filename, "/.")

return strings.Replace(filename, "../", "", -1)

}

如果一个归档文件中包含的文件带有绝对路径或者含有“..”路径组件,我们就有可能在解开归档的时候意外覆盖本地重要文件。为了降低这种风险,我们对保存在归档文件里每个文件的文件名都做了相应的净化。

该 sanitizedName函数会删除路径头部的盘符以及冒号(如果有的话),然后删除头部任何目录分隔符、点号以及任何“..”路径组件,并将文件分隔符强制转换成正向斜线。

8.2.2 创建可压缩的tar包

创建tar归档文件与创建.zip归档文件非常类似,主要不同点在于我们将所有数据都写入相同的writer 中,并且在写入文件的数据之前必须写入完整的头部,而非仅仅是一个文件名。我们在该pack程序的实现中使用了createTar和writeFileToTar函数。

func createTar(filename string, files string) error {

file, err := os.Create(filename)

if err != nil {

return err

}

defer file.Close

var fileWriter io.WriterCloser = file

if strings.HasSuffix(filename, ".gz") {

fileWriter = gzip.NewWriter(file)

defer fileWriter.Close

}

writer := tar.NewWriter(fileWriter)

defer writer.Close

for _, name := range files {

if err := writeFileToTar(writer, name); err != nil {

return err

}

}

return nil

}

该函数创建了包文件,而且如果扩展名显示该 tar 包需要被压缩则添加一个 gzip 过滤。gzip.NewWriter函数返回一个*gzip.Writer值,它满足io.WriteCloser接口(正如打开的*os.File一样)。

一旦文件准备好写入,我们创建一个*tar.Writer 往其中写入数据。然后迭代所有文件并将每一个写入归档文件。

func writeFileToTar(writer *tar.Writer, filename string) error {

file, err := os.Open(filename)

if err != nil {

return err

}

defer file.Close

stat, err := file.Stat

if err != nil {

return err

}

header := &tar.Header{

Name:   sanitizedName(filename),

Mode:   int64(stat.Mode),

Uid:   os.Getuid,

Gid:   os.Getuid,

Size:   stat.Size,

ModTime: stat.ModTime,

}

if err = writer.WriteHeader(header); err != nil {

return err

}

_, err = io.Copy(writer, file)

return err

}

函数首先打开需要处理的文件并设置延迟关闭。然后调用Stat方法取得文件的模式、大小以及修改日期/时间。这些信息用于填充*tar.Header,每个文件都必须创建一个tar.Header结构并写入到tar归档文件里,(此外,我们设置了头部的用户以及组ID,这会在类Unix系统中用到。)我们必须至少设置头部的文件名(其Name字段)以及表示文件大小的Size字段,否则这个.tar包就是非法的。

当*tar.Header结构体创建好后,我们将它写入归档文件,再接着写入文件的内容。

8.2.3 解开zip归档文件

解开一个.zip归档文件与创建一个归档文件一样简单,只是如果归档文件中包含带有路径的文件名,就必须重建目录结构。

func unpackZip(filename string) error {

reader, err := zip.OpenReader(filename)

if err != nil {

return err

}

defer reader.Close

for _, zipFile := range reader.Reader.File {

name := sanitizedName(zipFile.Name)

mode := zipFile.Mode

if mode.IsDir {

if err = os.MkdirAll(name, 0755); err != nil {

return err

}

} else {

if err = unpackZippedFile(name, zipFile); err != nil {

return err

}

}

}

return nil

}

该函数打开给定的.zip文件用于读取。这里没有使用os.Open函数来打开文件后调用zip.NewReader,而是使用zip包提供的zip.OpenReader函数,它可以方便地打开并返回一个*zip.ReadCloser 值让我们使用。zip.ReadCloser 最为重要的一点是它包含了导出的zip.Reader 结构体字段,其中包含一个包含指向 zip.File 结构体指针的*zip.File切片,其中的每一项表示.zip压缩文件中的一个文件。

我们迭代访问该reader的zip.File结构体,并创建一个净化过的文件及目录名(使用我们在pack程序中用到的sanitizedName函数),以降低覆盖重要文件的风险。

如果遇到一个目录(由*zip.File的os.FileMode的IsDir方法报告),我们就创建一个目录。os.MkdirAll函数传入了有用的属性信息,会自动创建必要的中间目录以创建特定的目标目录,如果目录已经存在则会安全地返回 nil 而不执行任何操作。[4]如果遇到的是一个文件,则交由自定义的unpackZippedFile函数进行解压。

func unpackZippedFile(filename string, zipFile *zipFile) error {

writer, err := os.Create(filename)

if err != nil {

return err

}

defer writer.Close

reader, err := zipFile.Open

if err != nil {

return err

}

defer reader.Close

if _, err = io.Copy(writer, reader); err != nil {

return err

}

if filename == zipFile.Name {

fm.Println(filename)

} else {

fmt.Printf("%s [%s]/n", filename, zipFile.Name)

}

return nil

}

unpackZippedFile函数的作用就是将.zip 归档文件里的单个文件抽取出来,写到filename指定的文件里去。首先它创建所需要的文件,然后,使用zip.File.Open函数打开指定的归档文件,并将数据复制到新创建的文件里去。

最后,如果没有错误发生,该函数会往终端打印所创建文件的文件名,如果处理后的文件名与原始文件名不一样,则将原始文件名包含在方括号中。

值得注意的是,该*zip.File类型也有一些其他的方法,如zip.File.Mode(在前面的unpackZip函数中已有使用),zip.File.ModTime(以time.Time值返回文件的修改时间)以及返回文件的os.FileInfo值的zip.FileInfo。

8.2.4 解开tar归档文件

解开tar归档文件比创建tar归档文档稍微简单些。然而,跟解开.zip文件一样,如果归档文件中的某些文件名包含路径,必须重建目录结构。

func unpackTar(filename string) error {

file, err := os.Open(filename)

if err != nil {

return err

}

defer file.Close

var fileReader io.ReadCloser = file

if strings.HasSuffix(filename, ".gz") {

if fileReader, err = gzip.NewReader(file); err != nil {

return err

}

defer fileReader.Close

}

reader := tar.NewReader(fileReader)

return unpackTarFiles(reader)

}

该方法首先按照Go语言的常规方式打开归档文件,并延迟关闭它。如果该文件使用了gzip压缩则创建一个 gzip 解压缩过滤器并延迟关闭它。gzip.NewReader函数返回一个*gzip.Reader 值,正如打开一个常规文件(类型为*os.File)一样,它也满足io.ReadCloser接口。

设置好了文件reader之后,我们创建一个*tar.Reader来从中读取数据,并将接下来的工作交给一个辅助函数。