GORM 字段使用自定義類型

起步

想在使用 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

因而程序報錯。是不是所有數據庫都這樣呢,其他類型又如何?前者我說不好,後者嘛,你可以做更多的嘗試或者去閱讀源碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章