Go 语言中数据编码与解码

基本概念

Go 语言 encoding 包提供对多种网络流行的数据格式编码和解码功能,主要包括对 JSON、XML、CSV 等数据格式转换和 Base64 编码的操作。

对数据的转换和还原也称为序列化(Serialization)和反序列化(Deserialization):

  • 序列化:序列化是指将数据转换成特定格式,同时保存好数据结构关键信息。
  • 反序列化:反序列化指从序列化的格式中恢复数据结构或对象状态。

数据序列化操作的目的是方便数据储存和传输,例如通过 YAML 来保存服务配置信息,通过 JSON 返回服务器响应内容。为了直观表达下面用「生成」和「解析」指代。

Base64

Base64 编码在网络编程中很常见,用于将二进制数据转换为纯文本格式(ASCII 字符)。这样一来可以通过纯文本来传输二进制数据,二来可以规避传输原始数据中的非法字符。在 Go 语言中内置标准库 encoding/base64 支持 Base64 编码和解码。

编码原理

Base64 编码将每 3 个字节的二进制数据转换成 4 个字节的文本数据。下面以字符串 Mon 为例说明过程:

  • 字符串 Mon 中 3 个字符 ASCII 码分别是 [77 111 110]
  • 将字符串转为字节切片表示,切片中每个字节占 8 位,以二进制形式表示为:[01001101 01101111 01101110],共 24 位。
  • Base64 每次处理 3 个字节,重新按照 6 位划分,变成 4 个字节:[010011 010110 111101 101110],对应十进制值为:[19 22 61 46]。由于 6 位只能表示数字 063 共 64 种(2^6)可能值,故得名 Base64。
  • 根据 Base64 索引表,这 4 个数字对应的字符是 TW9u,也就是字符串 Mon 对应的 Base64 编码。Base64 索引表包括大小写字母、数字、加号(+)和斜杠(/)。
  • 如果最后一次处理不足 3 个字节,会用全 0 填充到 24 位再处理。结果末 2 位字节为 00000000 时,用等号(=)填充。
  • 由于编码后数据以字符形式存储,相当于每转 3 个字节,就多出来 1 个字节,编码后体积比原先大 1/3。同理,Base32 编码后数据大小增加 3/5。

下面用简单直白的代码模拟这一过程,特意在原字符串前后加入控制字符,以观察填充处理:

package main

import (
	"encoding/base64"
	"fmt"
	"strconv"
)

func main() {
	// 定义数据,转为字节切片
	a := "\u0003Mon\u0003"
	b := []byte(a)
	fmt.Println(b) // 输出:[3 77 111 110 3],对应字符 ASCII 编码

	// 以 3 字节为倍数检查,补全位数
	if len(b)%3 != 0 {
		c := make([]byte, 3-len(b)%3)
		b = append(b, c...)
	}
	fmt.Println(b) // 输出:[3 77 111 110 3 0]

	// 转为二进制字符串并连接
	d := ""
	for _, b := range b {
		d += fmt.Sprintf("%08b", b)
	}
	fmt.Println(d) // 输出:000000110100110101101111011011100000001100000000

	// 分割为 6 位一组
	e := make([]string, 0)
	for i := 0; i < len(d); i += 6 {
		e = append(e, d[i:i+6])
	}
	fmt.Println(e) // 输出:[000000 110100 110101 101111 011011 100000 001100 000000]

	// 转为整型切片
	f := make([]int, len(e))
	for i, v := range e {
		n, _ := strconv.ParseInt(v, 2, 64)
		f[i] = int(n)
	}
	fmt.Println(f) // 输出:[0 52 53 47 27 32 12 0]

	// 生成 BASE64 编码映射
	g := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
	h := make(map[int]string)
	for i, v := range g {
		h[i] = string(v)
	}
	fmt.Println(h)

	// 对应到 BASE64 编码,输出最终结果
	j := ""
	for i, v := range f {
		vv := h[v]
		// 特殊规则,用 = 代替空位
		if i > len(f)-3 && len(f)>3 && vv == "A" {
			vv = "="
		}
		j += vv
	}
	fmt.Println(j) // 输出:A01vbgM=
}

