Gorm 預載入及輸出處理(二)- 查詢輸出處理
阿新 • • 發佈:2020-03-19
上一篇[《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