前言
之前在写练手的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
}