解码原理

解码则是编码逆过程,将 4 个字符转为 3 个字节,还原出原始字符串。转换时遇到填充字符 = 则忽略:

package main

import (
	"encoding/base64"
	"fmt"
	"strconv"
)

func main() {
	// 定义编码数据和索引表
	a := "A01vbgM="
	b := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
	c := make(map[string]int)
	for i, v := range b {
		c[string(v)] = i
	}
	fmt.Println(c)

	// 转为整型切片
	d := make([]int, 0)
	for _, v := range a {
		d = append(d, c[string(v)])
	}
	fmt.Println(d)

	// 转为二进制字符串
	e := ""
	for _, v := range d {
		e += fmt.Sprintf("%06b", v)
	}
	fmt.Println(e)

	// 分割为 8 位一组
	f := make([]string, 0)
	for i := 0; i < len(e); i += 8 {
		// 特殊规则,最后两位空位时忽略
		if i > len(e)-24 && len(e)>24 && e[i:i+8] == "00000000" {
			continue
		}
		f = append(f, e[i:i+8])
	}
	fmt.Println(f)

	// 转为字节切片,打印结果
	g := make([]byte, len(f))
	for i, v := range f {
		b, _ := strconv.ParseUint(v, 2, 8)
		g[i] = byte(b)
	}
	fmt.Println(g) // 输出:[3 77 111 110 3]
	fmt.Println(string(g))
}

标准编码

标准编码指使用标准 Base64 字符集进行编解码。在 Go 语言中,标准编码功能已经封装到 base64.StdEncoding 包中:

package main

import (
	"encoding/base64"
	"fmt"
)

func main() {
	// 标准编码
	fmt.Println(base64.StdEncoding.EncodeToString([]byte("a"))) // YQ==

	// 标准解码
	fmt.Println(base64.StdEncoding.DecodeString("YQ==")) // 输出:[97] <nil>

	// 实际应用中需要处理解码错误
	d, err := base64.StdEncoding.DecodeString("YQ=")
	if err != nil {
		fmt.Println("解码出错:", err) // 报错:illegal base64 data at input byte 3
		return
	}
	fmt.Println(string(d))
}

安全编码

标准 Base64 字符集包含符号 +/,在网络传输时不能保证安全。可使用 URL 安全编码方式 base64.URLEncoding,编码后 +/ 字符替换为 -_ 字符:

package main

import (
	"encoding/base64"
	"fmt"
)

func main() {
	// 在编码结果中产生 + 和 / 字符
	data := []byte{0xfb, 0xff, 0xbf}

	// 标准 Base64 编码,原样输出
	fmt.Println(base64.StdEncoding.EncodeToString(data)) // 输出:+/+/

	// 安全编码
	fmt.Println(base64.URLEncoding.EncodeToString(data)) // 输出:-_-_

	// 解码时也要使用对应方法
	fmt.Println(base64.URLEncoding.DecodeString("-_-_")) // 输出:[251 255 191] <nil>
}

JSON

JSON(JavaScript Object Notation)全称 JavaScript 对象表示法,是互联网上最流行的轻量级数据交换格式。在 Go 语言中,内置 encoding/json 库提供对 JSON 格式支持。

基本结构

JSON 使用文本格式保存(文件后缀名 .json),可以支持键值对集合和数组结构:

  • 对象(Object): 由花括号 {} 包围的一组无序键值对。键为字符串,值为任意类型。键和值之间用冒号分隔,键值对之间用逗号分隔。
  • 数组(Array): 由方括号 [] 包围的有序值列表。值可以为任意类型。多个值之间用逗号分隔。
  • 值(Value): 可以是字符串、数字、布尔值、数组、对象或者空值。

JSON 文档只能有一个顶级值,也就是说一个文档等于一个对象、数组或值,不能有多个并列顶级值。例如用对象保存用户信息:

