4

验证 golang 中 thrift enum 值是否合法的一个通用办法

 2 years ago
source link: https://blog.wolfogre.com/posts/golang-thrift-enum-validation/
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

验证 golang 中 thrift enum 值是否合法的一个通用办法

2019/05/27.

Golang , Thrift 1.3k+ 2

Thrift 是一种接口描述语言和二进制通讯协议,它被用来定义和创建跨语言的服务。它被当作一个远程过程调用(RPC)框架来使用,是由 Facebook 为“大规模跨语言服务开发”而开发的。要创建一个 thrift 服务,需要借助 IDL(Interface Definition Language)写一些 thrift 文件来描述它,为目标语言生成代码。Thrift IDL 提供了几乎所用你所需要用到的数据类型,其中就包括枚举类型(enum)。

但很可惜,golang 里是没有枚举类型的,所以当 thrift 生成 golang 代码时,对于枚举类型,只能用逐个定义常量的方式来实现。举例来说,对于这样一份 thrift 文件:

namespace go testenum

enum Weekday {
	Sunday
	Monday
	Tuesday
	Wednesday
	Thursday
	Friday
	Saturday
}

生成的 golang 代码中,用于描述枚举类型 Weekday 的部分是这样的:

type Weekday int64

const (
	Weekday_Sunday    Weekday = 0
	Weekday_Monday    Weekday = 1
	Weekday_Tuesday   Weekday = 2
	Weekday_Wednesday Weekday = 3
	Weekday_Thursday  Weekday = 4
	Weekday_Friday    Weekday = 5
	Weekday_Saturday  Weekday = 6
)

可以看到,这里是包装了 int64 为 Weekday,再挨个儿定义常量:Weekday_Sunday 到 Weekday_Saturday。

很显然,这里并没有实现枚举类型的约束能力,我完全可以定义一个 Weekday 类型的变量,但取值却不是该“枚举类型”的任何一个合法值:

var d testenum.Weekday = 100

这种明显的 bug 却不能在编译时检查到,于是我不得不在运行时去检查,一个比较噩梦的写法是这样的:

var d testenum.Weekday = 100
if d != testenum.Weekday_Sunday &&
	d != testenum.Weekday_Monday &&
	d != testenum.Weekday_Tuesday &&
	d != testenum.Weekday_Wednesday &&
	d != testenum.Weekday_Thursday &&
	d != testenum.Weekday_Friday &&
	d != testenum.Weekday_Saturday {
	// error
}

换成 switch 来做可能稍微优雅一点,但同样还是噩梦:

var d testenum.Weekday = 100
switch d {
case testenum.Weekday_Sunday, testenum.Weekday_Monday, testenum.Weekday_Tuesday, 
	testenum.Weekday_Wednesday, testenum.Weekday_Thursday, testenum.Weekday_Friday,
	testenum.Weekday_Saturday:
default:
	// error
}

这样通过逐个比较来校验枚举类型的值,不仅写起来繁琐,且一旦增加或减少了枚举值,这样的校验逻辑就需要一同修改,否则校验便就失效了。

在写了几段这样的噩梦代码后,我意识到长期以往下去这不是一个办法,如果哪一天出现个有几十个取值项的枚举类型,我手指头还得写断。

有没有一个通用的办法,不管扔进去任何类型的枚举值,它都能判断它的取值在它的类型定义里是否合法?

我通过查看 thrift 生成的代码,找到了突破口。关键点在于,thrift 额外为枚举类型生成了 String() string 方法,这样做的本意可能是为了实现 fmt.Stringer 接口,方便在打印时输出有意义的字符串而不是单纯的数字。

func (p Weekday) String() string {
	switch p {
	case Weekday_Sunday:
		return "Sunday"
	case Weekday_Monday:
		return "Monday"
	case Weekday_Tuesday:
		return "Tuesday"
	case Weekday_Wednesday:
		return "Wednesday"
	case Weekday_Thursday:
		return "Thursday"
	case Weekday_Friday:
		return "Friday"
	case Weekday_Saturday:
		return "Saturday"
	}
	return "<UNSET>"
}

