5

gtag: 在 golang 中优雅地获取字段 tag

 2 years ago
source link: https://blog.wolfogre.com/posts/gtag/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

gtag: 在 golang 中优雅地获取字段 tag

2020/05/29.

Golang 0.5k+ 1

事情是这样的,一个读写 mongo 的程序被我改出了点问题:测试同学发现,更新某类数据时,某个字段无法被修改,始终保持旧值,且程序也没有报错。

后来经过排查,问题大致是这样的,看下面这一段代码:

type Foo struct {
	ID         int    `bson:"_id" json:"_id"`
	Count      int64  `bson:"count" json:"count"`
	Top95Usage string `bson:"top_95_usage" json:"top_95_usage,omitempty"`
}

func main() {
	foo := Foo{}
	// ...
	_, _ = c.UpdateOne(ctx, bson.M{"_id": foo.ID}, bson.M{
		"$set": bson.M{
			"top95_usage": foo.Top95Usage,
		},
		"$inc": bson.M{
			"count": 1,
		},
	})
}

以上是示意代码,我已经省略了很多不必要的细节,所剩内容已经不多,区区 20 行不到的代码,我相信你如果不仔细看,仍然很难发现问题出在哪里。

注意看,定义 Foo 时,字段 Top95Usage 的 tag 是 bson:"top_95_usage",然而在构造更新操作时,写的却是 top95_usage

这只是一个失误,写代码的时候没注意,改好就是了。但这个失误反映了一个问题,当我要利用字段的 tag 去做一些操作时,只能硬编码所关心的 tag 值。同样的情况也发生在 json 操作时:

import "github.com/tidwall/gjson"

// ...

func main() {
	// ...
	top95Usage := gjson.Get(string(buffer), "top_95_usage")
}

当然,只要你足够细心,或者测试用例足够完善,这个问题也没什么大不了的,难道不是吗?

那我们换个问题,如果现在需要修改字段名,将 Top95Usage 改成 TopUsage,在 golang 这样一个静态强类型语言里,做这样修改是很简单的,即使你改漏了,因为编译无法通过,你也会很快发现漏改的地方。然而,别忘了,按道理 tag 也要跟着一起改的,改为 top_usage,这时候就蛋疼了,你不知道代码里有多少地方硬编码了对 top_95_usage 的操作,如果漏改了一处,程序就要出问题了,像前面举的例子一样。

既然如此,有没有办法避免硬编码呢?比如这样:

"$set": bson.M{
	getTag(foo.Top95Usage): foo.Top95Usage,
},

这样的代码确实优雅多了,但很可惜,这样的 getTag 函数是实现不了的,因为 tag 信息是属于 struct,不是属于 struct 的字段的值,单纯传参 foo.Top95Usage 是无法获取到 Foo.Top95Usage 的 tag 信息的。所以你不得不这么写:

"$set": bson.M{
	getTag(foo, "Top95Usage"): foo.Top95Usage,
},

实现函数 func getTag(obj interface{}, field string) string 是不难的,一通反射操作即可,但问题来了,这次我们没硬编码 top_95_usage,却硬编码了 Top95Usage,如果写错了,编译、运行也都能正常进行,又成了不容易发现的 bug:

"$set": bson.M{
	getTag(foo, "Top95Usaga"): foo.Top95Usage, // 拼写错误
},

于是,我意识到要解决这个问题,思路得再打开一点。

为了解决这个问题,我写了一个工具 gtag,这是一个 generator,可以帮助你生成一些代码,在解释它如何帮助你避免上述问题之前,我们先用一用看吧。

你可以通过运行 go get -u github.com/wolfogre/gtag/cmd/gtag 获取这个工具,或者在 releases 下载现成的编译结果,README.md 中有比较详细的使用介绍。

我们先用一用,解决一下前文中介绍的问题,执行命令:

$ gtag -types Foo .
generated .../main.go -> .../main_tag.go

这个命令会生成一个新文件,文件内容我们可以先不看,只需要知道它给 Foo 添加了一个新方法 Tags,使用方式如下:

tags := foo.Tags("bson")
_, _ = c.UpdateOne(ctx, bson.M{tags.ID: foo.ID}, bson.M{
	"$set": bson.M{
		tags.Top95Usage: foo.Top95Usage,
	},
	"$inc": bson.M{
		tags.Count: 1,
	},
})

tags = foo.Tags("json")
count = gjson.Get(string(buffer), tags.Top95Usage)

如果你担心可能会把 jsonbson 都拼错,那也没关系,可以在命令中添加参数:

$ gtag -types Foo -tags bson,json .
generated .../main.go -> .../main_tag.go

然后就可以这样用了:

tags := foo.TagsBson()
_, _ = c.UpdateOne(ctx, bson.M{tags.ID: foo.ID}, bson.M{
	"$set": bson.M{
		tags.Top95Usage: foo.Top95Usage,
	},
	"$inc": bson.M{
		tags.Count: 1,
	},
})

tags = foo.TagsJson()
count = gjson.Get(string(buffer), tags.Top95Usage)

其实只要打开生成的源文件,就能明白 gtag 做了啥了。

