Go Web框架 AOP 方案

763 阅读16分钟

一. AOP是什么?

AOP (Aspect Oriented Programming),面向切面编程。核心在于将横向关注点从业务中剥离出来。

横向关注点:就是那些跟业务没啥关系,但是每个业务又必须要处理的。常见的有几类:

  • 可观测性:logging、metric 和 tracing
  • 安全相关:登录、鉴权与权限控制
  • 错误处理:例如错误页面支持
  • 可用性保证:熔断限流和降级等

image.png

基本上 Web 框架都会设计自己的 AOP 方案。

二. go 主流 web 框架的 AOP 方案设计

2.1 Beego 设计

Beego 早期的时候设计完善的回调机制,大体上有三类:

image.png

image.png

image.png

  1. Middleware它的缺陷是基本脱离了 Beego 的控制。通常来说 Beego 的 HTTPServer 本身是一个 Handler,用户自己开发一个 http.Handler,然后将两者结合在一起。因此用户的 http.Handler 就没法子利用 Beego 内部的数据了。
  2. Filter:Beego 允许用户注册不同时机运行的 Filter。这些Filter 都是单向的,而不是环绕式的。
  3. FilterChain:FilterChain 可以看做是可以利用 Beego 内部数据的 Middleware。它将 Filter 组织成链,每一个 Filter 都要考虑调用下一个 Filter,否则就会中断执行。但同时,这些 Filter 也失去了指定时机运行的能力。

image.png

image.png

image.png

AOP 这种东西是最为简单最为方便扩展的。所以作为一个中间件设计者,要考虑并不是说提供越多实现越好,而是只提供绝大多数用户会用到,而且用起来没什么区别的那种实现。

2.2 Gin 设计

image.png

image.png

Gin 同样提供了 HandlerFunc ,用于来被用户注册于不同时机运行的方法,HandlerChain 可以看做是将被注册的 n 个 HandlerFunc 组织成链

image.png

image.png

image.png

image.png

通过处理 http 请求时,不断的通过链式调用下一个 注册的 handlerFunc ,与 beego 不同的是,调用 next 是由 context 来完成的。

metric 实现:github.com/penglongli/…

image.png

tracing 实现:github.com/opentracing…

image.png

image.png

2.3 Echo 设计

image.png

image.png

Echo 和 Beego 的 FilterChain 基本一样,依赖于 MiddlewareFunc 返回的 HandlerFunc 主动调用 next

2.4 Iris 设计

image.png

Iris、Echo 和 Beego 的设计没什么本质区别,无非是名字不同,以及构造方式上的差异。

三. AOP 方案核心

3.1 Middleware

这里也叫 Middleware ,它的定义也是接收 一个 HandleFunc 作为输入,返回一个 HandleFunc

Middleware 的提供者要决定何时调用 next 。它既是一个责任链模式,也是一个洋葱模式,后面可以看到在中间件设计里会大量使用这个模式。

// middleware.go

type Middleware func(next HandleFunc) HandleFunc
// server.go

func (s *HTTPServer) ServeHTTP(writer http.ResponseWriter, req *http.Request) {
   ctx := &Context{
      Request:  req,
      Response: writer,
   }

   // 最后一个应该是 HTTPServer 执行路由匹配,执行用户代码
   root := s.serve
   // 从后往前组装
   for i := len(s.mdls) - 1; i >= 0; i-- {
      root = s.mdls[i](root)
   }

   root(ctx)
}

3.2 调用 next

image.png

这里是构造 handlerChain 的过程, 构造完成后,正常执行下来,最终会到用户业务逻辑里面

image.png

某个 HandleFunc 没有调用 next,那么正常执行,最终会到用户业务逻辑里面执行流程就被打断了

四. 可观测性 Middleware

4.1 AccessLog

在日常工作中,用户可能希望能够记录所有进来的请求,以支持 DEBUG,也就是所谓的 AccessLog。Beego 里面支持了 AccessLog,Iris 也支持了AccessLog。这里提供一个简单的 AccessLog Middleware。

func NewBuilder() *MiddlewareBuilder {
   return &MiddlewareBuilder{
      logFunc: func(accessLog string) {
         log.Println(accessLog)
      },
   }
}

func (b *MiddlewareBuilder) LogFunc(logFunc func(accessLog string)) *MiddlewareBuilder {
   b.logFunc = logFunc
   return b
}

type MiddlewareBuilder struct {
   logFunc func(accessLog string)
}

type accessLog struct {
   Host       string
   Route      string
   HTTPMethod string `json:"http_method"`
   Path       string
}

