Go 语言 Middleware模式详解
如果您想在每个请求之前和之后执行一些代码,而不管请求的URL是什么,该怎么办呢?例如,如果您希望记录向服务器发出的所有请求,或者允许跨站调用所有API,或者确保当前用户在调用安全资源的处理程序之前已经过身份验证,该怎么办?我们可以使用中间件处理程序轻松有效地完成所有这些工作。
一个中间件就是一个http.handler对另一个http.handler的封装,实现的功能就是对请求前后做一些处理。称为“中间件”是因为它是作用在web服务器和实际请求处理函数之间的。
logging中间件
为了了解它是如何工作的,让我们构建一个带有日志中间件处理程序的简单web服务器。在$GOPATH/src目录中创建一个新目录,并在该目录中创建一个名为main.go的文件。将以下代码添加到该文件中:
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
)
func HelloHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
func CurrentTimeHandler(w http.ResponseWriter, r *http.Request) {
curTime := time.Now().Format(time.Kitchen)
w.Write([]byte(fmt.Sprintf("the current time is %v", curTime)))
}
func main() {
addr := os.Getenv("ADDR")
mux := http.NewServeMux()
mux.HandleFunc("/v1/hello", HelloHandler)
mux.HandleFunc("/v1/time", CurrentTimeHandler)
log.Printf("server is listening at %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
你可以通过设置ADDR环境变量并使用go run main.go来运行该服务器:
export ADDR=localhost:4000
go run main.go
服务器运行后,可以在浏览器中打开http://localhost:4000/v1/hello以查看HelloHandler()响应,打开http://localhost:4000/v1/time以查看CurrentTimeHandler()响应。
现在,假设我们希望记录向该服务器发起的所有请求,列出请求方法、资源路径以及处理所需的时间。我们可以向每个处理程序函数添加类似的代码,但如果我们可以在一个地方处理日志记录,那就更好了。我们可以使用中间件处理程序来实现这一点。
首先定义一个新结构体实现http.handler接口的ServeHTTP()方法。结构体需要有一个字段来跟踪实际http.handler,实际请求handler将在请求的预处理和后处理之间被调用.
//Logger 是一个打印请求日志的中间件
type Logger struct {
handler http.Handler
}
//ServeHTTP 通过将原请求handler传入然后调用,实现对请求的封装
//处理并打印请求的细节信息
func (l *Logger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
l.handler.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
}
//NewLogger常见Logger中间件实例
func NewLogger(handlerToWrap http.Handler) *Logger {
return &Logger{handlerToWrap}
}
NewLogger()函数以http.handler为参数,封装后返回一个Logger实例。因为http.ServeMux满足http.Handler接口,可以用Logger中间件对整个mux实例进行封装。而且Logger实现了ServeHTTP()函数,所以Logger也实现了http.Handler接口,因此可以取代原来的mux作为http.ListenAndServe()函数传入。修改main()如下:
func main() {
addr := os.Getenv("ADDR")
mux := http.NewServeMux()
mux.HandleFunc("/v1/hello", HelloHandler)
mux.HandleFunc("/v1/time", CurrentTimeHandler)
//用Logger中间件封装mux
wrappedMux := NewLogger(mux)
log.Printf("server is listening at %s", addr)
//使用wrappedMux代替mux作为根处理程序
log.Fatal(http.ListenAndServe(addr, wrappedMux))
}
现在重新启动web服务器并重新请求APIs。因为我们封装了整个mux,所以您应该可以看到打印到终端的所有请求,而不管请求的是哪个资源路径!
链接中间件
因为每个中间件构造函数都接受并返回一个http.handler,您可以将多个中间件处理程序链接在一起。例如,假设我们还想为添加到mux的所有处理程序编写的所有响应添加一个响应头。我们首先创建另一个中间件处理程序。
//ResponseHeader是一个向响应中添加报头的中间件处理程序
type ResponseHeader struct {
handler http.Handler
headerName string
headerValue string
}
//NewResponseHeader构造一个新的ResponseHeader中间件处理程序
func NewResponseHeader(handlerToWrap http.Handler, headerName string, headerValue string) *ResponseHeader {
return &ResponseHeader{handlerToWrap, headerName, headerValue}
}
//ServeHTTP在请求的返回信息中添加响应头
func (rh *ResponseHeader) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//add the header
w.Header().Add(rh.headerName, rh.headerValue)
//调用被封装的请求处理函数
rh.handler.ServeHTTP(w, r)
}
要同时使用上面实现的中间件和logger这两个中间件处理程序,只需将其中一个封装到另一个上即可:
func main() {
//...已有代码...
//用日志中间件和响应头中间件包装整个mux
wrappedMux := NewLogger(NewResponseHeader(mux, "X-My-Header", "my header value"))
log.Printf("server is listening at %s", addr)
//使用wrappedMux代替mux作为根处理程序
log.Fatal(http.ListenAndServe(addr, wrappedMux))
}
您可以通过将每个中间件处理程序封装到其他中间件处理程序之间,将任意多个中间件处理程序链接在一起。当你只有几个中间件处理程序时(这是很典型的),这种方法可以很好地工作,但如果你发现自己添加了很多,你应该尝试Mat Ryer的优秀文章《在#golang中编写中间件以及Go如何让它变得如此有趣》中描述的适配器模式。适配器模式可能很难理解,但它能以一种非常优雅的方式将许多中间件处理程序链接在一起。
中间件和请求范围值
现在让我们考虑一个稍微复杂一点的例子。假设我们有几个处理程序,它们都需要一个经过身份验证的用户,而且假设已经有了一个函数可以返回验证的用户或返回一个错误。如下所示:
func GetAuthenticatedUser(r *http.Request) (*User, error) {
//验证请求中的会话令牌
//从会话中获取会话状态,
//并返回经过验证的用户
//或者如果用户没有经过身份验证,则出现错误
}
func UsersMeHandler(w http.ResponseWriter, r *http.Request) {
user, err := GetAuthenticatedUser(r)
if err != nil {
http.Error(w, "please sign-in", http.StatusUnauthorized)
return
}
//GET = 响应当前用户的配置文件
//PATCH = 更新当前用户的配置文件
}
UserMeHandler()函数需要当前认证用户,因此调用GetAuthenticatedUser()并处理返回的错误。这个可以正常工作,如果我们增加更多的处理函数也需要当前用户该怎么处理呢?我们可以将上面的代码在每个handler都拷贝一份,但重复的代码不是一个好主意。我们可以创建一个中间件确保用户被认证才能调用最终的处理程序。我们可以从定义一个类似于上面的中间件处理程序开始:
type EnsureAuth struct {
handler http.Handler
}
func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, err := GetAuthenticatedUser(r)
if err != nil {
http.Error(w, "please sign-in", http.StatusUnauthorized)
return
}
//TODO: 调用真正的处理程序,但我们如何共享用户?
ea.handler.ServeHTTP(w, r)
}
func NewEnsureAuth(handlerToWrap http.Handler) *EnsureAuth {
return &EnsureAuth{handlerToWrap}
}
ServeHTTP()函数添加了用户认证的代码,如果GetAuthenticatedUser()返回错误,中间件将返回不会调用最终处理函数(handler)。但还有一个问题:如何将user共享给最终处理程序(handler)?
因为这个值和特定请求相关的,不能将其共享给所有其他的请求。因此需要将这个用户存在请求的context上下文中。
请求上下文是在Go1.7版本引入的,支持一些高级技术,但我们这里关心的是存储特定请求范围的值。请求上下文为我们提供了一个存储和检索保留在http.request中的键/值对的位置。由于该对象的新实例是在每个请求开始时创建的,所以我们放入的任何内容都是针对当前请求的。
首先定义需要存储的经过身份验证的用户的键值类型:
type contextKey int
const authenticatedUserKey contextKey = 0
下面在定义的中间件的ServeHTTP()函数中将当前认证的用户添加到请求context中:
func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, err := GetAuthenticatedUser(r)
if err != nil {
http.Error(w, "please sign-in", http.StatusUnauthorized)
return
}
//创建一个包含经过身份验证的用户的新请求上下文
ctxWithUser := context.WithValue(r.Context(), authenticatedUserKey, user)
//使用这个新上下文创建一个新请求
rWithUser := r.WithContext(ctxWithUser)
//调用真正的处理程序,传递新的请求
ea.handler.ServeHTTP(w, rWithUser)
}
请注意,将用户放到请求上下文中涉及到基于当前上下文中创建一个新的上下文,将用户作为值添加到其中,并使用该新上下文中创建一个新的请求对象。然后将新的请求传给正真的handler处理,因此后面的处理函数就能获得用户信息。这确保了当中间件处理程序返回时,链中较早的中间件处理程序看不到这些值。
在handler函数中检索值如下所示:
func UsersMeHandler(w http.ResponseWriter, r *http.Request) {
//从请求上下文中获取经过身份验证的用户
user := r.Context().Value(authenticatedUserKey).(*User)
//使用获取到的用户做一些事情...
}
这里我们使用authenticatedUserKey常量检索到context中的值,但是.value()返回的是一个接口,需要使用断言来拿到实际的类型。
另一种方法
上面用于存储和检索请求特定范围的值的语法看起来有点笨拙,而且它往往会模糊依赖关系,因此一些开发人员反对使用它。他们主张修改需要附加请求作用域值的处理程序的函数签名。如果这些handler处理函数需要这些值,需要明确这些依赖关系,当添加到web服务器mux时,需要一些中间件适配器。例如,我们的UsersMeHandler可以改成如下方式:
func UsersMeHandler(w http.ResponseWriter, r *http.Request, user *User) {
//使用user做一些事情...
}
添加这个额外的参数意味着该函数不再符合HTTP.handler函数签名,因此也不能将其与http.ServeMux使用,我们需要调整它。我们可以使用上面的中间件处理程序的一个修改版本来做到这一点:
//authenticatedHandler 是一个需要用户参数的函数类型
type AuthenticatedHandler func(http.ResponseWriter, *http.Request, *User)
type EnsureAuth struct {
handler AuthenticatedHandler
}
func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, err := GetAuthenticatedUser(r)
if err != nil {
http.Error(w, "please sign-in", http.StatusUnauthorized)
return
}
ea.handler(w, r, user)
}
func NewEnsureAuth(handlerToWrap AuthenticatedHandler) *EnsureAuth {
return &EnsureAuth{handlerToWrap}
}
在这里,我们为AuthenticatedHandler定义了一个新类型,这是一个处理函数,它接受一个*User类型的附加参数。然后,我们更改EnsureAuth中间件,以包装这些经过身份验证的处理程序函数之一,而不是http.Handler。然后,我们的ServeHTTP()方法可以简单地将user作为第三个参数传递给经过身份验证的处理程序函数。
这种方法的一个小缺点是,当我们将EnsureAuth添加到mux时,它现在是一个适配器,必须将每个经过身份验证的处理程序函数包装起来。例如,在main()函数中是这样使用它的:
mux.Handle("/v1/users/", NewEnsureAuth(UsersHandler))
mux.Handle("/v1/users/me", NewEnsureAuth(UsersMeHandler))
与一次包装整个mux不同,我们必须在将每个经过身份验证的处理函数添加到mux时包装它们。这是因为. handlefunc()方法要求函数只有两个参数,而不是三个。
通过创建自己的AuthenticatedServeMux结构,使用接受AuthenticatedHandler函数而不是普通HTTP处理函数的方法,可以克服这个小小的缺点。然后,您可以创建这个经过身份验证的mux实例,将所有经过身份验证的处理程序添加到它中,然后将经过身份验证的mux添加到主服务器mux中。