golang json 序列化时添加额外字段

目录


写在前面

本文介绍了一个不需要修改 struct 的定义,就可以让该 struct 序列化成 json 格式时,在序列化结果中“凭空”多出来额外字段的方法。

这个方法原本是我在开发过程中碰到类似问题拍脑袋想出来的,本以为是原创,但悲催的是,在撰写本文之前,无意中发现 stack overflow 上已经有人提出了这个问题,最佳答案与我想出的方法完全一样,见:Can I use MarshalJSON to add arbitrary fields to a json encoding in golang?

为避免学舌,原本打算不写这篇文章了,但考虑目前貌似搜不出来这种方法的中文介绍,且 stack overflow 上的问答只说明的问题和解决方法,并未讨论这种方法的实际用途。所以我打算结合我的实际遭遇,把这件事的来龙去脉细细讲明,而如果你赶时间,可直接跳到方法一节。

背景

你应当对下面的代码不陌生:

package main

import (
    "encoding/json"
    "fmt"
)

type ColorGroup struct {
    ID     int      `json:"id"`
    Name   string   `json:"name"`
    Colors []string `json:"colors"`
}

func main() {
    group := ColorGroup{
        ID:     1,
        Name:   "Reds",
        Colors: []string{"Crimson", "Red", "Ruby", "Maroon"},
    }
    b, _ := json.Marshal(group)
    fmt.Printf("%s\n", b)
    // 输出:
    // {"id":1,"name":"Reds","colors":["Crimson","Red","Ruby","Maroon"]}
}

这是一个简单地将 golang struct 序列化成 json 的例子,我从官网文档上拷下来的,稍有改动。

需要说明的是,golang 为 json 操作提供了 encoding/json 这个内置包,但也有如 github.com/json-iterator/gogithub.com/tidwall/gjson 这样的三方包提供额外的功能特性,为了缩小讨论范围,这里只考虑使用内置包来解决问题。

问题

我打赌将 struct 序列化成 json,十有八九是为了调 HTTP 接口。这里要讨论的问题也是如此,我们假设有一个 POST 接口,接受的 json 描述了一个富文本内容,如同下面的示例:

{
    "title": "A apple", // 标题
    "content": [ // 内容,接受三种类型的元素:文本、图片、超链接
        {
            "type": "text", // 一个文本元素
            "text": "There is a apple."
        },
        {
            "type": "text", // 又一个文本元素
            "text": "It is red."
        },
        {
            "type": "image", // 一个图片元素
            "src": "http://example.com/apple.png"
        },
        {
            "type": "link", // 一个超链接元素
            "text": "more info",
            "href": "http://example.com/more.info.html"
        }
    ]
}

为方便调用这个接口,或者为了将 API 封装成 SDK,我们可能需要构造这样 golang 代码:

package main

import (
    "encoding/json"
    "fmt"
)

type Message struct {
    Title   string        `json:"title"`
    Content []interface{} `json:"content"`
}

type TextContent struct {
    Type string `json:"type"`
    Text string `json:"text"`
}

type ImageContent struct {
    Type string `json:"type"`
    Src  string `json:"src"`
}

type LinkContent struct {
    Type string `json:"type"`
    Text string `json:"text"`
    Link string `json:"link"`
}

func main() {
    msg := Message{
        Title:   "A apple",
        Content: []interface{}{
            TextContent{
                Type: "text",
                Text: "There is a apple.",
            },
            TextContent{
                Type: "text",
                Text: "It is red.",
            },
            ImageContent{
                Type: "image",
                Src:  "http://example.com/apple.png",
            },
            LinkContent{
                Type: "link",
                Text: "more info",
                Link: "http://example.com/more.info.html",
            },
        },
    }

    buffer, _ := json.Marshal(msg)
    fmt.Printf("%s\n", buffer)
    // 输出:
    // {"title":"A apple","content":[{"type":"text","text":"There is a apple."},{"type":"text","text":"It is red."},{"type":"image","src":"http://example.com/apple.png"},{"type":"link","text":"more info","link":"http://example.com/more.info.html"}]}
}

貌似很完美,但有没有发现让人不舒服的地方?是的,在这里:

TextContent{
    Type: "text",
    Text: "It is red.",
}

这个 struct 已经是 TextContent 类型了,却还需要一个 Type 字段来表明它是 text,且不说这样多此一举,如果其他人调用这段代码,并没有细读 API 文档(事实上,API 封装成 SDK 就是为了向使用者屏蔽接口细节),写出了这样的代码:

TextContent{
    Type: "txt",
    Text: "It is red.",
}

哦吼,编译发现不了错,运行发现不了错,只有 API 请求真正发出去了,才可能收到 API 返回的错误信息,这不给人添麻烦么?

