创建接口比较简单,用户提交什么就创建什么。更新接口尤其是部分更新,会遇到一个麻烦问题:字段没提交、提交空字符串、提交 null,这三件事含义可能不同。比如昵称没提交表示不改,提交空字符串表示设置为空字符串,提交 null 表示清空昵称。
Go 的普通结构体很难直接区分这些状态。本文用用户资料 PATCH 接口讲一种入门可用的写法。
普通指针字段的问题
很多人会这样写:
type UpdateProfileRequest struct {
Nickname *string `json:"nickname"`
Bio *string `json:"bio"`
}
如果 JSON 是 {},Nickname 是 nil。如果 JSON 是 {"nickname": null},Nickname 也是 nil。也就是说,指针能区分“有字符串”和“没有字符串”,但不能区分“没提交”和“提交 null”。
有些业务不需要区分 null,那指针够用。但如果需要三态,就要更明确的类型。
定义 Optional 类型
type OptionalString struct {
Set bool
Valid bool
Value string
}
func (o *OptionalString) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Valid = false
o.Value = ""
return nil
}
var value string
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Valid = true
o.Value = value
return nil
}
含义:
Set=false:字段没提交Set=true, Valid=false:提交了 nullSet=true, Valid=true:提交了字符串
请求结构:
type UpdateProfileRequest struct {
Nickname OptionalString `json:"nickname"`
Bio OptionalString `json:"bio"`
}
应用更新
type UpdateProfileInput struct {
NicknameSet bool
Nickname *string
BioSet bool
Bio *string
}
func (r UpdateProfileRequest) ToInput() UpdateProfileInput {
var input UpdateProfileInput
if r.Nickname.Set {
input.NicknameSet = true
if r.Nickname.Valid {
v := strings.TrimSpace(r.Nickname.Value)
input.Nickname = &v
}
}
if r.Bio.Set {
input.BioSet = true
if r.Bio.Valid {
v := r.Bio.Value
input.Bio = &v
}
}
return input
}
业务层根据 NicknameSet 判断是否更新字段,根据 Nickname == nil 判断是否清空。
SQL 更新不要乱拼
简单做法是根据字段构造 set 子句:
func buildUpdate(input UpdateProfileInput) (string, []any) {
var sets []string
var args []any
if input.NicknameSet {
sets = append(sets, "nickname = ?")
if input.Nickname == nil {
args = append(args, nil)
} else {
args = append(args, *input.Nickname)
}
}
if input.BioSet {
sets = append(sets, "bio = ?")
if input.Bio == nil {
args = append(args, nil)
} else {
args = append(args, *input.Bio)
}
}
return strings.Join(sets, ", "), args
}
注意字段名来自代码,不来自用户输入;用户值仍然作为参数传入。不要把用户提交的字段名直接拼进 SQL。
如果没有任何字段被设置,可以返回 400:
if !input.NicknameSet && !input.BioSet {
return errors.New("no fields to update")
}
测试三态
func TestOptionalString(t *testing.T) {
tests := []struct {
name string
json string
set bool
valid bool
value string
}{
{name: "missing", json: `{}`, set: false},
{name: "null", json: `{"nickname":null}`, set: true, valid: false},
{name: "value", json: `{"nickname":"go"}`, set: true, valid: true, value: "go"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req UpdateProfileRequest
if err := json.Unmarshal([]byte(tt.json), &req); err != nil {
t.Fatal(err)
}
if req.Nickname.Set != tt.set || req.Nickname.Valid != tt.valid || req.Nickname.Value != tt.value {
t.Fatalf("nickname = %#v", req.Nickname)
}
})
}
}
这类测试非常重要。部分更新的 bug 通常不是语法错误,而是把没提交字段误清空。
不一定所有字段都要三态
有些字段不允许 null,比如用户名、邮箱、状态。它们可以只用指针表达“是否提交”,提交空字符串再由校验拒绝。不要为了统一,把所有字段都做成复杂 Optional。
API 设计应该先明确业务语义:字段能不能清空,空字符串是否合法,null 表示什么。代码只是把这个语义表达出来。
响应里返回更新后的资源
部分更新成功后,建议返回更新后的资源,而不是只返回 204 No Content。这样前端可以拿到服务端规范化后的值,比如 trim 后的昵称、默认头像、更新时间。
func (h *Handler) PatchProfile(w http.ResponseWriter, r *http.Request) {
var req UpdateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "JSON 格式不正确")
return
}
profile, err := h.service.UpdateProfile(r.Context(), req.ToInput())
if err != nil {
writeAppError(w, err)
return
}
writeJSON(w, http.StatusOK, ToProfileResponse(profile))
}
这也能减少前端自己猜测状态。部分更新的语义已经够复杂,响应尽量给出明确结果。对于移动端或弱网场景,返回最新资源还能减少一次额外查询。
小结
Go 部分更新 API 的核心问题是三态:未提交、提交 null、提交值。普通指针字段无法区分未提交和 null,可以用自定义 UnmarshalJSON 类型显式记录 Set 和 Valid。
实现 PATCH 接口时,要把 HTTP 请求模型转换成业务输入模型,再由仓储层安全更新。不要让模糊的零值穿透系统,否则用户资料被误清空只是迟早的事。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。