36

使用 Go 完成用户业务逻辑

 4 years ago
source link: https://www.tuicool.com/articles/buYVjib
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

简介

在上一节中, 已经大致学习了如何使用 Gin 读写请求.

这一节就是实践了, 完成一个用户业务逻辑处理.

主要包括以下功能:

  • 创建用户
  • 删除用户
  • 更新用户
  • 查询用户列表
  • 查询指定用户的信息

这一节是核心部分, 因为这个项目的主要功能就是在这部分实现的.

路由总览

这部分的代码改动很大, 毕竟要完成上述的功能会增加很多代码.

首先, 来看下路由, router.go 里增加了很多路由定义.

u := g.Group("/v1/user")
{
  u.GET("", user.List)
  u.POST("", user.Create)
  u.GET("/:id", user.Get)
  u.PUT("/:id", user.Save)
  u.PATCH("/:id", user.Update)
  u.DELETE("/:id", user.Delete)
}

um := g.Group("/v1/username")
{
  um.GET("/:name", user.GetByName)
}

从路由定义中我们可以看到, 用户的创建, 更新, 查询和删除都已经定义了.

另外, 还定义了获取用户列表的功能.

稍微要解释下的是用户的更新, 这里定义了两种方法, 一种使用 PUT 方法,

另一个种使用 PATCH 方法. 两者的区别在于, 前者是完整更新, 需要提供所有的

字段, 新的用户数据会完成替换掉旧的用户, 除了 ID 不变. 后者是部分更新,

更为灵活, 只需要提供你想改变的字段就行了.

在定义 API 接口的时候, 通常需要控制版本, 一般情况下, 第一个路由目录都是

版本号. 这里也遵从这种最佳实践.

定义 handler

所有用户相关的 handler 都定义在 handler/user/ 目录下.

先来看看如何创建新用户.

创建新用户的步骤如下:

  • 从请求中获取参数
  • 校验参数
  • 加密密码
  • 存储在数据库中
  • 返回响应

如果从请求中获取参数已经在上一节中介绍过了,

这里使用的模型绑定.

校验参数

Gin 的模型绑定中也有的校验, 一个常用的是指定必要的字段.

Gin 本身支持使用 go-playground/validator.v8 进行验证.

我这里使用的是 gopkg.in/go-playground/validator.v9 .

首先在 model/user.go 中定义了用户模型, 包括验证的方法.

// 定义用户的结构
type UserModel struct {
    BaseModel
    Username string `json:"username" gorm:"column:username;not null" binding:"required" validate:"min=1,max=32"`
    Password string `json:"password" gorm:"column:password;not null" binding:"required" validate:"min=5,max=128"`
}

// 验证字段
func (u *UserModel) Validate() error {
    validate := validator.New()
    return validate.Struct(u)
}

在使用模型绑定的时候需要注意区分一点, API 接口需要的结构和数据模型本身

是不一样的. 数据模型更多是指保存在数据库中的结构, 关系到如何设计表结构

和核心数据模型. 而请求中的参数结构体是服务于 API 接口本身的, 即这个接口

需要哪些参数.

可以在 handler/user/user.go 中查看所有的用户 API 接口的结构体.

加密密码和数据库存储

很多操作都已经封装在了用户模型中, 所以在 handler 中, 一般只需要调用函数,

并判断是否出现错误就行了. 尽量不要在 handler 中塞入很多代码, 通常只需要

显示出一个清晰的处理流程就行了, 具体的实现放在别的文件中.

加密和存储用户数据的过程非常清晰.

// 加密密码
if err := u.Encrypt(); err != nil {
  handler.SendResponse(ctx, errno.New(errno.ErrEncrypt, err), nil)
  return
}

// 插入用户到数据库中
if err := u.Create(); err != nil {
  handler.SendResponse(ctx, errno.New(errno.ErrDatabase, err), nil)
  return
}

最后, 将返回用户名作为响应. 至此, 一个创建用户的 handler 就完成了.

其他 handler

用户的删除和基于 ID 或名字查询也比较容易, 不再细说.

获取用户列表

来看一个获取用户列表的接口.

遵循前面讲到的原则, 大段的代码不宜直接放在 handler 中,

这里将具体的实现放在了一个叫做 service 的包中, 具体是

service.ListUser 函数.

其实在定义用户模型的时候已经定义了一个同名的方法从数据库中获取

用户列表和用户总数. 为什么不直接使用呢?

这是因为在模型中通常只定义非常通用的函数, 也就是从数据库中取出数据,

不对数据做非常具体的处理. 设计到具体业务的操作, 应该在别的地方处理.

具体看一下 service.ListUser 函数, 主要功能是对从数据库中获取的用户

数据进行扩展, 增加了一些字段, 对应 model.UserInfo 结构体.

