← 中文资源

简体中文版本

安全扫描 SQL 可空列

把数据库可空列映射为 sql.NullString 与指针字段,并通过自定义 JSON 视图输出干净的 null 值。

阅读英文原文

功能说明

数据库中的可空列在 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))
}