MisakaOJ项目,第六次记录内容
前五次记录已经把接口实现的差不多了,没实现的接口无非都是增删改查,没有本质上的区别,之后还是想着能不能加一些技术栈,加一两个拓展板块,或者别的能够加快访问速度的东西。
还能加的板块可以是题目的题解信息,题目评论区一类的,这俩可以加。
这次选择引入的是第三方库Zap
。
shellgo get -u go.uber.org/zap go get -u moul.io/zapgorm2
第一个是Zap
库,第二个是针对 gorm 的,配合Zap
库使用的 logger 第三方库。
先说这个 logger 怎么初始化。
govar 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 里面。
gofunc 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 中间件。
gofunc 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,看看对应的是哪个包。
gopackage 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 搞定。
gofunc 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 嵌入项目中了:
gofunc 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),
)
...
}
}
}
gotype 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
}
等到要用的时候,就大概类似这样:
gofunc 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"
}
使用第三方 Cron 包来运行定时任务。
shellgo get github.com/robfig/cron/v3@v3.0.0
假设这块有个什么任务:
gofunc Something() {...}
初始化这个定时任务,假设它一个小时一次:
gofunc 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,只需要实现两个函数即可:
govar 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
}
把前50名用户的数据通过定时任务实时更新到 Redis 中,访问接口的时候直接从 Redis 拿数据即可。
先说定时任务:
gofunc 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 的编码就是编码为以秒的小数):
本文作者:御坂19327号
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!