简介

gorilla/mux是 gorilla Web 开发工具包中的路由管理库。gorilla Web 开发包是 Go 语言中辅助开发 Web 服务器的工具包。它包括 Web 服务器开发的各个方面,有表单数据处理包gorilla/schema,有 websocket 通信包gorilla/websocket,有各种中间件的包gorilla/handlers,有 session 管理包gorilla/sessions,有安全的 cookie 包gorilla/securecookie。本文先介绍gorilla/mux(下文简称mux),后续文章会依次介绍上面列举的 gorilla 包。

mux有以下优势:

  • 实现了标准的http.Handler接口,所以可以与net/http标准库结合使用,非常轻量;
  • 可以根据请求的主机名、路径、路径前缀、协议、HTTP 首部、查询字符串和 HTTP 方法匹配处理器,还可以自定义匹配逻辑;
  • 可以在主机名、路径和请求参数中使用变量,还可以为之指定一个正则表达式;
  • 可以传入参数给指定的处理器让其构造出完整的 URL;
  • 支持路由分组,方便管理和维护。

快速使用

本文代码使用 Go Modules。

创建目录并初始化:

  1. $ mkdir -p gorilla/mux && cd gorilla/mux
  2. $ go mod init github.com/go-quiz/go-daily-lib/gorilla/mux

安装gorilla/mux库:

  1. $ go get -u github.com/gorilla/gorilla/mux

我现在身边有几本 Go 语言的经典著作:

每日一库之73:gorilla-mux - 图1

下面我们编写一个管理图书信息的 Web 服务。图书由 ISBN 唯一标识,ISBN 意为国际标准图书编号(International Standard Book Number)。

首先定义图书的结构:

  1. type Book struct {
  2. ISBN string `json:"isbn"`
  3. Name string `json:"name"`
  4. Authors []string `json:"authors"`
  5. Press string `json:"press"`
  6. PublishedAt string `json:"published_at"`
  7. }
  8. var (
  9. mapBooks map[string]*Book
  10. slcBooks []*Book
  11. )

定义init()函数,从文件中加载数据:

  1. func init() {
  2. mapBooks = make(map[string]*Book)
  3. slcBooks = make([]*Book, 0, 1)
  4. data, err := ioutil.ReadFile("../data/books.json")
  5. if err != nil {
  6. log.Fatalf("failed to read book.json:%v", err)
  7. }
  8. err = json.Unmarshal(data, &slcBooks)
  9. if err != nil {
  10. log.Fatalf("failed to unmarshal books:%v", err)
  11. }
  12. for _, book := range slcBooks {
  13. mapBooks[book.ISBN] = book
  14. }
  15. }

然后是两个处理函数,分别用于返回整个列表和某一本具体的图书:

  1. func BooksHandler(w http.ResponseWriter, r *http.Request) {
  2. enc := json.NewEncoder(w)
  3. enc.Encode(slcBooks)
  4. }
  5. func BookHandler(w http.ResponseWriter, r *http.Request) {
  6. book, ok := mapBooks[mux.Vars(r)["isbn"]]
  7. if !ok {
  8. http.NotFound(w, r)
  9. return
  10. }
  11. enc := json.NewEncoder(w)
  12. enc.Encode(book)
  13. }

注册处理器:

  1. func main() {
  2. r := mux.NewRouter()
  3. r.HandleFunc("/", BooksHandler)
  4. r.HandleFunc("/books/{isbn}", BookHandler)
  5. http.Handle("/", r)
  6. log.Fatal(http.ListenAndServe(":8080", nil))
  7. }

mux的使用与net/http非常类似。首先调用mux.NewRouter()创建一个类型为*mux.Router的路由对象,该路由对象注册处理器的方式与标准库的*http.ServeMux完全相同,即调用HandleFunc()方法注册类型为func(http.ResponseWriter, *http.Request)的处理函数,调用Handle()方法注册实现了http.Handler接口的处理器对象。上面注册了两个处理函数,一个是显示图书信息列表,一个显示具体某本书的信息。