func (b *MiddlewareBuilder) Build() web.Middleware {
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         defer func() {
            l := accessLog{
               Host:       ctx.Request.Host,
               Path:       ctx.Request.URL.Path,
               HTTPMethod: ctx.Request.Method,
               Route:      ctx.MatchedRoute,
            }
            val, _ := json.Marshal(l)
            b.logFunc(string(val))
         }()
         next(ctx)
      }
   }
}

在 defer 里面才最终输出日志,因为:

  • 确保即便 next 里面发生了 panic,也能将请求记录下来
  • 获得 MatchedRoute:它只有在执行了 next 之后才能获得,因为依赖于最终的路由树匹配(HTTPServer.serve),将匹配结果缓存到了MatchedRoute

AccessLog 潜在问题

  • 太死板了,只记录寥寥几个字段,如果用户要记录更多的数据怎么办?
  • 固定采用了 json 作为序列化的方式,要是用户想要用别的序列化方式,例如 protobuf,怎么办?
    • 让用户自己写一个 AccessLog!默认提供的实现是给大多数的普通用户使用,也相当于一个例子,有需要的用户可以参考这个实现写自己的实现。

4.2 Tracing 链路追踪

image.png

Tracing:踪迹,它记录从收到请求到返回响应的整个过程。在分布式环境下,一般代表着请求从 Web 收到,沿着后续微服务链条传递,得到响应再返回到前端的过程

image.png

image.png

需要注意的是,trace 不仅仅包含服务调用信息,实际上整条链路上的信息都可能包含在内,包括:

  • RPC 调用
  • HTTP 调用
  • 数据库查询
  • 丢消息
  • 业务步

这些步骤,也可以进一步被细分,分成更加细的 span。

Tracing相关的几个概念

  • tracer:表示用来记录 trace(踪迹)的实例, 一般来说,tracer 会有一个对应的接口。
  • span:代表 trace 中的一段。因此 trace 本身 也可以看做是一个 span。span 本身是一个层 级概念,因此有父子关系。一个 trace 的 span 可以看做是多叉树

image.png

这里有两个空隙,如果一个空隙很长的话,那么 说明打点不够详细。

用什么 tracing 工具 ?

目前在业界里面, tracing 工具还处于百花齐放阶段, 有很多开源实现。例如SkyWalking、Zipkin、Jeager 等。 那么该怎么支持这些开源框架

  • 每个 tracing 框架一个 Middleware
  • 定义一个统一的 API,允许用户注入自己的 tracing 实现

image.png

4.2.1 自定义 API

一般来说,如果一个中间件设计者想要摆脱对任何第三方的依赖,都会定义自己的 API,常见有:

  • 定义 Log API
  • 定义 Config API
  • 定义 Tracing API
  • 定义 Metrics API

image.png

这种缺点:

  • 过度设计:有些时候这些 API 只有一个默认实现, 而且也没有人愿意提供扩展实现
  • API 设计得并不咋样

如果自己设计不好 API,就别用这种设计方

4.2.1 用 OpenTelemetry API

image.png

采用 OpenTracing API 作为抽象层。 那么所有支持 OpenTracing API 的框架,用户都可 以使用。

4.2.1.1 OpenTelemetry 简介

image.png

image.png

OpenTelemetry 是 OpenTracing 和 OpenCensus 合并而来。

  • OpenTelemetry 同时支持了 logging、tracing 和 metrics
  • OpenTelemetry 提供了各种语言的 SDK
  • OpenTelemetry 适配了各种开源框架,如 Zipkin、Jeager、Prometheus, 是新时代的可观测性平台

4.2.1.2 OpenTelemetry GO SDK 入门

func TestOpenTelemetry(t *testing.T) {
   ctx := context.Background()
   tracer := otel.GetTracerProvider().Tracer(
      "gitee.com/geektime-geekbang/geektime-go/extra/opentelemetry")

   // 如果 ctx 已经和一个 span 绑定了,那么新的 span 就是老的 span 的儿子
   // ctx, span := tracer.Start(ctx, "opentelemetry-demo", trace.WithAttributes())
   ctx, span := tracer.Start(ctx, "opentelemetry-demo",
      trace.WithAttributes(attribute.String("version", "1")))
   defer span.End()

   // 重置名字
   span.SetName("otel-demo")
   span.SetAttributes(attribute.Int("status", 200))
   span.AddEvent("马老师,发生什么事了")

}
  • TracerProvider:用于构造 tracer 实例Provider 在平时开发中也很常见,有时候可以看作是轻量级的工厂模式。
  • tracer:追踪者,也就是用于构造 trace 的东西
    • 构造 tracer 需要一个 instrumentationName, 一般来说就是你构造 tracer 的地方的包名(其 实主要保证唯一就好了)
  • span:调用 tracer 上的 Start 方法。如果传入的 context 里面已经有一个 span 了,那么新创建的 span 就是老的 span 的儿子。span 要记住调用 End

