Custom Data Files

程序中经常需要保持内部的数据结构,并且提供导入和导出的功能以支持数据交换,也可以通过处部工具,以方便对数据进行处理。由于我们在这里关注的是文件的处理,所以我们重点将是如何从标准文件或自定义格式中读写数据到程序的内部数据结构。

在这一节中,我们例子都将使用相同的数据,所以我们可以在不同的文件中获得直观的比较。所有的代码摘自invoicedata程序(invoicedata目录下的invoicedata.go, gob.go,inv.go,jsn.go,txt.go,xml.go).这个程序接收两个文件名作为命令行参数,一个用于读,一个用于写(两个名字必须不同)。然后程序从第一个文件(根据文件后缀识别文件类型)中读取数据。然后将相同的数据写入到第二个文件中.

通过invoicedata创建的文件可以用于跨平台,即在Windows上创建的文件,可以在Mac OS X和Linux上读取,反之亦然,而不管它是什么格式。也可以读写Gzip程序压缩的文件(e.g., invoices.gob.gz);压缩的技术将在下一节中讲到。

所有的数组将成一个[]*Invoice,即指向到Invoice类型值的slice. 它的每一项保存一个Invoice的值,每一个invoice由零个或者多个item(钱用在什么地方),Items字段是一个[]*Item.

type Invoice struct {
    Id int
    CustomerId int
    Raised time.Time
    Due time.Time
    Paid bool
    Note string
    Items []*Item
}

type Item struct {
    Id string
    Price float64
    Quantity int
    Note string
}

这两个struct用于保存数据。表8.1显示了读和写50 000随机的发票,不同文件格式所花的时间以及文件的大小。时间以秒为单位,四舍五入到小数点1位。Size以字节为单位。对于这个数据集,压缩后的大小惊人的相似,即使在缩前的文件大小相差很大。代码数量(Read/Write LOC)排除了所有格式都有的代码(e.g.,用于压缩和解压缩的代码,以及上面两个structs的代码).

时间和文件大小跟我们期待的是一样的——除了读取纯文件格式的速度超出我们想向外。这是因为fmt package中的print和scan函数的优秀设计,使得我们可以简单的解析自定义文本格式。对于JSON和XML格式,我们没有使用默认的time.Time的值(ISO-8601 date/time string), 我们只是简单的存储了date部分,这稍微可以减小文件的大小,但牺牲了处理程序,也增加了一些额外的代码。如果我们使用默认的time.Time值,那么JSON的代码将会运行的更快,同时代码量将跟Go Binary一样。

对于二进制数据,Go binary format是最方便使用的——它非常快的处理速度,常紧凑,需要非常少的代码,并且是相对容易的适应数据的变化。可是,如果我们使用自定义类型,而自定义类型又不是gob最初可编码的类型,我们让这个自定义类型实现gob.Encoder, gob.Decoder接口,这会降低读写gob格式速度,同时也增加了目标文件的大小。

用户可读性最好的格式是XML, 同时在数据交换时非常有用。处理XML格式要比处理JSON格式复杂,需要更多行的代码。这是因为Go 1没有xml.Marshaler 接口,同时也因为我们并行数据类型(XMLInvoice和XMLItem), 帮且我们将XML数据映射为invoice数据(Invoice和Item). 其它应用程序可以不需要像invoicedata程序这样,使用到并行的数据类型和转换,所以可以运行的更快,也不需要这么多的代码。

除了读写速度,文件大小,代码的行数,还有一个问题,我们需要考虑:格式的健壮性。比如,如果我们给Invoice struct,Item struct中分别添加一个字段,于是我们就必须修改我们的文件格式。我们如何容易的调整我们的代码,以便读写新的格式——同时也可以继续读写旧的格式?只要我们注意我们文件的格式版本,这些变化都都很容易满足(在本章中有一个练习,将会讲解到)——有一个例外,对于JSON格式,为了调整它适应旧的和新的格式,需要一些技巧。

除了Invoice和Item struct, 所有的文件格式共享以下这些常量

const (
    fileType = "INVOICES" // Used by text formats
    magicNumber = 0x125D // Used by binary formats
    fileVersion = 100 // Used by all formats
    dateFormat = "2006-01-02" // This date must always be used
)

