功能说明
数据库中的可空列在 Go 里不能直接扫描进 string 或 int,否则遇到 NULL 会报转换错误。标准库提供 sql.NullString 等包装类型,用 Valid 字段区分空字符串和 NULL;扫描到指针类型时,NULL 会得到 nil,非空值会分配内存。两种方式各有用途:需要区分有效空串时用 NullString,面向 JSON 输出时用指针更自然。
本例的 Profile 同时包含两种字段。直接序列化会把 sql.NullString 变成带有 String 与 Valid 的对象,暴露存储细节,因此实现 MarshalJSON,通过内部视图类型把昵称转换成指针或 null。scanProfile 展示如何把查询行扫描进结构体。
关键代码
Profile 的 Nickname 标记 json 为短横线,禁止默认序列化;Bio 是字符串指针,NULL 时自然为 nil。profileJSON 是稳定的对外契约,两个可空字段都是指针。nullString 在 Valid 为假时返回 nil,否则返回字段地址;由于接收的是值拷贝,返回地址指向逃逸到堆上的副本,不会悬空。MarshalJSON 用值接收者,使结构体和指针都能调用。scanProfile 把字段地址直接传给 Scan,由数据库驱动完成转换。
使用注意
NullString 的零值就是无效,扫描前无需初始化。指针字段与 NullString 不要混用同一个列,团队应约定统一风格。自定义 MarshalJSON 会覆盖所有序列化路径,包括日志和缓存,字段改名时要同步更新。字符串指针指向的值可能被后续修改,跨 goroutine 共享时应复制。数据库驱动对时间、数字和 JSON 列的可空处理不同,应在仓储层做集成测试。
验证方式
运行脚本,第一条输出只含 id 与两个 null 字段,没有 String 和 Valid 这样的内部结构。第二条输出 nickname 为 ada,bio 仍为 null,说明有效值被正确转换。再把昵称设置为 Valid 为真的空字符串,输出应为空串而不是 null,体现两种空值的区别。
代码示例
package main
import (
"database/sql"
"encoding/json"
"fmt"
)
type Profile struct {
ID int64 `json:"id"`
Nickname sql.NullString `json:"-"`
Bio *string `json:"bio"`
}
type profileJSON struct {
ID int64 `json:"id"`
Nickname *string `json:"nickname"`
Bio *string `json:"bio"`
}
func nullString(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
func (p Profile) MarshalJSON() ([]byte, error) {
return json.Marshal(profileJSON{
ID: p.ID,
Nickname: nullString(p.Nickname),
Bio: p.Bio,
})
}
func scanProfile(row *sql.Row) (Profile, error) {
var profile Profile
err := row.Scan(&profile.ID, &profile.Nickname, &profile.Bio)
return profile, err
}
func main() {
encoded, _ := json.Marshal(Profile{ID: 7})
fmt.Println(string(encoded))
encoded, _ = json.Marshal(Profile{
ID: 8,
Nickname: sql.NullString{String: "ada", Valid: true},
})
fmt.Println(string(encoded))
}