Golang Web入门(3):如何优雅的设计中间件

栏目: IT技术 · 发布时间: 5年前

内容简介:在上一篇文章中,我们已经可以实现一个性能较高,且支持RESTful风格的路由了。但是,在Web应用的开发中,我们还需要一些可以被扩展的功能。因此,在设计框架的过程中,应该留出可以扩展的空间,比如:日志记录、故障恢复等功能,如果我们把这些业务逻辑全都塞进所以在这篇文章中,我们来探究如何更优雅的设计这些中间件。

摘要

在上一篇文章中,我们已经可以实现一个性能较高,且支持RESTful风格的路由了。但是,在Web应用的开发中,我们还需要一些可以被扩展的功能。

因此,在设计框架的过程中,应该留出可以扩展的空间,比如:日志记录、故障恢复等功能,如果我们把这些业务逻辑全都塞进 Controller / Handler 中,会显得代码特别的冗余,杂乱。

所以在这篇文章中,我们来探究如何更优雅的设计这些中间件。

1 耦合的实现方式

比如我们要实现一个日志记录的功能,我们可以用这种 简单粗暴 的方式:

package main

import (
	"fmt"
	"net/http"
	"time"
)

func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
	record(r.URL.Path)
	fmt.Fprintf(w, "Hello World !")
}

func main() {
	http.HandleFunc("/hello", helloWorldHandler)
	http.ListenAndServe(":8000", nil)
}

func record(path string)  {
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + path)
}

如果这样做的话,确实是实现了我们的目标,记录了访问的日志。

但是,这样一点都不优雅。

每一个 Handler 内部都需要调用 record函数 ,然后再把需要记录的 path 作为参数传进 record函数 中。

如果这样做,不管我们需要添加什么样的额外功能,都必须得把这个额外的功能和我们的业务逻辑牢牢地绑定到一起,不能实现扩展功能与业务逻辑间的解耦。

2 将记录与实现解耦

既然在上面的实现中,记录日志和业务实现完全的耦合在了一起,那么我们能不能把他们的业务实现解耦开来呢?

来看这段代码:

func record(w http.ResponseWriter, r *http.Request)  {
	path := r.URL.Path
	method := r.Method
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + method + " " + path)
}

func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
	record(w ,r)
	fmt.Fprintf(w, "Hello World !")
}

在这里,我们已经把业务实现和日志记录的耦合给解开了一部分。

我们只需要在业务代码中,调用 record(w,r) 函数,把请求的内容作为参数传进 record函数 中,然后在 record 这个方法内记录日志。这个时候,我们可以在方法内部任意的处理请求,保存如请求路径、请求方法等数据。而这个过程, 对业务实现是透明的

这样做的话,我们只需要在处理业务逻辑的 Handler 中调用函数,然后把参数传进去。而这个函数的具体实现,则是 与业务逻辑无关 的。

那么,有没有办法可以把业务逻辑和扩展功能 完全分开 ,让业务代码里只有业务代码,使代码变得更加整洁呢?我们接着往下看。

3 设计中间件

我们在上一篇文章里面,分析了 httprouter 这个包的实现。所以我们直接对他动手,修改他的代码,使得这个路由具有扩展性。

3.1 效果

在此之前,我们来看看效果:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/julienschmidt/httprouter"
)

func Hello(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
	fmt.Fprint(w, "Hello World!\n")
}

func record(w http.ResponseWriter, r *http.Request){
	path := r.URL.Path
	method := r.Method
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + method + " " + path)
}


func main() {
	router := httprouter.New()
	router.AddBeforeHandle(record)
	router.GET("/hello", Hello)
	log.Fatal(http.ListenAndServe(":8080", router))
}

这部分的代码和上一篇的几乎完全一样。也是创建一个路由,将 /hello 这个路径和 Hello 这个处理器绑定在 GET 的这颗前缀树中,然后开始监听8080端口。

这里比较重要的是 main方法 里面的第二行:

router.AddBeforeHandle(record)

从方法名可以看出,这个方法是在Handle之前增加了一个处理过程。

再看看参数,就是我们上面提到的记录访问日志的方法,这个方法记录了请求的 URL ,请求的方法,以及时间。

而在我们的 Hello(w http.ResponseWriter, r *http.Request, _ httprouter.Params) 函数中,已经不包含任何其他的业务逻辑了。

此时,这个 Handler专注于 处理业务逻辑,至于别的, 交给别的函数去实现 。这样,就实现了完全的 解耦

下面我们来看看具体的实现过程:

3.2 具体实现

先来看看 AddBeforeHandle 这个方法:

func (r *Router) AddBeforeHandle(fn func(w http.ResponseWriter, req *http.Request))  {
	r.beforeHandler = fn
}

