Go Gin 建立 API - 加入 Middleware, Database...

tags: Go Gin API
category: Back-End
description: Go Gin 建立 API - 加入 middleware, i18n, config, database, service, dao
created_at: 2021/07/09 14:00:00

cover image


回到 手把手開始 Go Gin 建立 API


寫法偏舊,未來有空會再寫一個新的系列,有興趣再往下閱讀


上一篇最後的檔案目錄

<gin-api>
  <internal>
    <requests>
      user.go
    <routers>
      <api>
        user.go
    <pkg>
      <app>
        request.go
        response.go
      <errors>
        error.go
        errors.go
        http.go
      router.go
  main.go

或是你可以直接去Github上載(?)


中介層(middleware) 與 i18n

在做 i18n 之前,先了解一下中介層,用一張圖簡單的說明

在有人發 Request 進來,可以先通過一層或多層的中介層做一些事,看你要塞資料還是驗證看要不要給人過或是導向別的頁面都可以。

所以打算註冊一個中介層,先抓到 Request 的語系,再來做之後的處理,所以要先建立檔案

<gin-api>
  <internal>
    <middleware>
      translations.go <-- 多了這個
  main.go

塞入下面程式碼

package middleware

import (
    "github.com/gin-gonic/gin"
    "github.com/gin-gonic/gin/binding"
    "github.com/go-playground/locales/en"
    "github.com/go-playground/locales/zh_Hant_TW"
    ut "github.com/go-playground/universal-translator"
    "github.com/go-playground/validator/v10"
    enTranslation "github.com/go-playground/validator/v10/translations/en"
    zhTranslation "github.com/go-playground/validator/v10/translations/zh_tw"
)

func Translations() gin.HandlerFunc {
    return func(ctx *gin.Context) {
        universalTranslator := ut.New(en.New(), zh_Hant_TW.New())  // 英文與繁體中文
        locale := ctx.GetHeader("locale") // 從 header 抓語系
        translator, _ := universalTranslator.GetTranslator(locale)
        v, ok := binding.Validator.Engine().(*validator.Validate)
        if ok {
            switch locale {
            case "en":
                _ = enTranslation.RegisterDefaultTranslations(v, translator)
                break
            default:
                _ = zhTranslation.RegisterDefaultTranslations(v, translator) // 預設中文
                break
            }
            ctx.Set("translator", translator) // 存一個值供之後拿
        }

        ctx.Next()  // 允許通過這個中介層
    }
}

中介層寫完之後要去路由註冊,告訴他誰使用,而當下來說是全部都要,所以在最外層 Use

// ...
router := gin.New()
router.Use(middleware.Translations())
// ...

這時候註冊好了,但是要去改 request.go

func BindAndValidation(c *gin.Context, v interface{}) (bool, []string) {
    var errors []string

    if err := c.ShouldBind(v); err != nil {
        translatorVal := c.Value("translator")  // 抓到中介層抓到的語系
        translator, _ := translatorVal.(ut.Translator)
        validationErrors, ok := err.(validator.ValidationErrors)
        if !ok {
            return false, errors
        }

        for _, value := range validationErrors.Translate(translator) {
            errors = append(errors, value)
        }

        return false, errors
    }

    return true, nil
}

然後這時去跑伺服器,忘記指令了嗎?

$ go run main.go

這時候噴一堆錯

go: github.com/LaiJunBin/gin-api/internal/middleware: package github.com/go-playground/locales/en imported from implicitly required module; to add missing requirements, run:
        go get github.com/go-playground/locales/[email protected]
go: github.com/LaiJunBin/gin-api/internal/middleware: package github.com/go-playground/locales/zh_Hant_TW imported from implicitly required module; to add missing requirements, run:
        go get github.com/go-playground/locales/[email protected]
go: github.com/LaiJunBin/gin-api/internal/middleware: package github.com/go-playground/universal-translator imported from implicitly required module; to add missing requirements, run:
        go get github.com/go-playground/[email protected]
go: github.com/LaiJunBin/gin-api/pkg/app: package github.com/go-playground/universal-translator imported from implicitly required module; to add missing requirements, run:
        go get github.com/go-playground/[email protected]

他其實是跟你說要去裝缺少的套件

$ go get github.com/go-playground/locales/[email protected]
$ go get github.com/go-playground/locales/[email protected]
$ go get github.com/go-playground/[email protected]

處理好i18n之後來比較一下,這是原本的

{
    "details": [
        "Key: 'RegisterUserRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag"
    ],
    "message": "InvalidParams",
    "success": false
}

這是處理之後的