{
  "name": "张三", // 字符串,用双引号包围
  "age": 28, // 数值,整数或浮点数
  "isStudent": false, // 布尔值,true 或 false
  "skills": ["Java", "Python", 100], // 数组,可以包含任意类型
  "address": { // 对象,包含任意数量键值对
    "street": null, // 空值
    "city": "北京",
    "money": 300,
    "country": "中国" // 注意最后一个键值对后不能有逗号
  }
}

在 Go 语言中,通过结构体标签来保存 JSON 字段名等信息。JSON 与 Go 语言类型默认对应关系如下:

类型 JSON Go
字符串 String string
数字 Number float64
布尔值 Boolean bool
数组 Array []interface{}
对象 Object map[string]interface{}
空值 Null nil

当然也可以自定义类型对应关系,例如把字符串格式的时间映射到 time.Time 类型,只要类型能匹配上:

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

// Event 结构体包含 time.Time 类型时间戳字段
type Event struct {
	Name      string    `json:"name"`
	Timestamp time.Time `json:"timestamp"`
}

func main() {
	// JSON 字符串包含标准格式的日期时间字符串
	j := `{
        "name": "Webinar",
        "timestamp": "2020-05-01T14:53:00Z"
    }`

	// 输出解析结果
	var event Event
	json.Unmarshal([]byte(j), &event)
	fmt.Println(event.Name, event.Timestamp) // 输出:Webinar 2020-05-01 14:53:00 +0000 UTC
}

如果使用 GoLand 编辑器,向编辑区粘贴 JSON 格式内容,会提示生成对应结构体定义。

生成

序列化函数有 MarshalMarshalIndent,后者能生成美化后的格式:

package main

import (
	"encoding/json"
	"fmt"
	"os"
)

// 先定义结构体类型,包含 JSON 标签
type User struct {
	Name string `json:"name"`
}

func main() {
	// 转换结构体
	data, err := json.Marshal(User{"张三"})
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(data)) // 输出:{"name":"张三"}

	// 转换映射,键必须为字符串
	data, _ = json.Marshal(map[string]string{"name": "李四"})
	fmt.Println(string(data)) // 输出:{"name":"李四"}

	// 转换结构体数组,使用 MarshalIndent 让最终结果好看一点
	data, _ = json.MarshalIndent([]User{{"张三"}, {"李四"}}, "", "  ")
	fmt.Println(string(data)) // 输出:[{"name":"张三"},{"name":"李四"}]

	// 结果为字节切片,可以直接写入到文件
	file, _ := os.Create("data.json")
	file.Write(data)
	file.Close()
}

虽然可以将基础类型数据转为 JSON 格式,但是没有实用价值。此外,转换 JSON 格式不支持的数据类型(例如接口和通道)会报错。

解析

反序列化使用对应函数 Unmarshal。一般先定义结构体,通过标签将字段名对应到 JSON 键名,然后传入结构体实例指针到函数,接收转换结果:

package main

import (
	"encoding/json"
	"fmt"
	"os"
)

// 定义一个结构体,Port 可以是 int 也可以是 string
type Server struct {
	Host string `json:"host"`
	Port any    `json:"port"`
}

func main() {
	// JSON 字符串
	jsonData := `
    [
        {
            "name": "backend",
            "host": "127.0.0.1",
            "port": 1080
        },
        {
            "name": "frontend",
            "host": "127.0.0.2",
            "port": "80"
        }
    ]`

	// 解析 JSON 到结构体切片,其中 name 字段不需要,直接忽略
	var servers []Server
	err := json.Unmarshal([]byte(jsonData), &servers) // 输出目标必须为实例指针
	if err != nil {
		fmt.Println("Error parsing JSON:", err)
	}

	// 打印结果,展示 port 字段类型多样性
	for _, server := range servers {
		fmt.Printf("Host: %s, Port: %v (%T)\n", server.Host, server.Port, server.Port)
	}

	// 从文件读取 JSON 内容不需要转为字节切片,直接使用
	bytes, _ := os.ReadFile("data.json")
	json.Unmarshal(bytes, &servers) // 存到同一个目标对象,会覆盖原数据
	fmt.Printf("Verified: %+#v\n", servers)
}

