在Go语言的日常业务开发中,我们经常会遇到需要将结构体数据转换为JSON格式的场景,大多数时候默认的序列化逻辑就能满足需求,但当结构体包含嵌入结构体,且需要按照指定顺序将内容序列化为JSON数组而不是默认的键值对对象时,默认的encoding/json库就无法直接实现这个目标。默认的序列化逻辑会把结构体的所有导出字段按照字母顺序排序后输出为对象,嵌入结构体的字段会被合并到外层结构体的字段集合中,同样遵循字母排序规则,完全无法满足自定义顺序和数组输出的要求。

Go结构体默认JSON序列化规则回顾
Go标准库的encoding/json包在序列化结构体时,只会处理所有导出的字段,也就是首字母大写的字段,未导出的字段会被直接忽略。对于普通的结构体,序列化后的JSON对象的键名默认是字段名,如果想要自定义键名,可以给字段添加json标签,标签的值就是自定义的键名。如果字段是结构体类型,且该结构体没有实现json.Marshaler接口,那么会递归序列化这个嵌套的结构体,输出为嵌套的JSON对象。
当结构体包含嵌入结构体时,嵌入结构体的所有导出字段会被提升到外层结构体,相当于外层结构体拥有了这些字段,序列化的时候这些字段会和外层结构体的原有导出字段一起,按照字母顺序排序后输出到JSON对象中。比如我们有一个Base嵌入结构体包含ID和Name字段,外层User结构体嵌入Base还有自己的Age字段,序列化后的键名顺序会是Age、ID、Name,因为按照字母排序A在I和N前面,完全不受字段定义顺序的影响。
默认逻辑还有一个特点,就是序列化输出的一定是JSON对象(键值对形式),即使我们想要输出数组,默认的Marshal方法也不会支持,因为结构体和对象的映射关系是JSON序列化库的默认约定。如果强行把结构体转换为数组,会出现类型不匹配的错误,或者输出不符合预期的结果。这也是为什么我们需要自定义序列化逻辑才能实现需求的核心原因。
方案一:实现MarshalJSON接口自定义序列化逻辑
最直接的方式是让需要序列化的结构体实现json.Marshaler接口,这个接口只有一个MarshalJSON方法,返回字节切片和错误。我们可以在这个方法里按照自己想要的顺序拼接字段值,最后输出为JSON数组的字节形式。这种方式的好处是可以完全控制序列化后的内容和顺序,灵活度最高,适合需要精确控制每一个字段输出顺序的场景。
首先我们定义包含嵌入结构体的结构体,比如下面的例子,Info是嵌入结构体,包含City和Address字段,外层Person结构体嵌入Info,还有Name和Age字段,我们需要按照Name、Age、City、Address的顺序输出为JSON数组:
package main
import (
"encoding/json"
"fmt"
)
type Info struct {
City string
Address string
}
type Person struct {
Name string
Age int
Info // 嵌入结构体
}
// 实现MarshalJSON接口,自定义序列化逻辑
func (p Person) MarshalJSON() ([]byte, error) {
// 按照指定顺序拼接数组元素
arr := []interface{}{
p.Name,
p.Age,
p.City,
p.Address,
}
// 序列化数组
return json.Marshal(arr)
}
func main() {
p := Person{
Name: "张三",
Age: 25,
Info: Info{
City: "北京",
Address: "海淀区中关村大街1号",
},
}
data, err := json.Marshal(p)
if err != nil {
fmt.Println("序列化错误:", err)
return
}
fmt.Println(string(data))
}
上面的代码中,我们在MarshalJSON方法里先构造了一个切片,按照想要的顺序放入结构体的各个字段值,包括嵌入结构体的City和Address字段,因为嵌入结构体的字段已经提升到外层,所以可以直接通过p.City这样的方式访问。最后调用json.Marshal序列化这个切片,就会输出JSON数组。运行上面的代码,输出结果是["张三",25,"北京","海淀区中关村大街1号"],完全符合我们指定的顺序。
这种方案的优点是灵活度极高,不管结构体嵌套多少层嵌入结构体,也不管字段顺序多么复杂,都可以在MarshalJSON方法里手动控制顺序。缺点是需要手动维护字段顺序,如果后续结构体新增了字段,需要同步修改MarshalJSON方法里的切片构造逻辑,否则新增的字段不会被序列化到数组中,可维护性会稍微差一些。如果结构体字段经常变动,这种方案就需要频繁修改代码。
方案二:使用临时结构体映射字段顺序
如果我们不想自己手动拼接数组元素,也可以通过定义临时结构体的方式来实现。思路是先定义一个和需要输出的数组元素顺序对应的临时结构体,这个结构体的字段顺序就是我们想要的数组顺序,然后把原结构体的字段值赋值给临时结构体的实例,最后把临时结构体的字段值提取出来组成数组。不过需要注意的是,Go的结构体字段顺序在序列化的时候默认是字段定义的顺序吗?其实并不是,标准库的json.Marshal对结构体序列化的时候还是会按照字母顺序排序字段,所以直接序列化临时结构体还是会得到对象,因此需要把临时结构体的字段值逐个取出来放到切片里。
我们还是用之前的Person和Info结构体的例子,假设我们想要的输出顺序还是Name、Age、City、Address,可以先定义一个临时结构体,字段顺序和想要的输出顺序一致,然后实现MarshalJSON接口,在接口里把临时结构体的字段值放到切片中:
package main
import (
"encoding/json"
"fmt"
)
type Info struct {
City string
Address string
}
type Person struct {
Name string
Age int
Info
}
func (p Person) MarshalJSON() ([]byte, error) {
// 定义临时结构体,字段顺序和想要的数组顺序一致
type Temp struct {
Name string
Age int
City string
Address string
}
t := Temp{
Name: p.Name,
Age: p.Age,
City: p.City,
Address: p.Address,
}
// 把临时结构体的字段按顺序放到切片里
arr := []interface{}{
t.Name,
t.Age,
t.City,
t.Address,
}
return json.Marshal(arr)
}
func main() {
p := Person{
Name: "李四",
Age: 30,
Info: Info{
City: "上海",
Address: "浦东新区陆家嘴环路1号",
},
}
data, err := json.Marshal(p)
if err != nil {
fmt.Println("序列化错误:", err)
return
}
fmt.Println(string(data))
}
这种方式的好处是临时结构体的字段定义顺序和想要的输出顺序一致,代码可读性比直接拼接切片要高一些,尤其是当字段比较多的时候,看临时结构体的定义就能清楚知道输出顺序。不过本质上还是需要手动维护字段的顺序,和第一种方案的区别不大,只是多了一层临时结构体的映射,适合字段比较多,想要更清晰看到字段对应关系的场景。
如果嵌入结构体的层级比较深,比如Person嵌入Info,Info又嵌入了Extra结构体,那么我们在构造临时结构体的时候,需要把每一层的字段都展开,按照想要的顺序放到临时结构体的字段中,然后赋值的时候从对应的嵌套层级取值。这种方式的扩展性也不错,但是如果嵌套层级非常多,临时结构体的字段就会变得很多,可读性会下降。
方案三:使用反射动态获取字段并排序
如果我们的结构体字段经常变动,或者有很多类似的结构体都需要按指定顺序序列化为JSON数组,手动维护每一个结构体的MarshalJSON方法就会非常繁琐。这时候可以使用反射来动态获取结构体的所有字段,包括嵌入结构体的字段,然后按照我们预先定义的字段顺序列表来排序,最后拼接成数组。这种方式的可维护性更高,适合通用场景的封装。
我们可以先定义一个字段顺序的切片,里面按照想要的顺序放置字段名,然后使用反射遍历结构体的所有字段,包括嵌入结构体的字段,找到对应字段的值,按照字段顺序切片的顺序放到结果数组中。需要注意的是,反射获取嵌入结构体的字段时,需要递归处理嵌入的结构体类型,才能拿到所有提升的字段。
package main
import (
"encoding/json"
"fmt"
"reflect"
)
type Info struct {
City string
Address string
}
type Person struct {
Name string
Age int
Info
}
// 获取结构体所有字段的值,包括嵌入结构体的字段,返回字段名到值的映射
func getAllFieldValues(v reflect.Value) map[string]interface{} {
result := make(map[string]interface{})
t := v.Type()
// 如果是指针类型,先取指向的值
if t.Kind() == reflect.Ptr {
v = v.Elem()
t = v.Type()
}
// 只处理结构体类型
if t.Kind() != reflect.Struct {
return result
}
// 遍历所有字段
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// 跳过未导出字段
if field.PkgPath != "" {
continue
}
fieldName := field.Name
fieldValue := v.Field(i)
// 如果字段是嵌入结构体,递归获取其字段
if field.Anonymous {
// 嵌入结构体的字段需要递归处理
embeddedFields := getAllFieldValues(fieldValue)
for k, v := range embeddedFields {
result[k] = v
}
} else {
result[fieldName] = fieldValue.Interface()
}
}
return result
}
func (p Person) MarshalJSON() ([]byte, error) {
// 指定字段顺序,按照想要的输出顺序排列
fieldOrder := []string{"Name", "Age", "City", "Address"}
// 获取所有字段的值
fieldValues := getAllFieldValues(reflect.ValueOf(p))
// 按照指定顺序拼接数组
arr := make([]interface{}, 0, len(fieldOrder))
for _, fieldName := range fieldOrder {
if val, ok := fieldValues[fieldName]; ok {
arr = append(arr, val)
} else {
// 如果字段不存在,可以放nil或者跳过,这里放nil
arr = append(arr, nil)
}
}
return json.Marshal(arr)
}
func main() {
p := Person{
Name: "王五",
Age: 28,
Info: Info{
City: "广州",
Address: "天河区珠江新城花城大道1号",
},
}
data, err := json.Marshal(p)
if err != nil {
fmt.Println("序列化错误:", err)
return
}
fmt.Println(string(data))
}
上面的代码中,getAllFieldValues函数使用反射递归获取结构体的所有导出字段,包括嵌入结构体的字段,返回一个字段名到值的映射。MarshalJSON方法里先定义好字段顺序的切片,然后获取所有字段的值,按照字段顺序切片依次从映射里取值放到数组里,最后序列化数组。这种方式的优点是如果后续结构体新增了字段,只需要在fieldOrder切片里加上对应的字段名就可以,不需要修改其他逻辑,适合字段经常变动的场景。
不过反射的方式也有一定的缺点,首先是性能会比前两种方案稍差,因为反射操作本身比直接访问字段要慢一些,如果是在高性能要求的场景下需要谨慎使用。其次,如果字段名有重复的情况,比如外层结构体和嵌入结构体有同名的导出字段,反射获取的时候外层字段会覆盖嵌入结构体的字段,这时候需要根据实际情况调整getAllFieldValues的逻辑,比如优先取外层字段或者优先取嵌入结构体的字段。另外,如果字段是私有的,反射也无法获取到,所以所有需要序列化的字段必须是导出的。
三种方案的对比与选择建议
第一种实现MarshalJSON手动拼接切片的方案,灵活度最高,性能最好,适合字段固定、不需要频繁变动,且对性能要求高的场景。比如一些固定的数据传输结构体,字段几乎不会变化,用这种方式最合适,代码逻辑清晰,没有额外的反射开销。
第二种临时结构体的方案,可读性好一些,适合字段数量中等,想要更直观看到字段对应关系的场景,本质上和第一种方案类似,只是多了一层结构体映射,性能和第一种差不多,也是手动维护顺序,适合字段变动不频繁的场景。
第三种反射的方案,可维护性最高,适合有多个类似的结构体需要实现同样的逻辑,或者字段经常变动的场景,虽然有一定的性能开销,但是在大多数业务场景下,这点性能损耗可以忽略不计。如果是在高频调用的核心接口里,也可以考虑用生成代码的方式代替反射,比如用go generate结合模板生成每个结构体的MarshalJSON方法,既保证了性能,又不需要手动维护代码。
在实际选择的时候,我们可以根据项目的实际需求来判断:如果结构体字段很少变动,优先选第一种;如果字段多,想要更清晰的对应关系,选第二种;如果有很多结构体需要实现同样的逻辑,或者字段经常变动,优先选第三种。另外需要注意的是,不管用哪种方案,嵌入结构体的字段都会被视为外层结构体的字段,所以在指定顺序的时候,需要把嵌入结构体的字段名也放到顺序列表中,否则对应的字段值不会被序列化到数组里。