go 项目框架及规范

2023-06-15 10:32:44

技术

个人觉得比较不错的规范,四处偷师得来的

项目结构与分层

├─controller
├─dao
│  ├─mysql
│  └─redis
├─logger
├─logic
├─middlewares
├─models
├─utils
│  ├─jwt
│  └─snowflake
├─routes
└─settings

controller: 请求处理与参数校验 logic: 业务逻辑的实现,也叫 service 层 dao: 对数据库的相关操作

model: 数据模型 routes: 路由接口 settings: 配置的加载 middlewares: 中间件 utils: 自定义功能的工具库,方便调用 logger: 日志库的封装

项目地址

配置文件的加载

示例配置

app:
  name: "web_app"
  mode: "dev"
  port: 8081
  start_time: "2023-01-01"
  machine_id: 1

log:
  # 日志级别
  level: "debug"
  #文件名
  filename: "web_app.log"
  # 文件最大大小(M)
  max_size: 200
  # 文件最大保存天数
  max_age: 30
  # 文件备份数量
  max_backups: 7
  

mysql:
  host: "127.0.0.1"
  port: 3306
  user: "root"
  password: "mysql123"
  dbname: "sql_demo"
  # 最大连接数
  max_open_conns: 200
  # 空闲连接数
  max_idle_conns: 50


redis:
  host: "127.0.0.1"
  port: 6379
  password: ""
  db: 0
  pool_size: 100

使用viper加载配置

package settings

import (
    "fmt"
    "github.com/fsnotify/fsnotify"
    "github.com/spf13/viper"
)

func Init() (err error) {
    // viper.SetConfigFile("config.yaml")   //指定文件加后缀
    viper.SetConfigName("config") // 指定配置文件名称,不需要带后缀,会自动识别指定目录下相同的文件名
    viper.SetConfigType("yaml")   //指定配置文件类型,用于远程获取配置,本地时不生效
    viper.AddConfigPath(".")      //指定查找配置文件的路径(这里用相对路径)
    err = viper.ReadInConfig()    //读取文件配置
    if err != nil {
        // 读取配置信息失败
        fmt.Println("viper.ReadInConfig() 读取配置信息失败:", err)
        return
    }

    viper.WatchConfig()
    viper.OnConfigChange(func(in fsnotify.Event) {
        fmt.Println("配置文件修改")
    })
    return
}

此后使用viper.GetString()、viper.GetInt()即可获取配置参数,如

viper.GetInt("app.port")
viper.GetString("mysql.user")

日志

使用zap日志库

package logger

import (
    "net"
    "net/http"
    "net/http/httputil"
    "os"
    "runtime/debug"
    "strings"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/spf13/viper"
    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
    "gopkg.in/natefinch/lumberjack.v2"
)

// var lg *zap.Logger

// Init 初始化Logger
func Init() (err error) {
    writeSyncer := getLogWriter(
        viper.GetString("log.filename"),
        viper.GetInt("max_size"),
        viper.GetInt("max_backups"),
        viper.GetInt("max_age"),
    )
    encoder := getEncoder()
    var l = new(zapcore.Level)
    err = l.UnmarshalText([]byte(viper.GetString("log.level")))
    if err != nil {
        return
    }
    var core zapcore.Core
    if viper.GetString("app.mode") == "dev" {
        // 开发模式,日志输出到终端
        consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
        core = zapcore.NewTee(
            zapcore.NewCore(encoder, writeSyncer, l),
            zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), zapcore.DebugLevel),
        )
    } else {
        // 非开发模式,日志只输出到文件
        core = zapcore.NewCore(encoder, writeSyncer, l)
    }

    lg := zap.New(core, zap.AddCaller())
    zap.ReplaceGlobals(lg) // 替换zap包中全局的logger实例,后续在其他包中只需使用zap.L()调用即可
    return
}

func getEncoder() zapcore.Encoder {
    encoderConfig := zap.NewProductionEncoderConfig()
    encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
    encoderConfig.TimeKey = "time"
    encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
    encoderConfig.EncodeDuration = zapcore.SecondsDurationEncoder
    encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder
    return zapcore.NewJSONEncoder(encoderConfig)
}