{
    "details": [
        "Name為必填欄位"
    ],
    "message": "InvalidParams",
    "success": false
}

抽離設定檔

建立一些檔案,負責存跟處理設定

<gin-api>
  <global>
    setting.go <-- 多了這個
  <pkg>
    <setting> <-- 多了這些
      section.go
      setting.go
  config.yaml <-- 多了這個

要讀取 yaml 的設定檔,可以安裝一個套件處理

$ go get github.com/spf13/viper

config.yaml 改成你的設定

Server:
  RunMode: debug
  HttpPort: 8080
  ReadTimeout: 60
  WriteTimeout: 60
Database:
  DBType: mysql
  UserName: root
  Password:
  Host: 127.0.0.1:3306
  DBName: gin_api_db
  Charset: utf8
  ParseTime: True

然後先來設定基底 setting 結構

setting.go

package settings

import "github.com/spf13/viper"

type Setting struct {
    viper *viper.Viper
}

func NewSetting() (*Setting, error) {
    viper := viper.New()
    viper.SetConfigName("config")
    viper.AddConfigPath("configs/")
    viper.SetConfigType("yaml")
    err := viper.ReadInConfig()

    if err != nil {
        return nil, err
    }

    return &Setting{viper}, nil
}

section.go 定義設定檔中每個區塊的結構

package setting

import "time"

type ServerSetting struct {
    RunMode      string
    HttpPort     string
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
}

type DatabaseSetting struct {
    DBType       string
    UserName     string
    Password     string
    Host         string
    DBName       string
    Charset      string
    ParseTime    bool
}

func (setting *Setting) ReadSection(k string, v interface{}) error {
    return setting.viper.UnmarshalKey(k, v)
}

再來在 global/setting.go 設定全域變數

package global

import "github.com/LaiJunBin/gin-api/pkg/setting"

var (
    ServerSetting   *setting.ServerSetting
    DatabaseSetting *setting.DatabaseSetting
)

然後最後修改 main.go 套用設定檔

package main

import (
    "fmt"
    "github.com/LaiJunBin/gin-api/global"
    "github.com/LaiJunBin/gin-api/internal/routers"
    "github.com/LaiJunBin/gin-api/pkg/setting"
    "github.com/gin-gonic/gin"
    "net/http"
    "time"
)


func init() {
    err := setupSetting()
    if err != nil {
        fmt.Printf("init.setupSetting error: %v", err)
    }
}

func setupSetting() error {
    setting, err := setting.NewSetting()
    if err != nil {
        return err
    }

    err = setting.ReadSection("Server", &global.ServerSetting)
    if err != nil {
        return err
    }

    err = setting.ReadSection("Database", &global.DatabaseSetting)
    if err != nil {
        return err
    }

    global.ServerSetting.ReadTimeout *= time.Second
    global.ServerSetting.WriteTimeout *= time.Second
    return nil
}

func main() {
    gin.SetMode(global.ServerSetting.RunMode)
    router := routers.NewRouter()

    s := &http.Server{
        Addr:           ":" + global.ServerSetting.HttpPort,
        Handler:        router,
        ReadTimeout:    global.ServerSetting.ReadTimeout,
        WriteTimeout:   global.ServerSetting.WriteTimeout,
    }

    s.ListenAndServe()
}

init() 是一個特殊的函數,他會在 main() 函數之前執行,有點像前端框架生命週期的概念,他也是一個特殊的函數,你放多個也沒關係,他會從上往下執行。


加入資料庫

安裝 ORM 套件

$ go get gorm.io/gorm

還有安裝 mysql 的 driver

$ go get gorm.io/driver/mysql

一樣先建立幾個檔案

<gin-api>
  <global>
    db.go   <-- global db
  <internal>
    <model>
      model.go <-- init db

model.go

package model