4.2.1.3 OpenTelemetry 与 Zipkin 和 Jeager 的结合

import (
   "go.opentelemetry.io/otel"
   "go.opentelemetry.io/otel/attribute"
   "go.opentelemetry.io/otel/exporters/jaeger"
   "go.opentelemetry.io/otel/exporters/zipkin"
   "go.opentelemetry.io/otel/sdk/resource"
   sdktrace "go.opentelemetry.io/otel/sdk/trace"
   semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
   "log"
   "os"
)

const (
   JeagerURL = "http://127.0.0.1:16686/api/traces"
   ZipkinURL = "http://127.0.0.1:19411/api/v2/spans"
)

func initJeager() {
   exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(JeagerURL)))
   if err != nil {
      panic(err)
   }
   tp := sdktrace.NewTracerProvider(
      // Always be sure to batch in production.
      sdktrace.WithBatcher(exp),
      // Record information about this application in a Resource.
      sdktrace.WithResource(resource.NewWithAttributes(
         semconv.SchemaURL,
         semconv.ServiceNameKey.String("opentelemetry-demo"),
         attribute.String("environment", "dev"),
         attribute.Int64("ID", 1),
      )),
   )
   // 核心就在于构造出一个 TracerProvider,并且调用 otel.SetTracerProvider。
   otel.SetTracerProvider(tp)
}

func initZipkin() {
   exporter, err := zipkin.New(
      ZipkinURL,
      zipkin.WithLogger(log.New(os.Stderr, "opentelemetry-demo", log.Ldate|log.Ltime|log.Llongfile)),
   )
   if err != nil {
      panic(err)
   }

   batcher := sdktrace.NewBatchSpanProcessor(exporter)
   tp := sdktrace.NewTracerProvider(
      sdktrace.WithSpanProcessor(batcher),
      sdktrace.WithResource(resource.NewWithAttributes(
         semconv.SchemaURL,
         semconv.ServiceNameKey.String("opentelemetry-demo"),
      )),
   )
   otel.SetTracerProvider(tp)
}

核心就在于构造出一个 TracerProvider,并且调用 otel.SetTracerProvide

4.2.1.4 OpenTelemetry Middleware

(1) Builder 模式

package opentelemetry

import (
   "go.opentelemetry.io/otel"
   "go.opentelemetry.io/otel/attribute"
   "go.opentelemetry.io/otel/propagation"
   "go.opentelemetry.io/otel/trace"
   web "web/v7"
)

const defaultInstrumentationName = "go/web/middle/opentelemetry"

type MiddlewareBuilder struct {
   Tracer trace.Tracer
}

func NewBuilder() *MiddlewareBuilder {
   return &MiddlewareBuilder{}
}

func (b *MiddlewareBuilder) Build() web.Middleware {
   if b.Tracer == nil {
      b.Tracer = otel.GetTracerProvider().Tracer(defaultInstrumentationName)
   }
   initZipkin()
   return func(next web.HandleFunc) web.HandleFunc {
   
   
   }

可以考虑允许用户指定 tracer。不过这个设计的意义不是特别大,大多数用户都不会设置。

(2) 关联上游

func (b *MiddlewareBuilder) Build() web.Middleware {
   if b.Tracer == nil {
      b.Tracer = otel.GetTracerProvider().Tracer(defaultInstrumentationName)
   }
   initZipkin()
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         // 为了和上游链路连在一起,也就是发起 HTTP 请求的客户端 (关联上下游)
         reqCtx := ctx.Request.Context()
         reqCtx = otel.GetTextMapPropagator().Extract(reqCtx,
            propagation.HeaderCarrier(ctx.Request.Header),
         )
         //  设置各种值
         reqCtx, span := b.Tracer.Start(reqCtx, "unknown", trace.WithAttributes())
         span.SetAttributes(attribute.String("http.method", ctx.Request.Method))
         span.SetAttributes(attribute.String("peer.hostname", ctx.Request.Host))
         span.SetAttributes(attribute.String("http.url", ctx.Request.URL.String()))
         span.SetAttributes(attribute.String("http.scheme", ctx.Request.URL.Scheme))
         span.SetAttributes(attribute.String("span.kind", "server"))
         span.SetAttributes(attribute.String("component", "web"))
         span.SetAttributes(attribute.String("peer.address", ctx.Request.RemoteAddr))
         span.SetAttributes(attribute.String("http.proto", ctx.Request.Proto))
      }
   }
}
  • 首先就是为了和上游链路连在一起,也就是发起 HTTP 请求的客户端,将 ctx.Req.Context 传入 Extract 方法,再传入 Start 方法获得一个新的 span,其实就是上游 span 的儿子
  • 其次是用 SetAttributes 设置各种值

