读懂一个 Go JSON 处理函数:从输入数据追踪结构体、校验与错误返回

0 阅读7分钟

假设你正在接入一个 Web 页面。页面发来一份 JSON,里面有产品名、能力描述和安装标识;接下来要给另一个模块提供更小的一份数据,只保留产品名与能力描述。

需求很具体:合法输入返回筛选后的 JSON;格式错误、产品名为空或输入过大时返回错误。沿着这条数据流,可以把 Go 的结构体、变量声明、地址和错误返回联系起来理解。

先看要发生什么变化

输入:

{"product":"Demo Web","capabilities":{"panel":"available"},"installationId":"local-123"}

希望得到的输出:

{"product":"Demo Web","capabilities":{"panel":"available"}}

变化只有一件事:installationId 不再出现。product 是一个字符串;capabilities 是一个键和值都为字符串的字典。字典里可以有不同能力名称,这个例子不需要枚举所有名称。

给完整数据选择一部分字段、形成较小表示的动作,常被称为投影。这里把函数命名为 Project,采用“执行投影”的动词含义;projection 则用作内部数据类型的名字。

先定义结果需要保留哪些字段

type projection struct {
    Product      string            `json:"product"`
    Capabilities map[string]string `json:"capabilities,omitempty"`
}

type 定义类型,struct 定义一组具名字段。Go 把类型写在名称后面:Product string 表示名为 Product 的字符串字段。map[string]string 是一个映射,也就是字典:方括号内的 string 是键类型,后面的 string 是值类型。

反引号中的内容叫结构体标签。JSON 库会读取 json:"product",把 Go 字段与 JSON 中的 product 对应起来。omitempty 用于编码输出:当 Capabilities 为空时,可以省略这个 JSON 字段。

标签不会自动检查输入是否必填。Product 没有填写,仍然需要后面的业务校验。将“怎么编码字段”和“字段是否合法”分开看,才能读懂后面的两个检查步骤。

projection 小写开头,是本包内部类型;两个字段大写开头,是可供 JSON 库访问的导出字段。内部类型并不要求它的所有字段也小写。encoding/json 的字段与标签规则

读懂函数签名

目标函数的声明部分是:

func Project(raw []byte) (string, error)

func 表示函数,Project 大写开头,供其他包调用。参数名是 raw,类型是 []byte。[]T 表示由多个 T 值组成的切片;这里保存的是原始 JSON 字节。

括号中的 (string, error) 表示返回两个值:结果字符串和错误。调用方不必从一个特殊字符串猜测成功与否,而是分别处理这两个结果。

例如,在调用方函数里:

result, err := contextview.Project(raw)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result)

err != nil 表示有错误。演示入口中的 log.Fatal 输出错误并以失败状态结束程序;处理数据的库函数只返回错误,由调用方决定怎样展示或处置。

第一步先限制输入

完整函数的开头是:

const maxInputBytes = 16 * 1024

func Project(raw []byte) (string, error) {
    if len(raw) == 0 {
        return "", errors.New("input is required")
    }
    if len(raw) > maxInputBytes {
        return "", errors.New("input is too large")
    }
    // 后面继续解码和处理
}

这段用于解释函数开头,完整可运行版本在文末。const 给固定值起名字,16 × 1024 是示例选择的字节上限,不是 Go 或 JSON 的统一限制。常量值在编译时确定,运行中不能重新赋值。

len(raw) 取得字节数量。每个 if 检查一种拒绝条件;命中后用 return 立即结束函数。返回的 "" 是空结果,errors.New(...) 创建表示失败原因的错误值。

函数一开始就处理这些分支,后续代码便可以在“输入非空且大小可接受”的前提下继续。

第二步把字节填入结构体

var value projection
err := json.Unmarshal(raw, &value)
if err != nil {
    return "", err
}

var value projection 声明一个变量,类型是前面定义的结构体。没有显式初始值时,各字段使用类型的零值:Product 初始为 "",Capabilities 初始为 nil。

err := ... 使用短变量声明。Go 根据右侧返回值推导 err 的类型;它是一个变量,不是固定常量。:= 只能在函数内部使用,包级变量要使用 var。

json.Unmarshal 负责解码 JSON 并填写目标。&value 取得变量的地址,让库函数能够修改这个变量。可以把它理解为“请将解码结果写到这里”。

