2024-09-04
Go & 后端
00

目录

1 引入第三方 Logger
2 读取配置文件
3 注册定时任务
4 优化查询用户排行榜接口

MisakaOJ项目,第六次记录内容

前五次记录已经把接口实现的差不多了,没实现的接口无非都是增删改查,没有本质上的区别,之后还是想着能不能加一些技术栈,加一两个拓展板块,或者别的能够加快访问速度的东西。

还能加的板块可以是题目的题解信息,题目评论区一类的,这俩可以加。

1 引入第三方 Logger

这次选择引入的是第三方库Zap

shell
go get -u go.uber.org/zap go get -u moul.io/zapgorm2

第一个是Zap库,第二个是针对 gorm 的,配合Zap库使用的 logger 第三方库。

先说这个 logger 怎么初始化。

go
var logger *zap.Logger func ZapInit() error { // 创建 log 文件 并且从 io.Writer 类型转换到 WriteSyncer 其实就是保证该文件对应的数据结构一定有一个 Sync 方法 使其能够被 zapcore.NewCore 使用 logFile, e := os.Create(config.GlobalConfig.LoggerFilePath + time.Now().Format("2006.01.02-15.04.05") + ".json") if e != nil { return e } logFileSyncer := zapcore.AddSync(logFile) /* lumberJackLogger := &lumberjack.Logger{ Filename: filename, // 文件位置 MaxSize: maxsize, // 进行切割之前,日志文件的最大大小(MB为单位) MaxAge: maxAge, // 保留旧文件的最大天数 MaxBackups: maxBackup, // 保留旧文件的最大个数 Compress: false, // 是否压缩/归档旧文件 } logFileSyncer := zapcore.AddSync(lumberJackLogger) 这是给分片 log 文件用的 避免 log 文件过大 当然这里感觉不是很有这个必要 */ // 获取一个 Production 下的 Encoder 开始自定义格式 encoderConfig := zap.NewProductionEncoderConfig() // 时间对应的键 encoderConfig.TimeKey = "time" // 时间格式化 2022-09-01T19:11:35.921+0800 encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // log 等级全部大写 encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder // 打印堆栈信息的时候用的 这个只显示代码所在文件的文件名和行号 不写完整路径 和它对着来的就是 FullCallerEncoder encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder // 打印出来的是 json 格式 如果需要控制台格式的就是 NewConsoleEncoder jsonEncoder := zapcore.NewJSONEncoder(encoderConfig) // 和输出到文件对应的 设置到控制台的输出 consoleSyncer := zapcore.AddSync(os.Stdout) // 控制台格式 Encoder consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig) // 将到文件和控制台的输出合并 core := zapcore.NewTee( zapcore.NewCore(jsonEncoder, logFileSyncer, zapcore.DebugLevel), zapcore.NewCore(consoleEncoder, consoleSyncer, zapcore.DebugLevel), ) // 正式获取 logger logger = zap.New(core, zap.AddCaller()) //logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) // 上面这一行是在原来的基础上加了一些配置 // zap.AddCaller 给每个 log 附加调用者信息 // zap.AddStackTrace(zapcore.ErrorLevel) 在 log 等级达到 Error 时 自动附加堆栈信息 // 使其能够在全局通过 zap.L 函数获取 logger zap.ReplaceGlobals(logger) return nil }

任何初始化需要用到的参数基本都写在注释里了,之后是把这个 logger 嵌入到 Gin 里面。

go
func GinLogger(logger *zap.Logger) 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) logger.Info("GIN", zap.Int("status", c.Writer.Status()), zap.String("method", c.Request.Method), zap.String("path", path), zap.String("query", query), zap.String("client_ip", c.ClientIP()), zap.String("user_agent", c.Request.UserAgent()), zap.String("error", c.Errors.ByType(gin.ErrorTypePrivate).String()), zap.Duration("time_cost", cost), /* 注意那个 error 它所捕获的是不会返回给客户端的 只在开发阶段出现的 这样出现的错误 c.Error(gin.Error{ Type: gin.ErrorTypePrivate, Err: errors.New("This is a private error"), Meta: "Some additional info", }) */ ) } } func GinRecovery(logger *zap.Logger, needStackInfo bool) gin.HandlerFunc { return func(c *gin.Context) { defer func() { if e := recover(); e != nil { // 这段来自 gin 的源码 具体是检查这个 panic 是否是系统爆出来的 向一个已经关闭的管道写入内容 或者 连接已被对方重置 的错误 // 如果是 那其实没有必要再试图向客户端发送 InternalError 状态码 直接 c.Abort 然后 return 出去就好 var brokenPipe bool if ne, ok := e.(*net.OpError); ok { var se *os.SyscallError if errors.As(ne, &se) { seStr := strings.ToLower(se.Error()) if strings.Contains(seStr, "broken pipe") || strings.Contains(seStr, "connection reset by peer") { brokenPipe = true } } } // 这也是来自源码 // 将请求转换为 HTTP1.x 版本的 便于打印 转换完了就变成字节数组了 注意即使是 HTTP2 也会被转换为 HTTP1.x 而不是它们自己的二进制形式 httpRequest, _ := httputil.DumpRequest(c.Request, false) if brokenPipe { logger.Error("GIN-Recovery From Panic", zap.String("path", c.Request.URL.Path), zap.Any("error", e), zap.String("request", string(httpRequest)), ) c.Error((e).(error)) c.Abort() return } if needStackInfo { logger.Error("GIN-Recovery From Panic", zap.Any("error", e), zap.String("request", string(httpRequest)), zap.Stack("stack_info"), ) } else { logger.Error("GIN-Recovery From Panic", zap.Any("error", e), zap.String("request", string(httpRequest)), ) } c.AbortWithStatus(http.StatusInternalServerError) } }() c.Next() } }

要写的都写注释里了,之后是到 router 那里注册 logger 中间件。

go
func Router() *gin.Engine { r := gin.New() // 启用 zap 作为 logger r.Use(logger.GinLogger(zap.L()), logger.GinRecovery(zap.L(), true)) ... }

注意这里一定要调用gin.New()函数而不是gin.Default(),因为gin.Default()里面默认会启用自己的 logger 和 recover:

go
// gin 源码 // Default returns an Engine instance with the Logger and Recovery middleware already attached. func Default(opts ...OptionFunc) *Engine { debugPrintWARNINGDefault() engine := New() engine.Use(Logger(), Recovery()) // 看这里 return engine.With(opts...) }

最后是嵌入到 gorm 中。注意 import,看看对应的是哪个包。

go
package models import ( "database/sql" "fmt" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/driver/mysql" "gorm.io/gorm" logger2 "gorm.io/gorm/logger" "moul.io/zapgorm2" "time" ) var DB *gorm.DB func InitMysql() error { logger := zapgorm2.New(zap.L()) logger.SlowThreshold = 2 * time.Second // 慢 sql 阈值 如果一个 sql 语句执行时间超过阈值 log 等级为 WARN 而不是 INFO logger.LogMode(logger2.Info) // 全局Debug模式 db, e := gorm.Open(mysql.Open("root:123456@tcp(127.0.0.1:19327)/misaka_oj?charset=utf8mb4&parseTime=True&loc=Local"), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, // 禁止自动创建关联对应的外键 Logger: logger, }) ... }

注意,这里需要控制,数据库必须在 logger 初始化之后开始连接,所以这里修改了初始化方式,没有再自动调用函数,而是交给 main 搞定。

go
func main() { e := config.LoadConfig("./config.json") if e != nil { panic(e) } e = logger.ZapInit() if e != nil { panic(e) } defer func(l *zap.Logger) { _ = l.Sync() }(zap.L()) e = models.InitMysql() if e != nil { panic(e) } models.InitRedis() gin.SetMode(gin.ReleaseMode) r := router.Router() e = r.Run(config.GlobalConfig.ServerAddr) if e != nil { panic(e) } }

这样,Zap就作为 logger 嵌入项目中了:

go
func HandleExecCodeRemoteRequest() { for { requestWithCallback := <-execCodeRequestChan client := <-availableClientChan go func(req *ExecCodeRequestWithCallBack, client *ClientWithConn) { response, e := client.client.ExecCodeRemote(context.Background(), requestWithCallback.request) // 错误处理 规定如果服务端执行代码过程中的所有错误全部定义为 Internal if e != nil { zap.L().Error("ExecCodeRemote", zap.String("response", response.String()), zap.Error(e), ) ... } } }

2 读取配置文件

go
type Config struct { ServerAddr string `json:"server_addr"` // 监听地址+端口 DefaultPage int `json:"default_page"` // 查询问题列表时 默认查询页数 DefaultSize int `json:"default_size"` // 查询问题列表时 默认查询问题个数 JwtExpiredTime int `json:"jwt_expired_time"` // Token默认过期时间 单位为分钟 JwtRefreshTime int `json:"jwt_refresh_time"` // Token自动刷新时间 单位为分钟 JwtKey string `json:"jwt_key"` // jwt所用的密钥 EmailAddr string `json:"email_addr"` // 使用的邮箱帐号 EmailSender string `json:"email_sender"` // 发出邮件的发信人 EmailSMTPHostName string `json:"email_smtp_host_name"` // QQ邮箱的SMTP服务器域名 EmailSMTPAuthCode string `json:"email_smtp_auth_code"` // SMTP授权码 在QQ邮箱中当密码用 EmailSMTPServerPort string `json:"email_smtp_server_port"` // SMTP服务器端口 587端口也可用 SubmitCodeSavePath string `json:"submit_code_save_path"` // 用户提交的代码的保存位置 ExecCodeRemotely bool `json:"exec_code_remotely"` // 是否远程执行代码 RegisterRemoteServerPort string `json:"register_remote_server_port"` // 注册远程执行代码服务端口 } var GlobalConfig *Config func LoadConfig(configFilePath string) error { file, e := os.Open(configFilePath) if e != nil { return e } defer func(file *os.File) { _ = file.Close() }(file) fileBytes, e := io.ReadAll(file) if e != nil { return e } var result *Config e = json.Unmarshal(fileBytes, result) if e != nil { return e } GlobalConfig = result return nil }

等到要用的时候,就大概类似这样:

go
func main() { e := config.LoadConfig("./config.json") if e != nil { panic(e) } r := router.Router() e = r.Run(config.GlobalConfig.ServerAddr) if e != nil { panic(e) } }

配置文件示例如下:

json
{ "server_addr": "127.0.0.1:8060", "default_page": 1, "default_size": 20, "jwt_expired_time": 60, "jwt_refresh_time": 30, "jwt_key": "jwt_key", "email_addr": "misaka19327@qq.com", "email_sender": "Misaka19327", "email_smtp_host_name": "smtp.qq.com", "email_smtp_auth_code": "email_smtp_auth_code", "email_smtp_server_port": ":465", "submit_code_save_path": "./code/", "exec_code_remotely": false, "register_remote_server_port": ":22332" }

3 注册定时任务

使用第三方 Cron 包来运行定时任务。

shell
go get github.com/robfig/cron/v3@v3.0.0

假设这块有个什么任务:

go
func Something() {...}

初始化这个定时任务,假设它一个小时一次:

go
func TaskInit() (e error) { C = cron.New() // 添加任务 _, e = C.AddFunc("@every 1h", Something) if e != nil { return e } // 立即执行一次的任务 Something() return nil }

添加任务有两种:一个就是上面这个函数型的,还有一个 Job,需要一个结构体实现 Run 方法再传给 Cron。定时也有两种方式,一种是上面的比较好懂的,另一种是按这个格式指定一个时间点:分 时 日 月 周历(即星期几),比如0 0 1 1 *即为每个1月1日的0时0分触发。当然这个格式可以自己自定义,这个需要的时候再查也行。

注意,Cron 初始化的时候,对于每一个小时触发一次这种,并不会立即触发一次任务。因为这个“每一个小时触发一次”,实质上就是0 * * * *

对于 Zap 来说,要想嵌入 Cron,只需要实现两个函数即可:

go
var C *cron.Cron type CronLogger struct { l *zap.Logger } func (c *CronLogger) Info(msg string, keysAndValues ...interface{}) { c.l.Info("Cron", zap.String("msg", msg), zap.Any("key_and_values", keysAndValues), ) } func (c *CronLogger) Error(e error, msg string, keysAndValues ...interface{}) { c.l.Error("Cron-Error", zap.Error(e), zap.String("msg", msg), zap.Any("key_and_values", keysAndValues), ) } func TaskInit() (e error) { cronLogger := &CronLogger{ l: zap.L(), } C = cron.New(cron.WithChain(cron.Recover(cronLogger)), cron.WithLogger(cronLogger)) // 添加任务 _, e = C.AddFunc("@every 1h", Something) if e != nil { return e } return nil }

4 优化查询用户排行榜接口

把前50名用户的数据通过定时任务实时更新到 Redis 中,访问接口的时候直接从 Redis 拿数据即可。

先说定时任务:

go
func RefreshUserRankListTop50() { var e error data := make([]*models.User, 50) e = models.GetUserDetailByColumn("", ""). Order("finish_question_num DESC, submit_num ASC").Limit(50). Find(&data).Error if e != nil { zap.L().Error("Cron", zap.Error(e), ) return } for i := range data { bytes, e := json.Marshal(data[i]) if e != nil { zap.L().Error("Cron", zap.Error(e), ) return } // hset key field value e = models.RDB.HSet(context.Background(), "UserRankTop50", strconv.Itoa(i), bytes).Err() if e != nil { zap.L().Error("Redis-Write", zap.String("method", "HSet"), zap.String("key", "UserRankTop50"), zap.String("field", strconv.Itoa(i)), zap.Any("value", string(bytes)), zap.Error(e), ) return } } } func TaskInit() (e error) { ... // 添加任务 _, e = C.AddFunc("@every 1h", RefreshUserRankListTop50) if e != nil { return e } ... }

然后修改 Handler:

go
// GetRankList // @Tags 公共方法 // @Summary 获取用户排行榜列表 // @Param page query int false "page" // @Param size query int false "size" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /rank_list [get] func GetRankList(c *gin.Context) { page, e := strconv.Atoi(c.DefaultQuery("page", strconv.Itoa(config.GlobalConfig.DefaultPage))) if e != nil { ErrorHandler(c, "Get Param page Error: "+e.Error()) return } size, e := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(config.GlobalConfig.DefaultSize))) if e != nil { ErrorHandler(c, "Get Param size Error: "+e.Error()) return } page = (page - 1) * size // page到offset的转换 var count int64 = 0 data := make([]*models.User, 0) // 看情况是从 Redis 里面取数据还是从数据库里面拿数据 // 拿数据的范围是从 page 到 page + size 之间的用户 if page + size <= 49 { // 如果是前50 从 Redis 里面拿 var r string rUser := &models.User{} for i := page; i <= page + size; i++ { r, e = models.RDB.HGet(context.Background(), "UserRankTop50", strconv.Itoa(i)).Result() if e != nil { if errors.Is(e, redis.Nil) { break } ErrorHandler(c, errors.Join(constants.RedisHGetErr, e).Error()) return } _ = json.Unmarshal([]byte(r), rUser) data = append(data, rUser) count += 1 } } else { // 从数据库里拿 e = models.GetUserDetailByColumn("", "").Count(&count). Order("finish_question_num DESC, submit_num ASC"). Offset(page).Limit(size). Find(&data).Error if e != nil { ErrorHandler(c, errors.Join(constants.DataBaseQueryErr, e).Error()) return } } c.JSON(http.StatusOK, gin.H{ "code": 200, "data": map[string]any{ "count": count, "list": data, }, }) }

从 log 来看确实快了不少(左侧是 Redis,右侧是把 Redis 部分删了,单位是秒,Zap 的默认对 Duration 的编码就是编码为以秒的小数):

image.png

本文作者:御坂19327号

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!