# 8.03 课后复习-Middleware-AccessLog

对生成器模式不太了解的读者建议先翻看一下[生成器模式](https://github.com/step-by-step-wiki/GoBook/blob/main/PART09.Appendix/%E9%99%84%E5%BD%952.%E7%94%9F%E6%88%90%E5%99%A8%E6%A8%A1%E5%BC%8F.md)

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

`middlewares/accessLog/middlewareBuilder.go`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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. 传递中间件

对函数选项模式不太了解的读者建议先翻看一下[函数选项模式](https://github.com/rayallen20/GoInAction/blob/master/note/%E7%AC%AC2%E5%91%A8-Web%E6%A1%86%E6%9E%B6%E4%B9%8BContext%E4%B8%8EAOP%E6%96%B9%E6%A1%88/PART5.%20Appendix/%E9%99%84%E5%BD%953.%20%E5%87%BD%E6%95%B0%E9%80%89%E9%A1%B9%E6%A8%A1%E5%BC%8F.md)

### 5.1 定义选项函数类型

`option.go`:

```go
package web

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

### 5.2 定义With()函数

`httpServer.go`:

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

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

`httpServer.go`:

```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`:

```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")
}
```


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://go.sai.show/part08.review/8.03-ke-hou-fu-xi-middlewareaccesslog.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
