GORM 字段使用自定义类型

傷城~ 2023-07-06 08:42 153阅读 0赞

文章目录

  • 起步
  • 方法1:类型别名
    • 场景 1
    • 场景 2
  • 方法2:定义结构体
    • 场景 3
  • 结合源码分析
    • Scan 与 Value 方法从何而来?
    • Valuer 接口的注意事项

起步

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

  • 方法 1:

    type MyString string

  • 方法 2:

    type MyString struct {

    1. string

    }

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

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

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

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

方法1:类型别名

场景 1

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

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

  1. type Plan struct {
  2. gorm.Model
  3. Name string `gorm:"column:name"`
  4. }

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

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

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

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

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

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

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

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

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

再运行程序就一切正常了: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 修改成下面这样:

  1. type MyTime time.Time
  2. type Plan struct {
  3. CreatedAt MyTime `gorm:"column:created_at"`
  4. Name string `gorm:"column:name"`
  5. }

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

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

  1. func (t MyTime) Value() (driver.Value, error) {
  2. // MyTime 转换成 time.Time 类型
  3. tTime := time.Time(t)
  4. return tTime.Format("2006/01/02 15:04:05"), nil
  5. }
  6. func (t *MyTime) Scan(v interface{ }) error {
  7. switch vt := v.(type) {
  8. case string:
  9. // 字符串转成 time.Time 类型
  10. tTime, _ := time.Parse("2006/01/02 15:04:05", vt)
  11. *t = MyTime(tTime)
  12. default:
  13. return errors.New("类型处理错误")
  14. }
  15. return nil
  16. }

可以看到,其实我们做类型处理时都借助了 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:

  1. type MyTime struct {
  2. Time time.Time
  3. }

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

方法2:定义结构体

场景 3

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

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

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

  1. type MyGender struct {
  2. string
  3. }
  4. func NewGender(v string) (MyGender, error) {
  5. var g MyGender
  6. if v != "男" && v != "女" {
  7. return g, errors.New("只支持 “男” 或者 “女”")
  8. }
  9. g.string = v
  10. return g, nil
  11. }

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

  1. func (g MyGender) Value() (driver.Value, error) {
  2. return g.string, nil
  3. }
  4. func (g *MyGender) Scan(v interface{ }) error {
  5. g.string = v.(string)
  6. return nil
  7. }

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

  1. type Stu struct {
  2. Name string `gorm:"column:name"`
  3. Gender MyGender `gorm:"column:gender"`
  4. }

结合源码分析

Scan 与 Value 方法从何而来?

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

  1. // go 源码
  2. type NullBool struct {
  3. Bool bool
  4. Valid bool // Valid is true if Bool is not NULL
  5. }
  6. // Scan implements the Scanner interface.
  7. func (n *NullBool) Scan(value interface{ }) error {
  8. // 如果 value 为空,则认为是 false
  9. if value == nil {
  10. n.Bool, n.Valid = false, false
  11. return nil
  12. }
  13. n.Valid = true
  14. return convertAssign(&n.Bool, value)
  15. }
  16. // Value implements the driver Valuer interface.
  17. func (n NullBool) Value() (driver.Value, error) {
  18. // 如果无效,就返回 nil
  19. if !n.Valid {
  20. return nil, nil
  21. }
  22. return n.Bool, nil
  23. }

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

Valuer 接口的注意事项

  1. // go 源码
  2. type Valuer interface {
  3. Value() (Value, error)
  4. }

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

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

  1. // go 源码
  2. func (defaultConverter) ConvertValue(v interface{ }) (Value, error) {
  3. if IsValue(v) {
  4. return v, nil
  5. }
  6. switch vr := v.(type) {
  7. case Valuer:
  8. sv, err := callValuerValue(vr)
  9. ...
  10. return sv, nil
  11. ...
  12. rv := reflect.ValueOf(v)
  13. switch rv.Kind() {
  14. ...
  15. case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
  16. return rv.Int(), nil
  17. case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32:
  18. return int64(rv.Uint()), nil
  19. case reflect.Uint64:
  20. u64 := rv.Uint()
  21. if u64 >= 1<<63 {
  22. return nil, fmt.Errorf("uint64 values with high bit set are not supported")
  23. }
  24. return int64(u64), nil
  25. case reflect.Float32, reflect.Float64:
  26. return rv.Float(), nil
  27. case reflect.Bool:
  28. return rv.Bool(), nil
  29. case reflect.Slice:
  30. ek := rv.Type().Elem().Kind()
  31. if ek == reflect.Uint8 {
  32. return rv.Bytes(), nil
  33. }
  34. return nil, fmt.Errorf("unsupported type %T, a slice of %s", v, ek)
  35. case reflect.String:
  36. return rv.String(), nil
  37. }
  38. return nil, fmt.Errorf("unsupported type %T, a %s", v, rv.Kind())
  39. }

我们随便取一例来关注:

  1. // go 源码
  2. ...
  3. case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
  4. return rv.Int(), nil
  5. ...

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

  1. // go 源码
  2. func (v Value) Int() int64 {
  3. k := v.kind()
  4. p := v.ptr
  5. switch k {
  6. case Int:
  7. return int64(*(*int)(p))
  8. case Int8:
  9. return int64(*(*int8)(p))
  10. case Int16:
  11. return int64(*(*int16)(p))
  12. case Int32:
  13. return int64(*(*int32)(p))
  14. case Int64:
  15. return *(*int64)(p)
  16. }
  17. panic(&ValueError{ "reflect.Value.Int", v.kind()})
  18. }

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

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

  1. // go 源码
  2. case Valuer:
  3. sv, err := callValuerValue(vr)
  4. ...
  5. return sv, nil

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

发表评论

表情:
评论列表 (有 0 条评论,153人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Gorm模型定义

    模型(orm gorm) 1.从数据库读取的数据会先保存到预先定义的模型对象,然后我们就可以从模型对象得到我们想要的数据。 2.插入数据到数据库也是先新建一个模型对象