当 JSON 数据是从一个流(如文件或网络)中获取时,可以直接使用 NewDecoder 配合 Decoder 函数来解码:

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

// User 结构体只取两个字段
type User struct {
	Login string `json:"login"`
	Id    int    `json:"id"`
}

func main() {
	// 从网络获取数据
	var user User
	r, _ := http.Get("https://api.github.com/users/hxz393")
	defer r.Body.Close()
	json.NewDecoder(r.Body).Decode(&user) // 直接解码响应体后赋值给 user
	fmt.Printf("%+#v\n", user)            // 输出:main.User{Login:"hxz393", Id:5063578}

	// 从文件获取数据
	var users []User
	file, _ := os.Open("data.json")
	defer file.Close()
	json.NewDecoder(file).Decode(&users) // 同样用法
	fmt.Printf("%+#v\n", users) // 输出:[]main.User{main.User{Login:"张三", Id:0}, main.User{Login:"李四", Id:0}}
}

标签选项

在序列化和反序列化时,可以通过标签选项来控制字段转换行为:

  • -:当标签值为 - 时,无论是序列化还是反序列化,该字段都会被忽略。
  • omitempty:在序列化时,如果字段值是类型零值,则不生成到 JSON 数据中。
  • string:在序列化时,将字段值转为 JSON 中的字符串。

此外要注意大小写规则:

  • 使用 json.Unmarshal 反序列化时,标签值匹配不区分字段大小写,例如 json:"login" 可以匹配到 JSON 数据中 LoginloginLOGIN 等字段。
  • 非导出字段数据序列化时,不会生成到 JSON 数据中,也就是小写字母开头的字段,转换时会被忽略。而非导出字段也不能储存反序列化结果,会导致报错。

下面是演示代码:

package main

import (
	"encoding/json"
	"fmt"
)

type Person struct {
	Name string `json:"Name"`                 // 必须处理字段,故意使用首字母大写
	Age  int    `json:"age,omitempty,string"` // 如果为 0,则在序列化时忽略
	City string `json:"-"`                    // 强制忽略字段
	id   int    `json:"id"`                   // 不能转换字段
}

func main() {
	// 反序列化。标签中的 Name 匹配到了 JSON 中 name 字段。city 字段值被忽略
	jsonStr := `{"name":"Alice", "age":30, "city":"New York"}`
	var p Person
	json.Unmarshal([]byte(jsonStr), &p)
	fmt.Printf("%+v\n", p) // 输出:{Name:Alice Age:0 City: id:0}

	// 序列化。Age 和 City 字段被忽略,不会出现在结果 JSON 中
	p = Person{Name: "", Age: 0, City: "Los Angeles", id: 011}
	jsonData, _ := json.Marshal(p)
	fmt.Println(string(jsonData)) // 输出:{"Name":""}
}

第三方库

内置库使用反射来实现解析,可以试试不用反射的第三方包 github.com/valyala/fastjson,性能更好:

package main

import (
	"encoding/json"
	"testing"

	"github.com/valyala/fastjson"
)

// 测试结构体
type Person struct {
	Name    string `json:"name"`
	Age     int    `json:"age"`
	Country string `json:"country"`
}

// 测试数据
var jsonData = `{"name":"John Doe","age":30,"country":"USA"}`

// 使用 encoding/json 解析
func BenchmarkEncodingJSON(b *testing.B) {
	var p Person
	for i := 0; i < b.N; i++ {
		if err := json.Unmarshal([]byte(jsonData), &p); err != nil {
			b.Fatal(err)
		}
	}
}

// 使用 fastjson 解析
func BenchmarkFastJSON(b *testing.B) {
	var p fastjson.Parser
	for i := 0; i < b.N; i++ {
		v, err := p.Parse(jsonData)
		if err != nil {
			b.Fatal(err)
		}
		_ = v.GetStringBytes("name")
		_ = v.GetInt("age")
		_ = v.GetStringBytes("country")
	}
}

