json-iterator/go使用笔记

json-iterator是由滴滴开源的第三方json编码库,它同时提供GoJava两个版本。

为什么使用

这个库具有很多优点。最常被人称道的就是性能高于充满反射的官方提供的编码库——据说在编码结构体时候,Go版本的效率是encoding/json的6倍,而Java版本的效率是官方的3倍。

同时这个库还完全兼容官方库的api,替换官方库的方式不需要那么hack

import jsoniter "github.com/json-iterator/go"

type Student struct {
  ID       uint     `json:"id"`
  Age      uint8    `json:"age"`
  Gender   uint8    `json:"gender"`
  Name     string   `json:"name"`
  Location Location `json:"location"`
}

type Location struct {
  Country  string
  Province string
  City     string
  District string
}

var s = Student{
  ID:     1,
  Age:    27,
  Gender: 1,
  Name:   "yuchanns",
  Location: Location{
    Country:  "China",
    Province: "Guangdong",
    City:     "Shenzhen",
    District: "Nanshan",
  },
}

func Marshal() {
  // 使用ConfigCompatibleWithStandardLibrary完全兼容官方库
  json := jsoniter.ConfigCompatibleWithStandardLibrary
  result, _ := json.Marshal(&s)
  println(result)
  // output: {"id":1,"age":27,"gender":1,"name":"yuchanns","location":{"Country":"China","Province":"Guangdong","City":"Shenzhen","District":"Nanshan"}}
}

对于gin-gonic/gin,官方提供[1]$ go build -tags=jsoniter .命令在编译时替换编码库。更多信息请看文后补充

对于一些没有提供替换接口的库,也可以通过monkey补丁[2]来简单粗暴的替换掉官方编码库。

// 使用go get -u "bou.ke/monkey"获取猴子补丁库
import (
  "bou.ke/monkey"
  "encoding/json"
  jsoniter "github.com/json-iterator/go"
)

func MonkeyPatch() ([]byte, error) {
  monkey.Patch(json.Marshal, func(v interface{}) ([]byte, error) {
    println("via monkey patch")
    return jsoniter.Marshal(v)
  })

  sjson, err := json.Marshal(&s)

  if err == nil {
    println(string(sjson))
  }
}

注意,该补丁只需在main函数中定义一次就可以到处使用。

自定义编码解码器

当然,如果只是这些,并不值得我专门写一篇笔记来记录。值得一提的是json-iterator/go还提供了一个十分好用的自定义解码编码功能

问题

对于写惯了php orm的笔者而言,在使用go编写web业务过程中,最让我困惑的问题之一就是输出替换。

$user = Db::name("user")->where("name", "yuchanns")
    ->field("id, name, created_at, updated_at")
    ->find();
$user.created_at = date("Y-m-d H:i:s", $user.created_at);
echo json($user);

简单看上面这个例子,在$user对象中,created_at原本是一个int类型的字段(因为在表中这个字段对应int)。但是在使用api提供json格式输出给前端时,一般会将这个字段替换成ISO 8601[3]的日期格式,提供人性化阅读。

这么做在php这类可随意变更变量/字段类型的动态语言中当然没有问题,但是在golang这种确定了变量类型之后不可变更的语言里就大有问题了。

旧的解决方案

这个问题我和使用go语言开发的朋友们讨论过,自己也想出了一种解决方法,不过不是很满意。

这个解决方案就是通过结构体tag和多余字段来进行转换。

以下这段代码完整版可以参考笔者的代码库yuchanns/gobyexample

type User struct {
  ID          uint   `json:"id" gorm:"primary_key"`
  Name        string `json:"name"`
  CreatedAt   int64  `json:"-"`
  UpdatedAt   int64  `json:"-"`
  CreatedTime string `json:"created_at" gorm:"-"`
  UpdatedTime string `json:"updated_at" gorm:"-"`
}

func (u *User) AfterFind() {
  const ISO8601 = "2006-01-02 15:04:05"
  u.CreatedTime = time.Unix(u.CreatedAt, 0).Format(ISO8601)
  u.UpdatedTime = time.Unix(u.UpdatedAt, 0).Format(ISO8601)
}

结合jinzhu/gorm[4]对应的钩子函数,在CURD之后将CreatedTimeCreatedAt字段做转化,并使用tag令json输出或者gorm交互sql过程中忽视不必要的字段和更改字段名。

这样做有一定的记忆负担,使用者需要记得哪个字段是int64类型哪个是string类型以及该修改哪个字段,并且在各个钩子中写上一大堆转化代码。

使用RegisterTypeEncoder/RegisterTypeDecoder解决

而在json-iterator/go中有一个更优的解法,不需要在原有的结构体中添加多余的字段,也不需要在钩子函数中写一堆重复代码,使用者也不需要记忆修改哪个字段。

光是查阅RegisterTypeEncoder/RegisterTypeDecoder这两个方法的源码的注释,可能会一时间摸不着头脑,幸好官方中extra.timeAsInt64Codec使用了这两个方法可供我们参考[5]