// 业务处理函数, 获取用户列表
func ListUser(username string, offset, limit int) ([]*model.UserInfo, uint, error) {
    infos := make([]*model.UserInfo, 0)
    users, count, err := model.ListUser(username, offset, limit)
    if err != nil {
        return nil, count, err
    }

    ids := []uint{}
    for _, user := range users {
        ids = append(ids, user.ID)
    }

    wg := sync.WaitGroup{}
    userList := model.UserList{
        Lock:  new(sync.Mutex),
        IdMap: make(map[uint]*model.UserInfo, len(users)),
    }

    errChan := make(chan error, 1)
    finished := make(chan bool, 1)

    // 并行转换
    for _, u := range users {
        wg.Add(1)
        go func(u *model.UserModel) {
            defer wg.Done()

            shortId, err := util.GenShortID()
            if err != nil {
                errChan <- err
                return
            }

            // 更新数据时加锁, 保持一致性
            userList.Lock.Lock()
            defer userList.Lock.Unlock()

            userList.IdMap[u.ID] = &model.UserInfo{
                ID:        u.ID,
                Username:  u.Username,
                SayHello:  fmt.Sprintf("Hello %s", shortId),
                Password:  u.Password,
                CreatedAt: util.TimeToStr(&u.CreatedAt),
                UpdatedAt: util.TimeToStr(&u.UpdatedAt),
                DeletedAt: util.TimeToStr(u.DeletedAt),
            }
        }(u)
    }

    go func() {
        wg.Wait()
        close(finished)
    }()

    // 等待完成
    select {
    case <-finished:
    case err := <-errChan:
        return nil, count, err
    }

    for _, id := range ids {
        infos = append(infos, userList.IdMap[id])
    }

    return infos, count, nil
}

实际上, 为了加速处理过程, 使用了 goroutine 进行并行处理:

在 ListUser() 函数中用了 sync 包来做并行查询,以使响应延时更小。在实际开发中,查询数据后,通常需要对数据做一些处理,比如 ListUser() 函数中会对每个用户记录返回一个 sayHello 字段。sayHello 只是简单输出了一个 Hello shortId 字符串,其中 shortId 是通过 util.GenShortId() 来生成的(GenShortId 实现详见 demo07/util/util.go)。像这类操作通常会增加 API 的响应延时,如果列表条目过多,列表中的每个记录都要做一些类似的逻辑处理,这会使得整个 API 延时很高,所以笔者在实际开发中通常会做并行处理。根据笔者经验,效果提升十分明显。

读者应该已经注意到了,在 ListUser() 实现中,有 sync.Mutex 和 IdMap 等部分代码,使用 sync.Mutex 是因为在并发处理中,更新同一个变量为了保证数据一致性,通常需要做锁处理。

使用 IdMap 是因为查询的列表通常需要按时间顺序进行排序,一般数据库查询后的列表已经排过序了,但是为了减少延时,程序中用了并发,这时候会打乱排序,所以通过 IdMap 来记录并发处理前的顺序,处理后再重新复位。

里面用到的知识点还挺多的, 涉及到了 goroutine, 锁与同步, range, select , chanel.

我觉有可以多看几遍体会一下.

更新用户

在更新用户的时候提供了两种方式, 完全更新与部分更新, 分别对应 PUT 和 PATCH.

对于用户模型而言, GORM 下的操作是很方便的.

// 保存用户, 会更新所有的字段
func (u *UserModel) Save() error {
    return DB.Self.Save(u).Error
}

// 更新字段, 使用 map[string]interface{} 格式
func (u *UserModel) Update(data map[string]interface{}) error {
    return DB.Self.Model(u).Updates(data).Error
}

重点在于获取数据和验证的阶段.

对于完全更新, 其实除了 ID 是已知的,

其他部分和创建用户时一致的, 同样是验证字段并加密密码, 最后更新数据库.

对于部分更新, 我们就需要去猜测传递过了的字段, 并对每种字段一一进行处理.

新写了一个验证方法 ValidateAndUpdateUser .

// ValidateAndUpdateUser 验证 map 结构, 并加密密码(如果存在的话)
func ValidateAndUpdateUser(data *map[string]interface{}) error {
    validate := validator.New()
    usernameTag, _ := util.GetTag(UserModel{}, "Username", "validate")
    passwordTag, _ := util.GetTag(UserModel{}, "Password", "validate")
    // 验证 username
    if username, ok := (*data)["username"]; ok {
        if err := validate.Var(username, usernameTag); err != nil {
            return err
        }
    }
    // 验证 password
    if password, ok := (*data)["password"]; ok {
        if err := validate.Var(password, passwordTag); err != nil {
            return err
        }
        // 加密密码
        newPassword, err := auth.Encrypt(password.(string))
        if err != nil {
            return err
        }
        (*data)["password"] = newPassword
    }

    return nil
}

对于每种字段都验证了一遍, 感觉有点繁琐.

总结

用户的核心逻辑就是这些了. 粗看起来这部分的代码改动是非常多的.

到此为止, 大部分的核心代码已经完成了, 这个 API 服务器算是

能够启动了, 并接收调用了.

当然, 还有许多地方还没完善, 比如权限认证, 接口文档等,

都会在接下来的文章中一一介绍.

当前部分的代码

作为版本 v0.7.0


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK