Go 结构体里的“可选时间”不要简单约定一个特殊日期。更稳妥的做法是先问清楚业务含义:这个字段是否必须存在?如果没有值,调用方需要区分“未填写”和“填写了一个时间”吗?
如果时间一定存在,直接用 time.Time;如果接口需要表达缺失,优先用 *time.Time,用 nil 表示没有值;如果还要区分“字段缺失”“显式 null”和“解析失败”,再使用带 Valid 或状态枚举的包装类型。不要用 1970 年或 Go 的零时间充当所有业务场景的哨兵值。
官方资料:https://pkg.go.dev/time
JSON 资料:https://pkg.go.dev/encoding/json
先把“没有时间”定义清楚
time.Time 是一个值类型。没有显式赋值时,它会得到自己的零值:公元 1 年 1 月 1 日 UTC。这个值不是“当前时间”,也不是系统自动推导出的缺省时间,而是 Go 用来表示“尚未初始化”的普通值。
我在改造接口结构体时最容易踩的坑,是把三种状态混在了一起:
- 必填时间:业务上必须有值,零值通常代表输入或初始化错误。
- 可选时间:没有填写是合法状态,需要用
nil或零值表达缺失。 - 时间与有效性分离:除了时间本身,还要表达是否存在、是否经过解析,甚至是否是显式 null。
因此,字段类型本身就是协议的一部分。不要先写一个 time.Time,等 JSON 或数据库出现歧义后,再靠注释补救。
只有“是否初始化”时,用 time.Time.IsZero
如果结构体内部只需要知道时间有没有被赋值,值类型加 IsZero 就够了。官方文档也建议用 IsZero 判断一个 Time 是否没有被显式初始化,而不是把某个日期写死在业务代码里。
package main
import (
"fmt"
"time"
)
type Job struct {
// CreatedAt 是必填的创建时间;零值通常意味着初始化链路有问题。
CreatedAt time.Time
// FinishedAt 允许任务尚未完成,所以零值代表“尚未结束”。
FinishedAt time.Time
}
func finishedAtText(t time.Time) string {
// IsZero 比较语义更直接,不依赖某个固定的历史日期。
if t.IsZero() {
return "尚未完成"
}
// 只在确认有值后格式化,避免把零时间暴露给调用方。
return t.Format(time.RFC3339)
}
func main() {
job := Job{CreatedAt: time.Now()}
fmt.Println(finishedAtText(job.FinishedAt))
}
这种写法适合内部状态、必然由某个阶段补齐的字段,或者你明确接受“零值就是未发生”。但它不适合所有 API:JSON 客户端可能仍然收到一个公元 1 年的时间字符串,调用方也无法仅凭字段值区分“没有传”与“传入了零时间”。
接口字段需要缺省语义时,用 *time.Time
*time.Time 把“有没有值”从时间点里拆出来了:非 nil 表示有一个时间点,nil 表示缺失。它特别适合 PATCH 请求、筛选条件和响应中的可选字段。
package main
import (
"encoding/json"
"fmt"
"time"
)
type UserPatch struct {
// Birthday 为 nil 时表示请求没有修改生日。
Birthday *time.Time `json:"birthday,omitempty"`
// DeletedAt 为 nil 时表示用户当前没有删除时间。
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
func main() {
birthday := time.Date(1995, time.May, 8, 0, 0, 0, 0, time.UTC)
patch := UserPatch{Birthday: &birthday}
body, err := json.Marshal(patch)
if err != nil {
// 序列化错误必须向上返回,不能把空 JSON 当成成功结果。
panic(err)
}
fmt.Println(string(body))
}
这里的 omitempty 依赖的是指针的 nil 状态,因此缺失字段可以被省略。需要注意,指针只解决“有值还是没有值”;它不自动告诉你字段是“未提交”“显式 null”还是“解析时出错”。PATCH 语义如果要区分这些状态,仍然需要额外的存在标记。
传递时间指针时不必为了取地址引入一个特殊日期。把解析得到的局部变量取地址即可,Go 会保证它在仍被引用时保持有效。
JSON 与持久化边界怎么处理
值类型和指针类型在 JSON 层的差异很直观:
| Go 字段 | 没有值时的内存语义 | 常见 JSON 结果 | 适用判断 |
|---|---|---|---|
time.Time | 零时间 | 仍可能编码为时间字符串 | 字段通常必填,或内部接受零值 |
*time.Time | nil | omitempty 下可省略 | API 需要表达缺失 |
OptionalTime | Valid=false | 由自定义编解码规则决定 | 需要更细的状态协议 |
标准 encoding/json 的传统 omitempty 主要针对 false、0、nil 指针或接口,以及空数组、切片、map、字符串;time.Time 是结构体,不能把它简单当作 nil 指针处理。较新的 Go JSON 文档还提供了 omitzero 语义,能依据零值或 IsZero 判断是否省略,但项目是否能使用它取决于目标 Go 版本和编码器配置。需要跨多个版本部署时,用 *time.Time 往往更容易让协议行为保持一致。
数据库边界也要保持同一个决定:如果数据库列允许 NULL,扫描层通常需要能接收空值的类型或驱动提供的 nullable 类型;如果列定义为 NOT NULL,就不要在领域对象里假装它是可选字段。领域模型、传输模型和存储模型可以不同,但转换时必须显式写出“空值如何映射”。
什么时候值得定义 OptionalTime
当业务需要区分“字段没有出现”“字段明确为 null”“字段有合法时间”“字段出现但格式错误”时,裸 time.Time 和裸指针都不够。此时可以定义一个包装类型,在边界层集中处理解析和序列化。
package api
import (
"encoding/json"
"time"
)
type OptionalTime struct {
// Time 保存解析后的时间;Valid 负责表达是否存在有效值。
Time time.Time
Valid bool
}
func (o OptionalTime) MarshalJSON() ([]byte, error) {
// 无有效值时输出 null,让调用方不必猜测零时间的含义。
if !o.Valid {
return []byte("null"), nil
}
return json.Marshal(o.Time)
}
func (o *OptionalTime) UnmarshalJSON(data []byte) error {
// null 表示明确清空;先重置旧值,避免复用对象时残留数据。
if string(data) == "null" {
o.Time = time.Time{}
o.Valid = false
return nil
}
var value time.Time
if err := json.Unmarshal(data, &value); err != nil {
// 保留解析错误,让上层返回可定位的 400,而不是吞掉错误。
return err
}
o.Time = value
o.Valid = true
return nil
}
包装类型的好处是协议规则集中,坏处是代码和测试成本更高。不要为了“看起来更严谨”给每个内部字段都包一层;只在 API、消息队列或数据库适配层确实需要多状态时使用。
一次迁移检查清单
- 先列出字段状态:必填、可选,还是要区分缺省与显式 null。
- 内部必填字段保留
time.Time,可选 API 字段优先改成*time.Time。 - 所有“没有值”的判断统一改成
IsZero或nil,删除散落的特殊日期常量。 - 为零时间、nil、合法时间和非法字符串分别准备 JSON 编解码用例。
- 检查数据库 NULL 与领域对象的转换,确认读取和写回不会把缺失值变成零时间。
- 如果采用
omitzero或新的 JSON 编码语义,明确写入 go.mod 的版本边界并在部署环境回归。
常见问题
time.Time 的零值能不能当成未设置?
可以,但前提是整个领域约定都接受这个语义。判断时用 IsZero,不要依赖格式化后的字符串或某个固定日期。
为什么可选字段通常推荐 *time.Time?
因为 nil 天然表达缺失,JSON 的 omitempty 也容易配合;它比把公元 1 年的零时间暴露给客户端更清楚。
用了 omitempty,time.Time 还是出现了,怎么办?
先确认字段是值类型还是指针类型,以及项目使用的 Go 版本和 JSON 编码器语义。跨版本场景可以改用 *time.Time;只需要零值省略时,再评估目标版本是否支持 omitzero。
数据库允许 NULL 时一定要把领域字段改成指针吗?
不一定。存储模型可以使用 nullable 类型,领域模型可以把缺失映射成指针、包装类型或业务状态;关键是转换规则必须显式且可测试。
最后可以用一句话做选择:必填就用 time.Time,只需表达缺失就用 *time.Time,需要区分多种边界状态再引入 Valid 包装;不要让特殊日期替代清晰的类型语义。

Go io.ReadAll 怎样限制内存占用