注意到路径/books/{isbn}使用了变量,在{}中间指定变量名,它可以匹配路径中的特定部分。在处理函数中通过mux.Vars(r)获取请求r的路由变量,返回map[string]string,后续可以用变量名访问。如上面的BookHandler中对变量isbn的访问。

由于*mux.Router也实现了http.Handler接口,所以可以直接将它作为http.Handle("/", r)的处理器对象参数注册。这里注册的是根路径/,相当于把所有请求的处理都托管给了*mux.Router

最后还是http.ListenAndServe(":8080", nil)开启一个 Web 服务器,等待接收请求。

运行,在浏览器中键入localhost:8080,显示书籍列表:

每日一库之73:gorilla-mux - 图2

键入localhost:8080/books/978-7-111-55842-2,显示图书《Go 程序设计语言》的详细信息:

每日一库之73:gorilla-mux - 图3

从上面的使用过程中可以看出,mux库非常轻量,能很好的与标准库net/http结合使用。

我们还可以使用正则表达式限定变量的模式。ISBN 有固定的模式,现在使用的模式大概是这样:978-7-111-55842-2(这就是《Go 程序设计语言》一书的 ISBN),即 3个数字-1个数字-3个数字-5个数字-1个数字,用正则表达式表示为\d{3}-\d-\d{3}-\d{5}-\d。在变量名后添加一个:分隔变量和正则表达式:

  1. r.HandleFunc("/books/{isbn:\\d{3}-\\d-\\d{3}-\\d{5}-\\d}", BookHandler)

灵活的匹配方式

mux提供了丰富的匹配请求的方式。相比之下,net/http只能指定具体的路径,稍显笨拙。

我们可以指定路由的域名或子域名:

  1. r.Host("github.io")
  2. r.Host("{subdomain:[a-zA-Z0-9]+}.github.io")

上面的路由只接受域名github.io或其子域名的请求,例如我的博客地址go-quiz.github.io就是它的一个子域名。指定域名时可以使用正则表达式,上面第二行代码限制子域名的第一部分必须是若干个字母或数字。

指定路径前缀:

  1. // 只处理路径前缀为`/books/`的请求
  2. r.PathPrefix("/books/")

指定请求的方法:

  1. // 只处理 GET/POST 请求
  2. r.Methods("GET", "POST")

使用的协议(HTTP/HTTPS):

  1. // 只处理 https 的请求
  2. r.Schemes("https")

首部:

  1. // 只处理首部 X-Requested-With 的值为 XMLHTTPRequest 的请求
  2. r.Headers("X-Requested-With", "XMLHTTPRequest")

查询参数(即 URL 中?后的部分):

  1. // 只处理查询参数包含key=value的请求
  2. r.Queries("key", "value")

最后我们可以组合这些条件:

  1. r.HandleFunc("/", HomeHandler)
  2. .Host("bookstore.com")
  3. .Methods("GET")
  4. .Schemes("http")

除此之外,mux还允许自定义匹配器。自定义的匹配器就是一个类型为func(r *http.Request, rm *RouteMatch) bool的函数,根据请求r中的信息判断是否能否匹配成功。http.Request结构中包含了非常多的信息:HTTP 方法、HTTP 版本号、URL、首部等。例如,如果我们要求只处理 HTTP/1.1 的请求可以这么写:

  1. r.MatchrFunc(func(r *http.Request, rm *RouteMatch) bool {
  2. return r.ProtoMajor == 1 && r.ProtoMinor == 1
  3. })

需要注意的是,mux会根据路由注册的顺序依次匹配。所以,通常是将特殊的路由放在前面,一般的路由放在后面。如果反过来了,特殊的路由就不会被匹配到了:

  1. r.HandleFunc("/specific", specificHandler)
  2. r.PathPrefix("/").Handler(catchAllHandler)

子路由