基准测试结果如下:

goos: windows
goarch: amd64
pkg: new
cpu: AMD Ryzen Threadripper 2990WX 32-Core Processor
BenchmarkEncodingJSON
BenchmarkEncodingJSON-64          639985              1677 ns/op
BenchmarkFastJSON
BenchmarkFastJSON-64             5361942               226.0 ns/op
PASS

XML

XML(Extensible Markup Language)叫可扩展标记语言,是一种基于文本的结构化标记语言,过去广泛用于 Windows 平台应用配置。Go 语言内置 encoding/xml 库提供支持。

基本结构

XML 和 HTML 语言很相似,但 XML 允许用户自定义元素标签和文档结构:

  • 声明(Prolog):在文档最开头声明 XML 版本和文件编码。例如:<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
  • 标签(Tags):XML 数据用标签包围,所有元素都必须有闭合标签或自闭合标签。例如:<Tracker>Peer Exchange</Tracker>
  • 属性(Attributes):标签内可以包含属性,用来提供更多关于 XML 元素的信息。例如:<BitFieldStatus TotalLength="75312677588" PieceLength="16777216">
  • 嵌套:标签内可以嵌套其他标签数据。例如:<TrackerList><Tracker>Peer Exchange</Tracker></TrackerList>

和 JSON 不同,XML 中数据均以字符串形式保存。

生成

序列化时通过标签选项 attr 来设置标签属性:

package main

import (
	"encoding/xml"
	"os"
)

type Person struct {
	Name string `xml:"name"`
	Age  int    `xml:"age,attr"` // 序列化为 Person 标签属性
	City string `xml:"city"`
}

func main() {
	p := Person{Name: "Aku", Age: 30, City: "New York"}
	// 带缩进格式序列化,指定前缀为空,省略错误处理
	output, _ := xml.MarshalIndent(p, "", "\t")
	os.Stdout.Write(output)
}

解析

反序列化是将 XML 数据转回 Go 数据结构,XML 文档中第一行声明会被自动忽略,不用特殊处理:

package main

import (
	"encoding/xml"
	"fmt"
	"os"
)

type Person struct {
	XMLName xml.Name `xml:"person"`
	Name    string   `xml:"name"`
	Age     int      `xml:"age"`
}

func main() {
	// 支持从文件直接读取内容
	data, _ := os.ReadFile("data.xml")
	data = []byte(`<person><name>Aku</name><age>30</age></person>`)

	// 输出结果:main.Person{XMLName:xml.Name{Space:"", Local:"person"}, Name:"Aku", Age:30}
	var p Person
	xml.Unmarshal(data, &p)
	fmt.Printf("%+#v", p)
}

CSV

CSV(Comma-Separated Values)全名叫逗号分隔值,是一种用于存储表格数据的文件格式,常见于数据库和表格数据导入、导出和处理。Go 语言由内置库 encoding/csv 提供支持。

基本格式

CSV 文件数据结构是表格式的,有表头、行和列概念:

  • :每行对应一条数据记录。第一行可以是标题行(表头),记录每个字段名。
  • 分隔符:每条记录中,字段值之间用分隔符隔开。默认是逗号,也可以自定义分隔符,常用制表符和分号。
  • 字段:字段使用双引号包围,支持转义字符。每条记录字段值数量必须一致,否则会解析出错。

CSV 采用纯文本格式储存数据,所有数据都是字符串类型。

解析

由于 CSV 是扁平数据结构,没有层级或嵌套关系,因此处理 CSV 文件和处理文本文件一样,可以直接读取得到二维切片:

package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"os"
)