(3) 响应状态

func (b *MiddlewareBuilder) Build() web.Middleware {
   if b.Tracer == nil {
      b.Tracer = otel.GetTracerProvider().Tracer(defaultInstrumentationName)
   }
   initZipkin()
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         // 为了和上游链路连在一起,也就是发起 HTTP 请求的客户端 (关联上下游)
         reqCtx := ctx.Request.Context()
         reqCtx = otel.GetTextMapPropagator().Extract(reqCtx,
            propagation.HeaderCarrier(ctx.Request.Header),
         )
         //  设置各种值
         reqCtx, span := b.Tracer.Start(reqCtx, "unknown", trace.WithAttributes())
         span.SetAttributes(attribute.String("http.method", ctx.Request.Method))
         span.SetAttributes(attribute.String("peer.hostname", ctx.Request.Host))
         span.SetAttributes(attribute.String("http.url", ctx.Request.URL.String()))
         span.SetAttributes(attribute.String("http.scheme", ctx.Request.URL.Scheme))
         span.SetAttributes(attribute.String("span.kind", "server"))
         span.SetAttributes(attribute.String("component", "web"))
         span.SetAttributes(attribute.String("peer.address", ctx.Request.RemoteAddr))
         span.SetAttributes(attribute.String("http.proto", ctx.Request.Proto))

         // span.End 执行之后,就意味着 span 本身已经确定无疑了,将不能再变化了
         defer span.End()
         // 将 带有 链路追踪信息的 reqCtx 设置回request
         ctx.Request = ctx.Request.WithContext(reqCtx)
         next(ctx)

         // 使用命中的路由来作为 span 的名字
         if ctx.MatchedRoute != "" {
            span.SetName(ctx.MatchedRoute)
         }
      }
   }
}

注意这里尝试记录命中的路由 MatchedRoute,而不是整个路径。这是因为 URL 可能非常复杂。 另外还有一个问题:能不能记录状态码?答案是可以的,这就是获取运行结果的 AOP 方案

4.3 获取运行结果

// context.go

type Context struct {
   Req *http.Request
   // Response 原生的 ResponseWriter。当你直接使用 Response 的时候,
   // 那么相当于你绕开了 RespStatusCode 和 RespData。
   // 响应数据直接被发送到前端,其它中间件将无法修改响应
   // 其实我们也可以考虑将这个做成私有的
   Resp http.ResponseWriter

   // 缓存的响应部分
   // 这部分数据会在最后刷新
   RespStatusCode int
   RespData       []byte

}

func (ctx *Context) RespJSONOK(val any) error {
   return ctx.RespJSON(http.StatusOK, val)
}

func (ctx *Context) RespJSON(code int, val any) error {
   bs, err := json.Marshal(val)
   if err != nil {
      return err
   }

   ctx.Response.WriteHeader(code)
   _, err = ctx.Response.Write(bs)
   return err
}
// server.go

func (s *HTTPServer) ServeHTTP(writer http.ResponseWriter, req *http.Request) {
   ctx := &Context{
      Request:   req,
      Response:  writer,
      tplEngine: s.TplEngine,
   }

   // 最后一个应该是 HTTPServer 执行路由匹配,执行用户代码
   root := s.serve
   // 从后往前组装
   for i := len(s.mdls) - 1; i >= 0; i-- {
      root = s.mdls[i](root)
   }

   // 第一个应该是回写响应的
   // 因为它在调用next之后才回写响应,
   // 所以实际上 Response 是最后一个步骤
   var m Middleware = func(next HandleFunc) HandleFunc {
      return func(ctx *Context) {
         next(ctx)
         s.flashResp(ctx)
      }
   }
   root = m(root)
   root(ctx)

   // s.serve(ctx)
}

func (s *HTTPServer) flashResp(ctx *Context) {
   if ctx.RespStatusCode > 0 {
      ctx.Response.WriteHeader(ctx.RespStatusCode)
   }
   _, err := ctx.Response.Write(ctx.RespData)
   if err != nil {
      log.Fatalln("回写响应失败", err)
   }
}

//  \middleware\opentelemetry\tracing.go