有时候对路由进行分组管理,能让程序模块更清晰,更易于维护。现在网站扩展业务,加入了电影相关信息。我们可以定义两个子路由分别管理:

  1. r := mux.NewRouter()
  2. bs := r.PathPrefix("/books").Subrouter()
  3. bs.HandleFunc("/", BooksHandler)
  4. bs.HandleFunc("/{isbn}", BookHandler)
  5. ms := r.PathPrefix("/movies").Subrouter()
  6. ms.HandleFunc("/", MoviesHandler)
  7. ms.HandleFunc("/{imdb}", MovieHandler)

子路由一般通过路径前缀来限定,r.PathPrefix()会返回一个*mux.Route对象,调用它的Subrouter()方法创建一个子路由对象*mux.Router,然后通过该对象的HandleFunc/Handle方法注册处理函数。

电影没有类似图书的 ISBN 国际统一标准,只有一个民间“准标准”:IMDB。我们采用豆瓣电影中的信息:

每日一库之73:gorilla-mux - 图4

定义电影的结构:

  1. type Movie struct {
  2. IMDB string `json:"imdb"`
  3. Name string `json:"name"`
  4. PublishedAt string `json:"published_at"`
  5. Duration uint32 `json:"duration"`
  6. Lang string `json:"lang"`
  7. }

加载:

  1. var (
  2. mapMovies map[string]*Movie
  3. slcMovies []*Movie
  4. )
  5. func init() {
  6. mapMovies = make(map[string]*Movie)
  7. slcMovies = make([]*Movie, 0, 1)
  8. data, := ioutil.ReadFile("../../data/movies.json")
  9. json.Unmarshal(data, &slcMovies)
  10. for _, movie := range slcMovies {
  11. mapMovies[movie.IMDB] = movie
  12. }
  13. }

使用子路由的方式,还可以将各个部分的路由分散到各自的模块去加载,在文件book.go中定义一个InitBooksRouter()方法负责注册图书相关的路由:

  1. func InitBooksRouter(r *mux.Router) {
  2. bs := r.PathPrefix("/books").Subrouter()
  3. bs.HandleFunc("/", BooksHandler)
  4. bs.HandleFunc("/{isbn}", BookHandler)
  5. }

在文件movie.go中定义一个InitMoviesRouter()方法负责注册电影相关的路由:

  1. func InitMoviesRouter(r *mux.Router) {
  2. ms := r.PathPrefix("/movies").Subrouter()
  3. ms.HandleFunc("/", MoviesHandler)
  4. ms.HandleFunc("/{imdb}", MovieHandler)
  5. }

main.go的主函数中:

  1. func main() {
  2. r := mux.NewRouter()
  3. InitBooksRouter(r)
  4. InitMoviesRouter(r)
  5. http.Handle("/", r)
  6. log.Fatal(http.ListenAndServe(":8080", nil))
  7. }

需要注意的是,子路由匹配是需要包含路径前缀的,也就是说/books/才能匹配BooksHandler

构造路由 URL

我们可以为一个路由起一个名字,例如:

  1. r.HandleFunc("/books/{isbn}", BookHandler).Name("book")

上面的路由中有参数,我们可以传入参数值来构造一个完整的路径:

  1. fmt.Println(r.Get("book").URL("isbn", "978-7-111-55842-2"))
  2. // /books/978-7-111-55842-2 <nil>

返回的是一个*url.URL对象,其路径部分为/books/978-7-111-55842-2。这同样适用于主机名和查询参数:

  1. r := mux.Router()
  2. r.Host("{name}.github.io").
  3. Path("/books/{isbn}").
  4. HandlerFunc(BookHandler).
  5. Name("book")
  6. url, err := r.Get("book").URL("name", "go-quiz", "isbn", "978-7-111-55842-2")

路径中所有的参数都需要指定,并且值需要满足指定的正则表达式(如果有的话)。运行输出:

  1. $ go run main.go
  2. http://go-quiz.github.io/books/978-7-111-55842-2

可以调用URLHost()只生成主机名部分,URLPath()只生成路径部分。

中间件

mux定义了中间件类型MiddlewareFunc

  1. type MiddlewareFunc func(http.Handler) http.Handler