func getLogWriter(filename string, maxSize, maxBackup, maxAge int) zapcore.WriteSyncer {
    lumberJackLogger := &lumberjack.Logger{
        Filename:   filename,
        MaxSize:    maxSize,
        MaxBackups: maxBackup,
        MaxAge:     maxAge,
    }
    return zapcore.AddSync(lumberJackLogger)
}

// GinLogger 接收gin框架默认的日志
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)
        zap.L().Info(path,
            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掉项目可能出现的panic,并使用zap记录相关日志
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 {
                    zap.L().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 {
                    zap.L().Error("[Recovery from panic]",
                        zap.Any("error", err),
                        zap.String("request", string(httpRequest)),
                        zap.String("stack", string(debug.Stack())),
                    )
                } else {
                    zap.L().Error("[Recovery from panic]",
                        zap.Any("error", err),
                        zap.String("request", string(httpRequest)),
                    )
                }
                c.AbortWithStatus(http.StatusInternalServerError)
            }
        }()
        c.Next()
    }
}

比较繁琐,之后使用zap.L().Error()即可打印错误日志信息

数据库初始化

使用sqlx


import (
    "fmt"

    "github.com/jmoiron/sqlx"
    "github.com/spf13/viper"
    "go.uber.org/zap"

    _ "github.com/go-sql-driver/mysql"
)

var db *sqlx.DB

func Init() (err error) {
    // dsn := "user:password@tcp(127.0.0.1:3306)/sql_test?charset=utf8mb4&parseTime=True"
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True",
        viper.GetString("mysql.user"),
        viper.GetString("mysql.password"),
        viper.GetString("mysql.host"),
        viper.GetString("mysql.port"),
        viper.GetString("mysql.dbname"),
    )
    // 也可以使用MustConnect连接不成功就panic
    db, err = sqlx.Connect("mysql", dsn)
    if err != nil {
        zap.L().Error("connect DB failed", zap.Error(err))
        return
    }
    db.SetMaxOpenConns(viper.GetInt("mysql.max_open_conns"))
    db.SetMaxIdleConns(viper.GetInt("mysql.max_idle_conns"))
    return
}

func Close() {
    _ = db.Close()
}

使用gorm

// 全局变量,db和error在其他文件里也需要使用
var db  *gorm.DB

func Init() (err error){
    //使用占位符,然后用setting.go里的配置参数来替代
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True",
        viper.GetString("mysql.user"),
        viper.GetString("mysql.password"),
        viper.GetString("mysql.host"),
        viper.GetString("mysql.port"),
        viper.GetString("mysql.dbname"),
    )
    db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
        //禁用默认表名的复数形式
        NamingStrategy: schema.NamingStrategy{SingularTable: true},
    })
    // if err != nil {
    //  fmt.Println("数据库连接失败,请检查连接参数", err)
    // }
    if err != nil {
        zap.L().Error("connect DB failed", zap.Error(err))
        return
    }

    //

    //数据库自动迁移,括号内的参数为需要构建的数据模型结构体
    db.AutoMigrate()

    sqlDB, err := db.DB()
    if err != nil {
        // fmt.Println("数据库连接设置出错,请检查连接参数", err)
        zap.L().Error("connect DB failed", zap.Error(err))
        return
    }
    // 以下这些参数可以写到配置文件里,然后使用 viper 来加载
    // SetMaxIdleConns 设置空闲连接池中连接的最大数量
    sqlDB.SetMaxIdleConns(50) //50可替换为 viper.GetInt("mysql.max_idle_conns"),下同

    // SetMaxOpenConns 设置打开数据库连接的最大数量。
    sqlDB.SetMaxOpenConns(200)

    // SetConnMaxLifetime 设置了连接可复用的最大时间。
    //不能超过 gin 框架的连接超时时间
    sqlDB.SetConnMaxLifetime(10 * time.Second)

    //sqlDB.Close()
}

func Close() {
    _ = db.Close()
}

错误处理/约定状态码

const (
    SUCCESS = 200
    ERROR   = 500
    //约定状态码
    //code=1000...用户模块错误
    ERROR_USERNAME_USED  = 1001
    ERROR_PASSWORD_WRONG = 1002
    ERROR_USER_NOT_EXIST = 1003

    ERROR_TOKEN_NOT_EXIST = 1004
    ERROR_TOKEN_OUT_TIME  = 1005
    ERROR_TOKEN_WRONG     = 1006
    ERROR_TYPE_WRONG      = 1007

    //code=2000...文章模块错误

    //code=3000...分类模块错误

    // 等等
)


