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")
}