Go 部分更新 API 入门:区分未提交、空值和清空字段

用用户资料 PATCH 接口讲 Go JSON 部分更新中的三态问题:字段未提交、提交空字符串、提交 null。

创建接口比较简单,用户提交什么就创建什么。更新接口尤其是部分更新,会遇到一个麻烦问题:字段没提交、提交空字符串、提交 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:提交了 null
  • Set=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 类型显式记录 SetValid

实现 PATCH 接口时,要把 HTTP 请求模型转换成业务输入模型,再由仓储层安全更新。不要让模糊的零值穿透系统,否则用户资料被误清空只是迟早的事。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页