Go如何惯用解析REST API返回的对象/对象数组JSON响应
问题场景
对接HTTP REST API做JSON反序列化时,部分接口会根据返回结果数量自动切换响应结构:结果数大于1时返回对象数组,结果数等于1时返回单个对象。
当前封装的GET请求方法原型:
// 待完善的响应转换函数原型 func (client *Client) Get(endpoint string, data map[string]string) (map[string]json.RawMessage, error) // 方法调用示例 client.Get("/v1/users", map[string]string{"filter.abc": "lorem ipsum"})
该接口存在两类响应格式:
- 多结果场景:返回对象数组
[ { "abc": "def", "efg": 123, "hij": [ { "klm": "nop" } ] }, { "abc": "def", "efg": 123, "hij": [ { "klm": "nop" } ] } ]
- 单结果场景:返回单个对象
{ "abc": "def", "efg": 123, "hij": [ { "klm": "nop" } ] }
现有实现仅支持单对象格式解析,核心逻辑如下:
// [...] byteBody, _ = ioutil.ReadAll(res.Body) // [...] var body map[string]json.RawMessage if err := json.Unmarshal(byteBody, &body); err != nil { /* 错误处理逻辑 */ }
核心诉求:
- 找到符合Go语言惯用写法的双结构响应解析方案
- 减少冗余代码,同时兼容两种响应格式
- 判断「新增参数指定响应绑定模型」的方案是否属于良好实践
Go 惯例实现方案
最简洁且性能最优的处理逻辑是:先检测响应体首非空白字符判断格式类型,再走对应解析分支,不需要执行两次完整反序列化,额外开销可以忽略。
基础实现(返回统一切片结构)
核心思路是在封装层抹平格式差异,对外永远返回统一的对象切片,调用方不需要做额外的类型判断:
import ( "encoding/json" "io/ioutil" "strings" ) func (client *Client) Get(endpoint string, data map[string]string) ([]map[string]json.RawMessage, error) { // 省略请求构造、发送的公共逻辑 res, err := client.sendRequest(endpoint, data) if err != nil { return nil, err } defer res.Body.Close() byteBody, err := ioutil.ReadAll(res.Body) if err != nil { return nil, err } // 裁剪首尾空白字符,判断响应格式 trimmed := strings.TrimSpace(string(byteBody)) if len(trimmed) == 0 { return nil, nil } switch trimmed[0] { case '{': // 单对象场景:解析后包装为长度为1的切片返回 var single map[string]json.RawMessage if err := json.Unmarshal(byteBody, &single); err != nil { return nil, err } return []map[string]json.RawMessage{single}, nil case '[': // 数组场景:直接解析为对象切片返回 var arr []map[string]json.RawMessage if err := json.Unmarshal(byteBody, &arr); err != nil { return nil, err } return arr, nil default: return nil, &json.SyntaxError{Msg: "unexpected response format"} } }
该方案的优势:
- 性能开销极低:仅做一次字符判断和一次反序列化,无冗余计算
- 对外接口稳定:不管后端返回单对象还是数组,上层拿到的永远是统一的切片类型,不需要在业务代码中重复写类型判断逻辑
- 错误处理收敛:所有格式解析、适配逻辑都在封装层完成,上层只需要处理正常的业务错误
实现注意事项
- 不推荐「先尝试反序列化为单对象,失败再尝试反序列化为数组」的逻辑:这种写法会将单对象解析过程中真实的语法错误(比如字段类型不匹配、JSON格式损坏)误判为「当前响应是数组」,不仅会吞掉原始错误增加排查难度,还会触发两次反序列化,带来不必要的性能开销。
- 不要向下透传格式差异:不要把两种格式的判断逻辑抛给上层调用方,否则每个接口调用点都要重复写类型断言、格式转换的冗余代码,极易引入bug。
关于「传入参数指定绑定模型」的方案评估
这个方案是Go语言封装HTTP客户端的标准良好实践,相比返回map[string]json.RawMessage类型安全性更高、使用更便捷,完全可以采用。
实现时可以参考标准库json.Unmarshal的设计,将接收结果的指针作为参数传入,由调用方指定目标绑定类型,封装层内部自动完成单对象/数组的适配。Go 1.18+版本可以结合反射实现自动适配,示例代码如下:
import ( "encoding/json" "io/ioutil" "reflect" "strings" ) func (client *Client) Get(endpoint string, params map[string]string, result any) error { // 省略请求构造、发送逻辑 res, err := client.sendRequest(endpoint, params) if err != nil { return err } defer res.Body.Close() byteBody, err := ioutil.ReadAll(res.Body) if err != nil { return err } trimmed := strings.TrimSpace(string(byteBody)) if len(trimmed) == 0 { return nil } switch trimmed[0] { case '{': // 如果传入的目标类型是切片指针,自动将单对象包装为数组再解析 rv := reflect.ValueOf(result) if rv.Kind() == reflect.Ptr && rv.Elem().Kind() == reflect.Slice { wrapped := make([]byte, 0, len(byteBody)+2) wrapped = append(wrapped, '[') wrapped = append(wrapped, byteBody...) wrapped = append(wrapped, ']') return json.Unmarshal(wrapped, result) } // 非切片类型直接解析 return json.Unmarshal(byteBody, result) case '[': return json.Unmarshal(byteBody, result) default: return &json.SyntaxError{Msg: "invalid response format"} } } // 调用示例 type User struct { Abc string `json:"abc"` Efg int `json:"efg"` Hij []struct { Klm string `json:"klm"` } `json:"hij"` } // 无论接口返回单用户还是用户数组,都可以直接绑定到[]User类型 var userList []User err := client.Get("/v1/users", map[string]string{"filter.abc": "lorem ipsum"}, &userList)
如果使用的Go版本低于1.18不支持any类型别名,将参数类型替换为interface{}即可,逻辑完全一致。
内容的提问来源于stack exchange,提问作者Mario Martín Moreno
相关产品推荐
相关产品推荐