import (
    "fmt"
    "github.com/LaiJunBin/gin-api/pkg/setting"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

func NewDBEngine(databaseSetting *setting.DatabaseSetting) (*gorm.DB, error) {
    dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=%s&parseTime=%t&loc=Local",
        databaseSetting.UserName,
        databaseSetting.Password,
        databaseSetting.Host,
        databaseSetting.DBName,
        databaseSetting.Charset,
        databaseSetting.ParseTime,
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

    if err != nil {
        return nil, err
    }

    return db, nil
}

db.go

package global

import "gorm.io/gorm"

var (
    DBEngine *gorm.DB
)

再來回到 main.go 去初始化他

// ..
func init() {
    err := setupSetting()
    if err != nil {
        fmt.Printf("init.setupSetting error: %v", err)
    }

    err = setupDBEngine()
    if err != nil {
        fmt.Printf("init.setupDBEngine error: %v", err)
    }
}

func setupDBEngine() error {
    var err error
    global.DBEngine, err = model.NewDBEngine(global.DatabaseSetting)
    return err
}
// ..

再來需要建立資料表

先建立 model 描述結構

檔案: internal/model/user.go

package model

import "gorm.io/gorm"

type User struct {
    gorm.Model
    Name string `gorm:"not null" json:"name,omitempty"`
    Username string `gorm:"not null; unique" json:"username,omitempty"`
    Password string `gorm:"not null" json:"-"`
}

再來建立一個檔案負責做 migration

檔案: scripts/migration.go

package main

import (
    "fmt"
    "github.com/LaiJunBin/gin-api/global"
    "github.com/LaiJunBin/gin-api/internal/model"
    "github.com/LaiJunBin/gin-api/pkg/setting"
)

func main(){
    setting, _ := setting.NewSetting()
    _ = setting.ReadSection("Database", &global.DatabaseSetting)

    db, err := model.NewDBEngine(global.DatabaseSetting)
    err = db.AutoMigrate(&model.User{})
    if err != nil {
        fmt.Println(err)
    }
}
$ go run scripts\migration.go

成功執行後資料庫中應該多出了一張 users 表,再來就可以開始做CRUD了,先建立一些資料夾與檔案

<gin-api>
  <internal>
    <dao>   <-- 資料訪問(存取)
      dao.go
      user.go
    <service>  <-- 就..service
      service.go
      user.go
  <pkg>
    <errors>   <-- user相關的error
      user.go

打算以這樣的流程設計: router -> api -> service -> dao -> model

先來看 dao 的部分

dao.go

package dao

import "gorm.io/gorm"

type Dao struct {
    Engine *gorm.DB
}

func New(engine *gorm.DB) *Dao {
    return &Dao{Engine: engine}
}

再來是 service.go

package service

import (
    "context"
    "github.com/LaiJunBin/gin-api/global"
    "github.com/LaiJunBin/gin-api/internal/dao"
)

type Service struct {
    Ctx context.Context
    Dao *dao.Dao
}

func New(ctx context.Context) Service{
    service := Service{Ctx: ctx}
    service.Dao = dao.New(global.DBEngine)
    return service
}

再來在model新增function model/user.go

func (u User) Create(db *gorm.DB) (User, error) {
    err := db.Create(&u).Error
    return u, err
}

再來處理 dao/user.go

package dao

import "github.com/LaiJunBin/gin-api/internal/model"

func (dao *Dao) CreateUser(name, username, password string) (model.User, error) {
    user := model.User{
        Name: name,
        Username: username,
        Password: password,
    }

    return user.Create(dao.Engine)
}

再來到 service/user.go

package service

import (
    "github.com/LaiJunBin/gin-api/internal/model"
    "github.com/LaiJunBin/gin-api/internal/requests"
)

func (s Service) CreateUser(params *requests.RegisterUserRequest) (model.User, error) {
    return s.Dao.CreateUser(params.Name, params.Username, params.Password)
}

最後回到 api/user.go 完成註冊 function

func (u User) Register(c *gin.Context) {
    params := requests.RegisterUserRequest{}
    response := app.NewResponse(c)
    valid, err := app.BindAndValidation(c, &params)

    if !valid {
        errorResponse := errors.InvalidParams.WithDetails(err...)
        response.MakeErrorResponse(errorResponse)
        return
    }

    s := service.New(c.Request.Context())
    user, userErr := s.CreateUser(&params)

    if userErr != nil {
        response.MakeErrorResponse(errors.CreateUserFail)
        return
    }

    response.MakeResponse(user.ID)
}

這時候功能運作正常,但是有一個問題,密碼是以明文存進資料庫的,所以要特別處理一下,我們可以安裝 bcrypt 套件

$ go get golang.org/x/crypto/bcrypt

然後建立一個檔案

<gin-api>
  <pkg>
    <utils>
      bcrypt.go

然後填入以下內容

package utils

import "golang.org/x/crypto/bcrypt"

// 加密
func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
    return string(bytes), err
}

// 檢查
func CheckPasswordHash(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

然後修改一下 service/user.go

func (s Service) CreateUser(params *requests.RegisterUserRequest) (model.User, error) {
    hashedPassword, _ := utils.HashPassword(params.Password)
    return s.Dao.CreateUser(params.Name, params.Username, hashedPassword)
}

稍微偷懶一下不做錯誤處理

另外RUD也順便偷懶了

下一篇來做登入、JWT Token與上傳檔案

這篇進度的Source code




最後更新時間: 2021年07月09日.