1. 程式人生 > >Gorm 預載入及輸出處理(二)- 查詢輸出處理

Gorm 預載入及輸出處理(二)- 查詢輸出處理

上一篇[《Gorm 預載入及輸出處理(一)- 預載入應用》](https://www.cnblogs.com/zhenfengxun/p/12486325.html)中留下的三個問題: - 如何自定義輸出結構,只輸出指定欄位? - 如何自定義欄位名,並去掉空值欄位? - 如何自定義時間格式? 這一篇先解決前兩個問題。 ## 模型結構體中指標型別的應用 先來看一個上一篇中埋下的坑,回顧下 User 模型的定義: ```go // 使用者模型 type User struct { gorm.Model Username string `gorm:"type:varchar(20);not null;unique"` Email string `gorm:"type:varchar(64);not null;unique"` Role string `gorm:"type:varchar(32);not null"` Active uint8 `gorm:"type:tinyint unsigned;default:1"` Profile Profile `gorm:"foreignkey:UserID;association_autoupdate:false"` } ``` 其中 Active 欄位型別為 uint8 型別,表示該使用者是否處於啟用狀態,0 為未啟用,1 為已啟用,預設值為 1,看起來好像沒什麼問題,如果要建立一個預設未啟用的使用者,自然是指定 Active 的值為 0,然後呼叫 Create 方法即可。但是,你會發現資料庫中寫入的仍然是 1,一起來看下 Gorm 使用的 sql 語句: ```sql INSERT INTO `user` (`created_at`,`updated_at`,`deleted_at`,`username`,`email`,`role`) VALUES ('2020-03-15 12:41:14','2020-03-15 12:41:14',NULL,'test14','[email protected]','admin') ``` 根本就沒有往 active 列中插入資料,然後就使用了預設值 1。這是 Gorm 的寫入機制引起的,Gorm 不會將零值寫入資料庫中,部分零值列舉如下: ```go false // bool 0 // integer 0.0 // float "" // string nil // pointer, function, interface, slice, channel, map ``` 解決此問題也很簡單,將欄位定義為對應的指標型別,賦值時也傳指標即可,只要傳的值不為 nil,即可正常寫入資料庫。 現調整 User 模型定義如下: ```go type User struct { ... Active *uint8 `gorm:"type:tinyint unsigned;default:1"` ... } ``` 到這裡,應該已經清楚 Gorm 模型欄位定義中指標型別的應用場景了,即任何需要儲存零值的欄位,都應定義為指標型別。利用該特性,順帶把上一篇中直接查詢 User 輸出空值 Profile 結構體的問題一併解決掉。只要將 User 模型中 Profile 欄位的型別修改為 Profile 的指標型別即可: ```go // 使用者模型 type User struct { ... Profile *Profile `gorm:"foreignkey:UserID;association_autoupdate:false"` } ``` 對應的,在建立 User 的時候,Profile 欄位接收的也要是指標型別。這樣處理以後,當直接查詢 User 而不關聯查詢 Profile 時,User 中 Profile 欄位將為 nil,而不是之前討厭的空值結構體,清爽了很多不是嗎。 ## 自定義輸出 Gorm 預設會查詢模型的所有欄位並按模型定義的結構返回資料,在實際應用中,往往並不需要輸出全部欄位,這就需要對輸出欄位進行過濾,通常有兩種方式: - 在查詢時指定查詢欄位; - 預設查詢所有欄位,序列化時對欄位進行過濾; 第一種方式非常直觀簡單,要什麼,查什麼,輸出什麼,在輸出比較固定的場景中非常實用。其缺點也很明顯,就是靈活性不高,如果多個介面查一張表,但每個介面所需要的欄位又不一樣,那麼就得為每個介面寫一個獨立的查詢來實現這個需求,這顯然不符合“少即是多”、“高複用”的程式設計思想。 第二種方式在 Model層(查詢階段)不做過濾或只做基礎過濾,通過介面對 Service層(邏輯層)提供一份較為完整的資料,Service 層將資料按需對映到自定義輸出結構體上然後序列化輸出。這樣,當需要反覆修改輸出結構時,Model 層幾乎不用做任何改動,只需 Service 層調整輸出結構並序列化即可,可最大限度將邏輯和源資料分離,便於維護。 下面通過實際應用來介紹如何自定義輸出結構並序列化。 ### 場景 使用者列表,輸出所有使用者,並且使用者資料只包含 id,username,role 欄位; 使用者詳情,輸出當前使用者,除上述資料,還應包含 Profile 中的 Nickname,Phone 欄位; ### 自定義輸出結構體 這一步只要按需求建立對應結構體即可,直接上程式碼: ```go // 自定義使用者輸出結構 type CustomUser struct { ID uint Username string Role string Profile *CustomProfile } // 自定義使用者資訊輸出結構 type CustomProfile struct { Nickname string Phone string } ``` ### JSON Tag 的簡單應用 - 自定義欄位名,去掉空值欄位 預設情況下,結構體序列化後的欄位名和結構體的欄位名保持一致,如在結構體中定義了對外公開的欄位,欄位名首字母都是大寫的,JSON 序列化後得到的也是首字母大寫的欄位名,並不符合日常開發習慣。 其實 go 提供了在結構體中使用 JSON Tag 定製序列化輸出的功能,本文僅使用了“自定義欄位名”和“忽略空值欄位”兩個功能,詳見 [go 標準庫 encoding/json 文件](https://studygolang.com/static/pkgdoc/pkg/encoding_json.htm)。 現在利用 JSON Tag 來改造上面兩個結構體,這裡要做的只有兩步: 1. 把欄位名全部改為小寫; 2. 對 CustomUser 中的 Profile 設定 omitempty 標籤,即當 Profile 的值為 nil 時,不輸出 Profile 欄位; 程式碼如下: ```go // 自定義使用者輸出結構 type CustomUser struct { ID uint `json:"id"` Username string `json:"username"` Role string `json:"role"` Profile *CustomProfile `json:"profile,omitempty"` } // 自定義使用者資訊輸出結構 type CustomProfile struct { Nickname string `json:"nickname"` Phone string `json:"phone"` } ``` 這裡有必要說明為什麼要在自定義輸出結構體中使用 JSON Tag,而不在模型結構體中直接定義。模型結構體定義的是資料模型,和資料庫相關,因此模型結構體的 Tag 最好只和資料庫相關,也就是 gorm Tag。而序列化往往根據業務需求經常調整,和資料庫操作無關,因此在自定義輸出結構體中使用 JSON Tag 更合理些,便於理解和維護。 ### 資料對映 - 自定義序列化方法 重點來了,如何將 Gorm 查詢得到的源資料對映到自定義輸出結構體上? 思路比較簡單,就是為 User 模型實現自定義的序列化方法,實現將源資料對映到自定義結構體上並輸出自定義結構資料。為了降低耦合,不建議對原 User 模型進行操作,而是建立 User 的副本,再進行操作。 同時為了清楚地演示從 Model 層到 Service 層的流程,將會建立 GetUserListModel(),GetUserModel(),GetUserListService(),GetUserService() 四個函式,用於模擬 Model 層和 Service 層的操作,GetUserListModel(),GetUserModel() 函式僅做查詢操作並返回查詢源資料,GetUserListService(),GetUserService() 函式將源資料對映到自定義結構體並返回對映後的資料。 上程式碼: ```go // 第一步:建立模型結構體的副本 type UserCopy struct{ User } // 第二步:重寫 MarshalJSON() 方法,實現自定義序列化 func (u *UserCopy) MarshalJSON() ([]byte, error) { // 將 User 的資料對映到 CustomUser 上 user := CustomUser{ ID: u.ID, Username: u.Username, Role: u.Role, } // 如果 User 的 Profile 欄位不為 nil, // 則將 Profile 資料對映到 CustomUser 的 Profile 上 if u.Profile != nil { user.Profile = &CustomProfile{ Nickname: u.Profile.Nickname, Phone: u.Profile.Phone, } } return json.Marshal(user) } // 第三步:獲取源資料 // 獲取使用者列表源資料 func GetUserListModel() ([]*User, error) { var users []*User err := DB.Debug().Find(&users).Error if err != nil { return nil, errors.New("查詢錯誤") } return users, nil } // 獲取使用者詳情源資料 func GetUserModel(id uint) (*User, error) { var user User err := DB.Debug(). Where("id = ?", id). Preload("Profile"). First(&user). Error if err != nil { return nil, errors.New("查詢錯誤") } return &user, nil } // 第四步:獲取自定義結構資料 // 獲取使用者列表自定義資料 func GetUserListService() ([]*UserCopy, error) { users, err := GetUserListModel() if err != nil { return nil, err } // 轉換成帶自定義序列化方法的 UserCopy 型別 list := make([]*UserCopy, 0) for _, user := range users { list = append(list, &UserCopy{*user}) } return list, nil } // 獲取使用者詳情自定義資料 func GetUserService(id uint) (*UserCopy, error) { user, err := GetUserModel(id) if err != nil { return nil, err } // 轉換成帶自定義序列化方法的 UserCopy 型別 return &UserCopy{*user}, nil } ``` 最後,通過呼叫 GetUserListService(),GetUserService() 方法分別獲取自定義結構的使用者列表資料和使用者詳情資料,然後直接序列化輸出即可。 列表輸出類似這樣: ```json [ { "id": 1, "username": "test", "role": "admin" }, { "id": 2, "username": "test2", "role": "admin" }, { "id": 3, "username": "test3", "role": "admin" } ] ``` 使用者詳情輸出類似這樣: ```json { "id": 1, "username": "test", "role": "admin", "profile": { "nickname": "test", "phone": "" } } ``` ### 資料對映 - Scan 方法的應用 其實 Gorm 提供了 Scan 方法,可直接將查詢的資料對映到自定義結構體上,使用也很方便,但為什麼前面一直不用,還要自己實現自定義序列化方法呢?原因在於,截止到 Gorm v1.9.12 版本,Scan 方法不支援預載入,需要自行解決預載入資料的支援問題,而且本文采用的 Model、Service 分離的方式,Model 層只負責輸出模型資料,自定義輸出的任務由 Service 層處理,因此也就沒有必要在 Model 層查詢時使用 Scan方法做映射了。 不過這裡還是介紹下 Scan 方法的使用吧,畢竟不是所有專案都真的需要 MVC,需要分層,有時最簡單的方法就是最有效的方法,按需而行才是上上策。 下面介紹如何使用 Scan 方法實現上述需求。這裡依然使用上面的 CustomUser 和 CustomProfile 這兩個自定義輸出結構體。 先實現使用者列表的輸出,由前面的場景需求可知,使用者列表不需要 Profile 資訊,也就無需預載入了,可直接這樣實現: ```go // 這裡直接使用 CustomUser,而不是實現了自定義序列化方法的 UserCopy // Scan 方法會自動做對映處理 var users []*CustomUser DB.Debug(). Model(&User{}). Scan(&users) ``` 如果要實現帶預載入的列表自定義輸出,直接使用自定義序列化方法的方式吧。 接著來看下如何使用 Scan 方法實現使用者詳情的自定義輸出,由於 Scan 不支援預載入,需要手動做些處理,程式碼如下: ```go var user User var profile Profile var userOutput CustomUser // 將不帶關聯查詢的資料直接按 userOutput 結構掃描賦值 err := DB.Debug(). Model(&user). Where("id = ?", 1). Scan(&userOutput). Error // 這裡要判斷查詢是否出錯,可能查詢本身出錯,也可能是查詢不到對應資料 if err != nil { return } // 只有正常查詢到 User 資料,才能繼續查詢其關聯的 Profile 資料, // 可以簡單構造一個對應的 User 資料用於下面的關聯查詢, // 這裡簡單構造一個 ID = 1 的 User 資料用於演示,並不嚴謹,實際應用需要根據需要進行調整 user.ID = 1 // 獲取 Profile 關聯資料,並賦值給變數 profile, // 注意,分步查詢中,Model方法中不能傳 &User{},而要傳遞同一個例項,否則無法保證兩次查詢資料的關聯性 DB.Debug(). Model(&user). Related(&profile, "UserID") // 手動賦值 userOutput.Profile = &CustomProfile{ Nickname: profile.Nickname, Phone: profile.Phone, } ``` 然後將 userOutput 序列化輸出即可。 ## 小結 本篇介紹瞭如何自定義輸出結構體,並使用“自定義序列化方法”、“Scan 方法”兩種資料對映方式,實現自定義結構的資料輸出。 在關鍵的資料對映方式的選擇上,兩種方式各有優劣,個人認為: - 簡單應用場景下,使用 Scan 方法方便快捷,程式碼量也少,但是不支援預載入,需自行處理; - 複雜應用場景下,推薦使用自定義序列化方法這種方式,雖然程式碼量多了,但這種方式更靈活,低耦合,便於理解和維護,程式碼的可讀性和可維護性更重要。 順帶丟擲一個疑問,在 Restful API 盛行的今天,關聯查詢是否還那麼重要?歡迎一起探討。 下一篇將介紹如何自定義時間輸出格式。 本文僅提供一種解決問題的思路,並不能以點概全,如發現任何問題,歡迎指正,有其他解決方案的也歡迎提出一起交流,謝謝觀看! ----- 參考資料: - [Gorm官方文件](http://gorm.io/zh_CN/docs/) - [go 標準庫 encoding/json 文件](https://studygolang.com/static/pkgdoc/pkg/encoding_j