magicNumber用来唯一标识invoice文件。fileVersion用来表明invoice文件版本——它得之后容易修改程序,以适应数据的变化。dateFormat向我们展示了如何将日期格式化为人类可读的格式。

我们同样创建了几个接口

type InvoicesMarshaler interface {
    MarshalInvoices(writer io.Writer, invoices []*Invoice) error
}
type InvoicesUnmarshaler interface {
    UnmarshalInvoices(reader io.Reader) ([]*Invoice, error)
}

这些接口的作用是,使我们可以以通用的方法,读写所有的的格式。经如,invoicedata程序中有一个函数,用于从一个打开的文件中读取invoice.

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 decompressor(类型为*gzip.Reader), 或者是一个string.Reader. suffix参数是一个字符串,表示文件的后缀(去除掉.gz后缀后的名字)。 GobMarshaler, InvMarshaler等等,都是自定义类型,它们提供了MarshalInvoices()和UnmarshalInvoice()方法,这些类型都将在下一章节中讲到。

JSON文件的处理

JSON是一种轻量的数据交换格式,也便于人类读写,同时机器也很容易解析和生成。JSON是一个纯文本的格式,使用UTF-8编码。JSON格式越来越受欢迎——特别是对于在网格连接上传递数据——因为相对于XML, 它写起来更加方便,简洁,同时在解析的时候需要更少的处理。

以下是单个invoice的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",
            ...
        }
    ]
}

正常情况下,encoding/json package写的JSON数据不会有非必要的空格,这里我们只是为了更好的理解数据的结构。虽然encoding/json package 支持tiime.Times,但我们还是选择实现我们自己的自定义方法MarshalJSON()和UnmarshalJSON()来处理raised 和 due dates. 这允许我们存储短的日期字符串(我们的例子中,时间部分都是0),比如"2012-09-06", 而不是整个的date/times, "2012-09-06T00:00:00Z".

Writing JSON Files

我们首先创建了一个空struct的JSONMarshaler类型,并且给这个类型定义MarshalInvoices()和UnmarshalInvoices().

type JSONMarshaler struct{}

这个类型实现了通用的InvoicesMarshaler和InvoicesUnmarshaler接口。

MarshalInvoices方法将整个[]*Invoices中的元素写入到io.Writer. 这个writer可以是通过os.Create()函数创建的*os.File类型,或者是通过gzip.NewWriter()函数返回的*gzip.Writer,或者其它任何实现了io.Writer接口的类型。

func (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类型没有任何数据,所以我们不需要向它的接收者receiver赋值。

在这个方向开始处,我们创建一个JSON encoder,它包装了io.Writer. 这样我们就可以通过这个encoder向io.Writer中写入JSON编码的数据。

我们通过json.Encoder.Encode()方法将数据写入到文件中。这个方法可以将invoices中的元素,以及每个发票的项(items)完全的复制到文件中。然后返回一个error或者nil. 如果写入失败,我们立即返回到这个函数的调用者。

写入文件的类型和文件的版本不是必要的,但本章后面中的练习所演示的那样,是为了让我们可以轻易的改变文件的格式。(e.g.,可以向Invoice和Item struct中添加字段),然后可以读取新的和旧的两种格式。

注意,这个函数实际上并不关心它编码的数据类型,所以不用创建一个相似的函数,来写其它可JSON编码的数据,而且,在写新的数据格式时,JSONMarshaler.MarshalInvoices()方法不需要任何修改,只要新格式中的字段是导出的,必且能够编码就行。

到目前为此所示的代码,可以完整的写出JSON数据,但为了更好的控制JSON的输出——特别是对time.Time的值格式化——我们提供为Invoice类型,这个类型通过MarshalJSON()方法,实现json.Marshaler接口。json.Encode()函数会对一个值检查它是否实现了json.Marshaler接口。如果是,这个函数使用这个值的MarshalJSON()方法,而不是使用它内置的编码。

type JSONInvoice struct {
    Id int
    CustomerId int
    Raised string // time.Time in Invoice struct
    Due string // time.Time in Invoice struct
    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编码后的的值。这个函数的第一条语句,只是简单的将invoice中的字段复制到JSONInvoice struct, 同时将它两个time.Time转化为字符串。由于JSONInvoice struct的字段都是Booleans, numbers,或者是string类型,所以这个struct可以使用json.Marshal()进行编码。

为了将date/times(i.e.,time.Time类型的值)作为字符串写入,我们必须使用time.Time.Format()方法,这个方法接受一个格式字符串,来指示date/time如何输出。这个格式字符串,不能是一般的字符串,它必须能够用一个字符串表示Unix时间,比如Unix时间为1136243045, 这个时间完整的字符串表示是2006-01-02T14:04:05Z07:00, 或者是这个字符格式的子集,如我们这里指定的"2006-01-02".

如果我们想要创建自定义的date/time格式,必须依据Go的date/time来写。比如,如果我们想要按weekday, month, day, year来输出日期,我们必须使用"Mon, Jan 02, 2006"或者"Mon, Jan _2, 2006"(如果我们不需要前导0). time package的文档有完成的细节——列出了所有已预定义的格式字符串。

Reading JSON Files

读取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(&kind); 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.Errorf("version %d is too new to read", version)
    }
    var invoices []*Invoice
    err := decoder.Decode(&invoices)
    return invoices, err
}

