
数据格式
在当前互联网时代,程序很少只在自己的进程中处理数据。网页接口要把数据返回给浏览器,配置中心要把配置发送给服务,日志系统要把事件交给其他程序,RPC 服务还要在不同语言之间传递请求和响应。
这些场景有一个共同问题:内存中的 Go 对象,如何变成可以保存、传输、再恢复的数据?
这个过程叫做序列化 。把一段已经保存或接收到的数据恢复成 Go 对象,叫做反序列化。
目前最常见的、独立于具体编程语言的数据格式主要有 XML、YAML、JSON 和 Protocol Buffers。它们都能表达结构化数据,但设计目标不一样:XML 更强调标签和兼容性,YAML 更适合人类编写配置,JSON 更适合 Web 数据交换,Protocol Buffers 则更适合高性能的服务间通信。
下面先做一个横向比较。表格里的"快"和"慢"是工程上的相对印象,实际结果还会受到数据大小、字段数量、实现版本、网络和磁盘等因素影响,不能把它当成固定的 benchmark 结论。
| 名称 | XML | YAML | JSON | Protocol Buffers |
|---|---|---|---|---|
| 数据结构 | 复杂 | 较简单 | 简单 | 较复杂 |
| 保存方式 | 文本 | 文本 | 文本 | 二进制 |
| 保存大小 | 大 | 中 | 中 | 小 |
| 解析效率 | 慢 | 中 | 中 | 快 |
| 语言支持 | 非常多 | 多 | 很多 | 较多 |
| 开发难度 | 繁琐 | 较简单 | 简单 | 需要代码生成 |
| 学习成本 | 低 | 低 | 低 | 中 |
| 适用范围 | 数据交换、传统系统 | 配置文件 | Web 数据交换 | RPC、内部服务通信 |
简单的选择建议如下:
- 对外提供 REST API,优先选择 JSON;
- 写给人修改的配置文件,优先选择 YAML;
- 对接已经使用 XML 的系统时,使用 XML;
- 服务之间需要高吞吐、低带宽和严格协议时,使用 Protocol Buffers。
序列化和反序列化
可以把数据处理过程理解成下面这样:
Go 结构体 --序列化--> XML/YAML/JSON/Protobuf 字节
Go 结构体 <--反序列化-- XML/YAML/JSON/Protobuf 字节
在 Go 中,如果想要让结构体参与序列化和反序列化,字段通常必须是对外暴露的字段,也就是字段名首字母大写。小写字段属于包内私有字段,标准库和大多数第三方编码库不会直接访问它们。
type Person struct {
Name string `json:"name"`
}
这里的 Name 可以被编码;如果写成 name,编码器会忽略它。反射是这些库实现自动编解码的基础,字段是否导出是反射能够访问到它的前提。
XML
XML 的全称是 eXtensible Markup Language,它使用开始标签、结束标签和属性描述层级数据。XML 起源较早,曾经广泛用于网络传输、数据交换、配置文件和文档存储。
XML 的优点是格式明确、扩展方式成熟、几乎所有语言都有解析库;缺点也很明显:标签会重复占用空间,文本内容比较冗长,复杂 XML 的解析和维护成本比较高。
Go 官方标准库提供了 encoding/xml 包,不需要额外安装依赖。
XML 的主要 API
func Marshal(v any) ([]byte, error)
func MarshalIndent(v any, prefix, indent string) ([]byte, error)
func Unmarshal(data []byte, v any) error
Marshal:把 Go 值编码成 XML;MarshalIndent:编码 XML,并为输出增加缩进;Unmarshal:把 XML 字节解析到目标变量中。
encoding/xml 还提供了 Encoder 和 Decoder。当 XML 很大,不能一次性读入内存时,可以使用流式 API:Encoder.Encode 负责写出,Decoder.Decode 负责逐个读取。
定义结构体
package main
import (
"encoding/xml"
"fmt"
)
type Person struct {
UserID string `xml:"id"`
Username string `xml:"name"`
Age int `xml:"age"`
Address string `xml:"address"`
}
结构体标签决定字段对应的 XML 节点名称。如果不写标签,encoding/xml 默认会使用字段名。
XML 序列化
func main() {
person := Person{
UserID: "120",
Username: "jack",
Age: 18,
Address: "usa",
}
data, err := xml.MarshalIndent(person, "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(data))
}
输出:
<Person>
<id>120</id>
<name>jack</name>
<age>18</age>
<address>usa</address>
</Person>
根节点默认使用结构体名称 Person。如果希望使用其他根节点,可以给结构体增加 XMLName 字段:
type Person struct {
XMLName xml.Name `xml:"person"`
UserID string `xml:"id"`
Username string `xml:"name"`
Age int `xml:"age"`
}
再次序列化时,根节点会变成小写的 <person>。
XML 反序列化
func main() {
xmlStr := `
<Person>
<id>120</id>
<name>jack</name>
<age>18</age>
<address>usa</address>
</Person>`
var person Person
if err := xml.Unmarshal([]byte(xmlStr), &person); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", person)
}
输出:
{UserID:120 Username:jack Age:18 Address:usa}
注意,反序列化的第二个参数必须传指针。编码器需要把解析结果写回原变量,如果传入普通值,函数无法修改调用方的数据。
XML 属性、嵌套节点和重复节点
XML 不只有普通节点,还经常使用属性、嵌套节点和重复节点。结构体标签可以表达这些情况:
type Order struct {
XMLName xml.Name `xml:"order"`
ID string `xml:"id,attr"`
User Person `xml:"user"`
Items []Item `xml:"items>item"`
}
type Item struct {
Name string `xml:"name"`
Count int `xml:"count"`
}
对应的 XML 可以写成:
<order id="A1001">
<user>
<id>120</id>
<name>jack</name>
</user>
<items>
<item><name>keyboard</name><count>1</count></item>
<item><name>mouse</name><count>2</count></item>
</items>
</order>
xml:",attr" 表示 XML 属性;xml:"items>item" 表示先进入 items 节点,再读取其中的多个 item 节点。由此可以看出,XML 的表达能力很强,但标签路径也更容易变得复杂。
如果 XML 结构非常复杂,或者需要保留注释、命名空间和任意层级,除了标准库,还可以考虑使用 etree 等第三方库。不过,简单的数据交换优先使用标准库,依赖越少,维护成本越低。
YAML
YAML 的全称是 YAML Ain't Markup Language。它主要使用缩进表示层级,语法接近自然配置文件,因此经常用于应用配置、CI 配置、容器编排和开发工具配置。
YAML 的优点是可读性好,缺点是缩进有语义,空格数量、列表位置和字符串类型都可能影响解析结果。Go 官方标准库没有提供 YAML 包,需要使用第三方库。
目前 Go 项目中常见的选择是 gopkg.in/yaml.v3。
安装 YAML 依赖
go get gopkg.in/yaml.v3
导入路径如下:
import "gopkg.in/yaml.v3"
YAML 的主要 API
func Marshal(in interface{}) (out []byte, err error)
func Unmarshal(in []byte, out interface{}) (err error)
yaml.Marshal:把 Go 值序列化为 YAML;yaml.Unmarshal:把 YAML 内容反序列化到目标变量。
库还提供 Encoder、Decoder 和 Node。普通配置文件使用 Marshal、Unmarshal 就够了;需要处理多文档、保留节点信息或做更严格校验时,再使用这些高级 API。
定义配置结构体
package main
import (
"fmt"
"gopkg.in/yaml.v3"
)
type Config struct {
Database string `yaml:"database"`
URL string `yaml:"url"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
YAML 标签和 XML、JSON 标签的作用类似,用于把 Go 字段映射到 YAML 键名。
YAML 配置文件
config.yml:
database: mysql
url: 127.0.0.1
port: 3306
username: root
password: "123456"
YAML 序列化
func main() {
config := Config{
Database: "oracle",
URL: "localhost",
Port: 3326,
Username: "root",
Password: "123456",
}
out, err := yaml.Marshal(config)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(out))
}
输出:
database: oracle
url: localhost
port: 3326
username: root
password: "123456"
YAML 本身就是面向人类阅读的文本格式,通常不需要像 JSON 那样额外调用"格式化序列化"函数。
YAML 反序列化
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
func main() {
data, err := os.ReadFile("./config.yml")
if err != nil {
fmt.Println(err)
return
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", config)
}
输出:
{Database:mysql URL:127.0.0.1 Port:3306 Username:root Password:123456}
这里仍然要传 &config,因为 Unmarshal 需要修改 config 的字段。
严格检查 YAML 字段
默认情况下,YAML 中出现结构体没有定义的字段时,很多场景不会直接报错。配置文件一旦写错键名,程序可能悄悄使用零值,这通常很难排查。
可以使用 KnownFields(true) 让解码器遇到未知字段时报告错误:
func decodeConfig(data []byte) (Config, error) {
var config Config
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&config); err != nil {
return Config{}, err
}
return config, nil
}
完整代码需要额外导入:
import "bytes"
对于生产环境中的配置文件,严格字段检查通常比"遇到未知字段直接忽略"更容易发现问题。
JSON
JSON 的全称是 JavaScript Object Notation。它是一种轻量级文本数据格式,能够表示对象、数组、字符串、数字、布尔值和 null。
JSON 在 RESTful 接口、浏览器通信、消息队列和日志系统中非常常见。它比 XML 更简洁,浏览器和几乎所有后端语言都能直接处理,因此成为 Web 领域最主流的数据交换格式之一。
Go 的标准库 encoding/json 提供了 JSON 编解码能力,不需要安装第三方依赖。
从 Go 1.27 开始,标准库还提供了 encoding/json/v2 和 encoding/json/jsontext。encoding/json 的常用 API 仍然继续支持,本文使用 encoding/json,这样示例可以兼容大量已有项目;如果新项目明确要求更严格的 JSON 语义,可以再阅读官方的 v2 文档。
JSON 的主要 API
func Marshal(v any) ([]byte, error)
func MarshalIndent(v any, prefix, indent string) ([]byte, error)
func Unmarshal(data []byte, v any) error
Marshal:把 Go 对象编码成紧凑的 JSON;MarshalIndent:把 Go 对象编码成带缩进、方便阅读的 JSON;Unmarshal:把 JSON 字节解析到 Go 对象。
对于文件或网络流,还可以使用:
func NewEncoder(w io.Writer) *Encoder
func NewDecoder(r io.Reader) *Decoder
定义结构体
package main
import (
"encoding/json"
"fmt"
)
type Person struct {
UserID string `json:"id"`
Username string `json:"name"`
Age int `json:"age"`
Address string `json:"address"`
}
如果不写 JSON 标签,默认输出的 key 会使用字段名,例如 UserID。在接口设计中,通常会显式写出小写的 JSON key,这样可以让协议保持稳定,也能避免 Go 命名和前端命名风格不一致。
JSON 序列化
func main() {
person := Person{
UserID: "120",
Username: "jack",
Age: 18,
Address: "usa",
}
data, err := json.Marshal(person)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(data))
}
输出:
{"id":"120","name":"jack","age":18,"address":"usa"}
默认输出没有多余空格,这是为了减少网络传输和文件保存所占用的空间。
JSON 缩进
调试、日志和配置文件经常需要人类阅读,这时可以使用 MarshalIndent:
func main() {
person := Person{
UserID: "1202",
Username: "jack",
Age: 19,
Address: "USA",
}
data, err := json.MarshalIndent(person, "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(data))
}
输出:
{
"id": "1202",
"name": "jack",
"age": 19,
"address": "USA"
}
第一个参数 prefix 会添加到每一行的开头,第二个参数 indent 表示每一层使用什么缩进。常见写法是 "" 配合 " " 或 "\t"。
JSON 反序列化
func main() {
jsonStr := `{"id":"120","name":"jack","age":18,"address":"usa"}`
var person Person
if err := json.Unmarshal([]byte(jsonStr), &person); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", person)
}
输出:
{UserID:120 Username:jack Age:18 Address:usa}
反序列化时,JSON key 会优先按照结构体标签匹配;没有标签时,再按照字段名匹配。JSON 的字段名通常不要求和 Go 字段大小写完全一致,但在接口协议中仍然建议明确写标签,避免依赖宽松匹配规则。
omitempty 忽略空字段
结构体标签还可以增加选项。omitempty 表示字段为零值时不输出:
type User struct {
Name string `json:"name"`
Age int `json:"age,omitempty"`
Email string `json:"email,omitempty"`
Admin bool `json:"admin,omitempty"`
}
func main() {
user := User{Name: "jack"}
data, _ := json.Marshal(user)
fmt.Println(string(data))
}
输出:
{"name":"jack"}
对于 int,零值是 0;对于 string,零值是空字符串;对于指针、切片、map 和接口,nil 也属于空值。需要注意的是,omitempty 会把"没有填写"和"明确填写了零值"合并成一种状态。如果业务必须区分这两种情况,可以使用指针或自定义类型。
使用 Decoder 严格检查未知字段
默认的 json.Unmarshal 会忽略 JSON 中结构体没有声明的字段:
func decodePerson(data []byte) (Person, error) {
var person Person
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&person); err != nil {
return Person{}, err
}
return person, nil
}
上面的代码需要额外导入:
import "bytes"
当 JSON 是外部请求时,DisallowUnknownFields 可以帮助我们更早发现客户端拼写错误;但如果协议需要向后兼容、允许客户端携带未来字段,就不应该盲目打开这个选项。
JSON 数字的注意事项
当 JSON 被解码到 map[string]any 时,数字默认会变成 float64:
func main() {
var value map[string]any
_ = json.Unmarshal([]byte(`{"id":9007199254740993}`), &value)
fmt.Printf("%T\n", value["id"])
}
输出类型是:
float64
对于很大的整数,直接使用 float64 可能丢失精度。可以让 Decoder 使用 json.Number:
func main() {
decoder := json.NewDecoder(strings.NewReader(`{"id":9007199254740993}`))
decoder.UseNumber()
var value map[string]any
if err := decoder.Decode(&value); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%T %v\n", value["id"], value["id"])
}
需要额外导入:
import "strings"
如果知道数据结构,优先反序列化到明确的结构体;只有在结构不固定时,才使用 map[string]any。这条经验比记住某一个 API 更重要。
Protocol Buffers
Protocol Buffers,通常简称 Protobuf,是 Google 开源的、语言中立、平台中立、可扩展的结构化数据序列化机制。它使用 .proto 文件描述数据结构,再通过 protoc 编译器生成各语言的代码。
和 XML、YAML、JSON 相比,Protobuf 的数据通常更小,编码和解析速度也更快,因此常用于 RPC、微服务内部通信、消息队列和高频数据传输。
Protobuf 的代价是需要维护协议文件并执行代码生成,生成后的二进制内容也不适合直接用文本编辑器查看。它更适合"机器之间长期、稳定地交换数据",不适合拿来写普通配置文件。
安装 Go 的 Protobuf 工具
先安装 protoc 编译器。不同操作系统的安装方式可以参考 Protocol Buffers 官方安装文档。
然后安装 Go 代码生成插件:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
确保 protoc-gen-go 所在的目录已经加入 PATH。官方 Go 教程使用的是 google.golang.org/protobuf,老项目中经常能看到的 github.com/golang/protobuf/proto 属于旧 API,新项目优先使用官方新模块。
编写 .proto 文件
在 Go module 根目录下创建 personpb/person.proto:
syntax = "proto3";
package person;
option go_package = "example.com/dataformat/personpb";
message Person {
string name = 1;
int32 age = 2;
repeated string emails = 3;
}
这里有几个关键点:
syntax = "proto3"表示使用 proto3 语法;message Person定义一个消息结构;name = 1中的数字是字段编号,不是数组下标;repeated表示一个可以包含多个元素的字段;go_package告诉生成器应该使用哪个 Go import path。
字段编号一旦发布就应该长期保留。删除字段后不要把旧编号立即分配给新字段,否则旧数据在新代码中可能被解释成完全不同的含义。
生成 Go 文件
在 Go module 根目录执行:
protoc --proto_path=. --go_out=. --go_opt=paths=source_relative personpb/person.proto
执行成功后,会生成 personpb/person.pb.go。这个文件不应该手工修改,协议发生变化时重新运行代码生成命令即可。
Protobuf 序列化和反序列化
假设生成的 Go 包路径是 example.com/dataformat/personpb,可以这样使用:
package main
import (
"fmt"
"google.golang.org/protobuf/proto"
pb "example.com/dataformat/personpb"
)
func main() {
person := &pb.Person{
Name: "wyh",
Age: 12,
}
data, err := proto.Marshal(person)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("proto buffer len:", len(data), "bytes:", data)
decoded := &pb.Person{}
if err := proto.Unmarshal(data, decoded); err != nil {
fmt.Println(err)
return
}
fmt.Println(decoded.GetName())
fmt.Println(decoded.GetAge())
fmt.Println(decoded.GetEmails())
}
如果只填写 name 和 age,编码结果类似下面这样:
proto buffer len: 7 bytes: [10 3 119 121 104 16 12]
wyh
12
[]
Protobuf 使用二进制 wire format 保存字段和值,所以输出的 bytes 不适合直接阅读。调试时可以使用生成结构体的 getter,也可以使用 protojson 把消息转换成 JSON:
import (
"fmt"
"google.golang.org/protobuf/encoding/protojson"
pb "example.com/dataformat/personpb"
)
func printJSON(person *pb.Person) {
text, err := protojson.Marshal(person)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(text))
}
Protobuf 的兼容性规则
Protobuf 的强项不只是"二进制更小",更重要的是它为协议演进提供了明确规则:
- 新增字段时,旧程序会忽略自己不认识的字段;
- 旧数据被新程序读取时,新字段会使用默认值;
- 已经使用过的字段编号不能重复分配;
- 删除字段后,可以使用
reserved保留字段名和编号; - 需要改变字段类型时,不能只看 Go 类型是否能编译,还要考虑 wire format 是否兼容。
例如:
message Person {
reserved 4;
reserved "old_phone";
string name = 1;
int32 age = 2;
repeated string emails = 3;
}
reserved 可以防止未来的开发者误用已经删除的编号或字段名。
四种格式如何选择
如果只是学习 API,可以把同一个 Person 结构分别编码成四种格式;如果是设计真实项目,则要先看数据的使用方式。
| 场景 | 建议格式 | 原因 |
|---|---|---|
| 浏览器和后端的公开接口 | JSON | 调试方便,生态成熟,前端支持好 |
| 服务配置、CI 配置 | YAML | 人类可读,修改方便 |
| 对接传统系统或已有 XML 协议 | XML | 兼容既有接口和工具链 |
| 内部 RPC、服务间高频通信 | Protobuf | 协议明确,体积小,解析快 |
| 需要人工直接编辑的数据 | YAML 或 JSON | 文本格式,排查问题方便 |
| 需要跨语言且长期演进的数据 | Protobuf 或 JSON | 有成熟的兼容和生态方案 |
这里还有一个经常被忽略的判断:数据格式和传输协议不是一回事。 HTTP、TCP、消息队列解决"怎么传",JSON、XML、YAML、Protobuf 解决"传什么"。HTTP 请求体可以是 JSON,也可以是 XML;gRPC 通常使用 Protobuf,但 Protobuf 本身并不等于 gRPC。
统一处理错误
无论使用哪一种格式,序列化和反序列化都可能失败。实际项目中不要忽略错误:
func encodePerson(person Person) ([]byte, error) {
data, err := json.Marshal(person)
if err != nil {
return nil, fmt.Errorf("encode person: %w", err)
}
return data, nil
}
调用方可以根据错误内容记录日志、返回 HTTP 错误码或终止启动流程。配置文件解析失败时,通常应该让程序启动失败,因为带着错误配置继续运行,往往比直接报错更危险。
反序列化时还要注意三件事:
- 输入数据可能为空、截断或格式错误;
- 字段可能缺失,缺失字段通常会得到 Go 类型的零值;
- 类型可能不匹配,例如把字符串传给
int字段。
"能成功解析"不代表"数据符合业务规则"。例如 JSON 中 age: -1 可能是合法 JSON,但未必是合法业务数据。格式校验之后,仍然需要进行业务校验。
常见问题
为什么结构体字段必须大写
因为 encoding/xml、encoding/json 以及大多数第三方库都通过反射读取字段。小写字段对当前包外部不可见,编码器无法直接访问它们。
Marshal 和 MarshalIndent 有什么区别
两者都会把 Go 值序列化成 JSON 或 XML。Marshal 输出更紧凑,适合网络传输;MarshalIndent 会插入换行和缩进,适合日志、调试和人工阅读。
YAML 是 Go 官方标准库吗
不是。Go 标准库目前提供 encoding/json 和 encoding/xml,YAML 通常使用 gopkg.in/yaml.v3 等第三方包。
Protocol Buffers 能不能代替 JSON
不能简单地说"完全代替"。对外 API 通常更看重可读性、调试体验和浏览器生态,JSON 更合适;内部 RPC 更看重协议约束、体积和性能,Protobuf 更合适。两者解决的是相似问题,但工程取舍不同。
为什么 Protobuf 代码需要生成
.proto 文件是跨语言协议描述,protoc 会根据它生成 Go、Java、C++ 等语言的类型和编解码代码。生成代码把协议规则固定下来,减少手写解析代码和字段编号错误。
总结
XML、YAML、JSON 和 Protocol Buffers 都可以完成结构化数据的序列化和反序列化,但它们服务的场景不同:XML 重兼容,YAML 重可读,JSON 重通用,Protobuf 重效率和协议演进。
学习时可以先掌握标准库里的 encoding/json 和 encoding/xml,再使用 gopkg.in/yaml.v3 处理配置文件,最后通过 .proto、protoc 和 google.golang.org/protobuf 理解 Protobuf 的完整流程。
真正进行项目选型时,不要只看"哪个格式更快"。先问三个问题:数据是给人看的,还是给机器看的?协议是否需要跨语言长期演进?数据量和传输频率是否已经让文本格式成为瓶颈?答案通常比一张简单的性能排行表更可靠。