Gorm 预加载及输出处理(二)- 查询输出处理
大漠风起沙飞扬 人气:2
上一篇《Gorm 预加载及输出处理(一)- 预加载应用》中留下的三个问题:
- 如何自定义输出结构,只输出指定字段?
- 如何自定义字段名,并去掉空值字段?
- 如何自定义时间格式?
这一篇先解决前两个问题。
## 模型结构体中指针类型的应用
先来看一个上一篇中埋下的坑,回顾下 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','aaa@bbb.com','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_CNhttps://img.qb5200.com/download-x/docs/)
- [go 标准库 encoding/json 文档](https://studygolang.com/static/pkgdoc/pkg/encoding_json.htm)
加载全部内容