func (b *MiddlewareBuilder) Build() web.Middleware {
   if b.Tracer == nil {
      b.Tracer = otel.GetTracerProvider().Tracer(defaultInstrumentationName)
   }
   initZipkin()
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         // 为了和上游链路连在一起,也就是发起 HTTP 请求的客户端 (关联上下游)
         reqCtx := ctx.Request.Context()
         reqCtx = otel.GetTextMapPropagator().Extract(reqCtx,
            propagation.HeaderCarrier(ctx.Request.Header),
         )
         //  设置各种值
         reqCtx, span := b.Tracer.Start(reqCtx, "unknown", trace.WithAttributes())
         span.SetAttributes(attribute.String("http.method", ctx.Request.Method))
         span.SetAttributes(attribute.String("peer.hostname", ctx.Request.Host))
         span.SetAttributes(attribute.String("http.url", ctx.Request.URL.String()))
         span.SetAttributes(attribute.String("http.scheme", ctx.Request.URL.Scheme))
         span.SetAttributes(attribute.String("span.kind", "server"))
         span.SetAttributes(attribute.String("component", "web"))
         span.SetAttributes(attribute.String("peer.address", ctx.Request.RemoteAddr))
         span.SetAttributes(attribute.String("http.proto", ctx.Request.Proto))

         // span.End 执行之后,就意味着 span 本身已经确定无疑了,将不能再变化了
         defer span.End()
         // 将 带有 链路追踪信息的 reqCtx 设置回request
         ctx.Request = ctx.Request.WithContext(reqCtx)
         next(ctx)

         // 使用命中的路由来作为 span 的名字
         if ctx.MatchedRoute != "" {
            span.SetName(ctx.MatchedRoute)
         }

         // 怎么拿到响应的状态呢?比如说用户有没有返回错误,响应码是多少,怎么办?
         span.SetAttributes(attribute.Int("http.status", ctx.RespStatusCode))
      }
   }
}

(1) HTTPServer serve 和 flashResp

// context.go

func (ctx *Context) RespJSON(code int, val any) error {
   bs, err := json.Marshal(val)
   if err != nil {
      return err
   }

   //ctx.Response.WriteHeader(code)
   //_, err = ctx.Response.Write(bs)
   ctx.RespStatusCode = code
   ctx.RespData = bs
   return err
}
// server.go

func (s *HTTPServer) flashResp(ctx *Context) {
   if ctx.RespStatusCode > 0 {
      ctx.Response.WriteHeader(ctx.RespStatusCode)
   }
   _, err := ctx.Response.Write(ctx.RespData)
   if err != nil {
      log.Fatalln("回写响应失败", err)
   }
}

(2) HTTPServer serve、flashResp 和其它 Middleware

image.png

(3) OpenTelemetry Middleware 运行结果

package opentelemetry

import (
   "go.opentelemetry.io/otel"
   "testing"
   "time"
   web "web/v7"
)

func TestMiddlewareBuilder_Build(t *testing.T) {
   tracer := otel.GetTracerProvider().Tracer("")
   testinitZipkin(t)
   s := web.NewHTTPServer()

   s.Get("/", func(ctx *web.Context) {
      ctx.Response.Write([]byte("hello, world"))
   })

   s.Get("/order", func(ctx *web.Context) {
      c, span := tracer.Start(ctx.Request.Context(), "first_layer")
      defer span.End()

      c, second := tracer.Start(c, "second_layer")
      time.Sleep(time.Second)

      c, third1 := tracer.Start(c, "third_layer_1")
      time.Sleep(100 * time.Millisecond)
      third1.End()
      c, third2 := tracer.Start(c, "third_layer_1")
      time.Sleep(300 * time.Millisecond)
      third2.End()
      second.End()
      ctx.RespStatusCode = 200
      ctx.RespData = []byte("hello, world")
   })

   s.Get("/user", func(ctx *web.Context) {
      c, span := tracer.Start(ctx.Request.Context(), "first_layer")
      defer span.End()

      c, second := tracer.Start(c, "second_layer")
      time.Sleep(time.Second)
      c, third1 := tracer.Start(c, "third_layer_1")
      time.Sleep(100 * time.Millisecond)
      third1.End()
      c, third2 := tracer.Start(c, "third_layer_2")
      time.Sleep(300 * time.Millisecond)
      third2.End()
      second.End()
      ctx.RespStatusCode = 200
      ctx.RespData = []byte("hello, world")
   })

   s.Use((&MiddlewareBuilder{Tracer: tracer}).Build())
   s.Start(":8081")
}

(4) OpenTelemetry Middleware 以 Zipkin 为例

image.png

image.png

4.4 Metrics

4.4.1 Prometheus

(1) Prometheus Metrics 类型:

