8.03 课后复习-Middleware-AccessLog

对生成器模式不太了解的读者建议先翻看一下生成器模式

PART1. 使用生成器模式创建Middleware

middlewares/accessLog/middlewareBuilder.go:

package accessLog

import "web"

// AccessMiddlewareBuilder 日志中间件构建器
type AccessMiddlewareBuilder struct{}

// Build 本方法用于构建一个日志中间件
func (b *AccessMiddlewareBuilder) Build() web.Middleware {
	return func(next web.HandleFunc) web.HandleFunc {
		return func(ctx *web.Context) {
			next(ctx)
			// 在这里记录日志 例如:命中的路由/HTTP动词/请求参数等
		}
	}
}

Tips:相比于示例,这里只是没有定义IBuilder接口,本质上还是为了调用不同的Build()函数能够得到不同"特征"(或者也可以说是不同功能)的中间件.我个人觉得这里定义接口,意义不大.原因:这个场景下没有示例中的Director类.换言之,这个场景下没有哪个类负责"编排中间件的构建过程",因为每个中间件在实例化时所需的字段或函数是不同的,没有办法让某个类去针对所有的中间件统一完成这个过程

PART2. 定义中间件结构

记录如下内容:

  • 请求主机地址

  • 命中的路由

  • 请求的HTTP动词

  • 请求的uri(也就是路径)

middlewares/accessLog/accessLog.go:

package accessLog

// accessLog 本结构体用于定义日志内容
type accessLog struct {
	Host       string `json:"host,omitempty"`        // Host 请求的主机地址
	Route      string `json:"route,omitempty"`       // Route 命中的路由
	HTTPMethod string `json:"http_method,omitempty"` // HTTPMethod 请求的HTTP动词
	Path       string `json:"path,omitempty"`        // Path 请求的路径 即:uri
}

PART3. 获取要记录的字段值

3.1 基本实现

middlewares/accessLog/middlewareBuilder.go:

package accessLog

import "web"

// AccessMiddlewareBuilder 日志中间件构建器
type AccessMiddlewareBuilder struct{}

// Build 本方法用于构建一个日志中间件
func (b *AccessMiddlewareBuilder) Build() web.Middleware {
	return func(next web.HandleFunc) web.HandleFunc {
		return func(ctx *web.Context) {
			// 构建日志内容
			defer func() {
				log := accessLog{
					Host:       ctx.Req.Host,
					Route:      "",
					HTTPMethod: ctx.Req.Method,
					Path:       ctx.Req.URL.Path,
				}
				
				// TODO: 写入日志
			}()
			next(ctx)
		}
	}
}

这里把记录的日志放在defer中的原因:

  1. 直接写在next(ctx)之后不合适,因为next(ctx)意味着执行责任链上的后续中间件.在后续中间件的执行过程中有可能出现panic,那样的话整个进程就结束了,因此写在next(ctx)之后的代码也就无法被执行

  2. 写在next(ctx)之前,也不太合适.2个原因:

    • 如果请求没有命中任何路由,有可能就不需要记录日志了,写在next(ctx)之前就意味着无论是否命中路由都记录日志

    • 如果在记录日志的过程中出现panic,就会因为这些非关键流程影响到关键流程

因此写在defer中最合适

3.2 记录命中的路由

这里的问题在于,不能直接从命中的节点上取路由,因为命中节点上的路由只是全路由中的最后一段,而非全路由.因此这里修改的思路是:

  • 新增路由时,在对应的节点(即HandleFunc所在的节点)上记录全路由

  • 匹配路由时,查找到对应节点后,将节点的全路由记录到Context上

step1. Context结构体增加用于记录命中的路由的字段

context.go:

// Context HandleFunc的上下文
type Context struct {
	Req        *http.Request       // Req 请求
	Resp       http.ResponseWriter // Resp 响应
	PathParams map[string]string   // PathParams 路径参数名值对
	queryValue url.Values          // queryValue 查询参数名值对
	MatchRoute string              // MatchRoute 请求命中的路由
}