这个方法很简单,也就是接收一个处理器类型的参数,然后赋值给 Router 中的字段 beforeHandler

这个名为 beforeHandler 字段也是我们新增在 Router 中的,相信你也能看得出来了,所谓的 AddBeforeHandle 方法,就是把我们传进去的处理函数,保存在 Router 中,在需要的时候调用他。

那么我们来看看,什么时候会调用这个方法。下面列出的这个方法,在上一篇文章有提到,是关于 httprouter 是如何处理路由的:

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	...
	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
		    if r.beforeHandler != nil{
				r.beforeHandler(w, req)
			}
			if ps != nil {
				handle(w, req, *ps)
				r.putParams(ps)
			} else {
				handle(w, req, nil)
			}
			return
		} 
	}
    ...
}

注意看,router在找到了Handler,准备执行之前,我们添加了这么几行:

if r.beforeHandler != nil{
	r.beforeHandler(w, req)
}

也就是说,如果我们之前调用了 AddBeforeHandle 方法,给 beforeHandler 这个字段赋了值,那么他就不会为 nil ,然后调用这个函数。这也就实现了我们的目的,在处理请求之前,先执行我们设置的函数。

3.3 思考

现在我们已经实现了一个 完全解耦 的中间件。并且,这个中间件是可以任意配置的。你可以拿来做日志记录,也可以做权限校验等等,而且这些功能还不会对Handler中的业务逻辑造成影响。

如果你是个 Java 开发者,你可能会觉得这个很像 Filter ,或者是 AOP

但是,和过滤器不同的是,我们不仅可以在请求到来之前处理,也可以在请求完成之后处理。比如这个请求发生了一些 panic ,你可以在最后处理它,或者你可以记录这个请求的时间等等,你要做的,只是在 Handle 方法之后,调用你所注册的方法。

比如:

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	...
	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
		    if r.beforeHandler != nil{
				r.beforeHandler(w, req)
			}
			if ps != nil {
				handle(w, req, *ps)
				r.putParams(ps)
			} else {
				handle(w, req, nil)
			}
			if r.afterHandler != nil {
				r.afterHandler(w, req)
			}
			return
		} 
	}
    ...
}

我们只是添加了一个 afterHandler 方法,就是这么的简单。

那么问题来了: 现在这样的处理操作,我们仅仅只能在请求前和请求后各自添加一个中间件。如果我们想要添加任意多个中间件,该怎么做呢?

可以先自己思考一下,然后我们来看看在 gin 中,是怎么实现的。

4 Gin的中间件

4.1 使用

总所周知,在阅读源码之前,一定要先看看他是怎么用的:

package main

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

func Hello(ctx *gin.Context) {
	fmt.Fprint(ctx.Writer, "Hello World!\n")
}

func main() {
	router := gin.New()
	router.Use(gin.Logger(), gin.Recovery())
	router.GET("/hello", Hello)
	router.Run(":8080")
}

可以看到,在 gin 中,使用中间件的方法和上文中我们所设计的是差不多的。都是业务和中间件完全解耦,并且在注册路由的时候,添加进去。

但是我们注意到,在 gin 中是不分 Handle 之前还是 Handle 之后的。那么他是如何做到的呢,我们来看看源码。

4.2 源码解释

先从Use方法看起:

func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
	engine.RouterGroup.Use(middleware...)
	engine.rebuild404Handlers()
	engine.rebuild405Handlers()
	return engine
}

func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
	group.Handlers = append(group.Handlers, middleware...)
	return group.returnObj()
}

在这里我们先不管 group 这个东西,他是路由分组,和我们这篇文章没有关系,我们先不管他。我们只需要看到 append 方法。Use方法就是把参数里面的函数,全部增加到 group.Handlers 中。这里的 group.Handlers ,是一个 Handler 类型的数组。

所以,在gin中,每一个中间件,也是 Handler 类型的。

在上一节我们留了一个问题, 要怎么实现多个中间件 。答案就在这里了,用 数组 保存。

那么问题又来了: 怎么保证调用的顺序呢?

我们继续往下看看路由的注册:

func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
	return group.handle(http.MethodGet, relativePath, handlers)
}

这里是不是也有点熟悉呢?和上一篇文章提到的 httprouter 很相似,我们直接看 group.handle

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
	absolutePath := group.calculateAbsolutePath(relativePath)
	handlers = group.combineHandlers(handlers)
	group.engine.addRoute(httpMethod, absolutePath, handlers)
	return group.returnObj()
}

在这段代码中,第一行关于 path 的我们先不管,这个也是和路由分组有关的,简单来说就是拼接出完整的请求 path

