为什么需要对struct进行转换?
一般而言,不同服务的协议,在生成桩代码后会放在不同仓库中。由于协议引用,相同的数据结构也会重新生成一份。而实践中,一些打包服务需要将上游的数据结构进行透传。由于数据结构所在包不同,被视为了不同的类型,所以无法直接赋值,此时就需要将上游的数据结构转换成当前服务response中的对应结构体。
现有的项目里也存在大量的struct的转换,里面的逻辑多是挨个成员拷贝赋值,代码量很多,开发效率较低。直觉告诉我,这种事情应该有更优雅的实现方式,于是开始调研。
如何进行优雅地转换?
先说结论,目前有以下6种方案:
- 手撸代码进行转换。目前项目现状,开发效率较低。
- golang类型直接转换。开发效率高,性能也高,但适用场景较窄。
- 利用三方库,先序列化再反序列化。适用绝大多数场景,性能较低。
- 专门的拷贝三方库。适用绝大多数场景,利用反射用于拷贝,性能较低。
- 利用unsafe.pointer进行转换。指针转换,效率几乎无损耗,但存在风险。
- goverter转换。利用工具自动生成转换代码,效率高,略有学习成本。
测试结构体
// 定义两个相似的结构体
type ANested struct {
A string
B int
C *string
D []int
E []*string
}
type A struct {
Nested *ANested
A string
}
type BNested struct {
A string
B int
C *string
D []int
E []*string
}
type B struct {
Nested *BNested
A string `json:"A"`
}
A、B成员中的ANested与BNested是不同类型。
1.手撸转换代码
func convertByHand(t *A) {
var p1 B
p1.Nested = &BNested{}
p1.Nested.A = t.Nested.A
p1.Nested.B = t.Nested.B
p1.Nested.C = t.Nested.C
p1.Nested.D = t.Nested.D
p1.Nested.E = t.Nested.E
p1.A = t.A
}
就不介绍了,和现状一致。只要手速够快,开发效率也能提上去(此处放置一个大狗头)。
2.golang类型直接转换
调研发现,golang是支持struct类型直接转换的,看示例:
func main() {
// A与B的元素名及类型完全一致,tag可以不一致
m1 := ANested{A: "hello world"}
m2 := BNested(m1)
fmt.Println(m2)
}
golang中若结构体的成员类型均一致,则可直接进行转换,go1.8后会忽略tag,转换限制进一步宽松。
这个方式为golang编译器支持,性能较高。但存在一个很大的问题,就是当struct元素的类型不一致时,即便其内部结构一样,也无法转换。
func main() {
t1 := A{A: "hello world", Nested: &ANested{
A: "",
B: 12,
D: []int{1, 2, 3, 4, 5},
},
}
// 编译报错
_ = B(t1)
}
golang社区也有人提出过支持嵌套类型自动转换,但没得到官方的支持,原因见:https://github.com/golang/go/issues/46205
3. 将struct进行序列化,然后再逆序列化转换
基本思路是用三方库将struct序列化为[]byte,然后再反序列化到新的结构体中。
该方案不受方案2的限制,数据结构中相同的成员可被直接赋值,即便不同,也可以通过三方库的tag映射进行转换。
示例:
func jsonProcess(t *A) {
// json序列化方式
bs2, _ := json.Marshal(t)
var p1 B
_ = json.Unmarshal(bs2, &p1)
}
上面的示例,用了json的序列化方式进行转换,可自由替换序列化方式提高性能,benchmark见后续。
4. 用于拷贝的三方库
在调研的过程中也发现了一些用于拷贝的库,也可用于这里的转换,如copier(https://github.com/jinzhu/copier)
这里没有深入研究原理,大体看了下代码应该是用反射来遍历field,还有诸多的特性,如根据method来copy等。
示例:
func copierProcess(t *A) {
var p1 B
_ = copier.Copy(&p1, t)
}
copier使用起来比较方便,只需要一行代码即可,但由于未对该库做深入调研,需自行评估使用。
5. 利用unsafe.pointer进行转换
当结构体数据内存布局相同时,可尝试使用unsafe.pointer进行转换,示例:
func main() {
t1 := A{A: "hello world", Nested: &ANested{
A: "",
B: 12,
D: []int{1, 2, 3, 4, 5},
},
}
m := (*B)(unsafe.Pointer(&t1))
fmt.Printf("m:[%+v]", m)
}
该方案性能几乎无损耗,但存在风险。如果两个结构体的内存布局存在差异,在访问时,可能会导致panic。对于业务场景,如果转换的一方的数据结构发生变更,导致双方内存布局不一致,则有可能会出现问题。
该方案未在实际场景尝试,这里仅列出,不做推荐。
6. 转换代码生成工具
方案1的性能很高,但开发效率过低,能否把开发效率提上去呢?自然也是有的:
goverter:https://github.com/jmattheis/goverter
这个工具可以自动生成转换代码,并且支持自定义field映射。
示例:
// goverter:converter
type Converter interface {
ConvertA(*A) *B
}
type ConverterImpl struct{}
func (c *ConverterImpl) ConvertA(source *A) *B {
var pMainB *B
if source != nil {
var mainB B
mainB.Nested = c.pMainANestedToPMainBNested((*source).Nested)
mainB.A = (*source).A
pMainB = &mainB
}
return pMainB
}
func (c *ConverterImpl) pMainANestedToPMainBNested(source *ANested) *BNested {
var pMainBNested *BNested
if source != nil {
var mainBNested BNested
mainBNested.A = (*source).A
mainBNested.B = (*source).B
var pString *string
if (*source).C != nil {
xstring := *(*source).C
pString = &xstring
}
mainBNested.C = pString
var intList []int
if (*source).D != nil {
intList = make([]int, len((*source).D))
for i := 0; i < len((*source).D); i++ {
intList[i] = (*source).D[i]
}
}
mainBNested.D = intList
var pStringList []*string
if (*source).E != nil {
pStringList = make([]*string, len((*source).E))
for j := 0; j < len((*source).E); j++ {
var pString2 *string
if (*source).E[j] != nil {
xstring2 := *(*source).E[j]
pString2 = &xstring2
}
pStringList[j] = pString2
}
}
mainBNested.E = pStringList
pMainBNested = &mainBNested
}
return pMainBNested
}
func goverterProcess(t *A) {
c := ConverterImpl{}
_ = c.ConvertA(t)
}
生成转换代码,可参照库文档操作。
Benchmark
package main
import (
"encoding/json"
"fmt"
"time"
"github.com/jinzhu/copier"
"github.com/vmihailenco/msgpack/v5"
)
var (
referAsAB = referas.NewReferAs(new(A), new(B)).(func(*A) *B)
)
// goverter:converter
type Converter interface {
ConvertA(*A) *B
}
type ConverterImpl struct{}
func (c *ConverterImpl) ConvertA(source *A) *B {
var pMainB *B
if source != nil {
var mainB B
mainB.Nested = c.pMainANestedToPMainBNested((*source).Nested)
mainB.A = (*source).A
pMainB = &mainB
}
return pMainB
}
func (c *ConverterImpl) pMainANestedToPMainBNested(source *ANested) *BNested {
var pMainBNested *BNested
if source != nil {
var mainBNested BNested
mainBNested.A = (*source).A
mainBNested.B = (*source).B
var pString *string
if (*source).C != nil {
xstring := *(*source).C
pString = &xstring
}
mainBNested.C = pString
var intList []int
if (*source).D != nil {
intList = make([]int, len((*source).D))
for i := 0; i < len((*source).D); i++ {
intList[i] = (*source).D[i]
}
}
mainBNested.D = intList
var pStringList []*string
if (*source).E != nil {
pStringList = make([]*string, len((*source).E))
for j := 0; j < len((*source).E); j++ {
var pString2 *string
if (*source).E[j] != nil {
xstring2 := *(*source).E[j]
pString2 = &xstring2
}
pStringList[j] = pString2
}
}
mainBNested.E = pStringList
pMainBNested = &mainBNested
}
return pMainBNested
}
type ANested struct {
A string
B int
C *string
D []int
E []*string
}
type A struct {
Nested *ANested
A string
}
type BNested struct {
A string
B int
C *string
D []int
E []*string
}
type B struct {
Nested *BNested
A string `json:"A"`
}
// StringPtr returns Pointer of string
func StringPtr(val string) *string {
return &val
}
func main() {
// A的与B的的类型不一致
t1 := A{A: "hello world", Nested: &ANested{
A: "",
B: 12,
C: StringPtr("hello world"),
D: []int{1, 2, 3, 4, 5},
E: []*string{StringPtr("hello world"),
StringPtr("hello world"),
StringPtr("hello world"),
StringPtr("hello world"),
StringPtr("hello world"),
StringPtr("hello world"),
StringPtr("hello world")},
},
}
convertByHand(&t1)
goverterProcess(&t1)
msgpackProcess(&t1)
jsonProcess(&t1)
copierProcess(&t1)
}
func convertByHand(t *A) {
s1 := time.Now()
for i := 0; i < 100000; i++ {
var p1 B
p1.Nested = &BNested{}
p1.Nested.A = t.Nested.A
p1.Nested.B = t.Nested.B
p1.Nested.C = t.Nested.C
p1.Nested.D = t.Nested.D
p1.Nested.E = t.Nested.E
p1.A = t.A
}
d := time.Since(s1)
fmt.Printf("byhand elapse:%v \n", d)
}
func copierProcess(t *A) {
s1 := time.Now()
for i := 0; i < 100000; i++ {
var p1 B
_ = copier.Copy(&p1, t)
//if err != nil {
// fmt.Printf("err:%v", err)
//}
}
d := time.Since(s1)
fmt.Printf("copier elapse:%v \n", d)
}
func msgpackProcess(t *A) {
s1 := time.Now()
for i := 0; i < 100000; i++ {
// msgpack序列化方式
bs1, _ := msgpack.Marshal(t)
var p1 B
_ = msgpack.Unmarshal(bs1, &p1)
// fmt.Printf("p1:[%+v],err:[%+v]", p1, err)
}
d := time.Since(s1)
fmt.Printf("msgpack elapse:%v \n", d)
}
func jsonProcess(t *A) {
s1 := time.Now()
for i := 0; i < 100000; i++ {
// json序列化方式
bs2, _ := json.Marshal(t)
var p1 B
_ = json.Unmarshal(bs2, &p1)
// fmt.Printf("p1:[%+v],err:[%+v]", p1, err)
}
d := time.Since(s1)
fmt.Printf("json elapse:%v \n", d)
}
func goverterProcess(t *A) {
c := ConverterImpl{}
s1 := time.Now()
for i := 0; i < 100000; i++ {
// json序列化方式
_ = c.ConvertA(t)
// fmt.Printf("p1:[%+v],err:[%+v]", p1, err)
}
d := time.Since(s1)
fmt.Printf("goverter elapse:%v \n", d)
}
在ebian 10 linux 5.4,AMD 16核,64G测试数据:
– byhand elapse:42.608µs
– goverter elapse:64.776683ms
– msgpack elapse:675.421475ms
– json elapse:858.326929ms
– copier elapse:449.271056ms
观察数据,可以发现:
1. 手撸代码最高性能较高,这是因为仅仅是浅拷贝操作,不涉及大量数据拷贝。
2. goverter性能第二,这是因为goverter生成的代码会做deep copy,相比于前者效率稍低。
3. copier的效率性能第三,猜测是因为运行时的大量反射操作影响了效率。
4. 序列化方案效率最低,msgp效率会相对较高。
另外,当转换的数据量(silce,map元素数量)变多时:
1. pointer转换不会增加耗时,因为本质上是指针转换,耗时与数据大小无关。
2. 手撸代码耗时增加较少,浅拷贝不会去拷贝slice,map元素。
3. 其余方案与数据量正相关,耗时会增长。
结论
综上建议:
1. 一般情况下,我们可以选择goverter的方案,平衡开发效率与性能;
2. 极端重性能的情况下,可以考虑手撸转换代码,另外如果能确保内存布局一致的情况下,可以尝试unsafe转换,但需要慎用;
3. 简单测试场景,可以采用序列化/copier方案,开发效率更高;
参考
https://tip.golang.org/doc/go1.8#language
嵌套讨论:https://github.com/golang/go/issues/46205
转换:https://go.dev/ref/spec#Conversion