登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

database/sql Null 类型扫描到业务结构体的转换

来源:17golang原创

时间:2026-10-10 19:28:16 481浏览 收藏

如果数据库列允许 NULL,把查询结果直接扫描到普通的 string、int64 或 time.Time 字段,常见结果是 Scan 报错,或者业务代码把“没有值”和“值为零”混在一起。稳定的做法是:先在数据库访问边界使用 sql.NullString、sql.NullInt64 等可空类型,读取它们的 Valid 字段,再转换成业务结构体需要的指针、可选值或明确默认值。

要点速览
  • 数据库驱动把 SQL NULL 传给 Scanner 时,对应的是 nil;普通值类型不能表达这个状态。
  • sql.Null* 用“值字段 + Valid”保留空值语义;支持 Go 1.22 及以上时也可以使用 sql.Null[T]。
  • 推荐把 Null 类型限制在持久化 DTO 中,再显式映射到业务结构体,避免数据库适配细节渗透到业务规则。

先还原 NULL 扫描失败的现场

database/sql.Scanner 接收的源值包含整数、浮点数、布尔值、字节切片、字符串、时间以及表示 SQL NULL 的 nil。当目标是普通的 string 或 int64 时,它们没有“缺失”这个额外状态,因此 Scan 无法在不丢失信息的前提下完成转换。

type User struct {
    ID       int64
    Nickname string
}

var user User
err := db.QueryRowContext(ctx, `SELECT id, nickname FROM users WHERE id = ?`, id).Scan(&user.ID, &user.Nickname)
if err != nil {
    // nickname 为 NULL 时,普通 string 无法承载缺失状态,错误会在 Scan 处返回。
    return fmt.Errorf("读取用户失败: %w", err)
}

这里的问题不是 SQL 语句一定错误,而是查询列的可空性与 Go 目标字段的表达能力不匹配。更重要的是,直接把 NULL 改成空字符串也会丢失信息:空字符串可能是用户主动保存的值,而 NULL 可能代表用户从未填写。

SQL NULL、driver nil、Scanner 与 sql.Null 类型和普通字段之间的静态结构关系图
图1:SQL NULL 扫描边界结构图,展示空值如何在 database/sql 与目标字段之间分流;这是静态说明图,不是运行截图。

在扫描边界使用 sql.Null 类型

database/sql 提供了一组实现 Scanner 和 driver.Valuer 的类型。以字符串为例,sql.NullString.String 保存实际值,sql.NullString.Valid 表示该值是否来自非 NULL 数据。数字类型的规则相同,例如 sql.NullInt64.Int64 和 sql.NullInt64.Valid。

type userRow struct {
    ID       int64
    Nickname sql.NullString
    Age      sql.NullInt64
}

var row userRow
err := db.QueryRowContext(ctx, `
    SELECT id, nickname, age
    FROM users
    WHERE id = ?
`, id).Scan(&row.ID, &row.Nickname, &row.Age)
if err != nil {
    // 查询失败和 NULL 本身是两件事;先处理查询错误,再读取 Valid。
    return fmt.Errorf("扫描用户行失败: %w", err)
}

if row.Nickname.Valid {
    log.Printf("昵称=%q", row.Nickname.String)
}
if !row.Age.Valid {
    // Age 为 NULL,不要把它误报成 0 岁。
    log.Println("用户没有填写年龄")
}

这个边界有两个好处:扫描阶段不会把 SQL 空值强行塞进普通字段,业务代码也能明确区分“字段没有值”和“字段值恰好为零”。如果使用 Rows 批量读取,仍然要在每一轮先检查 Scan,循环结束后再检查 rows.Err()。

把数据库 DTO 映射成业务结构体

持久化结构体可以接受 sql.Null*,但不建议让所有业务函数都知道 NullString.String 和 Valid。更清楚的分层方式是:查询得到一个数据库 DTO,随后在一个转换函数中把可空字段映射成业务层的指针或自定义可选类型。

type User struct {
    ID       int64
    Nickname *string
    Age      *int64
}

func (r userRow) toUser() User {
    user := User{ID: r.ID}
    if r.Nickname.Valid {
        // 只有 Valid 为 true 才把数据库值暴露给业务层。
        nickname := r.Nickname.String
        user.Nickname = &nickname
    }
    if r.Age.Valid {
        // 指针让业务层继续区分 NULL 与数值 0。
        age := r.Age.Int64
        user.Age = &age
    }
    return user
}

映射之后,业务代码可以通过 user.Nickname == nil 判断缺失,也可以在接口层统一把 nil 序列化为 JSON null。如果业务定义明确规定缺失字段必须显示为默认文案,也应在业务层做这个决定,而不是在 Scan 时静默替换。