先看看第二行,方法名是 combineHandlers ,我们可以猜测一下这个方法的作用,把各个Handler结合起来。看看详细的代码:

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
	finalSize := len(group.Handlers) + len(handlers)
	if finalSize >= int(abortIndex) {
		panic("too many handlers")
	}
	mergedHandlers := make(HandlersChain, finalSize)
	copy(mergedHandlers, group.Handlers)
	copy(mergedHandlers[len(group.Handlers):], handlers)
	return mergedHandlers
}

先解释一下,这里返回的 HandlersChain 类型,是 Handler 的数组。

也就是说,在这个方法里面,把之前放入group中的中间件,和当前路由的Handler,组合成一个新的数组。

并且,中间件在前面,路由Handler在后面。 注意,这个顺序很重要

然后我们继续往下,执行完这个方法之后执行的就是 addRoute 方法了。在这里不展开讲。所以最重要的是,这里把中间件和Handler全都 组合在了一起 ,绑定到了这个前缀树上。

到了这里注册方面的内容已经结束了,我们来看看他是怎么处理 各个中间件的调用顺序

因为我们的目的是看路由是怎么处理请求的,所以我们直接看 ginServeHTTP 方法:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := engine.pool.Get().(*Context)
	c.writermem.reset(w)
	c.Request = req
	c.reset()

	engine.handleHTTPRequest(c)

	engine.pool.Put(c)
}

这里要注意的是 *Context ,他是对请求的封装,包含了有 responseWriter*http.Request 等。

我们继续往下看看 handleHTTPRequest(c) 这个方法:

func (engine *Engine) handleHTTPRequest(c *Context) {
    httpMethod := c.Request.Method
	rPath := c.Request.URL.Path
	...
    t := engine.trees
	for i, tl := 0, len(t); i < tl; i++ {
		if t[i].method != httpMethod {
			continue
		}
		root := t[i].root
		// Find route in tree
		value := root.getValue(rPath, c.Params, unescape)
		if value.handlers != nil {
			c.handlers = value.handlers
			c.Params = value.params
			c.fullPath = value.fullPath
			c.Next()
			c.writermem.WriteHeaderNow()
			return
		}
		...
	}
	...
}

在这个方法中,其实和之前我们研究的 httprouter 是很相似的。也是先根据 请求方法 找到相对应的前缀树,然后获取相对应的 Handler ,并把获取到的 handler 数组保存在 Context 中。

这里我们注意看c.Next()方法,他是gin中关于中间件的调用 最精妙 的部分。我们来看看:

func (c *Context) Next() {
	c.index++
	for c.index < int8(len(c.handlers)) {
		c.handlers[c.index](c)
		c.index++
	}
}

我们可以看到,当调用这个 Next() 方法的时候,会增加保存在 Context 中的下标,然后根据这个下标的顺序执行 handler

而在前面我们有提到,我们把中间件排在了这个 handler 数组的前面,先执行中间件,然后最后才是执行用户自定义的 handler

我们再来看看日志记录这个中间件:

func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
	...
	return func(c *Context) {
		//开始计时
		start := time.Now()
		path := c.Request.URL.Path
		raw := c.Request.URL.RawQuery

		c.Next()
		...
		// Stop timer
		param.TimeStamp = time.Now()
		param.Latency = param.TimeStamp.Sub(start)
		...
	}
}

可以看到,先开始计时,然后调用了 c.Next() 这个方法,然后才结束计时。

那么我们可以由此推断, c.Next() 后面的代码,是执行完用户自定义的 Handler 才执行的。

Golang Web入门(3):如何优雅的设计中间件

也就是说,其实中间件的业务逻辑是这样的:

func Middleware(c *gin.Context){
    //请求前执行
    c.Next()
    //请求后执行
}

5 写在最后

首先,谢谢你能看到这里。

简单的来讲,我们应该考虑 解耦合 ,使得业务代码可以专注于业务,中间件专注于实现功能。为了实现这点,我们可以修改路由的实现逻辑,在执行 Handler 的前后加入中间件的调用。

在本文中,可能会有很多的疏漏。如果在阅读的过程中,有哪些解释不到位,或者作者的理解出现了一些差错,也请你留言指正。

再次感谢~

PS:如果有其他的问题,也可以在公众号找到作者。并且,所有文章第一时间会在公众号更新,欢迎来找作者玩~

Golang Web入门(3):如何优雅的设计中间件


以上所述就是小编给大家介绍的《Golang Web入门(3):如何优雅的设计中间件》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Think Python

Think Python

Allen B. Downey / O'Reilly Media / 2012-8-23 / GBP 29.99

Think Python is an introduction to Python programming for students with no programming experience. It starts with the most basic concepts of programming, and is carefully designed to define all terms ......一起来看看 《Think Python》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具