image.png

  • Counter:计数器,统计次数,比如说某件事发生了多少次
  • Gauge:度量,它可以增加也可以减少,比如说当 前正在处理的请求数
  • Histogram:柱状图,对观察对象进行采样,然后 分到一个个桶里面
  • Summary:采样点按照百分位进行统计,比如 99 线、999 线

Prometheus 一般是和 Grafana 结合使用,Grafana 作为数据展示平台。

(2) Prometheus 基本用法

image.png

image.png

image.png

  • Namespace 和 Subsystem 根据公司规范来设定。
  • 记得调用 MustRegister 把观察者注册进去。

(3) Prometheus Verctor 用法

image.png

  • 创建一个 Vector (向量),设置 ConstLabels 和 Labels
  • 使用 WithLabelValues 来获得具体的收集器

(4) Prometheus Middleware

package prometheus

import (
   "github.com/prometheus/client_golang/prometheus"
   "strconv"
   "time"
   web "web/v7"
)

func (m *MiddlewareBuilder) Build() web.Middleware {
   //  创建一个 Vector (向量)其实就是观察者,设置 ConstLabels 和 Labels
   summaryVec := prometheus.NewSummaryVec(prometheus.SummaryOpts{
      Name:        m.Name,
      Subsystem:   m.Subsystem,
      ConstLabels: m.ConstLabels,
      Help:        m.Help,
   }, []string{"pattern", "method", "status"})
   // 记得调用 MustRegister 把观 察者注册进去。
   prometheus.MustRegister(summaryVec)
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         startTime := time.Now()
         next(ctx)
         endTime := time.Now()
         go report(endTime.Sub(startTime), ctx, summaryVec)
      }
   }
}

func report(dur time.Duration, ctx *web.Context, vec prometheus.ObserverVec) {
   status := ctx.RespStatusCode
   route := "unknown"
   if ctx.MatchedRoute != "" {
      route = ctx.MatchedRoute
   }
   // 将响应时间记录成毫秒数
   ms := dur / time.Millisecond

   // 使用 WithLabelValues 来获得具体的收集器
   vec.WithLabelValues(
      route, ctx.Request.Method, strconv.Itoa(status)).Observe(float64(ms))
}

type MiddlewareBuilder struct {
   Name        string
   Subsystem   string
   ConstLabels map[string]string
   Help        string
}

这里将将响应时间记录成毫秒数

4.5 错误处理

func (s *HTTPServer) serve(ctx *Context) {
   mi, ok := s.findRoute(ctx.Request.Method, ctx.Request.URL.Path)
   if !ok || mi.n.handler == nil {
      ctx.Response.WriteHeader(404)
      ctx.Response.Write([]byte("Not Found"))
      return
   }
   ctx.PathParams = mi.pathParams
   // 命中的路由需要缓存起来
   ctx.MatchedRoute = mi.n.route
   mi.n.handler(ctx)
}

通常有一个需求:如果一个响应返回了 404, 那么应该重定向到一个默认页面,比如说重定位到 首页。 具体该怎么处理呢?

这里有一个很棘手的点:不是所有的 404 都是要重定向的。比如说你是异步加载数据的 RESTful 请求,在打开页面之后异步加载用户详情,即便 404 了也不应该重定向。

现在假设允许用户注册不同的状态码的回调,例如说 404 或 500 之类的,步骤如下:

定义 middleware

package errhdl

import (
   web "web/v7"
)

func (m *MiddlewareBuilder) Build() web.Middleware {
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         next(ctx)
         resp, ok := m.resp[ctx.RespStatusCode]
         if ok {
            ctx.RespData = resp
         }
      }
   }
}

// RegisterError 将注册一个错误码,并且返回特定的错误数据
// 这个错误数据可以是一个字符串,也可以是一个页面
func (m *MiddlewareBuilder) RegisterError(code int, resp []byte) *MiddlewareBuilder {
   m.resp[code] = resp
   return m
}

func NewBuilder() *MiddlewareBuilder {
   return &MiddlewareBuilder{
      // 这里可以非常大方,因为在预计中用户会关心的错误码不可能超过 64
      resp: make(map[int][]byte, 64),
   }
}

type MiddlewareBuilder struct {
   resp     map[int][]byte
   redirect map[int]string
}

准备 404 页面模板,并注册错误处理中间件

package errhdl

import (
   "bytes"
   "html/template"
   "testing"
   web "web/v7"
)

func TestNewMiddlewareBuilder(t *testing.T) {
   s := web.NewHTTPServer()
   s.Get("/user", func(ctx *web.Context) {
      ctx.RespData = []byte("hello, world")
   })
   page := `
<html>
   <h1>404 NOT FOUND 我的自定义错误页面</h1>
</html>
`
   tpl, err := template.New("404").Parse(page)
   if err != nil {
      t.Fatal(err)
   }
   buffer := &bytes.Buffer{}
   err = tpl.Execute(buffer, nil)
   if err != nil {
      t.Fatal(err)
   }
   s.Use(NewBuilder().
      RegisterError(404, buffer.Bytes()).Build())

   s.Start(":8081")
}

