使用 Go 完成用戶業務邏輯

簡介

在上一節中, 已經大致學習瞭如何使用 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章