Skip to content

常见的应用中间件

访问日志记录

当出现问题时,我们常常需要查看日志,除了查看错误日志、业务日志,还有一个很重要的日志类别,那就是访问日志。从功能上讲,它会记录每一次请求的请求方法、方法调用开始时间、方法调用结束时间、方法响应结果和方法响应结果状态码。除此之外,它还会记录 RequestId、TraceId、SpanId 等附加属性,以达到日志链路追踪的效果。

但在正式开始前,还会遇到一个问题,即无法直接获取方法返回的响应主体,这时需要巧妙利用 Go interface 的特性。实际上在写入流时,调用的是 http.ResponseWriter,代码如下:

go
type ResponseWriter interface{
    Header() Header
    Write([]byte) (int,error)
    WriteHeader(statusCode int)
}

只需写一个针对访问日志的 Writer 结构体,实现特定的 Write 方法就可以解决无法直接获取方法响应主体的问题了。打开 internal/middleware,创建 access_log.go 文件,写入如下代码:

go
package middleware

import (
	"bytes"
	"github.com/gin-gonic/gin"
)

type AccessLogWriter struct {
	gin.ResponseWriter
	body *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 取到值。接下来继续编写访问日志的中间件,写入如下代码:

go
func AccessLog() gin.HandlerFunc {
	return func(ctx *gin.Context) {
		bodyWriter := &AccessLogWriter{
			ResponseWriter: ctx.Writer,
			body:           bytes.NewBufferString(""),
		}
		ctx.Writer = bodyWriter

		beginTime := 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 中间件的代码如下:

go
package middleware

import (
	"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"
)

// 自定义捕获异常Recovery
func 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
go get gopkg.in/gomail.v2

gomail 是一个用于发送电子邮件的简单且高效的第三方开源库,目前只支持使用 SMTP 服务器发送电子邮件,但是其 API 较为灵活,如果有其他定制需求,则可以轻易地借助其实现。这恰好符合我们的需求,因为目前只需要一个“小而美”的可以发送电子邮件的库。

(2)邮件工具库

在项目目录 pkg 下新建 Email 目录,并创建 email.go 文件,写入如下代码(我们需要对发送电子邮件的行为进行封装):

go
package email

import (
	"crypto/tls"
	"gopkg.in/gomail.v2"
)

// 邮件工具库

type SMTPInfo struct {
	Host     string
	Port     int
	IsSSL    bool
	UserName string
	Password string
	From     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 配置项:

go
# 邮件服务
Email:
  Host: smtp.163.com
  Port: 465
  UserName: xxxx@163.com
  Password: xxxx
  IsSSL: true
  From: xxxx@163.com
  To:
    - xxxx@163.com

需要开启“POP3/SMTP 服务”和“IMAP/SMTP 服务”,根据获取的 SMTP 账户及密码进行设置即可

在 pkg/setting 下的 section.go 文件中,新增对应的 Email 配置项,代码如下:

go
// 邮件配置
type EmailSettingS struct {
	Host string
	Port int
	UserName string
	Password string
	IsSSL bool
	From string
	to []string
}

在项目目录 global 下的 setting.go 文件中,新增 Email 对应的配置全局对象,代码如下:

go
package global

// 全局配置文件
import "code.coolops.cn/blog_services/pkg/setting"

var (
	ServerSetting   *setting.ServerSettingS
	AppSetting      *setting.AppSettingS
	DatabaseSetting *setting.DatabaseSettingS
	JWTSetting      *setting.JWTSettingS
	EmailSetting    *setting.EmailSettingS
)

在 main.go 文件的 setupSetting 方法中,新增 Email 配置项的读取和映射,代码如下:

go
// 初始化配置文件
func setupSetting() error {
	setting, err := setting2.NewSetting()
	......
	// 初始化Email
	err = setting.ReadSection("Email", &global.EmailSetting)
	if err != nil {
		return err
	}
	return nil
}

编写中间件

打开 internal/middleware,创建 recovery.go 文件,写入如下代码:

go
package middleware

import (
	"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"
)

// 自定义捕获异常Recovery
func 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 文件,写入如下代码:

go
package middleware

import "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
go get github.com/juju/ratelimit

ratelimit 提供了一个简单又高效的令牌桶实现,可以帮助我们实现限流器的逻辑。

2、限流控制

(1)LimiterIface

打开 pkg/limiter,新建 limiter.go 文件,写入如下代码:

go
package limiter

import (
	"github.com/gin-gonic/gin"
	"github.com/juju/ratelimit"
	"time"
)

// 限流
type LimiterIface interface {
	Key(ctx *gin.Context) string
	GetBucket(key string) (*ratelimit.Bucket, bool)
	AddBuckets(rules ...LimiterBucketRule) LimiterIface
}

type Limiter struct {
	limiterBuckets map[string]*ratelimit.Bucket
}

type LimiterBucketRule struct {
	Key          string
	FillInterval time.Duration
	Capacity     int64
	Quantum      int64
}

在上述代码中,我们声明了 LimiterIface 接口,用于定义当前限流器所必需的方法。实际上限流器的形式有多种,可能某一类接口需要限流器 A,而另外一类接口需要限流器 B,它们所采用的策略并不完全一致,因此我们需要声明 LimiterIface 这类通用接口,保证其接口的设计。我们初步的在 Iface 接口中声明以下三个方法:

  • Key:获取对应的限流器的键值对名称。
  • GetBucket:获取令牌桶。
  • AddBuckets:新增多个令牌桶。

定义 Limiter 结构体,存储令牌桶与键值对名称的映射关系。定义 LimiterBucketRule 结构体,存储令牌桶的一些相应规则属性,具体如下:

  • Key:自定义键值对名称。
  • FillInterval:间隔多久时间放 N 个令牌。
  • Capacity:令牌桶的容量。
  • Quantum:每次到达间隔时间后所放的具体令牌数量。

至此就完成了一个 Limiter 最基本的属性定义,接下来针对不同的情况,实现这个项目中的限流器。

(2)MethodLimiter

我们对一部分接口进行限流。

打开 pkg/limiter,并新建 method_limiter.go 文件,写入如下代码:

go
package limiter

import (
	"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.RequestURI
	index := 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 文件,将整体的限流器与对应的中间件逻辑串联起来,写入如下代码:

go
package middleware

import (
	"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 文件,代码如下:

go
package middleware

import (
	"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 文件,修改注册应用中间件的逻辑,代码如下:

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 语言编程之旅》

最近更新