2

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

 2 years ago
source link: https://blog.wolfogre.com/posts/add-field-in-marshaling/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

2019/09/09.

Golang 1.0k+ 2

本文介绍了一个不需要修改 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"}]}
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK