Go 中优雅解析 JSON Sum Types,避免 Panic
Go 中优雅解析 JSON Sum Types,避免 Panic
2025 年 3 月 14 日
Go 编程语言本身并不原生支持 sum types,但我们将探讨如何模拟它们,如何将它们编码和解码为 JSON,以及在某些情况下,它们如何帮助避免运行时 panic 异常。
目录
- 无论我们是否觉得有用,sum types 总是存在
- 我在 Go 中遇到的第一个 nil pointer panic 就是因为缺少 sum types
- Go 中解析 JSON sum types,初次尝试
- OpenAPI 和 Protobuf 是如何处理这种情况的?
- Go 中解析 JSON sum types,再次尝试
- 替代实现
- Go 本来可以成为的样子:V lang?
- 其他语言的示例
无论我们是否觉得有用,sum types 总是存在
Sum types (又名 tagged unions, discriminated unions, variants, 或者有时 enums) 对于软件开发者来说是不可避免的。如果你编写代码的时间足够长,那么你几乎肯定会遇到它们。
许多语言原生支持 sum types:Zig, TypeScript, OCaml, Rust,仅举几例。甚至 OpenAPI 也有它们,这是使用 JSON 定义 HTTP API 的事实标准和语言无关的方式。因此,即使你使用的编程语言本身不支持 sum types,你可能仍然需要处理通过网络传输的 JSON payload,该 payload 被建模为 sum type。这需要决定如何在你选择的语言中解码该 payload。
撇开个人对 sum types 的感受不谈,我认为大多数人都会同意,它们有效地建模了可以_"是这些(可能非常不同的)事物之一,而不是其他任何事物"_ 的数据结构。一旦你体验过带有 switch 语句 或匹配表达式以及 穷举检查 的 sum types,就很难回去了。
让我们以一个原始类型为例,即 Rust 中的布尔值或 bool
。它有 2 个可能的值:true
或 false
(也称为“基数”)。结构体或记录被称为**“product type”**,因为你可以通过 乘以 每个字段的可能值的数量来计算可能值的数量(或基数)。所以如果我有一个带有 2 个布尔字段的结构体(这里是 Rust 中的示例):
struct UserStatus {
signed_up: bool,
subscribed: bool,
}
此结构体(或“product type”)的可能值的数量为:2x2 = 4。
现在我选择这个结构体示例并非完全随意。某些可能的值在此特定领域中无效:如果用户未 注册,则他们也不能 订阅。在讨论 sum types 时,你还会听到“使非法状态无法表示”这个短语。
“sum type” 被称为该名称是因为...你猜对了。你可以通过 对每个分支的可能值的数量进行求和 来计算可能值的数量(或基数)。所以如果我有以下 sum type(示例仍然在 Rust 中,它们被称为“enums”):
enum UserStatus {
Anonymous,
SignedUp { subscribed: bool },
}
此“sum type”的可能值的数量为:1+2 = 3。
我将把它作为一个练习留给读者,讨论并决定这两种数据结构中哪一种更适合表示这个特定的领域。
我在 Go 中遇到的第一个 nil pointer panic 就是因为缺少 sum types
好的,本节标题有点厚颜无耻,可能并不完全正确。但是,当我弄清楚是什么导致代码中的 panic 时,“sum types 会在编译时捕获此错误”的想法 确实 闪过我的脑海。我相信敏锐的读者可以找到更好的方法来构建我的第一个实现,即使没有 sum types。但为了本文,请迁就我一下。
现在让我说清楚:这不是一篇“Go 应该有 sum types”的文章。关于这个话题已经写了很多东西,我不想参与这场辩论(尽管你可能会猜到我的立场)。让我们假设我想在 Go 中 模拟 类似 sum types 的东西,那么:
- 如何做到这一点而又不会偏离该语言的惯用做法太远?
- 如何按照我们将在下面看到的结构,对其进行 JSON 的编码和解码?
这篇文章也 不是 对 Go 的批评。我在我的第一个 Go 项目中遇到了这个问题,实际上我很喜欢使用这门语言。在回避 Go 一段时间后(主要是因为缺少 sum types),我终于尝试了一下,因为它似乎很适合这个项目。快速的编译时间、强大的标准库、语言的简洁性以及出色的开发者工具都兑现了它们的承诺。
顺便提一句,我第一次运行 go build
是在 Alex Edward 的 "Let's Go Further" 书 (顺便说一句,这是一本很棒的书)中的示例代码库上,我不得不再次运行它,因为它比我过去使用的速度快得多(cough Haskell cough),我以为什么都没发生。
回到历史背景:我对 Go 在这个特定项目上的工作效率感到非常满意。反馈循环令人惊叹,我几天之内就获得了一个可行的概念验证。代码似乎只是从我的指尖溜走,一切都在第一次尝试时就奏效了,零值和指针不再让我感到害怕,我只需要添加最后一件事然后...然后它击中了我:
2024/12/07 12:16:53 http: panic serving [::1]:60984:
runtime error: invalid memory address or nil pointer dereference
goroutine 4 [running]:
net/http.(*conn).serve.func1()
/usr/local/go/src/net/http/server.go:1947 +0xb0
panic({0x100a00f00?, 0x100f19b00?})
/usr/local/go/src/runtime/panic.go:785 +0x124
example/main.TransformAction(0x14000115e08)
/Users/nicolashery/dev/example/main.go:110 +0x1c
example/main.(*Server).handleTransformActions(0x140001cad80, {0x100ad6358, 0x14000160380}, 0x140001597c0)
/Users/nicolashery/dev/example/main.go:157 +0x20c
[...]
哎哟。最近做了很多 Haskell 和(严格的)TypeScript,我忘记了人们会遇到这样的运行时错误。但是我没有 panic(双关语),我仔细查看了堆栈跟踪中提到的代码。
下面是代码的简化版本,为了本文的缘故(实际实现具有更大的结构和更多的情况)。你能发现错误吗?你有 5 秒钟的时间。
func TransformAction(a *Action) string {
var result string
switch a.Type {
case ActionType_CreateObject:
result = fmt.Sprintf(
"create_object %s %s %s", a.Object.Type, a.Object.ID, a.Object.Name,
)
case ActionType_UpdateObject:
result = fmt.Sprintf(
"update_object %s %s %s", a.Object.Type, a.Object.ID, a.Object.Name,
)
case ActionType_DeleteObject:
result = fmt.Sprintf("delete_object %s", a.Object.ID)
case ActionType_DeleteAllObjects:
result = "delete_all_objects"
}
return result
}
好的,显然你想在 Cmd/Ctrl+Click
上点击 Action
以查看它是什么:
type Action struct {
Type ActionType `json:"type"`
Object *Object `json:"object,omitempty"`
ID *string `json:"id,omitempty"`
}
func NewActionCreateObject(object *Object) Action {
return Action{
Type: ActionType_CreateObject,
Object: object,
}
}
func NewActionUpdateObject(object *Object) Action {
return Action{
Type: ActionType_UpdateObject,
Object: object,
}
}
func NewActionDeleteObject(id string) Action {
return Action{
Type: ActionType_DeleteObject,
ID: &id,
}
}
func NewActionDeleteAllObjects() Action {
return Action{
Type: ActionType_DeleteAllObjects,
}
}
你看到错误了吗?如果看到了,那么你可以停止阅读并回去工作了。我说笑的。在允许的时间限制内没有看到它?不用担心,Go 类型检查器也无法识别。
Go 中解析 JSON sum types,初次尝试
你可能会想,我是如何得到上面的代码的?嗯,想象一下我们的服务正在接收一个如下所示的 JSON payload:
[
{
"type": "create_object",
"object": {
"type": "user",
"id": "1",
"name": "user1"
}
},
{
"type": "update_object",
"object": {
"type": "user",
"id": "1",
"name": "user1 updated"
}
},
{
"type": "delete_object",
"id": "1"
},
{
"type": "delete_all_objects"
}
]
这些都是不同类型的“actions”,并且这种 JSON 表示形式并非不合理。OpenAPI 规范 有一个 discriminator "pet" 示例,Redocly 文档 有一个 "vehicle" 示例,它们与此类似。(我还没有遇到过带有宠物的 API,因此很抱歉我的示例不会那么有趣,但可能更现实。)
我尝试解码这个 JSON 的天真的尝试,因为我很匆忙(也许也是因为 Copilot 建议了它,如果我说实话),是创建一个 struct,我称之为 "bag of all the fields"。这是一个具有每个 action type 的所有可能字段合并的 struct,并使用指针。指针的零值是 nil
,它将为特定 action type“未使用”的字段设置。它的一切荣耀都在这里:
type Action struct {
Type ActionType `json:"type"`
Object *Object `json:"object,omitempty"`
ID *string `json:"id,omitempty"`
}
type ActionType string
const (
ActionType_CreateObject ActionType = "create_object"
ActionType_UpdateObject ActionType = "update_object"
ActionType_DeleteObject ActionType = "delete_object"
ActionType_DeleteAllObjects ActionType = "delete_all_objects"
)
这之所以有效,是因为 json.Unmarshal
不关心 JSON payload 中是否有缺失的字段,它只会为它们设置零值:
actions := []Action{}
if err := json.Unmarshal(data, &actions); err != nil {
return err
}
我们也可以反过来调用 json.Marshal
将 struct 编码为与上面代码段相同的 JSON 表示形式。omitempty
struct tag 选项将从结果 JSON 中删除每个 action type 未使用的字段。
所以我们开始比赛了,指针包会出什么问题呢?尝试访问该 action type 未使用的 nil
字段时出现细微的错误,这就是问题所在:
switch a.Type {
case ActionType_CreateObject:
result = fmt.Sprintf(
"create_object %s %s %s", a.Object.Type, a.Object.ID, a.Object.Name,
)
case ActionType_UpdateObject:
result = fmt.Sprintf(
"update_object %s %s %s", a.Object.Type, a.Object.ID, a.Object.Name,
)
case ActionType_DeleteObject:
// the bug was here!
// for this action type `a.Object` is `nil`
// the correct code should be: `*a.ID`
result = fmt.Sprintf("delete_object %s", a.Object.ID)
case ActionType_DeleteAllObjects:
result = "delete_all_objects"
}
OpenAPI 和 Protobuf 是如何处理这种情况的?
我在运行时 panic 之后振作起来,并有以下天才的想法:有 OpenAPI 的代码生成器,如果我给他们 JSON discriminated union 的规范,他们为 Go 输出什么?此外,Protocol Buffers 是一种基于代码生成的流行线路格式,并且 Oneof field 看起来很像 sum type,那么 他们 为 Go 生成什么?
Action 的 OpenAPI 模式如下所示:
Action:
type: object
discriminator:
propertyName: type
mapping:
"create_object": "#/components/schemas/CreateObject"
"update_object": "#/components/schemas/UpdateObject"
"delete_object": "#/components/schemas/DeleteObject"
"delete_all_objects": "#/components/schemas/DeleteAllObjects"
oneOf:
- $ref: "#/components/schemas/CreateObject"
- $ref: "#/components/schemas/UpdateObject"
- $ref: "#/components/schemas/DeleteObject"
- $ref: "#/components/schemas/DeleteAllObjects"
CreateObject:
type: object
properties:
type:
type: string
object:
$ref: "#/components/schemas/Object"
# ...
如果我将其提供给 OpenAPI Generator(请注意,我正在使用 useOneOfDiscriminatorLookup=true
选项以获得更好的输出),我会得到我称之为 "bag of all the branches":
type Action struct {
createObject *CreateObject
updateObject *UpdateObject
deleteObject *DeleteObject
deleteAllObjects *DeleteAllObjects
}
type CreateObject struct {
Object Object `json:"object"`
}
// ...
它为 Action
生成一个 UnmarshalJSON
方法,该方法:
- 首先解码 JSON 以检查
"type"
字段(这要归功于useOneOfDiscriminatorLookup=true
代码生成选项) - 根据
"type"
的值,它选择适当的分支并使用相应的 struct(CreateObject
,UpdateObject
等)解码 JSON
为了清楚起见,编辑后,它看起来像这样:
func (a *Action) UnmarshalJSON(data []byte) error {
var tagged struct {
Type ActionType `json:"type"`
}
if err := json.Unmarshal(data, &tagged); err != nil {
return err
}
var err error
switch tagged.Type {
case ActionType_CreateObject:
err = json.Unmarshal(data, &a.createObject)
case ActionType_UpdateObject:
err = json.Unmarshal(data, &a.updateObject)
case ActionType_DeleteObject:
err = json.Unmarshal(data, &a.deleteObject)
case ActionType_DeleteAllObjects:
err = json.Unmarshal(data, &a.deleteAllObjects)
}
return nil
}
为了获得实际的底层值,生成器创建一个方法(我在这里将其命名为 Value()
),该方法返回第一个非 nil 指针:
func (a *Action) Value() any {
if a.createObject != nil {
return a.createObject
}
if a.updateObject != nil {
return a.updateObject
}
if a.deleteObject != nil {
return a.deleteObject
}
if a.deleteAllObjects != nil {
return a.deleteAllObjects
}
return nil
}
因此,这已经比我的 "bag of all the fields" 方法有了很大的改进。由于底层值的访问器方法返回 any
,因此我现在正在检查 .(type)
,它可以是更精确的 struct 之一(CreateObject
,UpdateObject
等):
func TransformAction(action *Action) string {
var result string
switch v := action.Value().(type) {
case *CreateObject:
result = fmt.Sprintf(
"create_object %s %s %s", v.Object.Type, v.Object.ID, v.Object.Name,
)
case *UpdateObject:
result = fmt.Sprintf(
"update_object %s %s %s", v.Object.Type, v.Object.ID, v.Object.Name,
)
case *DeleteObject:
// can't make the same mistake here!
// trying to do `v.Object.ID` will cause a compiler error:
// "type *DeleteObject has no field or method Object"
result = fmt.Sprintf("delete_object %s", v.ID)
case *DeleteAllObjects:
result = "delete_all_objects"
}
return result
}
但是,仍然存在一些问题:
- 我“信任”访问器方法的
any
返回值是 action struct 之一(CreateObject
,UpdateObject
等),而不是其他任何东西 - 如果我添加一个“branch”(即另一个 action type),我很容易忘记更新
TransformAction
中的switch
语句
我尝试过的另一个生成器 oapi-codegen 使用了略有不同的方法。它保存一个 json.RawMessage
并延迟解码,直到我们调用等效于 action.Value()
访问器方法的方法:
type Action struct {
union json.RawMessage
}
type CreateObject struct {
Type string `json:"type"`
Object Object `json:"object"`
}
// ...
func (a *Action) Value() (any, err) {
// JSON decoding happens here now
}
解码的工作原理基本相同,首先解码到足以检查 "type"
字段的程度,然后根据其值,解编为 action struct 之一(CreateObject
,UpdateObject
等)。 json.RawMessage
文档 实际上有一个类似的示例。
由于延迟 JSON 解码在我的情况下并不是特别有用,因此我没有选择此路线。但是我想为了完整性而提及它。
Protocol Buffers (aka "Protobuf") 怎么样?我在他们的 Go 生成代码指南 中发现以下内容特别有趣:
对于 oneof 字段,protobuf 编译器生成一个具有接口类型
isMessageName_MyField
的单字段。它还为 oneof 中的每个单数字段生成一个 struct。这些都实现了此isMessageName_MyField
接口。
让我们尝试一下。即使我们正在使用 JSON API,我们的数据模型的 Protobuf 定义也可能如下所示:
message Action {
oneof value {
CreateObject create_object = 1;
UpdateObject update_object = 2;
DeleteObject delete_object = 3;
DeleteAllObjects delete_all_objects = 4;
}
}
message CreateObject {
Object object = 1;
}
// ...
生成的代码确实创建了一个带有单个方法的接口 isAction_Value
,以及一个带有该接口类型字段的 Action
struct:
type Action struct {
Value isAction_Value `protobuf_oneof:"value"`
// other protobuf-specific fields omitted
}
type isAction_Value interface {
isAction_Value()
}
type Action_CreateObject struct {
CreateObject *CreateObject `protobuf:"bytes,1,opt,name=create_object,json=createObject,oneof"`
}
func (*Action_CreateObject) isAction_Value() {}
// ...
现在,这两个代码生成器 OpenAPI 和 Protobuf 将成为我第二次尝试以更类型安全的方式解码 JSON sum type 的灵感来源...
Go 中解析 JSON sum types,再次尝试
在对“Go sum types”这一主题进行了一些搜索后,我偶然发现了以下内容:go-check-sumtype。从 README:
在 Go 中表示 sum types 的典型代理是使用带有未导出方法的接口,并在同一程序包中定义 sum type 的每个变体以满足所述接口。这保证了满足接口的类型集在编译时是关闭的。
这个“带有未导出方法的接口”(也称为“密封接口”或“标记接口”)听起来是一种合理的方法。而且这似乎也是 Protobuf 代码生成器正在使用的东西。
我用一个密封的接口 IsAction
和每个变体的 struct(CreateObject
,UpdateObject
等)替换了我的单个 "bag of all the fields" struct。每个变体 struct 都实现了该接口:
//sumtype:decl
type IsAction interface {
// sealed interface to emulate sum type
isAction()
}
type CreateObject struct {
Object Object `json:"object"`
}
func (*CreateObject) isAction() {}
type UpdateObject struct {
Object Object `json:"object"`
}
func (*UpdateObject) isAction() {}
type DeleteObject struct {
ID string `json:"id"`
}
func (*DeleteObject) isAction() {}
type DeleteAllObjects struct{}
func (*DeleteAllObjects) isAction() {}
现在我很高兴。这些特定于 action 的 struct 不仅提供了更高的类型安全性,而且如果我忘记在 switch
语句中处理一个变体(或者如果我添加一个新的变体来实现密封的接口),go-check-sumtype
linter 将会捕获它,而不是在运行时出现错误!
func TransformAction(action *Action) string {
var result string
// if we miss a case here, `go-check-sumtype` will catch it!
// for example, omitting `case *DeleteAllObjects` will cause a linter error:
// "exhaustiveness check failed for sum type IsAction: missing cases for DeleteAllObjects"
switch v := action.Value().(type) {
case *CreateObject:
result = fmt.Sprintf(
"create_object %s %s %s", v.Object.Type, v.Object.ID, v.Object.Name,
)
case *UpdateObject:
result = fmt.Sprintf(
"update_object %s %s %s", v.Object.Type, v.Object.ID, v.Object.Name,
)
case *DeleteObject:
// better type-safety for each branch!
// for example, trying to do `v.Object.ID` will cause a compiler error:
// "type *DeleteObject has no field or method Object"
result = fmt.Sprintf("delete_object %s", v.ID)
case *DeleteAllObjects:
result = "delete_all_objects"
}
return result
}
我仍然需要弄清楚如何将 JSON sum types payload 解码为这个接口和 struct。你不能直接解编为接口值,你需要传递一个具体的类型。所以我创建了一个包装 struct,如下所示:
type Action struct {
value IsAction
}
我还找到了 exhaustive linter,所以当你可以同时拥有 enums 时,为什么要止步于 sum types!我定义了一个 action type 的 enum,它在我的 tagged union 中用作“tags”,以及用于 JSON 和字符串表示的适当方法:
type ActionType int
const (
ActionType_CreateObject ActionType = iota
ActionType_UpdateObject
ActionType_DeleteObject
ActionType_DeleteAllObjects
)
func (t ActionType) MarshalJSON() ([]byte, error) {
// ...
}
func (t *ActionType) UnmarshalJSON(data []byte) error {
// ...
// note: this will return an error for any invalid action type string
}
func (t ActionType) String() string {
// ...
}
然后我为我的 Action
包装 struct 定义了 UnmarshalJSON
,如下所示:
func (a *Action) UnmarshalJSON(data []byte) error {
var tag struct {
Type ActionType `json:"type"`
}
if err := json.Unmarshal(data, &tag); err != nil {
return err
}
var v IsAction
// note: `exhaustive` linter will catch if we miss a case here
switch tag.Type {
case ActionType_CreateObject:
v = new(CreateObject)
case ActionType_UpdateObject:
v = new(UpdateObject)
case ActionType_DeleteObject:
v = new(DeleteObject)
case ActionType_DeleteAllObjects:
v = new(DeleteAllObjects)
}
if err := json.Unmarshal(data, v); err != nil {
return err
}
a.value = v
return nil
}
这与我们在 OpenAPI 生成的代码中看到的类似:
- 首先,仅解码 JSON 中检查
"type"
字段所需的内容 - 其次,根据
"type"
的值,选择 sum type 的适当变体 struct(CreateObject
,UpdateObject
等)并使用它来解码 JSON payload
另一方面,我还为包装 struct 定义了 MarshalJSON
:
func (a *Action) MarshalJSON() ([]byte, error) {
v := a.value
data, err := json.Marshal(&v)
if err != nil {
return nil, err
}
var tagged map[string]any
if err := json.Unmarshal(data, &tagged); err != nil {
return nil, err
}
// note: `go-check-sumtype` linter will catch if we miss a case here
switch v.(type) {
case *CreateObject:
tagged["type"] = ActionType_CreateObject
case *UpdateObject:
tagged["type"] = ActionType_UpdateObject
case *DeleteObject:
tagged["type"] = ActionType_DeleteObject
case *DeleteAllObjects:
tagged["type"] = ActionType_DeleteAllObjects
}
return json.Marshal(&tagged)
}
在此方法中,我们:
- 首先,将包装的接口编码为 JSON(与解码不同,我们可以这样做,因为此处的接口将使用底层具体类型初始化:
CreateObject
,UpdateObject
等) - 其次,为了在
"type"
字段中添加 tag,我们执行往返行程:解码为map[string]any
,将 tag 添加到该 map,然后将该 map 重新编码为 JSON
请注意,我在 UnmarshalJSON
中使用 exhaustive
linter 来确保我处理所有可能的 tag,并在 MarshalJSON
中使用 go-check-sumtype
linter 来确保我处理所有可能的变体 struct。因此,如果我使“enum”和“sum type”保持最新,我将在所有这些方法中(以及其他方法或函数中,例如我们之前看到的 TransformAction
)进行穷举检查。
就这样!是的,这里有一些样板代码,但是如果一个人使用 Go,他们可能已经可以接受这里和那里的一些样板代码。此外,在 AI 编码助手和其他代码生成工具之间,可以减轻样板代码的成本。最后,我们说的是“代码的读取(和维护)频率远高于编写频率”?因此,我认为添加的类型安全性和我们在编译时而不是运行时捕获问题的事实可能值得权衡。
替代实现
当然,上述实现只是在 Go 中解码 JSON sum types 的 一种可能的方式。以下是一些替代方案,其中一些我们已经提到过。
有 "bag of all branches" 方法(完整示例在此处):
type Action struct {
createObject *CreateObject
updateObject *UpdateObject
deleteObject *DeleteObject
deleteAllObjects *DeleteAllObjects
}
还有“延迟解码”方法(完整示例在此处):
type Action struct {
payload json.RawMessage
}
使用我最终使用的“密封接口”方法,我还考虑了 MarshalJSON
的实现,该实现不需要编码/解码往返行程来添加 tag,但代价是更多的样板代码。它使用 struct embedding 代替(完整示例在此处):
func (a *Action) MarshalJSON() ([]byte, error) {
var data []byte
var err error
switch v := a.value.(type) {
case *CreateObject:
tagged := struct {
Type ActionType `json:"type"`
CreateObject
}{
Type: ActionType_CreateObject,
CreateObject: *v,
}
data, err = json.Marshal(&tagged)
// ...
}
return data, err
}
最后,值得一提的是,在 JSON 中表示 sum types 有不同的方法,特别是:
- internally tagged(本文中使用的):
{"type": "delete_object", "id": "1", "soft_delete": true}
- adjacently tagged:
{"type": "delete_object", "value": {"id": "1", "soft_delete": true}}
- externally tagged:
{"delete_object": {"id": "1", "soft_delete": true}}
命名取自 Rust 库 Serde 的文档,该文档为每种表示形式提供了很好的解释和示例。
使用本文中描述的 Go sum types 实现,所有 JSON 表示形式都是可能的(你可以在此处找到 adjacently tagged [完整示例](https://nicolashery.com/decoding-json-sum-types-in-go/<https:/github.com/nicolashery