前言
之前在寫練手的go項目的時候, 一方面確實覺得使用go來作為開發語言覺得順手且速度快, 另一方面也感覺到了一些令人頭疼的地方, 比如在編寫某些接口時, 有一些復合查詢的條件, 例如招聘網站的按 省市/地鐵/商圈/工種/薪資/工齡/學歷 等條件查詢, 該查詢是復合的, 你不知道每次用戶選擇的是哪些類型等等類似的問題, 本篇總結一下我是怎樣去整理代碼的, 我也沒寫多久 go 的項目, 可能總結的不到位, 歡迎糾正
本文不使用 ORM, 只使用 Gin 和 Sqlx
正文
總是需要定義好多結構體
我們知道, 使用Gin返回數據時, Gin會自己將結構體轉換成 json 返回給前端, 但是因為每個接口的返回值總是不盡相同的, 這樣就會造成幾乎每個接口都需要定義一個結構體作為這個接口專用的返回值結構, 對於只用一次的結構體, 不應該單獨放置, 故我們使用匿名結構體來作為這個處理函數專用的結構體
func test(c *gin.Context) {
// init struct
res := struct {
Users *[]struct {
UserID int `json:"user_id" db:"id"`
} `json:"users"`
Count int `json:"count"`
}{}
// select
sqlStr := "SELECT id FROM user"
if err := db.DB.Select(&res.Users, sqlStr); err != nil {
fmt.Println("error")
// return error
// ...
}
// return res
// ...
}
我認為應盡早的將返回結構體定義出來, 這樣可減少函數內部的變量定義, 比如上面的代碼, res.Users 可直接作為查詢語句結果的掃描結構體使用, 避免出現先定義一個變量 users, 在賦值給 res.Users 的情況, 這是一個好習慣
當然, 對於某些通用的結構體同時適用多個接口的情況, 我們還是使用定義一個結構體復用的方式為佳, 如果是多個接口有大部分是相同的結構, 我們也可以寫一個通用的結構體, 然后每個接口獨立出來的單獨定義匿名結構體即可, 例如
type user struct {
ID int `json:"user_id" db:"id"`
}
func test(c *gin.Context) {
// init struct
res := struct {
Users *[]user `json:"users"`
Count int `json:"count"`
}{}
// select
sqlStr := "SELECT id FROM user"
if err := db.DB.Select(&res.Users, sqlStr); err != nil {
fmt.Println("error")
// return error
// ...
}
// return res
// ...
}
func test1(c *gin.Context) {
// init struct
res := struct {
Users *[]user `json:"users"`
Page int `json:"page"`
Count int `json:"count"`
}{}
// select
sqlStr := "SELECT id FROM user"
if err := db.DB.Select(&res.Users, sqlStr); err != nil {
fmt.Println("error")
// return error
// ...
}
// return res
// ...
}
組合查詢
如前言所說, 我們經常會需要對數據進行組合查詢, 會導致代碼變得混亂, 這里提供一個思路可以較好的保持代碼的整潔性和可讀性
func test(c *gin.Context) {
args := []interface{}{}
search := " "
j := "WHERE"
// get data
if name, ok := c.GetQuery("user_name"); !ok {
search += fmt.Sprintf("%v name LIKE ? ", j)
args = append(args, "%"+name+"%")
j = "AND"
}
if groupID, ok := c.GetQuery("group_id"); !ok {
search += fmt.Sprintf("%v group_id=? ", j)
args = append(args, groupID)
j = "AND"
}
search += "ORDER BY id DESC "
// init struct
res := struct {
Users *[]struct {
UserID int `json:"user_id" db:"id"`
} `json:"users"`
Count int `json:"count"`
}{}
// select
sqlStr := "SELECT id FROM user"
if err := db.DB.Select(&res.Users, sqlStr+search, args...); err != nil {
fmt.Println("error")
// return error
// ...
}
// return res
// ...
}
如上面所寫, 在先定義兩個變量, args為 interface 類型, 存放我們傳入的參數, search為 string 類型, 存放我們拼接的查詢sql語句, search為一個空格的字符串目的是保持SQL語句不報錯, 而后, 我們並不知道哪個參數為第一個參數, 所以我們定義一個連接的string, 默認為 WHERE , 然后我們獲取有可能存在的參數, 如果其存在則代表用戶選擇了這個篩選條件, 我們將其語句加在 search 之后, 同時將參數放置在 args 后, 假設找到的這個參數為第一個參數, 則使用 WHERE 連接, 同時將連接設置為 AND 保證格式合法, 注意拼接的SQL語句最后面都有一個空格目的是符合格式
如果想排序, 最后在后面加上 order by 來排序即可
帶分頁的組合查詢
實際的開發中, 往往接口都是需要帶分頁的, 那么帶分頁的組合查詢, 一般也需要在返回值中加入一個字段標示總條數來方便排序, 有人會使用 FOUND_ROWS() 來查詢上一次查詢的總條數, 但是這個函數 mysql 官方並不推薦使用, 並且在以后打算替代, 官方文檔, 其推薦使用 COUNT 來查詢, mysql對 COUNT(*)
進行了特別的優化, 使用該函數速度會很快(SELECT 從一個表查詢的時候) 官方文檔
首先我們編寫一個通用的函數來處理URL中的分頁值
// LimitVerify limit middleware
// Receive page and page_size from url
// page default 1, page_size default 20
func LimitVerify(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if err != nil {
pageSize = 20
}
c.Set("page", page)
c.Set("pageSize", pageSize)
c.Next()
}
將其加在需要分頁的接口里
groupGroup.GET("/:groupID/user", middleware.LimitVerify, test)
然后在具體的邏輯中, 即可使用 c.Get() 來獲取分頁數據
func test(c *gin.Context) {
args := []interface{}{}
countArgs := []interface{}{}
search := " "
countSearch := " "
j := "WHERE"
// get page
page, _ := c.Get("page")
pageSize, _ := c.Get("pageSize")
// get data
if name, ok := c.GetQuery("user_name"); !ok {
search += fmt.Sprintf("%v name LIKE ? ", j)
countSearch += fmt.Sprintf("%v name LIKE ? ", j)
args = append(args, "%"+name+"%")
countArgs = append(countArgs, "%"+name+"%")
j = "AND"
}
if groupID, ok := c.GetQuery("group_id"); !ok {
search += fmt.Sprintf("%v group_id=? ", j)
countSearch += fmt.Sprintf("%v group_id=? ", j)
args = append(args, groupID)
countArgs = append(countArgs, groupID)
j = "AND"
}
search += "ORDER BY id DESC "
if page != 0 {
// limit
search = search + " LIMIT ?,?"
args = append(args, pageSize.(int)*(page.(int)-1), pageSize.(int))
}
// init struct
res := struct {
Users *[]struct {
UserID int `json:"user_id" db:"id"`
} `json:"users"`
Count int `json:"count"`
}{}
// select
sqlStr := "SELECT id FROM user"
if err := db.DB.Select(&res.Users, sqlStr+search, args...); err != nil {
fmt.Println("error")
// return error
// ...
}
sqlStr = "SELECT COUNT(id) FROM user"
if err := db.DB.Get(&res.Count, sqlStr+countSearch, countArgs...); err != nil {
fmt.Println("error")
// return error
// ...
}
// return res
// ...
}
為了加入 count, 我們又新增一組參數和sql, 名為 countArgs 和 countSearch, 為了接口兼容性, 我們和前段商議當 page 參數為 0 時不進行分頁, 所以僅僅在 page 不等於 0 時加入分頁
通用的JSON返回函數
一般接口返回的數據都是JSON, 但是每次又要寫 c.JSON 於是我將其按照使用場景寫了幾個通用的函數
responseFormat.go
package tools
import (
"net/http"
"github.com/gin-gonic/gin"
)
// FormatOk ok
func FormatOk(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 200,
"data": "success",
})
// Return directly
c.Abort()
}
// FormatError err
func FormatError(c *gin.Context, errorCode int, message string) {
c.JSON(http.StatusOK, gin.H{
"code": errorCode,
"data": message,
})
// Return directly
c.Abort()
}
// FormatData data
func FormatData(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{
"code": 200,
"data": data,
})
// Return directly
c.Abort()
}
通用的JWT函數
JWT作為一種HTTP鑒權方式已經有非常多的人員使用, 這里提供自用的簽發和解密啊函數供參考
jwt.go
package tools
import (
"time"
"github.com/dgrijalva/jwt-go"
)
// UserData jwt user info
type UserData struct {
ID int `json:"id" db:"user_id"`
Name string `json:"name" db:"user_name"`
RoleName string `json:"role_name" db:"role_name"`
GroupID *int `json:"group_id" db:"group_id"`
}
type myCustomClaims struct {
Data UserData `json:"data"`
jwt.StandardClaims
}
// JWTIssue issue jwt
func JWTIssue(d UserData) (string, error) {
// set key
mySigningKey := []byte(EnvConfig.JWT.Key)
// Calculate expiration time
nowTime := time.Now()
expireTime := nowTime.Add(time.Duration(EnvConfig.JWT.Expiration) * time.Second)
// Create the Claims
claims := myCustomClaims{
d,
jwt.StandardClaims{
ExpiresAt: expireTime.Unix(),
Issuer: "remoteAdmin",
},
}
// issue
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
st, err := t.SignedString(mySigningKey)
if err != nil {
return "", err
}
return st, nil
}
// JWTDecrypt string token to data
func JWTDecrypt(st string) (*UserData, error) {
token, err := jwt.ParseWithClaims(st, &myCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(EnvConfig.JWT.Key), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*myCustomClaims); ok && token.Valid {
// success
return &claims.Data, nil
}
return nil, err
}
userVerify.go
package middleware
import (
"fmt"
"remoteAdmin/db"
"remoteAdmin/tools"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// TokenVerify get user info from jwt
func TokenVerify(c *gin.Context) {
t := c.Request.Header.Get("token")
if t == "" {
tools.FormatError(c, 2003, "token expired or invalid")
tools.Log.Warn(fmt.Sprintf("token invalid: %v", t))
return
}
u, err := tools.JWTDecrypt(t)
if err != nil {
tools.FormatError(c, 2003, "token expired or invalid")
tools.Log.Warn(fmt.Sprintf("token invalid: %v", t), zap.Error(err))
return
}
// get RDB token
if val, err := db.RDB.Get(db.RDB.Context(), strconv.Itoa(u.ID)).Result(); err != nil || val != t {
tools.FormatError(c, 2003, "token expired or invalid")
tools.Log.Info(fmt.Sprintf("token expired: %v", t), zap.Error(err))
return
}
// set userData to gin.Context
c.Set("userID", u.ID)
c.Set("userRoleName", u.RoleName)
if u.GroupID != nil {
c.Set("userGroupID", *u.GroupID)
}
// Next
c.Next()
}
其中結構體 userData 為存放的信息結構, 可按需修改
開啟Gin的跨域
一般前后端分離的項目后端都需要設置同意跨域, gin設置跨域代碼如下
CrossDomain.go
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// CorsHandler consent cross-domain middleware
func CorsHandler() gin.HandlerFunc {
return func(c *gin.Context) {
method := c.Request.Method // method
origin := c.Request.Header.Get("Origin") // header
var headerKeys []string // keys
for k := range c.Request.Header {
headerKeys = append(headerKeys, k)
}
headerStr := strings.Join(headerKeys, ", ")
if headerStr != "" {
headerStr = fmt.Sprintf("access-control-allow-origin, access-control-allow-headers, %s", headerStr)
} else {
headerStr = "access-control-allow-origin, access-control-allow-headers"
}
if origin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Origin", "*") // This is to allow access to all domains
c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE,UPDATE") // All cross-domain request methods supported by the server, in order to avoid multiple'pre-check' requests for browsing requests
// header
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Length, X-CSRF-Token, Token,session,X_Requested_With,Accept, Origin, Host, Connection, Accept-Encoding, Accept-Language,DNT, X-CustomHeader, Keep-Alive, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Pragma")
c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers,Cache-Control,Content-Language,Content-Type,Expires,Last-Modified,Pragma,FooBar")
c.Header("Access-Control-Max-Age", "172800")
c.Header("Access-Control-Allow-Credentials", "false")
c.Set("content-type", "application/json")
}
// Release all OPTIONS methods
if method == "OPTIONS" {
c.JSON(http.StatusOK, "Options Request!")
}
// Processing request
c.Next()
}
}
使用方法: 將其注冊到總路由 router 的中間件中即可, 例如
// InitApp init gshop app
func InitApp() *gin.Engine {
// gin.Default uses Use by default. Two global middlewares are added, Logger(), Recovery(), Logger is to print logs, Recovery is panic and returns 500
router := gin.Default()
// Add consent cross-domain middleware
router.Use(middleware.CorsHandler())
// init app router
user.Router(router)
return router
}
Gin日志
我通常使用 Zap 模塊來記錄日志, 將日志寫入進文件中, 但是 Gin 自己攜帶了日志, 尤其是設置 debug 關閉時無法完美的將其兼容到一起, 於是我找到了 李文周的博客 大佬的博客, 抄襲了一下, 達到了Gin日志與自己記錄的日志合並到同一個文件的效果, 並且共用日志分割功能
log.go
package tools
import (
"net"
"net/http"
"net/http/httputil"
"os"
"runtime/debug"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/natefinch/lumberjack"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Log zapLog
var Log *zap.Logger
// LumberJackLogger log io
var LumberJackLogger *lumberjack.Logger
// Log cutting settings
func getLogWriter() zapcore.WriteSyncer {
LumberJackLogger = &lumberjack.Logger{
Filename: "api.log", // Log file location
MaxSize: 10, // Maximum log file size(MB)
MaxBackups: 5, // Keep the maximum number of old files
MaxAge: 30, // Maximum number of days to keep old files
Compress: false, // Whether to compress old files
}
return zapcore.AddSync(LumberJackLogger)
}
// log encoder
func getEncoder() zapcore.Encoder {
// Use the default JSON encoding
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return zapcore.NewJSONEncoder(encoderConfig)
}
// InitLogger init log
func InitLogger() {
writeSyncer := getLogWriter()
encoder := getEncoder()
core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel)
Log = zap.New(core, zap.AddCaller())
}
// GinLogger Receive the default log of the gin framework
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
cost := time.Since(start)
Log.Info("[GIN]",
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("query", query),
zap.String("ip", c.ClientIP()),
zap.String("user-agent", c.Request.UserAgent()),
zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
zap.Duration("cost", cost),
)
}
}
// GinRecovery Recover the panic that may appear in the project, and use zap to record related logs
func GinRecovery(stack bool) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Check for a broken connection, as it is not really a
// condition that warrants a panic stack trace.
var brokenPipe bool
if ne, ok := err.(*net.OpError); ok {
if se, ok := ne.Err.(*os.SyscallError); ok {
if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
brokenPipe = true
}
}
}
httpRequest, _ := httputil.DumpRequest(c.Request, false)
if brokenPipe {
Log.Error(c.Request.URL.Path,
zap.Any("error", err),
zap.String("request", string(httpRequest)),
)
// If the connection is dead, we can't write a status to it.
c.Error(err.(error)) // nolint: errcheck
c.Abort()
return
}
if stack {
Log.Error("[Recovery from panic]",
zap.Any("error", err),
zap.String("request", string(httpRequest)),
zap.String("stack", string(debug.Stack())),
)
} else {
Log.Error("[Recovery from panic]",
zap.Any("error", err),
zap.String("request", string(httpRequest)),
)
}
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
c.Next()
}
}
我們在自己寫日志時, 調用全局變量 Log 即可
tools.Log.Warn("DB error", zap.Error(err))
記錄Gin的日志, 將其注冊進全局的 router 中間件即可
// InitApp init gshop app
func InitApp() *gin.Engine {
// gin.Default uses Use by default. Two global middlewares are added, Logger(), Recovery(), Logger is to print logs, Recovery is panic and returns 500
// gin.New not use Logger and Recovery
router := gin.Default()
// gin log
router.Use(tools.GinLogger(), tools.GinRecovery(true))
// Add consent cross-domain middleware
router.Use(middleware.CorsHandler())
// init app router
user.Router(router)
group.Router(router)
device.Router(router)
dynamic.Router(router)
control.Router(router)
return router
}