GORM 字段使用自定义类型

Go 小记 2020-02-17 7349 字 3907 浏览 点赞

起步

想在使用 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 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

2 条评论

  1. 第三
    第三

    但是取值却无法取啊

    1. youguanxinqing
      youguanxinqing

      scan 方法就是用来取值的

添加新评论