golang 序列化异常引发的思考

最近,同事遇到一个神奇的问题,在日志中,发现了打印了下面的 JSON 请求:

{
    "types": "Bgk=",
    "data": "something"
}

这个 types 就很神奇,按理说,它应该是一个枚举类型数组,至少是几个数字,而不是这样一串莫名其妙的字符;它也没有引起任何的异常,一切都像应该的那样运行。

看到这样一串数据,我首先想到的就是,它会不会是一个 base64 编码?因为它以等号 “=” 结尾,是一个比较明显的 padding 特征。使用在线 base64 工具解码后,发现它不是一个 UTF-8 字符:

image-20240918115458665

于是将它转换为十六进制 HEX,查看解码出来的结果是啥:

image-20240918115603704

可见,解码出来的信息是 0609,确实是原枚举值数组的 [6, 9]

不过,为什么 golang 的 encoding/json 包会选择这样序列化呢?

为了寻找这一问题的根源,我发现此枚举的定义是:

type XXEnum uint8

此枚举是 uint8 的别名!很快啊,我写了几个测试:

func TestUint8(t *testing.T) {
	var data uint8 = 32
	marshal, err := json.Marshal(data)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(marshal)) //32
}

一个 uint8 会被序列化成十进制数字。

func TestSliceUint8(t *testing.T) {
	var data = [1]uint8{
		32,
	}
	marshal, err := json.Marshal(data)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(marshal)) //"IA=="
}

一个 uint8 切片会被序列化成 base64 编码后的 字符串

func TestSliceUint8(t *testing.T) {
	var data = [1]uint8{
		32,
	}
	marshal, err := json.Marshal(data)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(marshal)) //[32]
}

一个 uint8 数组会被序列化成一个 JSON 数组。

func TestSliceUint16(t *testing.T) {
	var data = []uint16{
		32,
	}
	marshal, err := json.Marshal(data)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(marshal)) //[32]
}

一个 int16 切片会被序列化成一个 JSON 数组。

可见,只有 uint8 数组会被序列化为一个 base64 编码的字符串,这很奇怪,为什么 uint8 是特殊的呢?为什么不把它像 int8 那样用一个真正的 json 数组序列化呢?

事实上,golang 中,对 byte 的定义是这样的:

// byte is an alias for uint8 and is equivalent to uint8 in all ways. It is
// used, by convention, to distinguish byte values from 8-bit unsigned
// integer values.
type byte = uint8

在 golang 中,“byte 被认为是 uint8 的一个别名,它们是完全等同的”

对于 byte 切片,人们通常认为它是一个二进制序列,因而,在 go 自带的 JSON 序列化工具中,它被序列化为用 base64 编码的字符串,这在一定程度上具有合理性,而 uint8,倒反天罡,受到了对 byte 切片特殊处理的反作用!

笔者认为这一变换多少有点粗暴,”无符号整数切片“ 和 ”字节切片“ 在表意上还是有较大区别。

再查看 encoding/json 包的源码,发现在选择最终 Encoder 进行序列化时,事实上对 uint8 的切片做了特殊处理:

newTypeEncoder,使用反射实现对不同 type 的 序列化器 选择:

func newTypeEncoder(t reflect.Type, allowAddr bool) encoderFunc {
    //...	
    switch t.Kind() {
        case reflect.Bool:
        return boolEncoder
        case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        return intEncoder
        case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
        return uintEncoder
        case reflect.Float32:
        return float32Encoder
        case reflect.Float64:
        return float64Encoder
        case reflect.String:
        return stringEncoder
        case reflect.Interface:
        return interfaceEncoder
        case reflect.Struct:
        return newStructEncoder(t)
        case reflect.Map:
        return newMapEncoder(t)
        case reflect.Slice:
        return newSliceEncoder(t) // Let's go here
        case reflect.Array:
        return newArrayEncoder(t)
        case reflect.Pointer:
        return newPtrEncoder(t)
        default:
        return unsupportedTypeEncoder
    }
}

接着,进入 newSliceEncoder

func newSliceEncoder(t reflect.Type) encoderFunc {
	// Byte slices get special treatment; arrays don't.
	if t.Elem().Kind() == reflect.Uint8 {
		p := reflect.PointerTo(t.Elem())
		if !p.Implements(marshalerType) && !p.Implements(textMarshalerType) {
			return encodeByteSlice
		}
	}
	enc := sliceEncoder{newArrayEncoder(t)}
	return enc.encode
}

可见,go-lib 在这里进行特殊处理!甚至还贴心地写上注释:Byte slices get special treatment; arrays don't.在这个特殊的 encodeByteSlice 序列化器内,将会把字节切片/无符号整数切片给序列化为一个 base64 编码的字符串!

原因是找到了,但这合理吗?看着这行注释,我陷入了沉思。。。然后,写出了这样几行代码:

func TestArrayByte(t *testing.T) {
	var data = [...]byte{
		32,
	}
	marshal, err := json.Marshal(data)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(marshal)) //[32]
}

我有点搞不懂这个包的设计者的用意了,如果是为了 byte 的特殊处理,为什么不把 byte 数组也一起特殊处理呢?只处理切片是出于什么考虑呢?

// to be continued