step2. Node结构体中增加用于记录命中该节点时的全路由的字段

node.go:

package web

import (
	"fmt"
	"strings"
)

// node 路由树的节点
type node struct {
	path          string           // path 当前节点的路径
	children      map[string]*node // children 子路由路径到子节点的映射
	wildcardChild *node            // wildcardChild 通配符子节点
	paramChild    *node            // paramChild 参数子节点
	HandleFunc                     // HandleFunc 路由对应的业务逻辑
	fullRoute     string           // fullRoute 命中该节点时的完整路由
}

step3. 添加节点时记录全路由

在原先基础上,创建节点并设置HandleFunc后,设置全路由即可

router.go:

// addRoute 注册路由到路由森林中的路由树上
func (r *router) addRoute(method string, path string, handleFunc HandleFunc) {
	if path == "" {
		panic("web: 路由不能为空字符串")
	}

	if path[0] != '/' {
		panic("web: 路由必须以 '/' 开头")
	}

	if path != "/" && path[len(path)-1] == '/' {
		panic("web: 路由不能以 '/' 结尾")
	}

	root, ok := r.trees[method]
	if !ok {
		root = &node{
			path: "/",
		}
		r.trees[method] = root
	}

	if path == "/" {
		if root.HandleFunc != nil {
			panic("web: 路由冲突,重复注册路由 [/] ")
		}
		root.HandleFunc = handleFunc

		// 记录节点的全路由
		root.fullRoute = path
		return
	}

	path = strings.TrimLeft(path, "/")
	segments := strings.Split(path, "/")

	target := root
	for _, segment := range segments {
		if segment == "" {
			panic("web: 路由中不得包含连续的'/'")
		}

		child := target.childOrCreate(segment)
		target = child
	}

	if target.HandleFunc != nil {
		panic(fmt.Sprintf("web: 路由冲突,重复注册路由 [%s] ", path))
	}

	target.HandleFunc = handleFunc

	// 记录节点的全路由
	target.fullRoute = path
}

step4. 查找到节点后将节点的全路由赋值给Context

httpServer.go:

// serve 查找路由树并执行命中的业务逻辑
func (s *HTTPServer) serve(ctx *Context) {
	method := ctx.Req.Method
	path := ctx.Req.URL.Path
	targetNode, ok := s.findRoute(method, path)
	if !ok || targetNode.node.HandleFunc == nil {
		ctx.Resp.WriteHeader(http.StatusNotFound)
		_, _ = ctx.Resp.Write([]byte("Not Found"))
		return
	}
	
	ctx.PathParams = targetNode.pathParams
	
	// 命中节点则将节点的全路由设置到上下文中
	ctx.MatchRoute = targetNode.node.fullRoute
	
	targetNode.node.HandleFunc(ctx)
}

step5. 中间件中从Context中取值即可

middlewares/accessLog/middlewareBuilder.go:

package accessLog

import "web"

// AccessMiddlewareBuilder 日志中间件构建器
type AccessMiddlewareBuilder struct{}

// Build 本方法用于构建一个日志中间件
func (b *AccessMiddlewareBuilder) Build() web.Middleware {
	return func(next web.HandleFunc) web.HandleFunc {
		return func(ctx *web.Context) {
			// 构建日志内容
			defer func() {
				log := accessLog{
					Host:       ctx.Req.Host,
					Route:      ctx.MatchRoute,
					HTTPMethod: ctx.Req.Method,
					Path:       ctx.Req.URL.Path,
				}

				// TODO: 写入日志
			}()
			next(ctx)
		}
	}
}

PART4. 写日志操作

4.1 定义记录日志的函数

这里不要直接写死(写成log.Print()之类的),因为框架的使用者不一定想要以这种方式记录日志.这里的关键点在于:把定义记录日志过程的能力,交给框架的使用者

middlewares/accessLog/middlewareBuilder.go:

package accessLog

import (
	"encoding/json"
	"web"
)