所有满足该类型的函数都可以作为mux的中间件使用,通过调用路由对象*mux.RouterUse()方法应用中间件。如果看过我上一篇文章《Go 每日一库之 net/http(基础和中间件)》应该对这种中间件不陌生了。编写中间件一般会将原处理器传入,中间件中会手动调用原处理函数,然后在前后增加通用处理逻辑:

  1. func loggingMiddleware(next http.Handler) http.Handler {
  2. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  3. log.Println(r.RequestURI)
  4. next.ServeHTTP(w, r)
  5. })
  6. }

我在上篇文章中写的 3 个中间件可以直接使用,这就是兼容net/http的好处:

  1. func main() {
  2. logger = log.New(os.Stdout, "[goweb]", log.Lshortfile|log.LstdFlags)
  3. r := mux.NewRouter()
  4. // 直接使用上一篇文章中定义的中间件
  5. r.Use(PanicRecover, WithLogger, Metric)
  6. InitBooksRouter(r)
  7. InitMoviesRouter(r)
  8. http.Handle("/", r)
  9. log.Fatal(http.ListenAndServe(":8080", nil))
  10. }

如果不手动调用原处理函数,那么原处理函数就不会执行,这可以用来在校验不通过时直接返回错误。例如,网站需要登录才能访问,而 HTTP 是一个无状态的协议。所以发明了 Cookie 机制用于在客户端和服务器之间记录一些信息。

我们在登录成功之后生成一个键为token的 Cookie 表示已登录成功,我们可以编写一个中间件来出来这块逻辑,如果 Cookie 不存在或者非法,则重定向到登录界面:

  1. func login(w http.ResponseWriter, r *http.Request) {
  2. ptTemplate.ExecuteTemplate(w, "login.tpl", nil)
  3. }
  4. func doLogin(w http.ResponseWriter, r *http.Request) {
  5. r.ParseForm()
  6. username := r.Form.Get("username")
  7. password := r.Form.Get("password")
  8. if username != "go-quiz" || password != "handsome" {
  9. http.Redirect(w, r, "/login", http.StatusFound)
  10. return
  11. }
  12. token := fmt.Sprintf("username=%s&password=%s", username, password)
  13. data := base64.StdEncoding.EncodeToString([]byte(token))
  14. http.SetCookie(w, &http.Cookie{
  15. Name: "token",
  16. Value: data,
  17. Path: "/",
  18. HttpOnly: true,
  19. Expires: time.Now().Add(24 * time.Hour),
  20. })
  21. http.Redirect(w, r, "/", http.StatusFound)
  22. }

上面为了记录登录状态,我将登录的用户名和密码组合成username=xxx&password=xxx形式的字符串,对这个字符串进行base64编码,然后设置到 Cookie 中。Cookie 有效期为 24 小时。同时为了安全只允许 HTTP 访问此 Cookie(JS 脚本不可访问)。当然这种方式安全性很低,这里只是为了演示。登录成功之后重定向到/

为了展示登录界面,我创建了几个template模板文件,使用html/template解析:

登录展示页面:

  1. // login.tpl
  2. <form action="/login" method="post">
  3. <label>Username:</label>
  4. <input name="username"><br>
  5. <label>Password:</label>
  6. <input name="password" type="password"><br>
  7. <button type="submit">登录</button>
  8. </form>

主页面

  1. <ul>
  2. <li><a href="/books/">图书</a></li>
  3. <li><a href="/movies/">电影</a></li>
  4. </ul>

同时也创建了图书和电影的页面:

  1. // movies.tpl
  2. <ol>
  3. {{ range . }}
  4. <li>
  5. <p>书名: <a href="/movies/{{ .IMDB }}">{{ .Name }}</a></p>
  6. <p>上映日期: {{ .PublishedAt }}</p>
  7. <p>时长: {{ .Duration }}分</p>
  8. <p>语言: {{ .Lang }}</p>
  9. </li>
  10. {{ end }}
  11. </ol>
  1. // movie.tpl
  2. <p>IMDB: {{ .IMDB }}</p>
  3. <p>电影名: {{ .Name }}</p>
  4. <p>上映日期: {{ .PublishedAt }}</p>
  5. <p>时长: {{ .Duration }}分</p>
  6. <p>语言: {{ .Lang }}</p>

