新增DTO字段必须用指针类型(如*string)配合omitempty标签,才能区分“未提供”(nil)与“显式设为空字符串”(&""),避免旧客户端不传时触发错误校验;废弃字段应改为未导出小写字段并加json:"-",而非删除或仅用omitempty。

DTO字段新增必须用指针 + omitempty
旧客户端不传新字段时,如果直接定义Email string,JSON 解析后会得到空字符串"",而旧逻辑可能把它当有效值做非空校验并 panic。Go 的 json 包不会跳过未传字段——它会赋零值。
正确做法是:所有新增字段声明为指针类型,并显式加 omitempty 标签:
type UserV2 struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"` // ✅ 旧客户端不传 → 字段为 nil,序列化时被忽略
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
- 指针类型确保“未提供”和“提供空值”可区分(
nilvs&"") -
omitempty让json.Marshal在字段为 nil 时不输出该 key,v1 客户端完全感知不到 - 禁止只加
omitempty却不用指针:例如Email string `json:"email,omitempty"`,旧客户端不传时仍得"",不是“缺失”
废弃字段不能删,要保留但屏蔽序列化
字段名还在、结构体还兼容,但业务上已弃用——这时删掉字段会导致数据库 Scan、gob 序列化、反射调用全部失败,而且 v1/v2 DTO 共享底层 model 时更易出错。
正确做法是:保留字段,改为小写未导出字段,加 json:"-" 和注释说明:
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
type UserV1 struct {
ID int64 `json:"id"`
Name string `json:"name"`
// deprecated: use FullAddress instead
oldAddress string `json:"-"`
}
- 未导出字段不会被
json.Marshal输出,也不会被json.Unmarshal写入 - 数据库层仍能正常
Scan到该字段(如用sql.Rows.Scan(&u.ID, &u.Name, &u.oldAddress)) - 别用
json:"old_address,omitempty"—— 这样旧客户端发来该字段,仍会被解析,破坏“废弃”语义
字段重命名或类型变更必须双写 + 自定义 UnmarshalJSON
比如要把 Count int 改成 Count string,或者把 CreatedAt int64 升级为 CreatedAt time.Time,单靠 tag 无法兼容。旧客户端仍发数字,新逻辑却期待时间对象,json.Unmarshal 直接报错。
必须在结构体内同时保留新旧字段,并实现 UnmarshalJSON 做过渡处理:
type UserV2 struct {
ID int64 `json:"id"`
// v1 兼容字段(旧客户端发 int)
CountInt int `json:"count"`
// v2 主字段(新客户端发 string)
CountStr *string `json:"count_str,omitempty"`
// 实现自定义解析
Count int `json:"-"` // 运行时聚合值,不参与 JSON 编解码
}
func (u *UserV2) UnmarshalJSON(data []byte) error {
type Alias UserV2 // 防止无限递归
aux := &struct {
CountInt int `json:"count"`
CountStr *string `json:"count_str,omitempty"`
*Alias
}{
Alias: (*Alias)(u),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
u.Count = aux.CountInt
if aux.CountStr != nil {
u.Count = parseInt(*aux.CountStr)
}
return nil
}
- 不要试图用中间件统一转换——每个字段的兼容逻辑差异大,必须绑定到具体 DTO
- 避免在
UnmarshalJSON里调用业务逻辑或 DB 查询,保持纯数据转换 - 双字段方案只用于过渡期;稳定后删掉旧字段,但需同步升级所有依赖该 DTO 的客户端
DTO 必须按版本严格隔离,禁止跨版本复用 struct
哪怕 UserV1 和 UserV2 字段目前一模一样,也不能共用一个 struct。v1 客户端不该收到 v2 新增字段,v2 也不该意外继承 v1 已废弃字段——这是契约边界,不是代码懒。
- 每个版本的 handler 只接收对应版本的 DTO:
func createUserV1(w http.ResponseWriter, r *http.Request) { var req UserV1; json.NewDecoder(r.Body).Decode(&req) } - 禁止在 handler 里做
json.Marshal(domain.User{...}):domain model 不带版本语义,无法控制字段输出 - 禁止用
map[string]interface{}或interface{}当 DTO:失去编译检查、IDE 提示和文档生成能力 - Swagger 注释也要按版本分开写:
// @Success 200 {object} UserV1vs// @Success 200 {object} UserV2
最常被忽略的一点:DTO 不只是响应结构,更是 API 契约的具象化。字段是否可空、是否必传、是否废弃,都必须在 struct 定义里白纸黑字体现——而不是靠文档、注释或口头约定。

