func RegisterTypeEncoder(typ string, encoder ValEncoder) {
  typeEncoders[typ] = encoder
}

type ValEncoder interface {
  IsEmpty(ptr unsafe.Pointer) bool
  Encode(ptr unsafe.Pointer, stream *Stream)
}

首先这个注册函数接受两个参数,第一个参数为string类型,用来指定生效的类型,支持自定义类型;第二个字段是一个接口类型参数,也就是提供使用者进行自定义的入口。用户所需要做的就是实现这个jsoniter.ValEncoder接口,提供需要的方法,然后将接口的实现实例使用这个函数进行注册。

type locationAsStringCodec struct{}

func (locationAsStringCodec) IsEmpty(ptr unsafe.Pointer) bool {
  lc := *((*Location)(ptr))

  return lc.Country == "" && lc.Province == "" && lc.District == "" && lc.City == ""
}

func (locationAsStringCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
  lc := *((*Location)(ptr))

  stream.WriteString(strings.Join([]string{lc.Country, lc.Province, lc.City, lc.District}, " "))
}

func RegisterEncoder() {
  jsoniter.RegisterTypeEncoder("json_iterator.Location", &locationAsStringCodec{})
  sjson, err := jsoniter.Marshal(&s)

  if err == nil {
    println(string(sjson))
    // output: {"id":1,"age":27,"gender":1,"name":"yuchanns","location":"China Guangdong Shenzhen Nanshan"}
  }
}

如上,笔者使用locationAsStringCodec实现了jsoniter.ValEncoder接口。代码清晰易懂,只是涉及到了业务层不常用的unsafe.Pointer

unsafe.Pointer类型的变量用于获取传入的值,然后我们就可以在Encode方法中对字段的值进行任意的组合,最后使用*jsoniter.Stream写入到转化后的json中就可以了。在这个例子中笔者把location字段从Location结构类转化为一个字符串,前面小节提到的日期同理。

读者可以尝试自行实现jsoniter.ValDecoder接口。

补充

gin-gonic/gin,官方提供编译时替换编码库,每次在build中添加tags并不是很方便(当然也可以通过脚本控制),此外在写测试样例的时候也需要添加这个命令。

笔者使用Goland作为IDE,测试样例通常使用IDE的快捷功能进行。当读者也是如此情况,替换json编码库有两种选择:

  • 在IDE测试设置中添加Go tool arguments参数
  • pkg_test.go的头部添加build prama[6]

第一种方法的缺陷在于每个使用这段代码库的人都需要对IDE作出同样设置才能生效。

第二种方法则与IDE无关,编译指示写在了文件之中,所有获得这份代码的人都可以得到同样的设置。只不过使用IDE的时候进行测试方便一点。下面是pkg_test.go

// +build jsoniter

package json_iterator

import (
  "github.com/stretchr/testify/assert"
  "net/http"
  "net/http/httptest"
  "testing"
)

func TestSetupRouter() {
  router := SetupRoter()

  w := httptest.NewRecorder()
  req, _ := http.NewRequest("GET", "/jsoniter", nil)

  router.ServeHTTP(w, req)

  assert.Equal(t, http.StatusOK, w.Code)
  assert.Equal(t, fmt.Sprintln(`{"code":0,"data":{"id":1,"age":27,"gender":1,"name":"yuchanns","location":"China Guangdong Shenzhen Nanshan"}}`), w.Body.String())
}

以及对gin官方文档测试案例的稍微更改[7]

package json_iterator

import (
  "github.com/gin-gonic/gin"
  jsoniter "github.com/json-iterator/go"
  "net/http"
)

func SetupRoter() *gin.Engine {
  // to build with jsoniter, a build pragma should be in the main.go file
  // such as "// +build jsoniter"
  jsoniter.RegisterTypeEncoder("json_iterator.Location", &locationAsStringCodec{})
  r := gin.Default()
  r.GET("/jsoniter", func(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
      "code": 0,
      "data": &s,
    })
  })

  return r
}

同理正式编译时只需把上述编译指示放在main.go中即可。

补充二

jsoniter.RegisterTypeEncoder类似功能的函数还有jsoniter.RegisterFieldEncoder。它接受三个参数,分别是结构体类型(string)、结构体字段名(string)和自定义编码入口(jsoniter.ValEncoder)。

在上面的演示中,虽然我们成功地在输出Student结构体时把结构体中的Location转化成了一个字符串,但是这样一来单独输出Location结构体也会受到影响,所以正确的做法就是使用jsoniter.RegisterFieldEncoder。结果是只有在Student结构体输出时才会把Location结构体转化为一个字符串,其他地方则不受影响。

本文相关代码yuchanns/gobyexample


  1. 文档/jsoniter ↩︎

  2. bouk/monkey ↩︎

  3. wiki/ISO_8601 ↩︎

  4. jinzhu/gorm ↩︎

  5. extra/time_as_int64_codec.go ↩︎

  6. build constraints ↩︎

  7. 文档/test ↩︎