// AccessMiddlewareBuilder 日志中间件构建器
type AccessMiddlewareBuilder struct {
	logFunc func(content string) // logFunc 用于记录日志的函数
}

// SetLogFunc 本方法用于设置记录日志的函数
func (b *AccessMiddlewareBuilder) SetLogFunc(logFunc func(string)) {
	b.logFunc = logFunc
}

4.2 调用记录日志的函数

middlewares/accessLog/middlewareBuilder.go:

package accessLog

import (
	"encoding/json"
	"web"
)

// AccessMiddlewareBuilder 日志中间件构建器
type AccessMiddlewareBuilder struct {
	logFunc func(content string) // logFunc 用于记录日志的函数
}

// SetLogFunc 本方法用于设置记录日志的函数
func (b *AccessMiddlewareBuilder) SetLogFunc(logFunc func(string)) {
	b.logFunc = logFunc
}

// Build 本方法用于构建一个日志中间件
func (b *AccessMiddlewareBuilder) Build() web.Middleware {
	return func(next web.HandleFunc) web.HandleFunc {
		return func(ctx *web.Context) {
			// 构建日志内容
			defer func() {
				log := accessLog{
					Host:       ctx.Req.Host,
					Route:      ctx.MatchRoute,
					HTTPMethod: ctx.Req.Method,
					Path:       ctx.Req.URL.Path,
				}

				// 记录日志
				logJsonBytes, _ := json.Marshal(log)
				b.logFunc(string(logJsonBytes))
			}()
			next(ctx)
		}
	}
}

PART5. 传递中间件

对函数选项模式不太了解的读者建议先翻看一下函数选项模式

5.1 定义选项函数类型

option.go:

package web

// Option 本类型为 HttpServer 的选项函数
// 本类型的每个不同实例均用于修改 HttpServer 的不同字段值
type Option func(server *HTTPServer)

5.2 定义With()函数

httpServer.go:

// ServerWithMiddlewares 本函数用于根据给定的 Middleware 列表,创建
// 修改 HttpServer 实例的 middlewares 字段值的选项函数
func ServerWithMiddlewares(middlewares ...Middleware) Option {
	return func(server *HTTPServer) {
		server.middlewares = middlewares
	}
}

5.3 实例化HttpServer时根据选项函数修改成员属性的值

httpServer.go:

// NewHTTPServer 根据给定的 Option 列表(每个 Option 均表示要修改一个 HttpServer 的成员属性),创建HTTP服务器
func NewHTTPServer(options ...Option) *HTTPServer {
	httpServer := &HTTPServer{
		router: newRouter(),
	}

	for _, option := range options {
		option(httpServer)
	}

	return httpServer
}

Tips:相比于示例,这里也没有使用IOption接口,因为意义也不大.面向接口编程本质上是为了面向客户端代码时隐藏具体实现,这里我认为如果定义了IOption接口,事情反而更加复杂了,因为你还要定义这个接口的各种不同实现,例如MiddlewareOption,PortOption(这里我们假定HTTPSserver还有一个名为port的字段)等

PART6. 测试

middleware_test.go:

package accessLog

import (
	"fmt"
	"testing"
	"web"
)

// Test_Middleware 本函数用于测试 accessLog 是否工作正常
func Test_Middleware(t *testing.T) {
	// step1. 创建中间件
	logFunc := func(content string) {
		fmt.Printf("%#v\n", content)
	}
	middlewareBuilder := AccessMiddlewareBuilder{
		logFunc: logFunc,
	}
	accessLogMiddleware := middlewareBuilder.Build()

	// step2. 创建HTTPServer
	middlewareOption := web.ServerWithMiddlewares(accessLogMiddleware)
	httpServer := web.NewHTTPServer(middlewareOption)

	// step3. 启动HTTPServer
	handleFunc := func(ctx *web.Context) {
		ctx.Resp.Write([]byte("hello"))
	}
	httpServer.GET("/user/show", handleFunc)

	httpServer.Start(":8081")
}

Last updated