func main() {
	// 打开 CSV 文件
	file, _ := os.Open("data.csv")
	defer file.Close()
	// 创建 CSV 读取器
	reader := csv.NewReader(file)
	reader.Comma = ',' // 如果不是逗号作为分隔符,需要单独配置
    
	// 读取所有数据到二维切片
	data, _ := reader.ReadAll()
	fmt.Printf("%+#v", data)

	// 逐行读取方式
	for {
		record, err := reader.Read()
		if err != nil {
			if err == io.EOF { // 文件读取完成
				break
			}
		}
		fmt.Println(record)
	}

	// 带表头行,可以用映射切片来保存
	headers := data[0]
	var records []map[string]string
	for _, row := range data[1:] { // 跳过表头行
		record := make(map[string]string)
		for i, value := range row {
			record[headers[i]] = value
		}
		records = append(records, record)
	}
	fmt.Printf("%+#v", records)
	for _, record := range records {
		fmt.Println(record)
	}
}

大型 CSV 文件应使用 Read 循环逐行读取,以避免内存溢出。

生成

生成 CSV 文件用到 csv.NewWriter 函数,并通过 writer.Comma 来设置分隔符:

package main

import (
	"encoding/csv"
	"os"
)

func main() {
	// 原数据格式需要是二维切片
	records := [][]string{
		{"Name", "City", "Age"},
		{"Alice", "New York", "30"},
		{"Bob", "Los Angeles", "25"},
	}

	file, _ := os.Create("data.csv")
	defer file.Close()
	writer := csv.NewWriter(file)
	writer.Comma = ',' // 可自定义分隔符为别的符号
	defer writer.Flush()

	// 循环写入到文件
	for _, record := range records {
		if err := writer.Write(record); err != nil {
			panic(err)
		}
	}
}

YAML

YAML(YAML Ain’t Markup Language)格式储存数据方式类似 JSON,只是在书写格式上采用 Python 式缩进。Go 语言中需要用第三方库来处理 YAML,常用的是 gopkg.in/yaml/v3

基本语法

YAML 中用标量指代基本数据类型,如字符串、整数和浮点数:

  • 标量:单个不可分割的值。可以是单行或多行文本。
  • 列表:一系列有序排列值。列表元素前使用短横线 - 标记。
  • 映射:键值对集合。使用冒号 : 分隔键和值。
  • 注释:支持单行注释。注释行以井号 # 开始。
  • 多文档:一个文件可以包含多个文档。使用 --- 分隔符分隔多个文档。

下面是一个标准 YAML 文件内容:

name: Alice
skills:
  - Python
  - Golang
---
spring:
  application:
    name: order
  servlet: 
    multipart:
      max-file-size: 50MB
      max-request-size: 100MB
  mvc:
    async:  
      request-timeout: 300000
  sleuth:
    enabled: true
server:
  tomcat:
    relaxed-query-chars: "[,]"

注意每个层次之间有两个空格缩进,空格数不正确会导致解析失败。

解析

YAML 反序列化时,甚至不需要依赖结构体标签,第三方库做了非常多适配处理:

package main

import (
	"fmt"
	"os"
    
	"gopkg.in/yaml.v3" // 前面加空行来分组
)

// Config 结构体只取 spring 段
type Config struct {
	Spring struct {
		Application struct {
			Name string `yaml:"name"`
		} `yaml:"application"` // 结构体名相同时(不分大小写),标签可省
		Servlet struct {
			Multipart struct {
				MaxSizeBytes   string `yaml:"max-file-size"`
				MaxRequestSize string `yaml:"max-request-size"`
			}
		}
	}
}

// YAML 格式原生字符串
var data = `
spring:
  application:
    name: order
  servlet: 
    multipart:
      max-file-size: 50MB
      max-request-size: 100MB
server:
  tomcat:
    relaxed-query-chars: "[,]"
`

func main() {
	// 解析 YAML 到结构体
	var config Config
	yaml.Unmarshal([]byte(data), &config)
	fmt.Printf("%+v\n", config)

	// 从文件读取,直接使用流式处理
	var configGo Config
	file, _ := os.Open("data.yaml")
	defer file.Close()
	yaml.NewDecoder(file).Decode(&configGo)
	fmt.Printf("%+v\n", configGo)
}

生成