var codeMsg = map[int]string{
    SUCCESS:               "OK",
    ERROR:                 "FAIL",
    ERROR_USERNAME_USED:   "该用户名已存在",
    ERROR_PASSWORD_WRONG:  "密码错误",
    ERROR_USER_NOT_EXIST:  "用户不存在",
    ERROR_TOKEN_NOT_EXIST: "token不存在",
    ERROR_TOKEN_OUT_TIME:  "token已过期",
    ERROR_TOKEN_WRONG:     "token错误",
    ERROR_TYPE_WRONG:      " token格式错误",
}

// GetErrMsg 根据状态码获取对应的信息信息
func GetErrMsg(code int) string {
    return codeMsg[code]
}

路由接口

在 api/v1 创建控制数据模型的接口 user.go、article.go、category.go,以及控制登录的接口 login.go 以用户模块为例

//查询用户是否存在
func UserExist(ctx *gin.Context) {

}
//查询用户
//查询用户列表
func GetUsers(ctx *gin.Context) {

}
//添加用户
func AddUser(ctx *gin.Context) {

}
//编辑用户
func EditUser(ctx *gin.Context) {

}
//删除用户
func DeleteUser(ctx *gin.Context) {

}

在 routes/routes.go 的 v1 路由组中创建路由接口

router := r.Group("api/v1")
    {
        // User 用户模块路由接口
        router.POST("user/add", v1.AddUser)
        router.GET("users", v1.GetUsers)
        router.PUT("user/:id", v1.EditUser)
        router.DELETE("user/:id", v1.DeleteUser)

        // 其他模块路由接口

        // 其他模块路由接口
    }

主函数

package main

import (
    "fmt"
    "net/http"

    "web_app/dao/mysql"
    "web_app/dao/redis"
    "web_app/logger"
    "web_app/pkg/snowflake"
    "web_app/routes"
    "web_app/settings"

    // "web_app/pkg/snowflake"

    "context"

    "github.com/spf13/viper"
    "go.uber.org/zap"

    "os"
    "os/signal"
    "syscall"
    "time"
    // "github.com/gin-gonic/gin"
)

// Go Web 开发通用脚手架模板

func main() {
    // 1. 加载配置
    if err := settings.Init(); err != nil {
        fmt.Println("settings.Init() 加载配置失败:", err)
        return
    }
    // 2. 初始化日志
    if err := logger.Init(); err != nil {
        fmt.Println("logger.Init() 初始化日志失败:", err)
        return
    }
    // 延迟日志
    defer zap.L().Sync()
    zap.L().Debug("logger init success ...")
    // 3. 初始化 MySQL 连接
    if err := mysql.Init(); err != nil {
        fmt.Println("mysql.Init() 初始化mysql数据库失败:", err)
        return
    }
    defer mysql.Close()

    // 4. 初始化 Redis 连接
    if err := redis.Init(); err != nil {
        fmt.Println("redis.Init() 初始化redis数据库失败:", err)
        return
    }
    defer redis.Close()

    // 5. 注册路由
    r := routes.Setup()
    // 6. 启动服务 (优雅关机)

    srv := &http.Server{
        Addr:    fmt.Sprintf(":%d", viper.GetInt("app.port")),
        Handler: r,
    }

    go func() {
        // 开启一个goroutine启动服务
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            zap.L().Fatal("listen: %s\n", zap.Error(err))
        }
    }()

    // 等待中断信号来优雅地关闭服务器,为关闭服务器操作设置一个5秒的超时
    quit := make(chan os.Signal, 1) // 创建一个接收信号的通道
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 此处不会阻塞
    <-quit                                               // 阻塞在此,当接收到上述两种信号时才会往下执行
    zap.L().Info("Shutdown Server ...")
    // 创建一个5秒超时的context
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    // 5秒内优雅关闭服务(将未处理完的请求处理完再关闭服务),超过5秒就超时退出
    if err := srv.Shutdown(ctx); err != nil {
        zap.L().Fatal("Server Shutdown: ", zap.Error(err))
    }

    zap.L().Info("Server exiting")
}