玩转golang——JSON高性能自动字段名
原创前言
golang最近在中国非常火爆,尤其是后端服务开发场景。原生并发支持、优秀的性能、统一的风格,极大提升了开发效率。笔者用golang独立开发过不少小中型系统,写了几万行代码,确实很爽。
不过,统一的风格,也带来了一些问题。
从一个久远的争论说起
There are only two hard things in Computer Science: cache invalidation and naming things. by Phil Karlton
计算机科学只有两大难题,命名占了一半。
腾讯QQ的程序员喜欢匈牙利命名法,比如szName,stUser,astUserList,bOk。在名字前面加上类型的标记,写起来很有安全感。
linux开发或许最喜欢下划线命名法(GNU编码风格),比如do_linuxrc,release_libc_mem。单词之间有下划线分隔,更易读。
还有人习惯于驼峰命名法,尤其是几年前的前端,因为jQuery全是这样的API,连自己都是。这种风格节约空间,易读性也不错。
到了golang这里,情况就变了。公共字段、函数、方法,都必须使用大写字母开头,为了可读性,基本上只能使用Pascal风格,如ListenAndServe。
笔者在编码时,是比较认可这种风格的。公有自定义类型、方法、函数和结构体字段,使用Pascal风格,私有内容用驼峰式,局部变量用小写,写代码很清爽。
但是在网络协议和 数据库 存储中,Pascal风格比较难受。
- 一方面,每个字母都大写不符合英语阅读习惯,且英文单词间总是有空格,Pascal过于紧凑,不利于浏览协议、日志和数据
- 另一方面,在手敲协议或数据库语句时,每个字母都可能出现大写要按shift,主shift手经常同时按两个键。长期写代码的老油条一定都有这种感觉,左手按shift会导致手型变化,可能手腕会旋转,再去按字母键的话,效率比较低,且手腕更易磨损。
用下划线风格的好处,还不止这些。
- 如果数据接入 自然语言处理 的话,只有下划线风格可以方便地获得关键词
- 搜索系统同理
- 在使用文本查找的方式阅览代码或数据库时,通常不区分大小写,其他风格会出现很多跨词结果,造成干扰
- ……
不仅适合阅读,提升效率,便于扩展,甚至还能避免一些健康风险。
所以,在数据库和网络协议上,下划线命名法才是首选。
那么,用go语言时,如何让struct字段变成下划线风格呢?
原生的JSON字段命名方式
golang在默认情况下,json.Marshal的结果就是字段名,开发者也可以通过json tag来自定义字段名。
type Student struct {
Name string `json:"name"`
MathScore int `json:"math_score"`
StudentNO string `json:"student_no"`
}
这很好,且没有性能损失。只是多写了几个字而已。
对于一个只包含三五个,十个八个struct的系统而言,多写几行代码不成问题。但一个有几十个上百个struct的业务,也要一个一个写过来吗?
就算你敢写,我也不敢用。机械化重复的工作,人力太不可靠。执行的人可能出错,找人检查一样可能出错。几千条配置,还可能继续增加,完全依赖手写?太危险了。
朴素自动化方案
代码生成器
通过“某种方式”,获取代码中的全部结构体,自动生成设置了tag的新代码,再编译。
这种方式运行时效率是最高的,但是真的可行吗?
- 首先,go并未提供直接获取包中所有结构体的原生方法,所以只能自己做代码解析。
- 其次,并不是所有结构体都是type X struct开头的简单模式。在go中,匿名结构体有很多漂亮的用法,比如快速实现JSON数据的平铺组装。为了适配struct的各种场景,不得不做更深入的解析。
- 最后,代码生成器作为外部工具,很难管理生效范围。项目依赖外部包是否也要使用此法生成?如何界定哪里应该使用转换,哪里不用?随着项目的膨胀,这将会是一场灾难。
成本高,配置复杂,是其硬伤。
笔者曾使用go-protobuf来部分解决此问题,需要单独管理proto文件,在makefile中处理生成逻辑。后来需要对bson也照此处理,不得不去修改pb源码才支持。虽然省了手写tag,但依然要手写pb。每个新项目还要带着一坨定制环境。
非常难受。
修改JSON包
另一个直观的方式是修改json包。如无tag指定,golang默认使用代码中的字段名,在这里加一个逻辑,变成自己想要的风格,不就行了吗?
当然行了!而且开发成本和运行成本,都非常低!
但还是有几个问题:
-
直接修改GOROOT代码?
- 就掉坑里了。其它引用了json的包,全都受到了影响。
-
fork一份,只给自己用?
- 当其他格式也需要做转换时,就都要fork一份(不过一共也没几种格式)
- 如果想要修改bson,那需要将其所属的mgo包也一并带走,不然无法操作数据库。
- 如果引用了其他包含json/bson/mgo的包,要把这些包通通带走,并把其引用json/bson/mgo的代码改为指向自己的。
- 如果引用了“引用了上述其他包”的包,要把这些包通通带走,并……
每个引用都要想办法处理,还要考虑引用了那个引用的引用,子子孙孙无穷尽也。写个代码还要发扬一下愚公移山的精神
使用map
开发自己的Marshal函数,先把原始struct marshal一次,再unmarshal成map,再处理map key风格,再用json.Marshal。
这个很爽啊,写几行非常简单的代码,就解决了问题!
func MyMarshal(obj interface{}) (b []byte, e error) {
b, e = json.Marshal(obj)
if e != nil {
return
var m map[string]interface{}
e = json.Unmarshal(b, &m)
if e != nil {
return
HandleMapStyle(m)
return json.Marshal(m)
func HandleMapStyle(m map[string]interface{}) {
for key, value := range m {
switch v := value.(type) {
case []interface{}:
for i := range v {
if elem, ok := v.(map[string]interface{}); ok {
HandleMapStyle(elem)
case map[string]interface{}:
HandleMapStyle(v)
delete(m, key)
m[strings.ToLower(key)] = value //此处简化处理, 全变小写
}
写完之后发现,这个功能比想象中稍复杂一点,用了30行左右。但也足够简单了。下次招人的时候,我就先拿这个问题来考,10分钟以内写出来并考虑到一些特殊情况,说明对json包、go类型和递归,都有一些基本掌握。
那么这种方案好不好呢?我相信做过开发的一眼就能看出来,非常差。
- map丢失了原来struct的信息,无法再自定义字段名。不过这个可以通过在key上打标记来解决。
- 性能非常差。构造了一个简单struct测试,性能开销是原生方法的16倍。
这就意味着,你开发的服务,原来一台机器就能干的活,现在可能需要加10台。
优化map方案
上一个方案中,因为做了额外的Marshal和Unmarshal,导致了不必要的开销。那么,如果我直接用reflect构造map,是不是会好一些呢?
会的。
我们直接使用github.com/fatih/structs来处理struct to map,MyMarshal改造如下
import "github.com/fatih/structs"
func MyMarshal(obj interface{}) (b []byte, e error) {
m := structs.Map(obj)
HandleMapStyle(m)
return json.Marshal(m)
}
经过实测,性能损耗约12倍。boss还是会找你麻烦。
终极解决方案?
一个合理的方案,必须同时满足
- 性能损耗足够低。至少保证性能跟json.Marshal在同一数量级
- 保持扩展能力。风格转换只影响默认行为,对于自定义tag,仍然需要支持
- 易于维护。不污染项目环境,不影响外部依赖
那要怎么做呢?
基本思想
要解析一份数据结构,除了转map去搞,就只要用reflect。
所以,我们要充分利用reflect的能力,给struct的字段加上tag。
那不是很简单?go reflect包提供了StructOf方法,可以随意构造动态类型!拿笔来!
func MyStruct(t reflect.Type) reflect.Type {
if t.Kind() != reflect.Struct {
panic("invalid type")
fs := make([]reflect.StructField, t.NumField())
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fs[i] = f
// 目前不考虑其他tag
if f.Tag.Get("json") == "" {
fs[i].Tag = reflect.StructTag(`json:"` + strings.ToLower(f.Name) + `"`)
var ftype reflect.Type
switch f.Type.Kind() {
case reflect.Struct:
ftype = MyStruct(f.Type)
case reflect.Slice:
if f.Type.Elem().Kind() == reflect.Struct {
ftype = reflect.SliceOf(MyStruct(f.Type.Elem()))
} else if f.Type.Elem().Kind() == reflect.Slice {
panic("multi-d slice not supported") //多维数组暂不考虑
default: //样例暂中不考虑Ptr/Map/Array等场景, 处理方式类似
ftype = f.Type
fs[i].Type = ftype
return reflect.StructOf(fs)
}
10分钟再撸一个。测试一下
type Person struct {
Name string
Age int
Avatar struct {
Url string
Height int
Width int
func main() {
fmt.Println(MyStruct(reflect.TypeOf(Person{})))
}
输出美化后是
struct {
Name string "json:\"name\""
Age int "json:\"age\""
Avatar struct {
Url string "json:\"url\"";
Height int "json:\"height\"";
Width int "json:\"width\""
} "json:\"avatar\""
}
完美,成功设置上了。赶紧发布上线!
上面这份代码,有可能会触发go语言百年难遇,但程序员几乎全都知道的一个panic。
……
……
……
如果哪位同学看到这里就想到了,请在回复中留言。虽然没有物质奖励,笔者会替大家佩服你一下。
stack overflow
它曾是C开发者的噩梦,在go里几乎见不到。但是在这里,如果struct定义引用了自己,就会触发栈溢出。
栈溢出
在树或链表定义中经常能见到,节点类型包含了指向自己的指针。用自己定义自己,就是自引用
type Node struct {
V int
Next *Node
}
上述代码因为递归处理每个类型,如果存在自引用,就卡在自己身上出不来了。
不论是直接引用自己,还是隔代引用自己,或是子结构存在自引用,都会栈溢出。
遗憾的是,这个问题碰到了go reflect的天花板:go目前(1.12)没有办法通过reflect定义自引用struct。
怎么办?好不容易才找到正确的道路,就这么夭折了吗?
幸运的是,我们主要面对的场景是网络协议和数据库。事实上,协议和数据库是不会存在无限自引用结构的。不论链表还是树,都会用数组来存储。即便某个业务(或某个有个性的前端)非要用自引用的协议,也不可能是无限层的,现实的业务必然有其上限。
所以我们设定一个合理的上限,在递归中记录同一个struct出现的次数,达到后再出现就不再处理,即可满足实践中所有场景。
使用动态类型
现在我们获得了神奇的动态类型,赶紧写代码试试。
myStruct := MyStruct(Person{})
//然后咋写?
myStruct是个reflect.Type,这要怎么用啊?
//一般而言要这么用
inst := reflect.New(myStruct)
inst.Elem().FieldByName("Name").SetString("大福加冰")
inst.Elem().FieldByName("Avatar").FieldByName("Height").SetInt(1080)
json.Marshal(inst.Interface())
动态类型虽然是由静态类型生成的,但本质上不是一个东西,无法直接类型转换。为自引用做了一次限制后,实际上也已经完全不一样了。
难道只能想办法把静态对象的字段值一个个copy到动态类型里?但这样类型检查+copy,性能真的能比map好吗?
世界上最遥远的距离,是动态对象在我面前,我却过不去。
看到这里如果有高性能思路的同学,可以在评论留言,笔者佩服+1
内存解释器
go是开发语言中的新锐,但骨子里流淌着c的血。
一个对象,本质就是一段内存而已。其含义都是类型赋予的。
而类型,其实就是内存的解释器而已。
只要用动态类型去解释静态对象的内存,就可以了!
p := Person{
Name: "大福加冰",
Age: 29,
myPerson := MyStruct(p)
dynP := reflect.NewAt(myPerson, unsafe.Pointer(&p))
搞定!
注意:在创建动态类型时,注意保证其与静态类型的格式完全一致。遇到自引用类型终点时,用等长的[]byte来补位即可。
调用方式
上面利用reflect来构造动态类型对象,还是有很多限制的。比如使用转换函数
// 入参src必须是对象指针,不然只能copy一遍对象内存
// 此处只考虑对象指针的情况(如非指针, sv.Pointer()会panic)
func TypeConvert(src interface{}, dstType reflect.Type) interface{} {
sv := reflect.ValueOf(src)
return reflect.NewAt(dstType, sv.Pointer()).Interface()
}
1. 只有Marshal可以流畅调用
Marshal时可以使用
p := Person{}
json.Marshal(DynamicInstance(p, myStruct))
来获得动态结果。但Unmarshal时,只能传动态对象去接收结果,再转换成静态类型供代码使用。
dp := reflect.New(myStruct)
json.Unmarshal(buffer, dp.Interface())
pIntf := TypeConvert(dp.Interface(), reflect.TypeOf(Person{}))
var p *Person
p = pIntf.(*Person)
//到这里 才能获得原始Person对象, 供代码使用
2. 为了调用流畅性,只能自己封装Marshal/Unmarshal函数。但这样,就失去了扩展性
如果业务要对bson/xml使用此特性,只能自己重写方法。动态类型转换的公共能力,不可能给每种协议格式都专门写一个Marshal/Unmarshal
终结者unsafe
Too safe, sometimes naive.
reflect还是太safe了。我们要直接用unsafe对内存动手!
import (
. "unsafe"
. "reflect"
type emptyInterface struct {
pt Pointer
pv Pointer
func PointerOfType(t Type) Pointer {
p := *(*emptyInterface)(Pointer(&t))
return p.pv
func TypeCast(src interface{}, dstType Type) (dst interface{}) {
srcType := TypeOf(src)
eface := *(*emptyInterface)(Pointer(&src))
if srcType.Kind() == Ptr {
eface.pt = PointerOfType(PtrTo(dstType))
} else {
eface.pt = PointerOfType(dstType)
dst = *(*interface{})(Pointer(&eface))
return
}
上述代码是类型解释的终极杀器:直接解释入参的原始内存,避免了任何copy,Unmarshal可一步到位。
用map记录静态到动态类型的映射,每次操作时查找缓存,将TypeCast加一层快速调用封装,就可以优雅地写代码了!
结果
- 因为动态类型只需创建一次,这个方案本质上只多做了一次map查询和内存解释。几乎没有性能损耗
- 自定义tag仍然充分支持。
- 动态类型仅处理入参,对其他引用依赖没有影响。
完美!
后记
golang是非常秩序、优雅的语言。在腾讯,没有历史包袱的很多项目团队,都已经开始尝试用go来实现新业务了。
笔者作为后台开发,曾使用c/c++/python做主开发语言,但现在会用golang来解决所有问题。
有人会认为,语言只是工具,不必太执着。这是完全正确的。
但是,人类社会的每一科技革命,都是工具带来的。火车、马车都是工具,电力、煤炭,也都工具,互联网和书信,也都是工具。好的工具,意味着更高的效率、性能、可维护性……
golang就是生产力。
开源
本文所构建的模块,在 https://github.com/dovejb/quicktag 中可以找到。
样例
package main
import (
"encoding/json"
"fmt"
. "github.com/dovejb/quicktag"
"reflect"
type Person struct {
Name string
Age int
MyChildren []Person
func main() {
p := Person{
Name: "dovejb",
Age: 6,
MyChildren: []Person{
Person{
Name: "baby",
Age: 3,
var p2 Person
buf, _ := json.Marshal(Q(p))
fmt.Println(string(buf))
// {"name":"dovejb","age":6,"my_children":[{"name":"baby","age":3,"my_children":null}]}
json.Unmarshal(buf, Q(&p2))
fmt.Println(reflect.DeepEqual(p, p2))
// true
}
对quicktag包中全局变量进行修改,可以自定义转换风格和受影响标签
import "github.com/dovejb/quicktag"
import "time"
func init() {
// 自定义转换风格, 默认quicktag.PascalToUnderline, 无omitempty
quicktag.StyleConvert = MyStyleConvertFunc // func(string) string
// 自定义受影响业务tag, 默认 []string{"json","bson"}
quicktag.TagNames = []string{"json", "bson"}
// 自定义自引用最大层级, 默认5
quicktag.MaxSelfRefLevel = 3
// 注意!!!
// 如果某类型自己包含了MarshalJSON/UnmarshalJSON等方法,如time.Time,请在字段后手动添加quicktag:"-"来跳过
data := struct {
ID string `bson:"_id"`
CreatedTime time.Time `quicktag:"-"`
// struct中原有的tag, 均会保留
}
性能测试
root@dev:/w/try# go test github.com/dovejb/quicktag -bench=.
goos: linux
goarch: amd64
pkg: github.com/dovejb/quicktag
BenchmarkQMarshal-4 1000000 1343 ns/op
BenchmarkJsonMarshal-4 1000000 1565 ns/op
ok github.com/dovejb/quicktag 3.936s
root@dev:/w/try# go test github.com/dovejb/quicktag -bench=.
goos: linux