序列化时,如果结构体没有标签,YAML 键名沿用小写结构体字段名。默认情况下生成的 YAML 就是标准格式,没有也不需要 MarshalIndent 函数,但可自定义层级缩进量:

package main

import (
	"fmt"
	"gopkg.in/yaml.v3"
	"os"
)

type Config struct {
	Spring struct {
		Application struct {
			Name string `yaml:"name"`
		}
		Servlet struct {
			Multipart struct {
				MaxSizeBytes   string // 没有标签,自动生成键 maxsizebytes
				MaxRequestSize []int  `yaml:"max-request-size"`
			}
		}
	}
}

func main() {
	// 正常结构体示例,保存配置信息
	var config Config
	config.Spring.Application.Name = "pay"
	config.Spring.Servlet.Multipart.MaxRequestSize = []int{50, 100}

	// 从结构体生成 YAML 格式,默认缩进 4 个空格
	data, _ := yaml.Marshal(&config)
	fmt.Printf("%s\n", data)

	// 使用流式处理写入到文件
	file, _ := os.Create("config.yaml")
	defer file.Close()
	encoder := yaml.NewEncoder(file)
	encoder.SetIndent(2) // 调整设置缩进为 2 个空格
	encoder.Encode(config)
}

TOML

TOML(Tom’s Obvious, Minimal Language)是一种新近的格式,在 Rust 语言中作为配置用得比较多。TOML 在语法上类似传统的 INI 配置文件,但是支持更多数据类型。在 Go 语言中处理 TOML 文件常用 github.com/BurntSushi/toml 库和 github.com/pelletier/go-toml/v2 库。

基本语法

这里不详细说明 TOML 格式语句,仅简单介绍:

  • 键值对:内容最基本组成部分,用等号 = 分隔键和值。
  • 值类型:基本类型有字符串(支持多行和原生字符串)、数值、布尔值和时间(ISO 8601 格式)。此外还支持数组,数组用方括号 [] 括起相同基本类型元素,元素间用逗号 , 分隔。
  • :使用方括号 [] 括起表名,表示表的开始,支持嵌套。还有一种用双方括号 [[]] 括起来的数组表。
  • 注释:支持用井号 # 开头的注释。
  • 点分键:用于在一个表中定义层嵌套表结构。
  • 内联表:使用花括号 {}。例如下面来自官网的示例:
title = "TOML Example"

[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00Z类型

[database]
server = "192.168.1.1"
ports = [8001, 8001, 8002]
connection_max = 5000
enabled = true

解析

TOML 格式反序列化没有特别之处,库 github.com/BurntSushi/toml 提供从文件直接解析功能:

package main

import (
	"fmt"
	"os"
	"time"

	tomlB "github.com/BurntSushi/toml"
	"github.com/pelletier/go-toml/v2"
)

// 内嵌结构体最好单独定义,不要使用匿名嵌套
type Config struct {
	Title    string
	Owner    OwnerInfo
	Database DatabaseInfo
}

type OwnerInfo struct {
	Name string
	Dob  time.Time
}

type DatabaseInfo struct {
	Server        string
	Ports         []int
	ConnectionMax int `toml:"connection_max"`
	Enabled       bool
}

func main() {
	var config, configB Config

	// 先读取文件内容再解析
	data, _ := os.ReadFile("config.toml")
	toml.Unmarshal(data, &config)

	// 直接从文件解析
	tomlB.DecodeFile("config.toml", &configB)

	// 输出结果一样
	fmt.Printf("%+v\n", config)
	fmt.Printf("%+v\n", configB)
}

生成

这里用一个最简配置来演示序列化:

package main

import (
	"os"

	tomlB "github.com/BurntSushi/toml"
	"github.com/pelletier/go-toml/v2"
)

func main() {
	config := struct{ Title string }{"TOML Example"}

	// 序列化后写入
	data, _ := toml.Marshal(config)
	os.WriteFile("data.toml", data, 0644)

	// 调用 NewEncoder 方法写入,实际上两个库用法一样
	file, _ := os.Create("data.toml")
	tomlB.NewEncoder(file).Encode(config)
}