起步
想在使用 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
因而程序報錯。是不是所有數據庫都這樣呢,其他類型又如何?前者我說不好,後者嘛,你可以做更多的嘗試或者去閱讀源碼。