image.png

4.6 从 panic 中恢复

同样使用 Build 模式构造 middleware

package recovery

import (
   "log"
   "net/http"
   web "web/v7"
)

func (m *MiddlewareBuilder) Build() web.Middleware {
   return func(next web.HandleFunc) web.HandleFunc {
      return func(ctx *web.Context) {
         defer func() {
            if err := recover(); err != nil {
               ctx.RespStatusCode = m.StatusCode
               ctx.RespData = []byte(m.ErrMsg)
               // 万一 LogFunc 也panic,那我们也无能为力了
               m.LogFunc(ctx)
            }
         }()
         // 这里就是before route, before execute
         next(ctx)
         // 这里就是after route, after execute
      }
   }
}

func NewBuilder() *MiddlewareBuilder {
   return &MiddlewareBuilder{
      StatusCode: http.StatusInternalServerError,
      ErrMsg:     "服务器未知错误,请联系管理员!",
      LogFunc: func(ctx *web.Context) {
         log.Println(string(ctx.RespData))
      },
   }
}

type MiddlewareBuilder struct {
   StatusCode int
   ErrMsg     string
   LogFunc    func(ctx *web.Context)
}

使用时提供 Use 方法对目标 middlware 注册

package recovery

import (
   "log"
   "testing"
   web "web/v7"
)

func TestMiddlewareBuilder_Build(t *testing.T) {
   s := web.NewHTTPServer()
   s.Get("/user", func(ctx *web.Context) {
      ctx.RespData = []byte("hello, world")
   })

   s.Get("/panic", func(ctx *web.Context) {
      panic("闲着没事 panic")
   })

   s.Use((&MiddlewareBuilder{
      StatusCode: 500,
      ErrMsg:     "请求 Panic 了",
      LogFunc: func(ctx *web.Context) {
         log.Println(ctx.Request.URL.Path)
      },
   }).Build())

   s.Start(":8081")
}

4.7 思考

4.7.1 要不要考虑时机问题?

例如:要不要设计类似 Beego 那种复杂的在不同阶段运行的 Filter

image.png

这个问题和之前讨论的其它问题都不太一样,是因为用户完全没办法自己支持,只能依赖于框架支持(也就是必须 侵入式地修改框架)。

理论上来说是需要考虑的,但可以推迟到用户真正需要的时候再来评估。 因为大多数场景都是不需要考虑的,已有的设计完全能够满足。

image.png

4.7.2 Middleware 要不要考虑顺序问题

理论上来说,每一个 Middleware 都应该不依赖于其它的 Middleware

但这只是一个美好的愿景,比如现在已经实现的几个 Middleware 里面,Panic 很显然应该在最外层,也就是紧接着 flashResp 那里,错误处理应该在可观测性之后。 又比如,从业务上来看,鉴权应该在很靠前的位置,限流可以在鉴权前面,也可以在鉴权后面,取决于业务……

image.png

4.7.3 Middleware 要不要考虑分路由问题

// UseV1 会执行路由匹配,只有匹配上了的 mdls 才会生效
// 这个只需要稍微改造一下路由树就可以实现
func (s *HTTPServer) UseV1(method string, path string, mdls ...Middleware) {
   s.addRoute(method, path, nil, mdls...)
}

前面所有的 Middleware 都是对所有请求生效的。 但忽略了一个很常见的场景

用户希望区分不同的路由,进行不同的处理。例如:公开页面,用户不需要登录,但是有一些页面,用户就需要登录。

这里可以直接将 UseV1 的功能合并到各个路由注册的功能中, 但这里必须允许 handleFunc 的传参为 nil

// server.go

func (s *HTTPServer) Delete(path string, handleFunc HandleFunc, mdls ...Middleware) {
  s.addRoute(http.MethodDelete, path, handleFunc, mdls...)
}

func (s *HTTPServer) Post(path string, handleFunc HandleFunc, mdls ...Middleware) {
  s.addRoute(http.MethodPost, path, handleFunc, mdls...)
}

func (s *HTTPServer) Get(path string, handleFunc HandleFunc, mdls ...Middleware) {
  s.addRoute(http.MethodGet, path, handleFunc, mdls...)
}
// router.go