图书页面类似。接下来要解析模板:

  1. var (
  2. ptTemplate *template.Template
  3. )
  4. func init() {
  5. var err error
  6. ptTemplate, err = template.New("").ParseGlob("./tpls/*.tpl")
  7. if err != nil {
  8. log.Fatalf("load templates failed:%v", err)
  9. }
  10. }

访问对应的页面逻辑:

  1. func MoviesHandler(w http.ResponseWriter, r *http.Request) {
  2. ptTemplate.ExecuteTemplate(w, "movies.tpl", slcMovies)
  3. }
  4. func MovieHandler(w http.ResponseWriter, r *http.Request) {
  5. movie, ok := mapMovies[mux.Vars(r)["imdb"]]
  6. if !ok {
  7. http.NotFound(w, r)
  8. return
  9. }
  10. ptTemplate.ExecuteTemplate(w, "movie.tpl", movie)
  11. }

执行对应的模板,传入电影列表或某个具体的电影信息即可。现在页面没有限制访问,我们来编写一个中间件限制只有登录用户才能访问,未登录用户访问时跳转到登录界面:

  1. func authenticateMiddleware(next http.Handler) http.Handler {
  2. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  3. cookie, err := r.Cookie("token")
  4. if err != nil {
  5. // no cookie
  6. http.Redirect(w, r, "/login", http.StatusFound)
  7. return
  8. }
  9. data, _ := base64.StdEncoding.DecodeString(cookie.Value)
  10. values, _ := url.ParseQuery(string(data))
  11. if values.Get("username") != "dj" && values.Get("password") != "handsome" {
  12. // failed
  13. http.Redirect(w, r, "/login", http.StatusFound)
  14. return
  15. }
  16. next.ServeHTTP(w, r)
  17. })
  18. }

再次强调,这里只是为了演示,这种验证方式安全性很低。

然后,我们让booksmovies子路由应用中间件authenticateMiddleware(需要登录验证),而login子路由不用:

  1. func InitBooksRouter(r *mux.Router) {
  2. bs := r.PathPrefix("/books").Subrouter()
  3. // 这里
  4. bs.Use(authenticateMiddleware)
  5. bs.HandleFunc("/", BooksHandler)
  6. bs.HandleFunc("/{isbn}", BookHandler)
  7. }
  8. func InitMoviesRouter(r *mux.Router) {
  9. ms := r.PathPrefix("/movies").Subrouter()
  10. // 这里
  11. ms.Use(authenticateMiddleware)
  12. ms.HandleFunc("/", MoviesHandler)
  13. ms.HandleFunc("/{id}", MovieHandler)
  14. }
  15. func InitLoginRouter(r *mux.Router) {
  16. ls := r.PathPrefix("/login").Subrouter()
  17. ls.Methods("GET").HandlerFunc(login)
  18. ls.Methods("POST").HandlerFunc(doLogin)
  19. }

运行程序(注意多文件程序运行方式):

  1. $ go run .

访问localhost:8080/movies/,会重定向到localhost:8080/login。输入用户名go-quiz,密码handsome,登录成功显示主页面。后面的请求都不需要验证了,请随意点击点击吧😀

总结

本文介绍了轻量级的,功能强大的路由库gorilla/mux。它支持丰富的请求匹配方法,子路由能极大地方便我们管理路由。由于兼容标准库net/http,所以可以无缝集成到使用net/http的程序中,利用为net/http编写的中间件资源。下一篇我们介绍gorilla/handlers——一些常用的中间件。

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. gorilla/mux GitHub:github.com/gorilla/gorilla/mux
  2. Go 每日一库 GitHub:https://github.com/go-quiz/go-daily-lib