查询列、UserRow 数据库 DTO、Valid 判断、User 业务结构体和 Value 写回之间的关系图
图2:数据库 DTO 到业务结构体的映射关系图,展示 Valid 判断与业务可选字段之间的职责边界;这是静态说明图,不是运行截图。

按工具链选择泛型 Null[T]

如果项目使用 Go 1.22 或更高版本,可以用 sql.Null[T] 表达可空值。它把值放在 V 字段中,把是否有效放在 Valid 字段中,减少为不同基础类型分别记忆字段名的成本。

type userRowV2 struct {
    ID       int64
    Nickname sql.Null[string]
    Age      sql.Null[int64]
}

var row userRowV2
if err := db.QueryRowContext(ctx, `
    SELECT id, nickname, age FROM users WHERE id = ?
`, id).Scan(&row.ID, &row.Nickname, &row.Age); err != nil {
    // 泛型 Null 仍然需要在 Scan 阶段检查错误。
    return fmt.Errorf("读取用户失败: %w", err)
}
if row.Nickname.Valid {
    // V 是值字段;Valid 为 false 时不要读取 V 作为业务结论。
    fmt.Println(row.Nickname.V)
}

sql.Null[T] 的 T 不是任意业务对象,而应当是驱动值可以支持的类型。旧工具链、需要兼容旧版本的公共库,或者团队已经统一使用 NullString 等类型时,继续使用具体类型也完全合理;选择的关键是保持项目工具链和数据边界的一致。

批量查询和写回时保持同一套语义

批量查询时,Null 类型的使用方式不会因为结果集变多而改变。每行先扫描到 DTO,再转换到业务对象;循环结束后检查 rows.Err(),这样可以把扫描错误和迭代错误区分开。

rows, err := db.QueryContext(ctx, `SELECT id, nickname FROM users WHERE state = ?`, "active")
if err != nil {
    // QueryContext 尚未返回结果集时,直接处理建立阶段错误。
    return err
}
defer rows.Close()

for rows.Next() {
    var row userRow
    if err := rows.Scan(&row.ID, &row.Nickname); err != nil {
        // 当前行扫描失败时立即停止,避免把不完整数据交给业务层。
        return fmt.Errorf("扫描用户列表失败: %w", err)
    }
    consumeUser(row.toUser())
}
if err := rows.Err(); err != nil {
    // Next 返回 false 不一定是正常结束,迭代阶段错误在这里收口。
    return fmt.Errorf("读取用户列表失败: %w", err)
}
return nil

写回数据库时,sql.NullString 等类型实现了 driver.Valuer。当 Valid 为 false 时,传给驱动的值会保持为空值语义;当 Valid 为 true 时,才写入内部值。

row := userRow{
    ID: 1,
    Nickname: sql.NullString{String: "阿岚", Valid: true},
}
_, err := db.ExecContext(ctx, `UPDATE users SET nickname = ? WHERE id = ?`, row.Nickname, row.ID)
if err != nil {
    // Value 转换或执行阶段失败都要向上层返回,不能假设写回成功。
    return fmt.Errorf("更新用户昵称失败: %w", err)
}

常见问题

为什么不直接把 NULL 当成空字符串?

因为空字符串是一个真实字符串值,而 NULL 表示没有值。两者在搜索、更新、接口展示和统计时可能有不同含义,应在业务规则明确之后再决定是否合并。

Valid 为 false 时,String 或 V 一定是空的吗?

不要依赖无效状态下的值字段。业务判断应当先看 Valid,只有为 true 时才读取 String、Int64 或 V。

业务结构体也可以直接使用 sql.NullString 吗?

可以,但会让业务层依赖数据库访问包。小型程序可以接受;如果项目需要清晰分层、多个存储实现或稳定的接口模型,使用 DTO 到业务结构体的显式映射更容易维护。

Rows.Scan 报错后还要检查 rows.Err 吗?

当前行的 Scan 错误应当立即处理;如果循环是因为 Next 返回 false 结束,仍然要检查 rows.Err(),确认不是迭代阶段错误。

总结

处理 database/sql 的 NULL,核心不是寻找一个默认值,而是先保留“有没有值”这个事实。使用 sql.Null* 或 sql.Null[T] 接住扫描结果,在 DTO 到业务结构体的转换处决定指针、默认值或其它领域表达,再用同一套规则处理批量读取和写回。这样既能避免 Scan 类型错误,也不会把 NULL 悄悄伪装成零值。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>