同学们好!打开《我家租房网》项目前端服务器工程,可以看到在有关用户退出、获取用户信息、更新用户名等业务的路由处理函数的开始部分,无一例外地都要从Session中读取用户名,如果不能从Session中读到用户名,则判定为用户未登录,返回相应的错误代码和错误描述给浏览器。这不禁让我们想到,也许能够找到某种机制,将一些带有共通性的预操作,安插在路由处理函数被调用之前执行,并根据特定的条件决定,是继续调用路由处理函数,还是直接返回响应给浏览器。这其实就是所谓路由中间件,简称中间件,所要解决的问题。
对于中间件,我们并不陌生,这里不妨先回忆一下之前的课程中,我们在哪里使用过中间件。这节课我们会为大家详细讲解究竟什么是中间件?并结合案例看看Gin框架是怎样支持中间件的?最后,我们会为《我家租房网》项目的前端服务器,增加一个为用户做登录验证的中间件。
我们在之前的课程中,哪里用过中间件呢?
在前端服务器main函数的开始部分,我们以路由器对象为参数,调用了模型层的InitSession函数,用于对Session做初始化。
在该函数的执行过程中,调用了sessions包的Sessions函数,为容器中的Session设置Cookie的键。该函数返回一个处理函数,通过路由器的Use方法将其设置为中间件。这样一来,所有发自浏览器的HTTP请求都会先经由中间件处理函数处理,根据Cookie的键获取其值,解密得到Session的键,再从Session容器中得到与之对应的Session的值,放到上下文中,然后再调用实际的路由处理函数,这时就可以从上下文中获得Session直接使用了。
那么究竟什么是中间件呢?
传统意义上的中间件,是指工作在操作系统和上层应用之间的软件组件,旨在建立内核态的系统服务与用户态的应用程序之间的联系。现代意义上的中间件,已经泛化为任何介于模块与模块之间,起承上启下作用的功能单元。我们这里所说的中间件,专指连接路由器和控制器的媒介。所有来自浏览器的请求,都会被路由器递送给中间件,经过必要的过滤和预处理,再被传送到控制器。控制器返回的响应,也会先经历中间件,再通过路由器返回给浏览器。如果说控制器所提供的是与业务逻辑密切相关的核心功能,那么中间件的作用则更多体现在预处理、过滤、拦截、后处理等辅助功能上。
作为一种静态编译型语言,Go语言在运行时既不依赖任何动态库,也不需要虚拟机或解释器,对环境的要求非常低,其在中间件开发领域具有得天独厚的优势。就象Python、Lua等脚本语言一样,Go经常被作为使用其它语言开发的软件组件的粘合剂。中间件的位置决定了它的作用范围,当信息流过中间件时,中间件的行为只会影响其下游组件,而对其上游组件,不构成任何影响。
那么Gin框架对中间件的开发,都提供了哪些支持呢?
Gin框架的中间件,其本质就是一个函数,其类型为HandleFunc,即所谓中间件处理函数。该函数带有一个上下文参数且没有返回值。该函数由程序开发者定义,但被Gin框架调用。通过路由器对象的Use方法,将中间件安装到路由中。Gin框架会在适当的时机,执行中间件处理函数中的代码。中间件处理函数的行为,会影响所有位于其后的路由处理函数。
下面我们通过一个测试程序,体验一下在Gin框架中使用中间件的方法。在front-end工程目录下的test子目录中创建一个名为middleware.go的文件:
x1package main
2
3import (
4 "github.com/gin-gonic/gin"
5 "log"
6)
7
8func middleware2(contex *gin.Context) {
9 log.Println("中间件2")
10}
11
12func createMiddleware3() gin.HandlerFunc {
13 return func(contex *gin.Context) {
14 log.Println("中间件3")
15 }
16}
17
18func main() {
19 // 初始化路由
20 router := gin.Default()
21
22 // 使用中间件
23 router.Use(func(context *gin.Context) {
24 log.Println("中间件1")
25 })
26
27 // 路由匹配
28 router.GET("/1", func(contex *gin.Context) {
29 log.Println("控制器1")
30 contex.Writer.WriteString("控制器1")
31 })
32
33 // 使用中间件
34 router.Use(middleware2)
35
36 // 路由匹配
37 router.GET("/2", func(contex *gin.Context) {
38 log.Println("控制器2")
39 contex.Writer.WriteString("控制器2")
40 })
41
42 // 使用中间件
43 router.Use(createMiddleware3())
44
45 // 路由匹配
46 router.GET("/3", func(contex *gin.Context) {
47 log.Println("控制器3")
48 contex.Writer.WriteString("控制器3")
49 })
50
51 // 运行
52 router.Run(":8080")
53}
在这段代码中,我们一共定义并使用了三个中间件。第一个中间件以匿名函数的形式,直接放在路由器对象Use方法的参数中。第二个中间件以具名函数的形式先行定义,而后通过函数名引用该函数,并作为路由器对象Use方法的参数。第三个中间件从createMiddleware3函数中返回,同时作为参数传递给路由器对象的Use方法。另外,我们在通过Use方法安装每个中间件之后,又调用了同一个路由器对象的GET方法,分别为“/1”、“/2”和“/3”三个路径,设置了路由处理函数。我们在三个中间件处理函数和三个路由处理函数中都打印了调试日志,以便于观察其被调用的情况。最后通过路由器对象的Run方法运行这个服务器,并启动对当前主机任意IP地址8080端口的监听。
直接在test目录下通过“go run”命令启动测试程序。用浏览器访问localhost:8080/1,可以看到“控制器1”显示在浏览器窗口中。观察测试程序的日志输出,先打印“中间件1”,再打印“控制器1”。
用浏览器访问localhost:8080/2,可以看到“控制器2”显示在浏览器窗口中。观察测试程序的日志输出,依次打印“中间件1”、“中间件2”和“控制器2”。
用浏览器访问localhost:8080/3,可以看到“控制器3”显示在浏览器窗口中。观察测试程序的日志输出,依次打印“中间件1”、“中间件2”、“中间件3”和“控制器3”。
通过这个实验,关于中间件和控制器,不难得出如下结论:安装在同一个路由对象上的多个中间件,依安装顺序,呈线性排列;安装在同一个路由对象上的每个控制器,挂接在最近一个中间件上。传递到每个控制器上的HTTP请求,会依次经历每个安装在它前面的中间件。
中间件和控制器,除了按照这种默认的顺序,依次被执行以外,我们也可以通过某种手段,施加额外的干预。比如,在中间件中,调用上下文对象的Next方法,可以先执行其后的中间件和控制器,待其返回后再执行调用Next方法后面的代码。比如,我们可以把前面代码中的middleware2函数改成这样:
xxxxxxxxxx
71...
2func middleware2(contex *gin.Context) {
3 log.Println("中间件2")
4 contex.Next()
5 log.Println("2件间中")
6}
7...
启动测试程序,用浏览器访问localhost:8080/3,可以看到“2件间中”打印在“控制器3”的后面。
另一种情况是,在中间件中,调用上下文对象的Abort方法,它会在执行完当前中间件后,跳过其后所有的中间件和控制器。比如,我们可以把前面代码中的middleware2函数改成这样:
xxxxxxxxxx
71...
2func middleware2(contex *gin.Context) {
3 log.Println("中间件2")
4 contex.Abort()
5 log.Println("2件间中")
6}
7...
启动测试程序,用浏览器访问localhost:8080/3,可以看到本来应该在执行完中间件2执行的中间件和控制器,现在都不再执行了,但中间件2还是被完整地执行了。
与这两种情况不同的是,如果只是从中间件中简单地返回,被跳过的仅仅是位于return语句后面的代码,提前返回后,依然会继续执行后面的中间件和控制器。比如,我们把前面代码中的middleware2函数改成这样:
xxxxxxxxxx
71...
2func middleware2(contex *gin.Context) {
3 log.Println("中间件2")
4 return
5 log.Println("2件间中")
6}
7...
启动测试程序,用浏览器访问localhost:8080/3,可以看到被跳过的,仅仅是打印“2件间中”一句,而对后续中间件和控制器的执行,与没有return语句时完全一样。
了解了在Gin框架中使用中间件的方法,下面我们将结合具体案例,看看中间件到底有什么实用价值?
在这个案例中,我们希望打印出某个路由处理函数的执行耗时。为此,我们在front-end工程目录下的test子目录中创建一个名为elapsedtime.go的文件:
xxxxxxxxxx
381package main
2
3import (
4 "github.com/gin-gonic/gin"
5 "log"
6 "time"
7)
8
9func elapsedTimeLogger(contex *gin.Context) {
10 start := time.Now()
11
12 contex.Next()
13
14 log.Printf("业务处理耗时:%dms\n", time.Now().Sub(start).Milliseconds())
15}
16
17func businessHandler(contex *gin.Context) {
18 log.Println("业务处理开始")
19
20 time.Sleep(1234 * time.Millisecond)
21 contex.Writer.WriteString("业务处理结果")
22
23 log.Println("业务处理结束")
24}
25
26func main() {
27 // 初始化路由
28 router := gin.Default()
29
30 // 使用中间件
31 router.Use(elapsedTimeLogger)
32
33 // 路由匹配
34 router.GET("/business", businessHandler)
35
36 // 运行
37 router.Run(":8080")
38}
在这段代码中,定义了一个名为elapsedTimeLogger的中间件处理函数。该函数在记录下当前系统时间后,通过上下文对象的Next方法,调用其后的路由处理函数,并在该函数返回后,再次获取系统时间,与起始时间相减,将所得时差作为路由处理函数的执行耗时,以毫秒为单位打印出来。业务处理函数businessHandler,调用time包的Sleep函数,睡眠1234毫秒,模拟业务处理过程所消耗的时间。main函数在获得路由器对象后,先通过它的Use方法安装elapsedTimeLogger中间件,再通过它的GET方法为“/business”路径指定路由处理函数businessHandler,最后通过路由器对象的Run方法运行这个服务器,并启动对当前主机任意IP地址8080端口的监听。
直接在test目录下通过“go run”命令启动测试程序。用浏览器访问localhost:8080/business,可以看到“业务处理结果”显示在浏览器窗口中。观察测试程序的日志输出,显示业务处理耗时1296毫秒。
从上面的案例不难看出,中间件最擅长的,就是在控制器执行之前和之后,做必要的准备和善后工作。具体到《我家租房网》项目,其中的很多业务都需要以用户登录为前提,将有关登录验证的操作放到中间件中,实在是再合适也没有了。
为此,我们先要定义一个用于登录验证的中间件,并将其安装到路由器中。
在front-end工程目录下创建一个名为filter的子目录,并在该子目录下创建user.go文件:
xxxxxxxxxx
201package filter
2
3import (
4 "github.com/gin-gonic/gin"
5 "iHome/front-end/model"
6 "iHome/front-end/utils"
7 "net/http"
8)
9
10func UserLogin(ctx *gin.Context) {
11 // 从Session中读取用户名
12 username := model.ReadUsername(ctx)
13 if username == "" {
14 rsp := make(map[string]string)
15 rsp["errno"] = utils.ERROR_USER_NOT_LOGIN
16 rsp["errmsg"] = utils.StrError(rsp["errno"])
17 ctx.JSON(http.StatusOK, rsp)
18 ctx.Abort()
19 }
20}
定义登录验证中间件处理函数UserLogin,在其中调用模型层的ReadUsername函数,从Session中读取用户名。如果没有读到,则响应“用户未登录”错误,同时通过上下文对象的Abort方法,取消其后所有的中间件和控制器。如果读到了,则正常执行其后的处理流程。
打开在front-end工程目录下的main.go文件,将登录验证中间件安装到路由器中:
xxxxxxxxxx
181...
2func main() {
3 ...
4 // 路由匹配
5 ...
6 apiv10.GET("/imagecode/:uuid", controller.GetImageCode)
7 apiv10.GET("/smscode/:mobile", controller.GetSMSCode)
8 apiv10.POST("/users", controller.UserRegister)
9 apiv10.GET("/areas", controller.GetAreas)
10 apiv10.POST("/sessions", controller.UserLogin)
11 apiv10.Use(filter.UserLogin)
12 apiv10.GET("/session", controller.GetSession)
13 apiv10.DELETE("/session", controller.UserLogout)
14 apiv10.GET("/user", controller.GetUser)
15 apiv10.PUT("/user/name", controller.SetUsername)
16 ...
17}
18...
位于登录验证中间件之前的控制器,如GetImageCode、GetSMSCode、UserRegister、GetAreas、UserLogin等,是不需要验证用户是否登录的,而位于该中间件之后的控制器,如GetSession、UserLogout、GetUser、SetUsername等,则要求用户必须先成功登录。
登录验证中间件的职责,就是拦截未登录用户的请求,使其无法到达那些以用户登录为前提的控制器。因此,我们原先为这些控制器编写的,针对用户未登录的错误处理,在现在看来,已显多余。
打开front-end工程目录下controller子目录中的user.go文件,修改GetSession、UserLogout、GetUser、SetUsername四个函数的代码,删除针对用户未登录的错误处理:
xxxxxxxxxx
871...
2// 获取Session
3func GetSession(ctx *gin.Context) {
4 rsp := make(map[string]interface{})
5 rsp["errno"] = utils.ERROR_OK
6 rsp["errmsg"] = utils.StrError(rsp["errno"].(string))
7
8 // 从Session中读取用户名
9 rsp["data"] = struct {
10 Name string `json:"name"`
11 }{model.ReadUsername(ctx)}
12
13 ctx.JSON(http.StatusOK, rsp)
14}
15
16// 用户退出
17func UserLogout(ctx *gin.Context) {
18 rsp := make(map[string]string)
19 rsp["errno"] = utils.ERROR_OK
20
21 // 从Session中删除用户名
22 if err := model.DeleteUsername(ctx); err != nil {
23 fmt.Println(err)
24 rsp["errno"] = utils.ERROR_USER_LOGOUT
25 }
26
27 rsp["errmsg"] = utils.StrError(rsp["errno"])
28 ctx.JSON(http.StatusOK, rsp)
29}
30
31// 获取用户信息
32func GetUser(ctx *gin.Context) {
33 // 从Session中读取用户名
34 username := model.ReadUsername(ctx)
35
36 // 调用微服务
37 srv := micro.NewService(micro.Registry(consul.NewRegistry()))
38 srv.Init()
39 c := pbGetUser.NewGetUserService("getuser", srv.Client())
40 rsp, err := c.Call(context.Background(), &pbGetUser.CallRequest{
41 Username: username,
42 })
43 if err != nil {
44 fmt.Println(err)
45 return
46 }
47
48 ctx.JSON(http.StatusOK, rsp)
49}
50
51// 更新用户名
52func SetUsername(ctx *gin.Context) {
53 // 从Session中读取原用户名
54 oldUsername := model.ReadUsername(ctx)
55
56 // 获取新用户名
57 req := struct {
58 NewUsername string `json:"name"`
59 }{}
60 ctx.Bind(&req)
61
62 // 调用微服务
63 srv := micro.NewService(micro.Registry(consul.NewRegistry()))
64 srv.Init()
65 c := pbSetUsername.NewSetUsernameService("setusername", srv.Client())
66 rsp, err := c.Call(context.Background(), &pbSetUsername.CallRequest{
67 OldUsername: oldUsername,
68 NewUsername: req.NewUsername,
69 })
70 if err != nil {
71 fmt.Println(err)
72 return
73 }
74
75 // 如果更新成功
76 if rsp.Errno == utils.ERROR_OK {
77 // 则将新用户名保存到Session中
78 if err = model.SaveUsername(ctx, rsp.Data.Name); err != nil {
79 fmt.Println(err)
80 rsp.Errno = utils.ERROR_USER_LOGIN
81 rsp.Errmsg = utils.StrError(rsp.Errno)
82 }
83 }
84
85 ctx.JSON(http.StatusOK, rsp)
86}
87...
在这四个函数中,不再针对用户是否登录进行验证,而是假设用户已经成功登录,因为如果用户未登录,登录验证中间件会进行拦截,并向浏览器返回有关用户未登录的错误响应。当需要获取当前登录用户的用户名时,可以直接调用模型层的ReadUsername函数,且不对其返回的用户名字符串做非空检测,登录验证中间件保证了所获得用户名的有效性。
在完成这部分编码工作以后,我们需要对登录验证中间件进行测试。
启动前端服务器。用浏览器打开默认主页,如果当前已经是登录状态,退出登录。在未登录状态,直接打开用户页面“my.html”,观察浏览器调试窗格中针对user路径的响应体。关于用户未登录的错误信息,即来自登录验证中间件。
谢谢大家,我们下节课再见!