func (r *router) findMdls(root *node, segs []string) []Middleware {
   mdls := make([]Middleware, 0, 10)
   // 用来存放节点的队列
   if len(root.mdls) > 0 {
      mdls = append(mdls, root.mdls...)
   }
   queue := []*node{root}
   for _, seg := range segs {
      queueLen := len(queue)
      for i := 0; i < queueLen; i++ {
         cur := queue[0]
         curChilds, curMdls := cur.childMldsOf(seg)
         queue = append(queue, curChilds...)
         mdls = append(mdls, curMdls...)
         queue = queue[1:len(queue)]
      }
   }
   return mdls
}

func (r *router) findAndLoadMdls(root *node) {
   // 用来存放节点的队列
   queue := []*node{root}

   for len(queue) > 0 {
      queueLen := len(queue)
      for i := 0; i < queueLen; i++ {
         cur := queue[0]
         if cur.route != "" {
            segs := strings.Split(strings.Trim(cur.route, "/"), "/")
            cur.matchMdls = r.findMdls(root, segs)
         } else {
            cur.matchMdls = cur.mdls
         }
         curChilds := cur.onlyChildNodesOf(cur.path)
         queue = append(queue, curChilds...)
         queue = queue[1:len(queue)]
      }
   }

}

func (n *node) onlyChildNodesOf(path string) []*node {
   res := make([]*node, 0, 10)
   if n.children != nil && len(n.children) > 0 {
      if n.path == path {
         for _, staticNode := range n.children {
            res = append(res, staticNode)
         }
      } else {
         staticNode, ok := n.children[path]
         if ok {
            res = append(res, staticNode)
         }
      }
   }

   if n.regChild != nil {
      res = append(res, n.regChild)
   }
   if n.paramChild != nil {
      res = append(res, n.paramChild)
   }
   if n.starChild != nil {
      res = append(res, n.starChild)
   }

   return res
}

func (n *node) childMldsOf(path string) ([]*node, []Middleware) {
   res := make([]*node, 0, 10)
   mdls := make([]Middleware, 0, 10)
   if n.children != nil && len(n.children) > 0 {
      staticNode, ok := n.children[path]
      if ok {
         mdls = append(mdls, staticNode.mdls...)
         res = append(res, staticNode)
      }
   }
   if n.regChild != nil {
      mdls = append(mdls, n.regChild.mdls...)
      res = append(res, n.regChild)
   }
   if n.paramChild != nil {
      mdls = append(mdls, n.paramChild.mdls...)
      res = append(res, n.paramChild)
   }
   if n.starChild != nil {
      mdls = append(mdls, n.starChild.mdls...)
      res = append(res, n.starChild)
   }

   return res, mdls
}
// server.go

func (s *HTTPServer) Start(addr string) error {

   linstener, err := net.Listen("tcp", addr)

   if err != nil {
      return err
   }

   for _, root := range s.trees {
      s.findAndLoadMdls(root)
   }

   println("成功监听地址", addr)

   return http.Serve(linstener, s)

   // return http.ListenAndServe(addr, s)

}

五. AOP 总结

  • 什么是 AOP ? AOP 就是面向切面编程,用于解决横向关注点问题,如可观测性问题、安全问题等。
  • 什么是洋葱模式?形如洋葱,拥有一个核心,这个核心一般就是业务逻辑。而后在这个核心外面层层包裹,每一层就是一个 Middleware。一般用洋葱模式来无侵入式地增强核心功能,或者解决 AOP 问题。
  • 什么是责任链模式?不同的 Handler 组成一条链,链条上的每一环都有自己功能。一方面可以用责任链模式将复杂逻辑分成链条上的不同步骤,另外一方面也可以灵活地在链条上添加新的 Handelr。
  • 怎么实现?最简单的方案就函数式方案,还有一种是集中调度的模式。
  • 什么是可观测性?也就是 logging、metrics 和 tracing
  • 常用的可观测性的框架有哪些?你举例自己公司用的,开源的 OpenTelemetry、SkyWalking、 Prometheus
  • 怎么集成可观测性框架?一般都是利用 Middleware 机制,不仅仅是 Web 框架,几乎所有的框架都有 类似 Middleware 的机制
  • Prometheus 的 Histogram 和 Summary 分别是什么?直接解释 Histogram 和 Summary 的概念
  • 全链路追踪(tracing)的几个概念?解释一下 tracer、tracing 和 span 的概念
  • tracing 是怎么构建的?核心在于解释清楚 tracing 进程内和跨进程的运作
  • HTTP 应该观测一些什么数据?也就是我们 OpenTelemetry 和 Promtheus 两个 Middleware 里面写的 那些指标
  • 什么是 99 线、999线?就是响应的比例,99% 的响应、99.9 % 的响应的响应时间在多少以