目录
1.相关联的数据库表
2.使用gorm操作数据库
使用gen生成model和对数据库的操作
3.使用viper进行配置管理
读取配置文件
进行热更新
4.使用Pflag来进行命令行参数解析
5.使用日志slog
日志轮转与切割功能
6.错误码和http返回格式标准化
提供错误码
提供错误类型
提供统一的http返回格式
7.使用gin构建web服务器
路由管理
实现优雅关闭
项目地址: https://github.com/liwook/PublicReview
1.相关联的数据库表
创建的sql语句保存在dianping.sql文件中。其中已经添加了一些用户信息和商户的信息。
该章节对应的文件目录:
2.使用gorm操作数据库
数据库是使用MySql。我们使用第三方的开源库 gorm来操作数据库。gorm是目前 Go 语言中最流行的 ORM 库,同时也是一个功能齐全且对开发人员友好的 ORM 库。
创建internal/db目录,在这创建mysql.go文件。
var DBEngine *gorm.DBfunc NewMySQL() (*gorm.DB, error) {dsn := "root:123456@tcp(127.0.0.1:3306)/mydbname?charset=utf8mb4&parseTime=True&loc=Local"db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})if err != nil {return nil, err}sqlDB, err := db.DB()if err != nil {return nil, err}sqlDB.SetMaxOpenConns(100) //设置数据库连接池最大连接数sqlDB.SetMaxIdleConns(30) //连接池最大允许的空闲连接数,如果没有sql任务需要执行的连接数大于MaxIdleConns,超过的连接会被连接池关闭return db, nil
}
提示:要是出现错误gorm.io/plugin/dbresolver@v1.2.1/dbresolver.go:139:18: cannot use map[string]gorm.Stmt{} (value of type map[string]gorm.Stmt) as type map[string]*gorm.Stmt in struct literal。
解决方案是:执行 go get gorm.io/plugin/dbresolver@latest 把 gorm.io/plugin/dbresolver 升级到最新版本 。
之后需要写数据库表的表结构和表的一些操作。
使用gen生成model和对数据库的操作
以前需要手动写每个数据库表的model和自己写增删改查。而GEN 是一个基于 GORM 的安全 ORM 框架,其主要通过代码生成方式实现 GORM 代码封装。旨在安全上避免业务代码出现 SQL 注入,同时给研发带来最佳用户体验。
官方文档:Gen Guides | GORM - The fantastic ORM library for Golang, aims to be developer friendly.
而现有库gen,写极少代码就可以生成表结构和表操作的代码,很方便。
创建cmd目录,在该目录下创建generate目录,在generate目录添加generate.go文件。添加如下代码。这样就可以生成表的结构体和对表操作的一些函数。
// 这里使用的是gorm.io/gen@v0.3.16
func main() {db, err := db.NewMySQL()if err != nil {panic(err)}g := gen.NewGenerator(gen.Config{// OutPath是相对执行`go run`时的路径OutPath: "./dal/query", //代码生成的路径// ModelPkgPath: "./dal/model",不写是最好,不然就出现目录:dal/dal/modelMode: gen.WithDefaultQuery | gen.WithoutContext | gen.WithQueryInterface,FieldNullable: false,FieldCoverable: false,FieldSignable: true,FieldWithIndexTag: false,FieldWithTypeTag: true,})g.UseDB(db)dataMap := map[string]func(detailType string) (dataType string){"tinyint": func(detailType string) (dataType string) { return "int8" },"smallint": func(detailType string) (dataType string) { return "int16" },"bigint": func(detailType string) (dataType string) { return "int64" },"int": func(detailType string) (dataType string) { return "int64" },}g.WithDataTypeMap(dataMap)autoUpdateTimeField := gen.FieldGORMTag("modified_on", "column:modified_on;type:int unsigned;autoUpdateTime")autoCreateTimeField := gen.FieldGORMTag("created_on", "column:created_on;type:int unsigned;autoCreateTime")// softDeleteField := gen.FieldType("deleted_on", "soft_delete.DeletedAt")// 模型自定义选项组fieldOpts := []gen.ModelOpt{autoCreateTimeField, autoUpdateTimeField}allModel := g.GenerateAllTable(fieldOpts...)g.ApplyBasic(allModel...)g.Execute()
}
3.使用viper进行配置管理
我们一般不会在代码中把一些可能会改变的参数写成常量,比如监听的端口。我们一般是配置在配置文件中,然后程序读取配置文件。
创建configs目录,该目录是用来存放配置文件,html等文件的。在该目录中添加文件config.yaml。
Server:RunMode: debugHttpPort: 10000ReadTimeout: 60WriteTimeout: 60
mysql:Username: root # 填写你的数据库账号Password: 123456 # 填写你的数据库密码Host: 127.0.0.1:3306DBName: dianpingMaxIdleConns: 30MaxOpenConns: 100
然后在internal目录下创建config目录,在该目录中新建 config.go 文件,用于声明配置属性的结构体并编写读取段配置的配置方法。
var (ServerOption *ServerSettingMysqlOption *MysqlSetting
)type ServerSetting struct {RunMode stringHttpPort stringReadTimeout time.DurationWriteTimeout time.Duration
}type MysqlSetting struct {UserName stringPassword stringHost stringDbName stringMaxIdleConns intMaxOpenConns int
}
读取配置文件
编写读取配置文件的方法,按照每个结构体来读取,即是分段读取。
//config.go//打开配置文件进行读取
func ReadConfigFile() error {//viper是可以开箱即用的,这样写法就类似单例模式//也可以创建viper 比如 vp:=viper.New()viper.SetConfigFile("../configs/config.yaml") // 指定配置文件名和位置return viper.ReadInConfig()
}//分段读取
func ReadSection(key string, v any) error {return viper.UnmarshalKey(key, v)
}
进行读取。
func init() {InitConfig()
}func InitConfig() {if err := ReadConfigFile(); err != nil {panic(err)}err := ReadSection("server", &ServerOption)if err != nil {panic(err)}err = ReadSection("mysql", &MysqlOption)if err != nil {panic(err)}
}
在cmd目录中添加main.go文件,添加代码进行读取配置文件并打印。因为config.go文件使用了init函数,所以在main.go文件中无需手动进行初始化,会自动使用init函数。
package mainimport ("dianping/internal/config""fmt"
)func main() {fmt.Println("Hello, World!")fmt.Println(config.ServerOption)fmt.Println(config.MysqlOption)
}
接着修改下mysql.go文件中的NewMySQL函数,现在是使用了参数设置。
//mysql.go
func NewMySQL(config *config.MysqlSetting) (*gorm.DB, error) {dsn := fmt.Sprintf(`%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=true&loc=Local`,config.UserName,config.Password,config.Host,config.DbName)..............sqlDB.SetMaxOpenConns(config.MaxOpenConns) //设置数据库连接池最大连接数sqlDB.SetMaxIdleConns(config.MaxIdleConns) //连接池最大允许的空闲连接数,如果没有sql任务需要执行的连接数大于MaxIdleConns,超过的连接会被连接池关闭//添加这句query.SetDefault(db) //设置了才能使用query包,这样方便
}//generate.go的也要修改
var options config.MysqlSettingfunc init() {err := config.ReadConfigFile()..........err = config.ReadSection("mysql", &options)
}func main() {db, err := db.NewMySQL(&options)if err != nil {panic(err)}................
}
进行热更新
我们也会有些要求,想要在程序运行中,更改了配置文件后,程序不用重启也可以生效。viper也可以实现该效果。使用如下代码可以实现其效果,就是设置回调函数。注意:WatchConfig要使用在前面。
func ReadConfigFile() error {//viper是可以开箱即用的,这样写法就类似单例模式//也可以创建viper 比如 vp:=viper.New()viper.SetConfigFile("./configs/config.yaml")if err := viper.ReadInConfig(); err != nil {return err}viper.WatchConfig() //该函数内部是开启了一个新协程去监听配置文件是否更新//设置回调函数viper.OnConfigChange(func(in fsnotify.Event) {reloadAllSection()fmt.Println("更新了 :", in.Name)})return nil
}
在配置文件有更新后,就需要使用函数reloadAllSection再次读取配置并重新赋值给之前的变量。
把要解析的配置存储在key,而ServerOption等存储在value。之后有更新时候,使用变量sections的key来更新其value,那么该ServerOption也就更新了。
ReadSection函数也有改动,每次读取时候,往sections添加key,value。
var sections = make(map[string]any)func ReadSection(key string, v any) error {err := viper.UnmarshalKey(key, v)if err != nil {return nil}//增加读取section的存储记录,以便在重新加载配置的方法中进行处理if _, ok := sections[key]; !ok {sections[key] = v}return nil
}
reloadAllSection函数就是在配置文件更新时候会执行的函数,其内容也就是重新读取文件嘛。
// 用于重新读取配置
func reloadAllSection() error {for k, v := range sections {if err := ReadSection(k, v); err != nil {return nil}}return nil
}
在main.go中进行测试,程序运行后修改配置文件,之后查看打印出来的是否有改动即可。
注意:这个热更新尽量少用。因为有些在程序启动后一些参数就不能改动的。比如http的监听端口,在程序启动后,你再更新配置文件也不会起作用的了。
这个热更新可以用于更新日志等级。这样不重启程序,新的日志等级也会在程序中生效。
func main() {go func() {for {fmt.Println(*global.ServerOption)fmt.Println(*global.DatabaseOption)fmt.Println()time.Sleep(5 * time.Second)}}()time.Sleep(20 * time.Second)
}
4.使用Pflag来进行命令行参数解析
Go服务开发中,经常需要给开发的组件加上各种启动参数来配置服务进程,影响服务的行为。一些大型服务就有多达上百个启动参数,而且这些参数的类型各不相同(例如:string、int、ip类型等),使用方式也不相同(例如:需要支持--
长选项,-
短选项等),所以我们需要一个强大的命令行参数解析工具。
比如我们的配置文件位置改变了,那我们就读取不了配置,所以需要可以在命令行来获取配置文件位置。比如执行 ./main --config=./config.yaml。
Go源码中提供了一个标准库Flag包,用来对命令行参数进行解析,但在大型项目中应用更广泛的是另外一个包:Pflag。
Pflag提供了很多强大的特性,非常适合用来构建大型项目。Pflag有很多功能,这里就只介绍和本项目相关的部分。
想要手动指定配置文件的位置。可以使用pflag.StringP函数。参数config是对应的长选项,c是短选项。"../configs/config.yaml"是默认值。
//main.go
func init() {configPath := pflag.StringP("config", "c", "../configs/config.yaml", "config file path")pflag.Parse()fmt.Println("path:", *configPath)config.InitConfig(*configPath)..................
}func main() {............................
}//这样,就需要修改config.go文件,不在该文件提供init函数。
//config.go//不再使用
// func init() {
// InitConfig()
// }func ReadConfigFile(path string) error {viper.SetConfigFile(path) // 指定配置文件名和位置............
}func InitConfig(path string) {if err := ReadConfigFile(path); err != nil {panic(err)}err := ReadSection("server", &ServerOption)if err != nil {panic(err)}err = ReadSection("mysql", &MysqlOption)if err != nil {panic(err)}err = ReadSection("log", &LogOption)if err != nil {panic(err)}
}
查看效果
5.使用日志slog
在程序出错的时候,我们会想记录一些出错的位置和原因。这时我们就需要使用日志来记录了。
Go语言中有自己的官方日志库slog,也支持结构化。
创建pkg目录,在该目录下创建logger目录,创建log.go文件。
// 初始化后,日志使用直接 slog.Info("dfsf")就行
func InitLogger(level string) error {file, err := os.OpenFile("dianping.log", os.O_CREATE|os.O_APPEND, 0666)if err != nil {return err}//使用json格式logger := slog.New(slog.NewJSONHandler(file, &slog.HandlerOptions{AddSource: true,Level: LogLevel(level),}))slog.SetDefault(logger)return nil
}// 获得日志等级
func LogLevel(level string) slog.Level {switch strings.ToLower(level) {case "debug":return slog.LevelDebugcase "info":return slog.LevelInfocase "warn":return slog.LevelWarncase "error":return slog.LevelError}return slog.LevelInfo
}
日志轮转与切割功能
要是不断记录日志,日志文件会越来越大。单个文件过大会影响写入效率,那我们就会希望在日志文件达到一个大小上限后,自动开启一个新文件再记录日志。还有 最多保留可以保留多少个日志文件,最多保留多少天,要不要做压缩处理?而这些使用库lumberjack都可以方便做到。
lumberjack
是一个专门设计用于日志轮转和切割的库,其作用可以类比于一个可插拔的组件。我们可以通过配置该组件,并将其 集成 到所选的日志库中,从而实现日志文件的轮转与切割功能。
有v2.0版本,需要go get gopkg.in/natefinch/lumberjack.v2。
初始化 lumberjack
组件的代码如下所示:
log := &lumberjack.Logger{Filename: "/path/file.log", // 日志文件的位置MaxSize: 10, // 文件最大尺寸(以MB为单位)MaxBackups: 3, // 保留的最大旧文件数量MaxAge: 28, // 保留旧文件的最大天数Compress: true, // 是否压缩/归档旧文件LocalTime: true, // 使用本地时间创建时间戳
}
需要注意的是, lumberjack
的 Logger
结构体实现了 io.Writer
接口。这意味着所有关于日志文件的轮转与切割的核心逻辑都封装在 Write
方法中。这一实现也方便 Logger
结构体被集成到任何支持 io.Writer
参数的日志库中。
在log.go中添加日志参数变量,并进行解析。之后在config.yaml添加log的参数。
//config
var LogOption *logger.LogSetting//并进行解析.............//config.yaml
log :Filename : ../dianping.loglevel : debugMaxSize : 10 #mbMaxBackups : 10 #保留的最大文件个数MaxAge : 30 #保留的最大天数
修改InitLogger函数。
// 日志选项结构体
type LogSetting struct {Filename stringLevel slog.LevelMaxSize intMaxBackups intMaxAge int
}var LogLevel = new(slog.LevelVar)// 使用lumberjack库将日志轮转与切割
func InitLogger(config *LogSetting) error {log := lumberjack.Logger{Filename: config.Filename, //日志文件的位置MaxSize: config.MaxSize, //文件最大尺寸(以mb为单位)MaxBackups: config.MaxBackups, //保留的最大文件个数MaxAge: config.MaxAge, //保留旧文件的最大天数LocalTime: true, //使用本地时间创建时间戳}LogLevel.Set(GetLogLevel(config.Level)) //这样就可以在运行时更新日志等级//使用json格式logger := slog.New(slog.NewJSONHandler(&log, &slog.HandlerOptions{AddSource: true,Level: LogLevel,}))slog.SetDefault(logger)return nil
}
要可以在热更新时候使日志等级生效,需要修改热更新函数。在设置回调函数时候,更新日志等级。
func ReadConfigFile() error {.................viper.WatchConfig() //该函数内部是开启了一个新协程去监听配置文件是否更新//设置回调函数viper.OnConfigChange(func(in fsnotify.Event) {reloadAllSection()//查看是否有更新了日志等级level := viper.GetString("log.level")// fmt.Println("new_level:", level)logger.LogLevel.Set(logger.GetLogLevel(level))})
}
在mian.go中进行测试。config.yaml中level是info,之后修改为debug,这样不用重启程序,debug等级的也可打印出来。
func init() {err := logger.InitLogger(config.LogOption)if err != nil {panic(err)}
}func main() {fmt.Println(config.LogOption)go func() {for {slog.Info("Error")slog.Error("Error")slog.Debug("debug")time.Sleep(5 * time.Second)}}()time.Sleep(30 * time.Second)
}
6.错误码和http返回格式标准化
首先,我们要对错误码有统一的处理,哪个错误码对应哪个错误。接着,服务端返回错误给客户端时候,我们也要统一错误信息的格式,这样客户端解析的时候就可以统一解析。
提供错误码
创建pkg/code文件夹,添加commoncode.go文件,添加如下代码:
使用的httpcode就使用200、400、401、403、404、500这6个HTTP错误码,不需要过多的HTTP错误码展示给用户。
var OnlyUseHTTPStatus = map[int]bool{200: true, 400: true, 401: true, 403: true, 404: true, 500: true}//http状态码 5开头表示服务器端错误。4开头表示客户端错误// 基础错误
// code must start with 1xxxxx
const (ErrSuccess int = iota + 100001ErrUnknownErrBindErrValidation //validation failedErrTokenInvalid //token invalid
)// 数据库类错误
const (ErrDatabase int = iota + 100101
)// 认证授权类错误
const (ErrEncrypt int = iota + 100201ErrSignatureInvalidErrExpiredErrInvalidAuthHeaderErrMissingHeader //The `Authorization` header was empty.ErrPasswordIncorrectErrPermissionDenied //Permission denied.
)// 编解码类错误
const (// ErrEncodingFailed - 500: Encoding failed due to an error with the data.ErrEncodingFailed int = iota + 100301ErrDecodingFailedErrInvalidJSONErrEncodingJSONErrDecodingJSON// ErrInvalidYaml - 500: Data is not valid Yaml.ErrInvalidYamlErrEncodingYamlErrDecodingYaml
)
提供错误类型
添加code.go。错误码用来指代一个错误类型,该错误类型需要包含一些有用的信息,例如对应的HTTP Status Code、对外展示的Message,以及跟该错误匹配的帮助文档。所以,我们还需要实现一个Coder来承载这些信息。我们定义了ErrCode
结构体:
type Errcode struct {code intHTTP intmessage string
}func (coder Errcode) Error() string {return coder.message
}func (coder Errcode) Code() int {return coder.code
}func (coder Errcode) String() string {return coder.message
}func (coder Errcode) HTTPStatus() int {if coder.HTTP == 0 {return 500}return coder.HTTP
}
之后再用前面的错误码和错误码结构体进行注册,方便后续的使用。
var codes = map[int]*Errcode{}
var codeMux = &sync.Mutex{}func register(code int, httpStaus int, message string) {if code == 0 {panic("code 0 is reserved")}if _, ok := OnlyUseHTTPStatus[httpStaus]; !ok {panic("httstatuscode and code are not good")}codeMux.Lock()defer codeMux.Unlock()errcode := &Errcode{code: code,HTTP: httpStaus,message: message,}codes[code] = errcode
}func ParseCoder(code int) *Errcode {if coder, ok := codes[code]; ok {return coder}return &Errcode{code: 1, HTTP: http.StatusInternalServerError, message: "unknown error"}
}
添加code_generated.go文件,进行注册Coder。
func init() {register(ErrSuccess, 200, "OK")register(ErrUnknown, 500, "Internal server error")register(ErrBind, 400, "Error occurred while binding the request body to the struct")............................
}
提供统一的http返回格式
添加response.go文件,在该文件添加response结构体:
// Response defines project response format
// 使用json标签中的omitempty选项来实现当字段为空值时不返回该字段
type Response struct {Code int `json:"code,omitempty"`Message string `json:"message,omitempty"`Data any `json:"data,omitempty"`
}// WriteResponse used to write an error and JSON data into response.
func WriteResponse(c *gin.Context, code int, data interface{}) {coder := ParseCoder(code)if coder.HTTPStatus() != http.StatusOK {c.JSON(coder.HTTPStatus(), Response{Code: coder.Code(),Message: coder.String(),Data: data,})return}c.JSON(http.StatusOK, Response{Data: data})
}
7.使用gin构建web服务器
路由管理
创建目录internal/routeer,在该目录添加router.go文件。该文件是对所有的路由进行管理的。
func NewRouter() *gin.Engine {r := gin.Default()r.GET("/ping", func(c *gin.Context) {code.WriteResponse(c, code.ErrSuccess, "pong")})r.GET("/test", func(c *gin.Context) {code.WriteResponse(c, code.ErrDatabase, "not find this data")})r.GET("/test2", func(c *gin.Context) {code.WriteResponse(c, code.ErrDatabase, nil)})return r
}
在main.go文件中测试
func main() {r := router.NewRouter()err := r.Run(":" + config.ServerOption.HttpPort)if err != nil {panic(err)}
}
实现优雅关闭
对于一个web服务器,客户端正在使用,而这时服务器却关闭了,用户可能会收到错误消息,并且不知道发生了什么。所以要是服务器可以等待已连接的所有数据处理完所有用户,并且这个时段也不再接收客户端的新请求。
- 使用信号监听。通过监听操作系统信号来触发服务器的停止。当接收到特定信号(如
os.Interrupt
或syscall.SIGTERM
)时,开始执行停止流程。 - 使用 http.Server 内置的 Shutdown() 方法优雅地关机。
func main() {r := router.NewRouter()// err := r.Run(":" + config.ServerOption.HttpPort)// if err != nil {// panic(err)// }//创建HTTP服务器server := http.Server{Addr: ":" + config.ServerOption.HttpPort,Handler: r,}go func() {err := server.ListenAndServe()if err != nil {panic(err)}}()quit := make(chan os.Signal, 1)signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // syscall.SIGKILL是无法捕捉的<-quitfmt.Println("shutdown server...")//创建超时上下文,Shutdown可以让未处理的连接在这个时间内关闭ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()if err := server.Shutdown(ctx); err != nil {panic(err)}fmt.Println("server shutdown success")
}