添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

起步

想在使用 GORM 时使用自定义类型必然事出有因,一般可有以下两种方式:

  • 方法 1:
type MyString string
  • 方法 2:
type MyString struct {
    string
}

当需求比较简单时,可采取方法1,也就是类型别名;如果需求复杂,就不得不把数据字段嵌入自定义结构体中。字段是否匿名并不重要,主要是用来承载目的数据。

单单把数据类型定义了还不够,还需要实现两个方法,你把这理解为一种 协议 即可。

// 写入数据库之前,对数据做类型转换
func (s MyString) Value() (driver.Value, error) {
// 将数据库中取出的数据,赋值给目标类型
func (s *MyString) Scan(v interface{}) error {
}

下面将结合我在实际开发遇到的业务场景,讲解为什么需要自定义类型,以及如何去实现上述的两个方法。

方法1:类型别名

场景 1

第一个场景 :我需要自定义时间的显示格式。

当我的 model 嵌入 gorm.Model 时,会多四个字段,分别是:id, created_at, updated_at, deleted_at。

type Plan struct {
    gorm.Model
    Name string `gorm:"column:name"`
}

我面对的需求是,把数据从数据库中取出来,并按照规定的格式显示时间,最后返回给前端(需要 JSON 处理)。当然,我比较懒,希望直接取出数据,立马返给前端,时间的格式还是我期望的那样。为简便起见,这里只用到 created_at,name 两个字段。

先定义一个返给前端的数据结构:

type MyTime time.Time
// 返回给前端的数据结构
type Resp struct {
    CreatedAt MyTime `gorm:"column:created_at"`
    Name      string `gorm:"column:name"`
}

查询数据库代码如下。同时我用 json.Marshal 方法将结构体转换成 json 字符串,相当于模拟了将数据传递给前端的一个过程。

var resp Resp
db.Model(&Plan{}).Select("created_at, name").Limit(1).Scan(&resp)
data, _ := json.Marshal(resp)
log.Println(string(data))

然而日志输出不是我们想看到的: 2020/02/16 19:21:28 {"CreatedAt":{},"Name":"早饭"}

这里还需要注意程序并没有报错。没报错是因为 MyTime 是 time.Time 类型的别名 ,两个类型之间允许相互转换。但是为什么输出是一个空值呢?

MyTime 作为 time.Time 的别名,但是并没有继承 time.Time 的方法,也就不支持 json.Marshal 转换。所以还需要为 MyTime 绑定 MarshalJSON 方法。

func (t MyTime) MarshalJSON() ([]byte, error) {
    tTime := time.Time(t)
    tStr := tTime.Format("2006/01/02 15:04:05") // 设置格式
    // 注意 json 字符串风格要求
    return []byte(fmt.Sprintf("\"%v\"", tStr)), nil
}

再运行程序就一切正常了: 2020/02/16 19:31:38 {"CreatedAt":"2020/02/16 18:53:13","Name":"早饭"}

这里尤其需要注意 json 字符串的风格要求,不然你很有可能得不到你想要的结果。详见 两个不经意间的报错

场景 2

第二个场景 :基于自定义类型正常读写数据库。

第二个场景是基于第一个场景之上提出一些奢望。因为你不妨打开数据库看看(我用的是 Navicat Premium 可视化工具),可以看到 created_at 字段数据显示为: 2020-02-16 18:53:13.8644852+08:00 。我们让时间格式打一开始就是目标格式不好吗?

Plan model 修改成下面这样:

type MyTime time.Time
type Plan struct {
    CreatedAt MyTime `gorm:"column:created_at"`
    Name      string `gorm:"column:name"`
}

删除之前创建的数据表。一切准备就绪,我们先调用 CreateTable 创建一个新表。程序倒是没有报错的运行完毕,但是你打开表一看:没有 create_at 字段!!!

回到开篇提到的,我们还需要为自定义类型实现 Value() (driver.Value, error) Scan(v interface{}) error 这两个方法才行。

func (t MyTime) Value() (driver.Value, error) {
    // MyTime 转换成 time.Time 类型
    tTime := time.Time(t)
    return tTime.Format("2006/01/02 15:04:05"), nil
func (t *MyTime) Scan(v interface{}) error {
    switch vt := v.(type) {
    case string:
        // 字符串转成 time.Time 类型
        tTime, _ := time.Parse("2006/01/02 15:04:05", vt)
        *t = MyTime(tTime)
    default:
        return errors.New("类型处理错误")
    return nil
}

可以看到,其实我们做类型处理时都借助了 time.Time 类型做中转。所以不论我们的自定义类型基于时间类型还是整型、浮点型,我们都应该先转换成 go 默认支持的类型,再进行一系列操作。

另外一个重点,关注 Value 和 Scan 的职责。Value 返回的数据是要写入数据库的,我们这里明明是时间类型,但是 return 出去的居然是字符串。同理在 Scan 方法中,参数 v 是来自数据库中的数据,MyTime 对应的字段是时间类型,但我们的处理方式明显是把 v 作为了字符串类型处理。( 前提:数据库为 sqlite3

如果不是 sqlite 数据库,如 mysql,照理说应该可以直接 return 出 time.Time 类型的数据。但我发现程序会抛出这样一个错误: Error 1265: Data truncated for column 'xxxx' at row 1 。暂时没找到解决方案,怀疑这是一个 BUG。因为数据库为 mysql 时,将时间字段放在结构体中就可以了。eg:

type MyTime struct {
    Time time.Time
}

自定义类型为 struct 时如何处理,下面马上说到。

方法2:定义结构体

场景 3

第三个场景 :我需要对类型限制。

我遇上了这样一个需求:要在 gender 字段中存储“男”或者“女”,且类型为字符串。类型别名就明显不适合了,因为它无法限制数据的内容。解决方案当然很多,我说说我的思考方式。

我想把存储性别这个值作为私有属性,不允许外界直接对其赋值,必须通过我提供的 New 方法,这样我就可以对传入的参数做校验。

type MyGender struct {
    string
func NewGender(v string) (MyGender, error) {
    var g MyGender
    if v != "男" && v != "女" {
        return g, errors.New("只支持 “男” 或者 “女”")
    g.string = v
    return g, nil
}

同理,要做到数据库驱动支持,还需要实现两个方法:

func (g MyGender) Value() (driver.Value, error) {
    return g.string, nil
func (g *MyGender) Scan(v interface{}) error {
    g.string = v.(string)
    return nil
}

核心思想不变:将自定义类型转换成 go 支持的基础类型。现在 Stu model 就可以正常用来读写数据库了。

type Stu struct {
    Name   string   `gorm:"column:name"`
    Gender MyGender `gorm:"column:gender"`
}

结合源码分析

Scan 与 Value 方法从何而来?

事实上我们知道 go 提供了一些可空值的类型供开发者使用,即:sql.NullTime, sql.NullBool, sql.NullString……可以选一个看看它的源码。

// go 源码
type NullBool struct {
    Bool  bool
    Valid bool // Valid is true if Bool is not NULL
// Scan implements the Scanner interface.
func (n *NullBool) Scan(value interface{}) error {
    // 如果 value 为空,则认为是 false
    if value == nil {
        n.Bool, n.Valid = false, false
        return nil
    n.Valid = true
    return convertAssign(&n.Bool, value)
// Value implements the driver Valuer interface.
func (n NullBool) Value() (driver.Value, error) {
    // 如果无效,就返回 nil
    if !n.Valid {
        return nil, nil
    return n.Bool, nil
}

当你需要自定义新类型时,可以照着源码包中的代码依葫芦画瓢。

Valuer 接口的注意事项

// go 源码
type Valuer interface {
    Value() (Value, error)
}

之前说 Value() (driver.Value, error) 方法,其实就是实现 Valuer 接口。当你的程序出现下面这类错误时,你就要注意了,可能是 Value 方法没写恰当。
sql: converting argument $5 type: non-Value type main.MyNum returned from Value

在官方包 database.sql.driver.types.go 中有这样一段源码:

// go 源码
func (defaultConverter) ConvertValue(v interface{}) (Value, error) {
    if IsValue(v) {
        return v, nil
    switch vr := v.(type) {
    case Valuer:
        sv, err := callValuerValue(vr)
        return sv, nil
    rv := reflect.ValueOf(v)
    switch rv.Kind() {
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        return rv.Int(), nil
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32:
        return int64(rv.Uint()), nil
    case reflect.Uint64:
        u64 := rv.Uint()
        if u64 >= 1<<63 {
            return nil, fmt.Errorf("uint64 values with high bit set are not supported")
        return int64(u64), nil
    case reflect.Float32, reflect.Float64:
        return rv.Float(), nil
    case reflect.Bool:
        return rv.Bool(), nil
    case reflect.Slice:
        ek := rv.Type().Elem().Kind()
        if ek == reflect.Uint8 {
            return rv.Bytes(), nil
        return nil, fmt.Errorf("unsupported type %T, a slice of %s", v, ek)
    case reflect.String:
        return rv.String(), nil
    return nil, fmt.Errorf("unsupported type %T, a %s", v, rv.Kind())
}

我们随便取一例来关注:

// go 源码
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
    return rv.Int(), nil
...

能够看到当数据为整型时,不论是 int 还是 int8、int16 等等,最后都去调用了 Int() 方法。再去看 Int() 的源码:

// go 源码
func (v Value) Int() int64 {
    k := v.kind()
    p := v.ptr
    switch k {
    case Int:
        return int64(*(*int)(p))
    case Int8:
        return int64(*(*int8)(p))
    case Int16:
        return int64(*(*int16)(p))
    case Int32:
        return int64(*(*int32)(p))
    case Int64:
        return *(*int64)(p)
    panic(&ValueError{"reflect.Value.Int", v.kind()})
}

也就是说,不管你是啥整型,一律转成 int64。而前面之所以会遇到异常 sql: converting argument ... 是因为我 Value 中返回了 uint8 类型。

再从下列代码中我们可以看出:当你的自定义类型实现 Valuer 接口以后,官方包就不会再给你做类型转换了。

// go 源码
case Valuer:
    sv, err := callValuerValue(vr)
    return sv, nil

因而程序报错。是不是所有数据库都这样呢,其他类型又如何?前者我说不好,后者嘛,你可以做更多的尝试或者去阅读源码。

  • Guan: 你是在哪儿看到报403的,我本地试了一下,可以正常访问,不过这个...
  • Veron: url=https://feed.hamibot.com/api...
  • Veron: 好东西,老Repo看着没更新怕用不了了,直接上你的版本,感谢:)
  • Guan: 这个很简单,你手动试一下就知道了
  • nigosim: 想请问一下,这个场景是B和网关还在同一网段,所以B仍能通过请求网...
  • youguanxinqing: scan 方法就是用来取值的
  • 第三: 但是取值却无法取啊
  • 九路: 测试
  • 396601500: 有用,帮到我了,谢谢
  • chauncey: 讲的很好,可以进一步讲一下vlan 和vxlan
  • Python 设计模式 Go MySQL vim c++ 继承 切片 指针 shell linux vscode 静态库 动态库 systemctl nginx rss 元类 类属性 私有属性 递归 汉诺塔 赏金问题 分支语句 循环语句 单例 跳转语句 赋值语句 defer type 项目管理 函数 闭包 结构体 数组 字典 异常机制 引用 方法 接口 匿名组合 排列 组合 动态规划 编辑距离 默认传参 递归中的参数 abc 抽象基类 ORM socket编程 简易聊天系统 select IO复用 并发编程 信号通道 enum vector std 友元 虚函数 动态联编 字符串处理 sed 平均负载 sftp 预处理 宏定义 条件编译 文件包含 操作符 预定义宏 其他命令 命名空间 导包 相对导入 pidstat vmstat 中断次数 上下文切换 lychee 编译 链接 mysqld ngx_fastdfs_module 真假值 去重 TypeError flutter 二分搜索 对角线问题 cpp 3n+1 缓存 mac 快捷键 终端 c centos7 docker 子域 主从配置 custom https http2 lychee-docker 创建型 django manjaro xfce databases k8s ssh log 网络 wireshark rust 多态 clickhouse trait 读书 编程 range 声明语句