这里有一个容易漏掉的细节:& 是 Go 的取地址语法,而“解码目标需要可填写的指针”是 json.Unmarshal 的接口要求。若写成 json.Unmarshal(raw, value),这次调用能够编译,但库会在运行时返回非指针目标错误。处理 err 才能看见失败。

非法 JSON、与字段不匹配的值类型等都可能产生解码错误。失败后函数立即返回,不继续使用解码过程中可能部分填写的结构体。json.Unmarshal 文档

第三步检查业务要求

if strings.TrimSpace(value.Product) == "" {
    return "", errors.New("product is required")
}

value.Product 访问结构体中的字段。strings.TrimSpace 去掉两端空白,再与空字符串比较,因此缺失、空字符串和仅有空白的产品名都会被拒绝。这里并未将修剪后的名称重新赋回字段,合法名称会按原值输出。

解码成功只说明输入能够按目标类型读取,不代表满足业务要求。{} 是合法 JSON,也能解码为结构体,但它没有产品名;这一分支负责把它判定为业务上的非法输入。

根值 null 同样不必触发 JSON 语法错误,但在本例新建的结构体上仍留下空产品名,最终会被这个业务检查拒绝。

第四步重新编码结果

encoded, err := json.Marshal(value)
if err != nil {
    return "", err
}
return string(encoded), nil

json.Marshal 把结构体重新编码为 JSON 字节,同时返回可能的错误。encoded, err := ... 是多值接收;虽然 err 已在本函数当前范围内声明过,encoded 是新变量,因此短变量声明仍然成立。若一个新变量也没有,就应使用普通赋值 =。

string(encoded) 把字节序列转换成字符串。最后的 nil 表示没有错误。

普通结构体解码默认忽略不匹配的字段。原始输入中的 installationId 没有对应结构体字段,因此没有被存入 value;重新编码时,自然也不会出现在结果中。这里不需要再手工删除它。

输出的字段选择范围由结构体决定。假如保留的产品名本身含有不应传递的信息,投影依然会保留它;字段选择不能替代字段内容的业务判断。

改变输入观察不同失败层次

完整示例的 main.go 使用一个 JSON 字符串作为输入。每次替换这个字符串,再运行 go run .,就可以观察下面的结果。每次调用都新建结构体,不沿用前次解码结果。

输入结果对应的处理步骤
{"product":"Demo Web","installationId":"local-123"}{"product":"Demo Web"}未声明字段没有进入投影
{"product":"Demo Web","capabilities":{}}{"product":"Demo Web"}omitempty 在编码时省略空字典
{"product":"Demo Web","capabilities":{"custom.preview":"available"}}保留这个能力名称和值map 接收字符串键和值
{"product":42}解码错误字段类型不匹配
{"product":解码错误JSON 语法不完整
{} 或 nullproduct is requiredJSON 可解码,但产品名不满足业务约束

“能够解码”和“业务有效”是两层不同判断。omitempty 影响输出,不能替代任一层输入检查。

完整可运行代码

按下面的目录保存三个文件,就能运行这个示例:

json-view/
├── go.mod
├── main.go
└── contextview/
    └── context.go

go.mod:

module example.com/json-view

go 1.22

contextview/context.go:

package contextview

import (
	"encoding/json"
	"errors"
	"strings"
)

const maxInputBytes = 16 * 1024

type projection struct {
	Product      string            `json:"product"`
	Capabilities map[string]string `json:"capabilities,omitempty"`
}

func Project(raw []byte) (string, error) {
	if len(raw) == 0 {
		return "", errors.New("input is required")
	}
	if len(raw) > maxInputBytes {
		return "", errors.New("input is too large")
	}

	var value projection
	err := json.Unmarshal(raw, &value)
	if err != nil {
		return "", err
	}
	if strings.TrimSpace(value.Product) == "" {
		return "", errors.New("product is required")
	}

	encoded, err := json.Marshal(value)
	if err != nil {
		return "", err
	}
	return string(encoded), nil
}

main.go:

package main

import (
	"example.com/json-view/contextview"
	"fmt"
	"log"
)

func main() {
	raw := []byte(`{"product":"Demo Web","capabilities":{"panel":"available"},"installationId":"local-123"}`)
	result, err := contextview.Project(raw)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(result)
}

在 json-view/ 下执行:

go run .
go build ./...

输出为:

{"product":"Demo Web","capabilities":{"panel":"available"}}

参考与数据源