有没有发现,这里的 switch 语句和上文检查枚举值是否合法的 switch 语句有异曲同工之妙,所以从这个角度来说,String() 方法变相得完成的对枚举值合法性的检查,如果检查不通过,则会返回 <UNSET>

而为了通用,可以通过反射来调用 interface{} 的 String() 方法,判断返回值是否为 <UNSET>,如是,则说明不是合法的枚举值。

最终,实现这个检验逻辑的逻辑如下:

package utils

import (
	"fmt"
	"reflect"
)

func ValidateThriftEnum(in interface{}) error {
	v := reflect.ValueOf(in)
	m := v.MethodByName("String")
	errNotEnum := fmt.Errorf("type %s is not thrift enum", v.Type().Name())
	errIllegalValue := fmt.Errorf("%d is not a illegal value of %s", in, v.Type().Name())

	if v.Type().Kind() != reflect.Int64 {
		return errNotEnum
	}
	if !m.IsValid() {
		return errNotEnum
	}
	rets := m.Call([]reflect.Value{})
	if len(rets) != 1 {
		return errNotEnum
	}
	ret := rets[0].String()
	if ret == "" {
		return errNotEnum
	}
	if ret == "<UNSET>" {
		return errIllegalValue
	}
	return nil
}

如代码所示,想要通过上面这个校验函数,使得返回的 error 为 nil,输入参数必需满足以下条件:

  • 输入参数必需是 int64 的包装类型;
  • 输入参数的类型必需有 String() 方法;
  • 调用输入参数的 String() 方法返回值不能为空字符串;
  • 调用输入参数的 String() 方法返回值不能为 <UNSET>

虽然这些条件不是完备的,它们并不能真正理解什么是“thrift 的枚举类型”,而只是做了一次“推断”。这意味着,我依然可以伪造一个数据类型,包装一下 int64,再实现 String() 方法,虽然不是正宗的 thrift 枚举类型,却能轻松骗过这个函数的校验逻辑。但这未免也过于极端,要知道,这里要做是“验证 thrift enum 值是否合法”,而不是“验证一个数据类型是否是 thrift enum 类型”,只要输入参数是一个真切实在的 thrift enum 类型,校验结果就不会出错。

单元测试如下:

func TestValidateThriftEnum(t *testing.T) {
	// case: 一般情况
	assert.NoError(t, utils.ValidateThriftEnum(testenum.Weekday(1)))
	assert.NoError(t, utils.ValidateThriftEnum(testenum.Weekday(2)))
	assert.NoError(t, utils.ValidateThriftEnum(testenum.Weekday(3)))

	// case: 非法 enum 值
	err := utils.ValidateThriftEnum(testenum.Weekday(100))
	assert.Error(t, err)
	t.Log(err)

	// case: 非 enum 类型
	err = utils.ValidateThriftEnum(1)
	assert.Error(t, err)
	t.Log(err)

	err = utils.ValidateThriftEnum(int64(1))
	assert.Error(t, err)
	t.Log(err)

	err = utils.ValidateThriftEnum("test")
	assert.Error(t, err)
	t.Log(err)
}

以及对应的单元测试结果:

=== RUN   TestValidateThriftEnum
--- PASS: TestValidateThriftEnum (0.00s)
    validation_test.go:21: 100 is not a illegal value of Weekday
    validation_test.go:26: type int is not thrift enum
    validation_test.go:30: type int64 is not thrift enum
    validation_test.go:34: type string is not thrift enum
PASS

原本我是想着封装一个包发布出去,在 GitHub 上骗几个 star 的。但这几十行代码的原理确实有一点 tricky,虽然好用,但着实谈不上有优雅。且为了几十代码就封装一个包,可能小题大做了。

所以,如果你也遇到了同样的问题,Ctrl C & Ctrl V 吧少年。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK