访问日志记录
当出现问题时,我们常常需要查看日志,除了查看错误日志、业务日志,还有一个很重要的日志类别,那就是访问日志。从功能上讲,它会记录每一次请求的请求方法、方法调用开始时间、方法调用结束时间、方法响应结果和方法响应结果状态码。除此之外,它还会记录RequestId、TraceId、SpanId等附加属性,以达到日志链路追踪的效果。
但在正式开始前,还会遇到一个问题,即无法直接获取方法返回的响应主体,这时需要巧妙利用Go interface的特性。实际上在写入流时,调用的是http.ResponseWriter,代码如下:
type ResponseWriter interface{Header() HeaderWrite([]byte) (int,error)WriteHeader(statusCode int)}
只需写一个针对访问日志的Writer结构体,实现特定的Write方法就可以解决无法直接获取方法响应主体的问题了。打开internal/middleware,创建access_log.go文件,写入如下代码:
package middlewareimport ("bytes""github.com/gin-gonic/gin")type AccessLogWriter struct {gin.ResponseWriterbody *bytes.Buffer}func (w AccessLogWriter) Write(p []byte) (int, error) {if n, err := w.body.Write(p); err != nil {return n, err}return w.ResponseWriter.Write(p)}
在 AccessLogWriter 的 Write 方法中实现了双写,因此可以直接通过AccessLogWriter的body取到值。接下来继续编写访问日志的中间件,写入如下代码:
func AccessLog() gin.HandlerFunc {return func(ctx *gin.Context) {bodyWriter := &AccessLogWriter{ResponseWriter: ctx.Writer,body: bytes.NewBufferString(""),}ctx.Writer = bodyWriterbeginTime := time.Now().Unix()ctx.Next()endTime := time.Now().Unix()fields := logger.Fields{"request": ctx.Request.PostForm.Encode(),"response": bodyWriter.body.String(),}s := "access.log: method: %s, status_code: %d, " + "begin_time: %d, end_time: %d"global.Logger.WithFields(fields).InfoF(s, ctx.Request.Method,bodyWriter.Status(),beginTime,endTime,)}}
在AccessLog方法中,我们初始化了AccessLogWriter,将其赋予当前的Writer写入流(可理解为替换原有),并且通过指定方法得到所需的日志属性,最终写到日志中,其中涉及的信息如下:
- method:当前的调用方法。
- request:当前的请求参数。
- response:当前的请求结果响应主体。
- status_code:当前的响应结果状态码。
- begin_time/end_time:调用方法的开始时间、调用方法的结束时间。
异常捕获处理
1、自定义Recovery
gin本身已经自带了一个Recovery中间件,但在项目中,我们需要针对内部情况或生态圈自定义Recovery中间件,确保异常在被正常捕获之余能及时地被识别和处理。自定义Recovery中间件的代码如下:
package middlewareimport ("code.coolops.cn/blog_services/global""code.coolops.cn/blog_services/pkg/app""code.coolops.cn/blog_services/pkg/errcode""github.com/gin-gonic/gin")// 自定义捕获异常Recoveryfunc Recovery() gin.HandlerFunc {return func(ctx *gin.Context) {defer func(){if err := recover();err != nil{s := "panic recovery err: %v"global.Logger.WithCallerFrames().ErrorF(s,err)app.NewResponse(ctx).ToErrorResponse(errcode.ServerError)ctx.Abort()}}()ctx.Next()}}
2、邮件报警处理
在实现Recovery中间件的同时,还需要实现一个简单的邮件报警功能,确保出现Panic后,在捕获之余能够通过邮件报警及时地通知对应的负责人。
(1)安装
go get gopkg.in/gomail.v2
gomail是一个用于发送电子邮件的简单且高效的第三方开源库,目前只支持使用SMTP服务器发送电子邮件,但是其API较为灵活,如果有其他定制需求,则可以轻易地借助其实现。这恰好符合我们的需求,因为目前只需要一个“小而美”的可以发送电子邮件的库。
(2)邮件工具库
在项目目录pkg下新建Email目录,并创建email.go文件,写入如下代码(我们需要对发送电子邮件的行为进行封装):
package emailimport ("crypto/tls""gopkg.in/gomail.v2")// 邮件工具库type SMTPInfo struct {Host stringPort intIsSSL boolUserName stringPassword stringFrom string}type Email struct {*SMTPInfo}func NewEmail(info *SMTPInfo) *Email {return &Email{info}}func (e *Email) SendEmail(to []string, subject, body string) error {m := gomail.NewMessage()m.SetHeader("From", e.From)m.SetHeader("To", to...)m.SetHeader("Subject", subject)m.SetBody("text/html", body)dialer := gomail.Dialer{Host: e.Host,Port: e.Port,Username: e.UserName,Password: e.Password,}dialer.TLSConfig = &tls.Config{InsecureSkipVerify: e.IsSSL}return dialer.DialAndSend(m)}
在上述代码中,我们定义了SMTPInfo结构体,用于传递发送邮箱所必需的信息。在SendMail方法中,首先,调用 NewMessage 方法创建一个消息实例,用于设置邮件的一些必要信息,具体如下:
- 发件人(From)。
- 收件人(To)。
- 邮件主题(Subject)。
- 邮件正文(Body)。
接着调用NewDialer方法创建一个新的SMTP拨号实例,设置对应的拨号信息,用于连接SMTP服务器最后调用DialAndSend方法,打开与SMTP服务器的连接并发送电子邮件。
(3)初始化配置信息。
本次要做的发送电子邮件的行为实际上可以理解为是与一个SMTP服务进行交互,即除自建 SMTP 服务器外,还可以使用目前市面上常见的邮件提供商。打开项目的配置文件config.yaml,新增如下所示的Email配置项:
# 邮件服务Email:Host: smtp.163.comPort: 465UserName: xxxx@163.comPassword: xxxxIsSSL: trueFrom: xxxx@163.comTo:- xxxx@163.com
需要开启“POP3/SMTP 服务”和“IMAP/SMTP服务”,根据获取的SMTP账户及密码进行设置即可
在pkg/setting下的section.go文件中,新增对应的Email配置项,代码如下:
// 邮件配置type EmailSettingS struct {Host stringPort intUserName stringPassword stringIsSSL boolFrom stringto []string}
在项目目录global下的setting.go文件中,新增Email对应的配置全局对象,代码如下:
package global// 全局配置文件import "code.coolops.cn/blog_services/pkg/setting"var (ServerSetting *setting.ServerSettingSAppSetting *setting.AppSettingSDatabaseSetting *setting.DatabaseSettingSJWTSetting *setting.JWTSettingSEmailSetting *setting.EmailSettingS)
在main.go文件的setupSetting方法中,新增Email配置项的读取和映射,代码如下:
// 初始化配置文件func setupSetting() error {setting, err := setting2.NewSetting()......// 初始化Emailerr = setting.ReadSection("Email", &global.EmailSetting)if err != nil {return err}return nil}
编写中间件
打开internal/middleware,创建recovery.go文件,写入如下代码:
package middlewareimport ("code.coolops.cn/blog_services/global""code.coolops.cn/blog_services/pkg/app""code.coolops.cn/blog_services/pkg/email""code.coolops.cn/blog_services/pkg/errcode""fmt""github.com/gin-gonic/gin""time")// 自定义捕获异常Recoveryfunc Recovery() gin.HandlerFunc {defailtMailer := email.NewEmail(&email.SMTPInfo{Host: global.EmailSetting.Host,Port: global.EmailSetting.Port,IsSSL: global.EmailSetting.IsSSL,UserName: global.EmailSetting.UserName,Password: global.EmailSetting.Password,From: global.EmailSetting.From,})return func(ctx *gin.Context) {defer func() {if err := recover(); err != nil {s := "panic recovery err: %v"global.Logger.WithCallerFrames().ErrorF(s, err)err := defailtMailer.SendEmail(global.EmailSetting.To,fmt.Sprintf("异常抛出,发生时间: %d", time.Now().Unix()),fmt.Sprintf("错误信息:%v", err),)if err != nil {global.Logger.ErrorF("mail.SendEmail err: %v", err)}app.NewResponse(ctx).ToErrorResponse(errcode.ServerError)ctx.Abort()}}()ctx.Next()}}
服务信息存储
我们经常需要在进程内上下文设置一些内部信息,既可以是应用名称和应用版本号这类基本信息,也可以是业务属性信息。例如,想要根据不同的租户号获取不同的数据库实例对象,这时就需要在一个统一的地方进行处理。
打开internal/middleware,新建app_info.go文件,写入如下代码:
package middlewareimport "github.com/gin-gonic/gin"// 服务信息func AppInfo() gin.HandlerFunc{return func(ctx *gin.Context) {ctx.Set("app_name","blog_service")ctx.Set("app_version","1.0.0")ctx.Next()}}
在上述代码中,我们需要用到gin.Context提供的setter和getter,在gin中被称为元数据管理(Metadata Management)。
接口流量限制
1、安装
go get github.com/juju/ratelimit
ratelimit提供了一个简单又高效的令牌桶实现,可以帮助我们实现限流器的逻辑。
2、限流控制
(1)LimiterIface
打开pkg/limiter,新建limiter.go文件,写入如下代码:
package limiterimport ("github.com/gin-gonic/gin""github.com/juju/ratelimit""time")// 限流type LimiterIface interface {Key(ctx *gin.Context) stringGetBucket(key string) (*ratelimit.Bucket, bool)AddBuckets(rules ...LimiterBucketRule) LimiterIface}type Limiter struct {limiterBuckets map[string]*ratelimit.Bucket}type LimiterBucketRule struct {Key stringFillInterval time.DurationCapacity int64Quantum int64}
在上述代码中,我们声明了LimiterIface接口,用于定义当前限流器所必需的方法。实际上限流器的形式有多种,可能某一类接口需要限流器 A,而另外一类接口需要限流器B,它们所采用的策略并不完全一致,因此我们需要声明LimiterIface这类通用接口,保证其接口的设计。我们初步的在Iface接口中声明以下三个方法:
- Key:获取对应的限流器的键值对名称。
- GetBucket:获取令牌桶。
- AddBuckets:新增多个令牌桶。
定义Limiter 结构体,存储令牌桶与键值对名称的映射关系。定义LimiterBucketRule 结构体,存储令牌桶的一些相应规则属性,具体如下:
- Key:自定义键值对名称。
- FillInterval:间隔多久时间放N个令牌。
- Capacity:令牌桶的容量。
- Quantum:每次到达间隔时间后所放的具体令牌数量。
至此就完成了一个Limiter最基本的属性定义,接下来针对不同的情况,实现这个项目中的限流器。
(2)MethodLimiter
我们对一部分接口进行限流。
打开pkg/limiter,并新建method_limiter.go文件,写入如下代码:
package limiterimport ("github.com/gin-gonic/gin""github.com/juju/ratelimit""strings")type MethodLimiter struct {*Limiter}func NewMethodLimiter() MethodLimiter {l := &Limiter{limiterBuckets: make(map[string]*ratelimit.Bucket),}return MethodLimiter{Limiter: l,}}func (l MethodLimiter) Key(ctx *gin.Context) string {uri := ctx.Request.RequestURIindex := strings.Index(uri, "?")if index == -1 {return uri}return uri[:index]}func (l MethodLimiter) GetBucket(key string) (*ratelimit.Bucket, bool) {bucket, ok := l.limiterBuckets[key]return bucket, ok}func (l MethodLimiter) AddBuckets(rules ...LimiterBucketRule) MethodLimiter {for _, rule := range rules {if _, ok := l.limiterBuckets[rule.Key]; !ok {bucket := ratelimit.NewBucketWithQuantum(rule.FillInterval,rule.Capacity,rule.Quantum,)l.limiterBuckets[rule.Key] = bucket}}return l}
在上述代码中,对LimiterIface接口实现了MethodLimiter限流器,主要逻辑是在Key方法中根据RequestURI切割出核心路由作为键值对名称,并从GetBucket和AddBuckets中获取和设置Bucket的对应逻辑。
(3)编写中间件
在编写完限流器的逻辑后,打开 internal/middleware,新建 limiter.go 文件,将整体的限流器与对应的中间件逻辑串联起来,写入如下代码:
package middlewareimport ("code.coolops.cn/blog_services/pkg/app""code.coolops.cn/blog_services/pkg/errcode""code.coolops.cn/blog_services/pkg/limiter""github.com/gin-gonic/gin")// 限流中间件func RateLimiter(l limiter.LimiterIface) gin.HandlerFunc {return func(ctx *gin.Context) {key := l.Key(ctx)if bucket, ok := l.GetBucket(key); ok {count := bucket.TakeAvailable(1)if count == 0 {response := app.NewResponse(ctx)response.ToErrorResponse(errcode.TooManyRequests)ctx.Abort()return}}ctx.Next()}}
在RateLimiter中间件中,需要注意的是入参应该为LimiterIface接口类型。这样一来,只要符合该接口类型的具体限流器实现都可以传入并使用。另外,TakeAvailable 方法会占用存储桶中立即可用的令牌的数量,返回值为删除的令牌数。如果没有可用的令牌,则返回 0,即已经超出配额了。这时将返回errcode.TooManyRequest状态,让客户端减缓请求速度。
统一超时时间
在应用程序的运行过程中,经常会遇到一个让人头疼的问题,即假设应用A调用应用B,应用B调用应用C,如果应用C出现问题,则在没有任何约束的情况下仍持续调用,就会导致应用A、B、C均出现问题。这就是十分常见的上下游应用的相互影响所导致的连环反应,最终使得整个集群应用出现一定规模的不可用。
为了避免出现这种情况,最简单的一个约束点,就是统一在应用程序中针对所有请求都进行一个最基本的超时时间控制。
下面编写一个上下文超时时间控制中间件来实现这个需求。打开internal/middleware,新建context_timeout.go文件,代码如下:
package middlewareimport ("context""github.com/gin-gonic/gin""time")// 统一超时时间配置func ContextTimeout(t time.Duration) gin.HandlerFunc {return func(ctx *gin.Context) {c, cancel := context.WithTimeout(ctx.Request.Context(), t)defer cancel()ctx.Request = ctx.Request.WithContext(c)ctx.Next()}}
在上述代码中,我们调用了context.WithTimeout方法来设置当前context的超时时间,并重新赋给gin.Context。
需要注意的是,如果在进行多应用/服务的调用时,把父级的上下文信息(ctx)不断地传递下去,那么在统计超时控制的中间件中所设置的超时时间,其实是针对整条链路的。如果需要单独调整某条链路的超时时间,那么只需调用context.WithTimeout等方法对父级 ctx 进行设置,然后取得子级 ctx,再进行新的传递即可。
注册中间件
在编写完一连串的通用中间件后,打开 internal/routers 下的 router.go 文件,修改注册应用中间件的逻辑,代码如下:
var methodLimiters = limiter.NewMethodLimiter().AddBucket(limiter.LimiterBucketRule{Key: "/auth",FillInterval: time.Second,Capacity: 10,Quantum: 10,},)func NewRouter() *gin.Engine {r := gin.New()if global.ServerSetting.RunMode == "debug" {r.Use(gin.Logger())r.Use(gin.Recovery())} else {r.Use(middleware.AccessLog())r.Use(middleware.Recovery())}r.Use(middleware.RateLimiter(methodLimiters))r.Use(middleware.ContextTimeout(60 * time.Second))r.Use(middleware.Translations()).....}
根据不同的部署环境(RunMode)对应用中间件进行了设置。实际上,在使用了自定义的Logger和Recovery后,就没有必要使用gin提供的了。在本地开发环境中,因为没有应用生态圈,所以需要进行特殊处理。另外,在常规项目中,自定义的中间件不仅包含了基本的功能,还包含了很多定制化的功能。同时,在注册顺序上也需要注意,Recovery这类应用中间件应当尽可能地早注册,我们可以根据实际所需应用中间件的情况进行顺序定制。
来自:《Go语言编程之旅》
