Go语言纯文本文件的读写操作 图片看不了?点击切换HTTP 返回上层
对于纯文本文件,我们必须创建自定义的格式,理想的格式应该易于解析和扩展。
下面是某单个发票以自定义纯文本格式存储的数据。
与以
我们本可以将 write() 函数传给辅助函数的,例如,writeinvoice(write, invoice)。但不同于此做法的是,我们往前更进了一步,将该方法添加到 writerFunc 类型中。
这是通过声明接受一个 writerFunc 值作为其接收器的方法(即函数)来达到,跟定义任何其他类型一样。这样就允许我们以 write.writeinvoice(invoice) 这样的形式调用,也就是说,在 write() 函数自身上调用方法。并且,由于这些方法接受 write() 函数作为它们的接收器,我们就可以使用 write() 函数。
需注意的是,我们必须显式地声明 write() 函数的类型 (writerFunc)。如果不这样做,Go语言就会将其类型定义为
有了方便的 write() 函数(及其方法),我们就可以开始写入文件类型和文件版本(后者使得容易适应数据的改变)。然后,我们迭代每一个发票项,针对每一次迭代,我们调用 write() 函数的 writeinvoice() 方法。
发票数据一次性就可以写入,如果给出了注释文本,我们就在其前面加入冒号以及空格来将其写入。对于日期/时间(即 time.Time值),我们使用 time.Time.Format() 方法,跟我们以 JSON 和 XML 格式写入数据时一样。而对于布尔值,我们使用 %t 格式指令,也可以使用 %v 格式指令或 strconv.FormatBol() 函数。
一旦发票行写好了,就开始写发票项。最后,我们写入分页符和一个换行符,表示发票数 据的结束。
有 4 种方法可以使用。前 3 种方法包括将每行切分,然后针对非字符串的字段使用转换函数如 strconv.Atoi() 和 time.Parse()。这些方法是:
按照常规,从行号 1 开始,该文件被逐行读取。第一行用于检查文件是否有个合法的类型和版本号,因此处理实际数据时,行号 (lino) 从 2 开始读起。
由于我们逐行工作,并且每一个发票文件都表示成两行甚至多行(一行 INVOICE 行和一行或者多行 ITEM 行),我们需跟踪当前发票,以便每读一行就可以将其添加到当前发票数据中。这很容易做到,因为所有的发票数据都被追加到一个发票切片中,因此当前发票永远是处于位置 invoices[len(invoices)-1] 处的发票。
当 parseTxtLine() 函数解析一个 INVOICE 行时,它会创建一个新的 Invoice 值,并将一个指向该值的指针追加到 invoices 切片中。
如果要在一个函数内部往一个切片中追加数据,有两种技术可以使用。第一种技术是传入一个指向切片的指针,然后在所指向的切片中操作。第二种技术是传入切片值,同时返回(可能被修改过的)切片给调用者,以赋值回原始切片。parseTxtLine() 函数使用第二种技术。
如果该行以文本“INVOICE”开头,我们就调用 parseTxtInvoice() 函数来解析该行,并创建一个 Invoice 值,并返回一个指向它的指针。然后,我们将该 *Inovice 值追加到 invoices 切片中,并在最后返回该 invoices 切片和 nil 值或者错误值。
需要注意的是,这里的发票信息是不完整的,我们只有它的 ID、客户 ID、创建和持续时间、是否支付以及注释信息,但是没有任何发票项。
如果该行以“ITEM”开头,我们首先检查当前发票是否存在(即 invoices 切片不为空)。如果存在,我们调用 parseTxtItem() 函数来解析该行并创建一个 Item 值,然后返回一个指向该值的指针。然后我们将该项添加到当前发票的项中。这可以通过取得指向当前发票项的指针以及将指针的值设置为追加新 *Item 后的结果来达到。当然,我们本可以使用
任何其他的行(例如空和换页行)都被忽略。顺便提一下,理论上而言,如果我们优先处理“ITEM”的情况该函数会更快,因为数据中发票项的行数远比发票和空行的行数多。
日期使用 time.Parse() 函数来解析,这在之前的节中已有阐述。如果发票行有冒号,则意味着该行末尾处有注释,那么我们就删除其空白符,并将其返回。我们使用了表达式 line[i+1:] 而非 line[i+len(noteSep):],因为我们知道 noteSep 的冒号字符占用了一个 UTF-8 字节,但为了更为健壮,我们选择了对任何字符都有效的方法,无论它占用多少字节。
使用 fmt 包的打印函数来写文本文件比较容易。解析文本文件却挑战不小,但是 Go语言的 regexp 包中提供了 strings.Fields() 和 strings.Split() 函数,fmt 包中提供了扫描函数,使得我们可以很好的解决该问题。
下面是某单个发票以自定义纯文本格式存储的数据。
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
写纯文本文件
由于 Go语言的 fmt 包中打印函数强大而灵活,写纯文本数据非常简单直接。type TxtMarshaler struct{} func (TxtMarshaler) Marshallnvoices(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.Customerld, 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.FormatBol() 函数。
一旦发票行写好了,就开始写发票项。最后,我们写入分页符和一个换行符,表示发票数 据的结束。
func (write writerFunc) writelterns(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 }该 writeltems() 方法接受发票的发票项,并使用作为接收器传入的 write() 函数来写数据。它迭代每一个发票项并将其写入,并且也跟写入发票数据一样,如果其注释文档为空则无需写入。
读纯文本文件
打开并读取一个纯文本格式的数据跟写入纯文本格式数据一样简单。要解析文本来重建原始数据可能稍微复杂,这需根据格式的复杂性而定。有 4 种方法可以使用。前 3 种方法包括将每行切分,然后针对非字符串的字段使用转换函数如 strconv.Atoi() 和 time.Parse()。这些方法是:
- 第一,手动解析(例如,一个字母一个字母或者一个字一个字地解析),这样做实现起来烦琐,不够健壮并且也慢;
- 第二,使用 fmt.Fields() 或者 fmt.Split() 函数来将每行切分;
- 第三,使用正则表达式。对于该 invoicedata 程序,我们使用
- 第四,无需将每行切分或者使用转换函数,因为我们所 需的功能都能够交由fmt包的扫描函数处理。
func (TxtMarshaler) Unmarshallnvoices(reader io.Reader) ([]*Invoice, error) { bufferedReader := bufio.NewReader(reader) if err := checkTxtVersion(bufferedReader); err != nil { return nxl, 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 nilz 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) ([]*Invoicer error) { var err error if strings.HasPrefix(line, "INVOICE") { var invoice *Invoice invoice, err = parseTxtlnvoice(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 %dM, 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 值,并返回一个指向它的指针。然后,我们将该 *Inovice 值追加到 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 parseTxtHnvoice(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.CustomerTd, &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 值,因此我们将创建以及持续时间以字符串的形式输入,并单独解析它们。下表中列岀了 fmt 中的扫描函数。
表:fmt 中的扫描函数
语法 | 描述 |
---|---|
fmt.Fscan(r, args) | 读取 r 中连续的空格或者空行分隔值以填充 args |
fmt.Fscanf(r, fs, args) | 读取 r 中连续的空格分隔的指定为 fs 格式的值以填充 args |
fmt.Fscanln(r, args) | 读取 r 中连续的空格分隔的值以填充 args,同时以新行或者 io.EOF 结尾 |
fmt.Scan(args) | 读取 os.Stdin 中连续的空行分隔的值以填充 args |
fmt.Scanf(fs, args) | 读取 os.Stdin 中连续的空格分隔的指定为 fs 格式的值以填充 args |
fmt.Scanln(args) | 读取 os.Stdin 中连续的空格分隔的值以填充 args,以新行或 io.EOF 结束 |
fmt.Sscan(s, args) | 读取 s 中连续的空行分隔的值以填充 args |
fmt.Sscanf(s, fs, args) | 读取 s 中连续的空格分隔的指定为 fs 格式的值以填充 args |
fmt.Sscanln(s, args) | 读取 s 中连续的空格分隔的值以填充 args,同时以新行或者 io.EOF 结束 |
如果 fmt.Sscanf() 函数不能读入与我们所提供的值相同数量的项,或者如果发生了错误(例如,读取错误),函数就会返回一个非空的错误值。其中:参数 r 是一个从其中读数据的 io.Reader,s 是一个从其中读数据的字符串,fs 是一个用于 fmt 包中打印函数的格式化字符串,args 表示一个或者多个需填充的值的指针。所有这些扫描函数返回成功解析(即填充)项的数量,以及另一个空或者非空的错误值。
日期使用 time.Parse() 函数来解析,这在之前的节中已有阐述。如果发票行有冒号,则意味着该行末尾处有注释,那么我们就删除其空白符,并将其返回。我们使用了表达式 line[i+1:] 而非 line[i+len(noteSep):],因为我们知道 noteSep 的冒号字符占用了一个 UTF-8 字节,但为了更为健壮,我们选择了对任何字符都有效的方法,无论它占用多少字节。
func parseTxtltem(lino int, line string) (item *Item, err error) { item = &Item{} if _, err = £mt.Sscanf(line, "ITEM ID=%s PRICE=%f QUANTITY=%d", &itern. Id, &item. Price, &item.Quantity); err ! = nil { return nil, fmt.Errorf("invalid item %v line %d", err, lino) } i£ 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{ retrun 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 包中提供了扫描函数,使得我们可以很好的解决该问题。