摘要

上一篇文章中,我们已经可以实现一个性能较高,且支持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才执行的。

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

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

5 写在最后

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

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

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

再次感谢~

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

Golang Web入门(3):如何优雅的设计中间件的更多相关文章

  1. Golang Web入门(4):如何设计API

    摘要 在之前的几篇文章中,我们从如何实现最简单的HTTP服务器,到如何对路由进行改进,到如何增加中间件.总的来讲,我们已经把Web服务器相关的内容大概梳理了一遍了.在这一篇文章中,我们将从最简单的一个 ...

  2. Golang Web入门(1):自顶向下理解Http服务器

    摘要 由于Golang优秀的并发处理,很多公司使用Golang编写微服务.对于Golang来说,只需要短短几行代码就可以实现一个简单的Http服务器.加上Golang的协程,这个服务器可以拥有极高的性 ...

  3. Golang Web入门(2):如何实现一个高性能的路由

    摘要 在上一篇文章中,我们聊了聊在Golang中怎么实现一个Http服务器.但是在最后我们可以发现,固然DefaultServeMux可以做路由分发的功能,但是他的功能同样是不完善的. 由Defaul ...

  4. golang web框架设计7:整合框架

    把前面写好的路由器,控制器,日志,都整合在一起 全局变量和初始化 定义一些框架的全局变量 var ( BeeApp *App AppName string AppPath string StaticD ...

  5. golang web框架设计6:上下文设计

    context,翻译为上下文,为什么要设计这个结构?就是把http的请求和响应,以及参数结合在一起,便于集中处理信息,以后框架的扩展等.好多框架比如gin,都是有这个上下文结构. context结构为 ...

  6. golang web框架设计5:配置设计

    配置信息的解析,实现的是一个key=value,键值对的一个配置文件,类似于ini的配置格式,然后解析这个文件,把解析的数据保存到map中,最后调用的时候通过几个string,int之类的函数返回相应 ...

  7. golang web框架设计4:日志设计

    beego的日志设计思路来自于seelog,根据不同的level来记录日志,beego设计的日志是一个轻量级的,采用系统log.Logger接口,默认输出到os.Stdout,用户可以实现这个接口然后 ...

  8. golang web框架设计3:controller设计

    继续学习golang web框架设计 controller作用 MVC设计模式里面的这个C,控制器. Model是后台返回的数据: View是渲染页面,通常是HTML的模板页面: Controller ...

  9. golang web框架设计2:自定义路由

    继续学习谢大的Go web框架设计 HTTP路由 http路由负责将一个http的请求交到对应的函数处理(或者一个struct的方法),路由在框架中相当于一个事件处理器,而这个时间包括 用户请求的路径 ...

随机推荐

  1. [模拟] Codeforces - 1191C - Tokitsukaze and Discard Items

    Tokitsukaze and Discard Items time limit per test 1 second memory limit per test 256 megabytes input ...

  2. 使用Jenkins与Docker持续集成与发布NetCore项目(实操篇)

    使用Jenkins与Docker持续集成与发布NetCore项目(教程一) 原文地址:https://www.cnblogs.com/Jackyye/p/12588182.html 基本环境 该教程的 ...

  3. uni-app实现文件上传(h5方式)

    1.嵌入H5页面,需要采用web-view标签,如下: <web-view src="/hybrid/html/index.html" @message="hand ...

  4. 详解firewalld 和iptables

    在RHEL7里有几种防火墙共存:firewalld.iptables.ebtables,默认是使用firewalld来管理netfilter子系统,不过底层调用的命令仍然是iptables等. fir ...

  5. SpringMVC常见面试题总结(超详细回答)

    SpringMVC常见面试题总结(超详细回答) 1.什么是Spring MVC ?简单介绍下你对springMVC的理解? Spring MVC是一个基于Java的实现了MVC设计模式的请求驱动类型的 ...

  6. 使用RandomString方法后,结果返回相同的随机数解决办法

    所遇问题: 在做超市管理系统的登录项目时,在对“随机数的产生”出现一个问题,在产生多个随机数的时候,出现了产生了多个一样的随机数,具体代码如下: /// <summary> /// 生成随 ...

  7. Unix 下 使用 RVM 管理 Ruby 和 gem

    转载:http://www.ibm.com/developerworks/cn/aix/library/au-aix-manage-ruby/   尽管 Internet Relay Chat.论坛和 ...

  8. centos默认终端bash美化、颜色设置

    centos默认终端bash是一个很简单的界面,又无法通过像zsh一样直接安装主题和代码高亮插件,但是我们可以在bashrc的配置文件中通过代码实现一部分功能: 1.代码介绍: 这里推荐一篇大佬的文章 ...

  9. 1036 Boys vs Girls (25分)(水)

    1036 Boys vs Girls (25分)   This time you are asked to tell the difference between the lowest grade o ...

  10. 前端笔记(使用html\css\jquery造n*n的格子,根据预算购买到最多的商品)

    需求:创建一个n*n的格子,n是输入框的数字,点击重渲染可以重新画一个n*n的格子,鼠标移入格子中,对应的格子背景设变成红色,点击对应的格子,背景色变成蓝色,再点一次还原颜色. 要造格子有好几种方式, ...