Gorm 预加载及输出处理(二)- 查询输出处理

上一篇《Gorm 预加载及输出处理(一)- 预加载应用》中留下的三个问题:

  • 如何自定义输出结构,只输出指定字段?
  • 如何自定义字段名,并去掉空值字段?
  • 如何自定义时间格式?

这一篇先解决前两个问题。

模型结构体中指针类型的应用

先来看一个上一篇中埋下的坑,回顾下 User 模型的定义:

// 用户模型
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 语句:

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 不会将零值写入数据库中,部分零值列举如下:

false    // bool
0        // integer
0.0      // float
""       // string
nil      // pointer, function, interface, slice, channel, map

解决此问题也很简单,将字段定义为对应的指针类型,赋值时也传指针即可,只要传的值不为 nil,即可正常写入数据库。

现调整 User 模型定义如下:

type User struct {
    ...
    Active   *uint8     `gorm:"type:tinyint unsigned;default:1"`
    ...
}

到这里,应该已经清楚 Gorm 模型字段定义中指针类型的应用场景了,即任何需要保存零值的字段,都应定义为指针类型。利用该特性,顺带把上一篇中直接查询 User 输出空值 Profile 结构体的问题一并解决掉。只要将 User 模型中 Profile 字段的类型修改为 Profile 的指针类型即可:

// 用户模型
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 字段;

自定义输出结构体

这一步只要按需求创建对应结构体即可,直接上代码:

// 自定义用户输出结构
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 文档

现在利用 JSON Tag 来改造上面两个结构体,这里要做的只有两步:

  1. 把字段名全部改为小写;
  2. 对 CustomUser 中的 Profile 设置 omitempty 标签,即当 Profile 的值为 nil 时,不输出 Profile 字段;

代码如下:

// 自定义用户输出结构
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() 函数将源数据映射到自定义结构体并返回映射后的数据。

上代码:

// 第一步:创建模型结构体的副本
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() 方法分别获取自定义结构的用户列表数据和用户详情数据,然后直接序列化输出即可。

列表输出类似这样:

[
    {
        "id": 1,
        "username": "test",
        "role": "admin"
    },
    {
        "id": 2,
        "username": "test2",
        "role": "admin"
    },
    {
        "id": 3,
        "username": "test3",
        "role": "admin"
    }
]

用户详情输出类似这样:

{
    "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 信息,也就无需预加载了,可直接这样实现:

// 这里直接使用 CustomUser,而不是实现了自定义序列化方法的 UserCopy
// Scan 方法会自动做映射处理
var users []*CustomUser

DB.Debug().
    Model(&User{}).
    Scan(&users)

如果要实现带预加载的列表自定义输出,直接使用自定义序列化方法的方式吧。

接着来看下如何使用 Scan 方法实现用户详情的自定义输出,由于 Scan 不支持预加载,需要手动做些处理,代码如下:

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 盛行的今天,关联查询是否还那么重要?欢迎一起探讨。

下一篇将介绍如何自定义时间输出格式。

本文仅提供一种解决问题的思路,并不能以点概全,如发现任何问题,欢迎指正,有其他解决方案的也欢迎提出一起交流,谢谢观看!


参考资料:

本文出处:https://www.cnblogs.com/zhenfengxun/
本文链接:https://www.cnblogs.com/zhenfengxun/p/12525365.html

posted @ 2020-03-19 16:53  大漠风起沙飞扬  阅读(4980)  评论(2编辑  收藏  举报