前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >玩转golang——JSON高性能自动字段名

玩转golang——JSON高性能自动字段名

原创
作者头像
大福加冰
修改2019-08-05 11:32:27
3.3K0
修改2019-08-05 11:32:27
举报
文章被收录于专栏:大福的云

前言

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来自定义字段名。

代码语言:go
复制
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。

这个很爽啊,写几行非常简单的代码,就解决了问题!

代码语言:go
复制
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改造如下

代码语言:javascript
复制
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方法,可以随意构造动态类型!拿笔来!

代码语言:javascript
复制
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分钟再撸一个。测试一下

代码语言:go
复制
type Person struct {
    Name   string
    Age    int 
    Avatar struct {
        Url    string
        Height int 
        Width  int 
    }   
}

func main() {
    fmt.Println(MyStruct(reflect.TypeOf(Person{})))
}

输出美化后是

代码语言:go
复制
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定义引用了自己,就会触发栈溢出。

栈溢出

在树或链表定义中经常能见到,节点类型包含了指向自己的指针。用自己定义自己,就是自引用

代码语言:go
复制
type Node struct {
    V int
    Next *Node
}

上述代码因为递归处理每个类型,如果存在自引用,就卡在自己身上出不来了。

不论是直接引用自己,还是隔代引用自己,或是子结构存在自引用,都会栈溢出。

遗憾的是,这个问题碰到了go reflect的天花板:go目前(1.12)没有办法通过reflect定义自引用struct。

怎么办?好不容易才找到正确的道路,就这么夭折了吗?

幸运的是,我们主要面对的场景是网络协议和数据库。事实上,协议和数据库是不会存在无限自引用结构的。不论链表还是树,都会用数组来存储。即便某个业务(或某个有个性的前端)非要用自引用的协议,也不可能是无限层的,现实的业务必然有其上限。

所以我们设定一个合理的上限,在递归中记录同一个struct出现的次数,达到后再出现就不再处理,即可满足实践中所有场景。

使用动态类型

现在我们获得了神奇的动态类型,赶紧写代码试试。

代码语言:javascript
复制
myStruct := MyStruct(Person{})
//然后咋写?

myStruct是个reflect.Type,这要怎么用啊?

这是什么鬼
这是什么鬼
代码语言:javascript
复制
//一般而言要这么用
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的血。

一个对象,本质就是一段内存而已。其含义都是类型赋予的。

而类型,其实就是内存的解释器而已。

只要用动态类型去解释静态对象的内存,就可以了!

代码语言:go
复制
p := Person{
    Name: "大福加冰",
    Age: 29,
}
myPerson := MyStruct(p)
dynP := reflect.NewAt(myPerson, unsafe.Pointer(&p))

搞定!

注意:在创建动态类型时,注意保证其与静态类型的格式完全一致。遇到自引用类型终点时,用等长的[]byte来补位即可。

调用方式

上面利用reflect来构造动态类型对象,还是有很多限制的。比如使用转换函数

代码语言:go
复制
// 入参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时可以使用

代码语言:javascript
复制
p := Person{}
json.Marshal(DynamicInstance(p, myStruct))

来获得动态结果。但Unmarshal时,只能传动态对象去接收结果,再转换成静态类型供代码使用。

代码语言:javascript
复制
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对内存动手!

代码语言:go
复制
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中可以找到。

样例

代码语言:go
复制
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包中全局变量进行修改,可以自定义转换风格和受影响标签

代码语言:go
复制
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, 均会保留
}

性能测试

代码语言:go
复制
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为什么比原生还快了一点……(多次执行,结果也存在调转的情况,不过此法性能无损是确定的)

欢迎交流,共同进步!

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 从一个久远的争论说起
  • 原生的JSON字段命名方式
  • 朴素自动化方案
    • 代码生成器
      • 修改JSON包
        • 使用map
          • 优化map方案
          • 终极解决方案?
            • 基本思想
              • 栈溢出
                • 使用动态类型
                  • 内存解释器
                    • 调用方式
                      • 终结者unsafe
                        • 结果
                        • 后记
                        • 开源
                        相关产品与服务
                        数据库
                        云数据库为企业提供了完善的关系型数据库、非关系型数据库、分析型数据库和数据库生态工具。您可以通过产品选择和组合搭建,轻松实现高可靠、高可用性、高性能等数据库需求。云数据库服务也可大幅减少您的运维工作量,更专注于业务发展,让企业一站式享受数据上云及分布式架构的技术红利!
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档