我们在这里需要读取三个数据:文件类型,文件的版本,和整个的 invoices数据。json.Decoder.Decode()方法,接收一个指针,并将解码后的JSON数据填充到这个指针指向的值,同时返回error或者nil. 前两个变量(kind 和 version)用来检查这个文件的类型和版本是否是我们可以处理的。然后我们在读取invoices. 在这个过程中,json.Decoder.Decode()方法将增加invoices slice的大小,以适合它读取到的数据。并且将读取到的数据填充到invoices slice. 在最后,这个方法返回invoices和nil.或者是nil和error(读取时发生错误)。

如果我们依赖于json package包中的内置函数,并且让raised date and due date以默认的方式编码(完成的时间格式), 我们完成可以使用这段代码来解析invoice文件,但是,由于我们选择了自已自定义的方法来编码raised date 和 due date. 所以我们必须提代自定义的方法来解码。

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 struct, 并且依赖于内置的json.Unmarshal()函数。

json.Decoder.Decode()方法可以足够聪明的检查一个它要解码的值是否符合json.Unmarshaler接口,如果是,它将使用这个值自已的UnmarshalJSON()方法。

如果invoics数据增加了额外的导出字段,这个方法同样可以工作。 而且如果不能接收新的字段为零值,我们可以在读取原始格式化,必须对数据进行处理,给新添加的字段赋于想要的值(在本章后面的练习中就涉及到新添加字段和这种后处理)。

顺便提一下,还有一种跟JSON类似的BSON(Binaay JSON), 它比JSON更加简洁,并且更快的读写。Go语言也提供了对BSON的支持(gobson)在godashboard.appspot.com/project(第三方包在第九章中讲解)

处理XML文件

xml格式被广泛用于数据交换。XML相对于JSON来说更加复杂和精细,但也更加繁琐。

encoding/xml package 跟encoding/json一样,可以编码和解码struct. 但XML编码和解码的功能相对于JSON更加严格。部分原因是encoing/xml package要求struct的字段拥有相匹配的格式标签。所以, Go 1的encoding/xml包中,没有xml.Marshaler接口,所以对对于JSON和Go binary formats, 我们必须写更多的代码来处理XML.

以下是单个的invoice 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>

对于这段标签tags中原始字符数据(e.g., 比如invoices和item中的Note字段), 使用xml package的encoder的decoder处理起来非常麻烦,所以在这个invoicedata例子中,明确的使用了<NOTE>标签。

写入XML文件

encoding/xml package要求我们使用的struct,它的字段必须有encoding/xml 包中指定的标签tags. 因此我们不能直接使用Invoice和Item struct. 所以我们创建了一个XMLInvoices,XMLInvoice和XMLItems struct来解决这个问题。由于invoicedata程序要求我们的struct是并行的集合,所以在他们之间必须可以转换。在实际的应用程序中使用XML,我们可以只使用一个struct(或者一个struct集合), 然后可以直接给这个struct字段添加encoding/xml package tags。

以下是XMLInvoices struct的声明,用来保存整个数据集。

type XMLInvoices struct {
    XMLName xml.Name `xml:"INVOICES"`
    Version int `xml:"version,attr"`
    Invoice []*XMLInvoice `xml:"INVOICE"`
}

在Go语言中,struct tag没有任何含义——它们只是Go reflection 接口要用到的字符串。