玩转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还是会找你麻烦。

终极解决方案?

一个合理的方案,必须同时满足

  1. 性能损耗足够低。至少保证性能跟json.Marshal在同一数量级
  2. 保持扩展能力。风格转换只影响默认行为,对于自定义tag,仍然需要支持
  3. 易于维护。不污染项目环境,不影响外部依赖

那要怎么做呢?

基本思想

要解析一份数据结构,除了转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
PASS
ok      github.com/dovejb/quicktag      3.936s
root@dev:/w/try# go test github.com/dovejb/quicktag -bench=.
goos: linux
goarch: amd64
pkg: github.com/dovejb/quicktag
BenchmarkQMarshal-4              1000000              1635 ns/op
BenchmarkJsonMarshal-4           1000000              2024 ns/op
PASS
ok      github.com/dovejb/quicktag      3.714s

QMarshal为什么比原生还快了一点……(多次执行,结果也存在调转的情况,不过此法性能无损是确定的)

欢迎交流,共同进步!

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券