首先,var 中定义的一系列变量,目的是在初始化时就预先执行反射获取 Foo 的所有字段的 tag,避免在业务逻辑执行时重复反射,拖慢效率,毕竟一个结构体各字段的 tag 值是不会变的。

var (
	valueOfFoo = Foo{}
	typeOfFoo  = reflect.TypeOf(valueOfFoo)

	_               = valueOfFoo.ID
	fieldOfFooID, _ = typeOfFoo.FieldByName("ID")
	tagOfFooID      = fieldOfFooID.Tag

	_                  = valueOfFoo.Count
	fieldOfFooCount, _ = typeOfFoo.FieldByName("Count")
	tagOfFooCount      = fieldOfFooCount.Tag

	_                       = valueOfFoo.Top95Usage
	fieldOfFooTop95Usage, _ = typeOfFoo.FieldByName("Top95Usage")
	tagOfFooTop95Usage      = fieldOfFooTop95Usage.Tag
)

其次,文件中定义了一个 struct 类型 FooTags,它的字段与 Foo 是一一对应的,且都是字符串类型,用于表示所对应的字段的 tag 值。

// FooTags indicate tags of type Foo
type FooTags struct {
	ID         string // `bson:"_id" json:"_id"`
	Count      string // `bson:"count" json:"count"`
	Top95Usage string // `bson:"top_95_usage" json:"top_95_usage"`
}

然后是最重要的,给 Foo 附加一个 Tags 方法,返回一个 FooTags

// Tags return specified tags of Foo
func (Foo) Tags(tag string, convert ...func(string) string) FooTags {
	conv := func(in string) string { return strings.TrimSpace(strings.Split(in, ",")[0]) }
	if len(convert) > 0 {
		conv = convert[0]
	}
	if conv == nil {
		conv = func(in string) string { return in }
	}
	return FooTags{
		ID:         conv(tagOfFooID.Get(tag)),
		Count:      conv(tagOfFooCount.Get(tag)),
		Top95Usage: conv(tagOfFooTop95Usage.Get(tag)),
	}
}

调用这个函数时需要指明如何提取指定 tag。必需指定 tag 名,比如 json,且可选地指定转换函数,用来转换原始的 tag 值,举例来说:

  • 使用默认转换函数,输出 top_95_usage

    tags = foo.Tags("json")
    fmt.Println(tags.Top95Usage)
    
  • 传入一个和默认转换函数相同的函数,输出 top_95_usage

    tags = foo.Tags("json", func(in string) string {
    	return strings.TrimSpace(strings.Split(in, ",")[0])
    })
    fmt.Println(tags.Top95Usage)
    
  • 空转换函数表示保持原样,输出 top_95_usage,omitempty

    tags = foo.Tags("json", nil)
    fmt.Println(tags.Top95Usage)
    
  • 自定义转换函数,输出 TOP_95_USAGE,OMITEMPTY

    tags = foo.Tags("json", strings.ToUpper)
    fmt.Println(tags.Top95Usage)
    

最后,TagsBsonTagsJson 仅是包装了 Tags("bson")Tags("json"),便于调用。

到这里,相信你已经清楚 gtag 的功能和原理了,那现在就要对这个工具进行一些考验,看看它能不能应对各类意外情况,保证正确运行。我们逐条来看。

  • 字段的 tag 修改了,但是忘了重新执行 gtag?

这种情况其实是最不用担心的情况,因为 gtag 并不是在生成的代码里硬编码 tag 的值(虽然我有考虑过这样做),而是在执行时通过反射获取的,所以如果只是单纯的更新 tag 值,并不一定需要重新执行 gtag。

  • 增加了字段,但是忘了重新执行 gtag?

如果给上文中的 Foo 追加一个字段 Enable,但没有执行 gtag,那么旧的生成代码中,FooTags 就不会有对应的 Enable 字段,如果你写 tags.Enable 就会编译错误,这个时候你就会记起来执行 gtag 了。

  • 删除了字段,但是忘记了重新执行 gtag?

有注意到吗,前文中,生成的代码里有一些这样看似无意义的语句:

var (
	// ...
	_ = valueOfFoo.ID
	// ...
	_  = valueOfFoo.Count
	// ...
	_ = valueOfFoo.Top95Usage
	// ...
)

这些语句在运行时确实没什么作用,但如果你删除了 FooCount 字段,这里就会编译失败,只有重新执行 gtag,对应的语句才会消失,而此时 FooTagsCount 字段也会消失,那些引用了 tags.Count 的地方就都会报错了。

  • 生成的代码被误修改了?

生成的文件第一行是一句注释:

// Code generated by gtag. DO NOT EDIT.

如果写代码的人足够负责,应该会注意到这句注释并避免修改这个文件,如果他仍然尝试修改这个文件,IDE 应该会给出提示: image

而他忽略提示仍一意孤行地去修改,那我认为他知道自己在做什么,是不会改出问题的。

就不继续吹牛了,gtag 就是个小工具,能帮你解决一些问题,无它。

事实上,它是我最近沉迷于“生成代码”这件事而带来的产物,说的高大上一点就是“元编程”,说直白一点用代码生成代码,以此偷懒。golang 提供了比较完善的工具链去做这件事,有时间的话会我再写一个分享,先挖个坑。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK