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

目录


问题

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 吧少年。

参考:

评论加载中……

若长时间无法加载,请刷新页面重试。