HTTP路由器)负责侦听HTTP请求并根据匹配条件(例如HTTP方法或URL)调用适当的处理程序。
Golang提供了一个非常简单的路由器ServeMux。但它太基础简单,所以大家一般都会选择第三方路由模块,比如gorilla/mux。
今天我们来学习下如何从零自己构建一个HTTP路由。
概述
一个HTTP路由器主要负责以下几件事:
404处理程序:为不匹配的请求提供404响应
匹配:匹配URL路径和HTTP方法并调用路由处理程序
参数:提取动态网址参数,例如/users/(?P\d+)
紧急恢复:赶上紧急情况并回复500
下面是一个代码片段,展示了上述的所有功能:
r := NewRouter()r.Route("GET", "/", homeRoute)r.Route("POST", "/users", createUserRoute)r.Route("GET", "/users/(?P\d+)", getUserRoute)r.Route("GET", "/panic", panicRoute)http.ListenAndServe("localhost:8000", r)
基本路由
首先,我们构建一个路由,该路由负责响应无效请求,并返回404响应。
路由器处理进入Web服务器的每个HTTP请求,可以通过将其传递到Golang的http.ListenAndServe方法中来完成。ListenAndServe的第二个参数是http.Handler,它负责处理每个传入的请求。为了实现这一点,我们的路由器将需要实现该Handler接口。
Handler只声明一个方法,ServeHTTP所以我们创建一个结构来匹配它。
type Router struct {}func (sr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {http.NotFound(w, r)}
这样就有一种可以在任何http.Handler接受的地方使用的路由类型。把加入到可运行的程序中httper.go。
package httperimport "net/http"func main() {r := &Router{}http.ListenAndServe(":8000", r)}type Router struct{}func (sr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {http.NotFound(w, r)
从命令行运行该程序go run httper.go,然后就可以通过Web浏览器中打开127.0.0.1:8000,验证其是否响应”404页面未找到”。
路由匹配
一个总是返回404请求的路由并什么太多用处。我们继续修改路由以便可以匹配的列表。
对于每个传入请求,需要执行以下操作:
从请求中提取HTTP方法和URL路径;
检查是否存在与方法和路径匹配的路由;
匹配时调用它;
如果找不到匹配项,则返回404。
为此,为每条路由需要保存这些信息:路由的HTTP方法,路由的路径以及如果找到匹配项,则调用的处理函数。我们创建一个结构RouteEntry来将存储在他们。
type RouteEntry struct {Path stringMethod stringHandler http.HandlerFunc}
还需要更新Router以存储的列表RouteEntry。为了改善使用路由的体验,我们添加一个名为helper的辅助功能Route来完成这项工作。路由功能将创建一个新路由RouteEntry并将其添加到路由列表中。
type RouteEntry struct {Path stringMethod stringHandler http.HandlerFunc}type Router struct {routes []RouteEntry}func (rtr *Router) Route(method, path string, handlerFunc http.HandlerFunc) {e := RouteEntry{Method: method,Path: path,HandlerFunc: handlerFunc,}rtr.routes = append(rtr.routes, e)}
最后,编写逻辑以检查传入的请求并找到匹配的路由。
匹配逻辑有两个明显的地方:Router本身还是RouteEntry。这些位置中的任何一个都可以使用,但是使用RouteEntry匹配负责是明智的,因为它存储了要匹配的条件。
我们给RouteEntry结构添加一个Match方法。由于基于请求的信息进行匹配,因此将request作为参数。为了表明匹配成功,将让它返回一个布尔值。
func (re *RouteEntry) Match(r *http.Request) bool {if r.Method != re.Method {return false }if r.URL.Path != re.Path {return false }
return true
}
现在,路由器所需要做的就是遍历所有路由,并检查其中是否有匹配请求。
func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {for _, e := range rtr.routes {match := e.Match(r)if !match {continue}e.HandlerFunc.ServeHTTP(w, r)return}http.NotFound(w, r)}
为了确保所有操作都能正常进行,新添加一条简单的路由来处理。
r := &Router{}r.Route("GET", "/", func(w http.ResponseWriter, r *http.Request) {w.Write([]byte("Hello,Chongchong!"))})
当加入这些代码,然后go run httper.go。可以通过浏览器访问127.0.0.1:8000/来验证其是否有效。应该看到它以”Hello,Chongchong!”回应。任何其路径会返回404响应。
提取路由参数
现在,有了一个基本实用的HTTP路由器。我们进一步添加功能充实它。常用的系统处理API中都会涉及增删改查(CRUD)的动态参数的定义的路由。例如,URL通过ID获取用户的路由,可能的路径为/users/10 ,其中10为用户ID。在当前的路由器中,如果一个一个的为每个可能的用户ID都定义一个路由显然是冗杂和不必要的。实际上需要的是一种定义带有动态路径的方法/users/?。
为了执行动态匹配,需要使用利器——正则表达式。
访问参数
不过,在深入探讨正则表达式之前,先讨论一下路由处理程序将如何访问提取的参数。一个fetchUserRoute将需要能够从URL中提取ID来获取正确的用户。
幸运的是,Golang提供了一种机制,可以将短暂的数据存储在称为context的请求对象上。用这种机制,路由器可以将参数添加到请求上下文中,以供处理程序在调用时读取。
下面是处理程序如何访问参数的示例。注意,由于访问请求上下文中的内容有点麻烦,因此又创建一个了辅助函数来减少重复。
r.Route("GET", `/hello/(?P\w+)`, func(w http.ResponseWriter, r *http.Request) {message := URLParam(r, "Message")w.Write([]byte("Hello " + message))})func URLParam(r *http.Request, name string) string {ctx := r.Context()params := ctx.Value("params").(map[string]string)return params[name]}
用正则匹配
将把参数存储在中map[string]string,其中映射中的每个键都是参数名称,而值是从URL中提取的值。正则表达式已命名了适合此用例的组。在Golang中,可以使用FindStringSubmatch方法匹配这些命名组。
r := regexp.MustCompile(`/books/(?P\d+)/(?P\d+)`,)match := r.FindStringSubmatch("/books/123/456")if match == nil {return}fmt.Println(match) // [123, 456]fmt.Println(r.SubexpNames()) // [AuthorID, BookID]
保存网址参数
知道如何匹配正则表达式组,我们将可以更新RouteEntry结构的匹配逻辑以使用它们。为此,需要将Path属性从字符串更改为Regexp类型。然后,需要更新Match方法逻辑。
type RouteEntry struct {Path *regexp.RegexpMethod stringHandlerFunc http.HandlerFunc}func (ent *RouteEntry) Match(r *http.Request) map[string]string {match := ent.Path.FindStringSubmatch(r.URL.Path)if match == nil {return nil }params := make(map[string]string)groupNames := ent.Path.SubexpNames()for i, group := range match {params[groupNames[i]] = group}return params}
注意,上面还更改了的签名Match以返回参数映射,而非布尔值。
最后需要做的一件事是更新路由器逻辑,以在找到匹配项后将参数添加到请求上下文中。
for _, e := range rtr.routes {params := e.Match(r)if params == nil {continue }ctx := context.WithValue(r.Context(), "params", params)e.HandlerFunc.ServeHTTP(w, r.WithContext(ctx))return}
我们在程序中添加这些部分,然后测试:
Panic恢复
添加动态URL参数极大地提高了路由器的实用性。现在可以将其在一些项目中使用。为了防止生产中发生坏事,应该增加另外一件事,那就是紧急恢复。
当前,如果路由处理程序之一出现紧急情况,服务器将返回一个空响应,而不是默认页面。将添加以下几行代码来捕获这些紧急情况并返回适当的500(内部服务器错误)状态代码。
func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {defer func() {if r := recover(); r != nil {log.Println("ERROR:", r) http.Error(w, "发生错误…", http.StatusInternalServerError)}}()// ...}
为了测试它是否有效,我们添加一条特殊的/panic路由来触发该恢复逻辑。
r.Route("GET", "/panic", func(w http.ResponseWriter, r *http.Request) {panic("something bad happened!")})
测试访问 127.0.0.1:8000/panic,就会返回 Uh oh!
总结
本我们实例介绍了如何使用Golang语言的标准库,从头开始构建一个路由器,当然我们构建的路由器仅仅为HTTP路由原理说明、练手和好玩,不建议在生产环境使用!在生产中使用建议使用成熟的类库,比如gorilla/mux。