思路

其实这里的主要矛盾,就是 TextContent 本身的类型已经说明它是文本元素了,并不需要一个 Type 字段来显性的地说明它是 text。换句话说,所有的 TextContent 实例的 Type 字段的值都应该固定是 text,那这个字段压根儿就没有存在的必要不是吗?

但 json 的数据类型系统并不买账,对于 json 来说,object 就是 object,没有“object 是什么类型”这么一说,如果要区分两个 object,只能通过设置不同的字段或不同的字段值。所以它并不认为 type 字段是可有可无的。

所以思路来了,能不能让 TextContent 没有 Type 字段,但是在序列化成 json 时自动添加上 "type": "text" 呢?如果能实现成这样,那使用时就相当优雅了:

    msg := Message{
        Title:   "A apple",
        Content: []interface{}{
            TextContent{
                Text: "There is a apple.",
            },
            TextContent{
                Text: "It is red.",
            },
            ImageContent{
                Src:  "http://example.com/apple.png",
            },
            LinkContent{
                Text: "more info",
                Link: "http://example.com/more.info.html",
            },
        },
    }

瞬间清爽了对吗?可惜 json 默认的序列化操作并不知道我的小心思,我们需要额外的代码。

方法

golang json 包在做序列化或反序列化时,会有默认的行为,并不需要我们操太多心。但如果我们非要操心,可以让目标数据类型实现特定的接口,来自定义序列化或反序列化行为,相应的接口定义可以在 encoding/json 的接口文档里看到:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

所以我们可以让上文中的三种 Content 分别实现 Marshaler 接口,以 TextContent 为例:

type TextContent struct {
    Text string `json:"text"`
}

func (c TextContent) MarshalJSON() ([]byte, error) {
    return json.Marshal(struct {
        TextContent
        Type string `json:"type"`
    }{
        TextContent: c,
        Type:        "text",
    })
}

但注意!这是一个错误的示范,这样的代码会出现无限递归直到栈溢出,因为 TextContent.MarshalJSON 函数里调用了 json.Marshal,而 json.Marshal 会隐性地调用 TextContent.MarshalJSON,这样一个死结就解不开了。

修补的方案是不让 json.Marshal 调用 TextContent.MarshalJSON,给 TextContent 换个马甲让 json.Marshal 认不出来:

type jsonTextContent TextContent

type TextContent struct {
    Text string `json:"text"`
}

func (c TextContent) MarshalJSON() ([]byte, error) {
    return json.Marshal(struct {
        jsonTextContent
        Type string `json:"type"`
    }{
        jsonTextContent: jsonTextContent(c),
        Type:            "text",
    })
}

这就是我想说的方法的全部内容了。

完整的示例代码:

package main

import (
    "encoding/json"
    "fmt"
)

type Message struct {
    Title   string        `json:"title"`
    Content []interface{} `json:"content"`
}

type jsonTextContent TextContent

type TextContent struct {
    Text string `json:"text"`
}

func (c TextContent) MarshalJSON() ([]byte, error) {
    return json.Marshal(struct {
        jsonTextContent
        Type string `json:"type"`
    }{
        jsonTextContent: jsonTextContent(c),
        Type:            "text",
    })
}

type jsonImageContent ImageContent

type ImageContent struct {
    Src  string `json:"src"`
}

func (c ImageContent) MarshalJSON() ([]byte, error) {
    return json.Marshal(struct {
        jsonImageContent
        Type string `json:"type"`
    }{
        jsonImageContent: jsonImageContent(c),
        Type:             "image",
    })
}

type jsonLinkContent LinkContent

type LinkContent struct {
    Text string `json:"text"`
    Link string `json:"link"`
}

func (c LinkContent) MarshalJSON() ([]byte, error) {
    return json.Marshal(struct {
        jsonLinkContent
        Type string `json:"type"`
    }{
        jsonLinkContent: jsonLinkContent(c),
        Type:            "link",
    })
}

func main() {
    msg := Message{
        Title: "A apple",
        Content: []interface{}{
            TextContent{
                Text: "There is a apple.",
            },
            TextContent{
                Text: "It is red.",
            },
            ImageContent{
                Src:  "http://example.com/apple.png",
            },
            LinkContent{
                Text: "more info",
                Link: "http://example.com/more.info.html",
            },
        },
    }

    buffer, _ := json.Marshal(msg)
    fmt.Printf("%s\n", buffer)
    // 输出:
    // {"title":"A apple","content":[{"text":"There is a apple.","type":"text"},{"text":"It is red.","type":"text"},{"src":"http://example.com/apple.png","type":"image"},{"text":"more info","link":"http://example.com/more.info.html","type":"link"}]}
}