Go 每日一庫之 gorilla-mux

簡介

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有以下優勢:

快速使用

本文代碼使用 Go Modules。

創建目錄並初始化:

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

安裝gorilla/mux庫:

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

我現在身邊有幾本 Go 語言的經典著作:

下面我們編寫一個管理圖書信息的 Web 服務。圖書由 ISBN 唯一標識,ISBN 意爲國際標準圖書編號(International Standard Book Number)。

首先定義圖書的結構:

type Book struct {
  ISBN        string   `json:"isbn"`
  Name        string   `json:"name"`
  Authors     []string `json:"authors"`
  Press       string   `json:"press"`
  PublishedAt string   `json:"published_at"`
}

var (
  mapBooks map[string]*Book
  slcBooks []*Book
)

定義init()函數,從文件中加載數據:

func init() {
  mapBooks = make(map[string]*Book)
  slcBooks = make([]*Book, 0, 1)

  data, err := ioutil.ReadFile("../data/books.json")
  if err != nil {
    log.Fatalf("failed to read book.json:%v", err)
  }

  err = json.Unmarshal(data, &slcBooks)
  if err != nil {
    log.Fatalf("failed to unmarshal books:%v", err)
  }

  for _, book := range slcBooks {
    mapBooks[book.ISBN] = book
  }
}

然後是兩個處理函數,分別用於返回整個列表和某一本具體的圖書:

func BooksHandler(w http.ResponseWriter, r *http.Request) {
  enc := json.NewEncoder(w)
  enc.Encode(slcBooks)
}

func BookHandler(w http.ResponseWriter, r *http.Request) {
  book, ok := mapBooks[mux.Vars(r)["isbn"]]
  if !ok {
    http.NotFound(w, r)
    return
  }

  enc := json.NewEncoder(w)
  enc.Encode(book)
}

註冊處理器:

func main() {
  r := mux.NewRouter()
  r.HandleFunc("/", BooksHandler)
  r.HandleFunc("/books/{isbn}", BookHandler)
  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

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,顯示書籍列表:

鍵入localhost:8080/books/978-7-111-55842-2,顯示圖書《Go 程序設計語言》的詳細信息:

從上面的使用過程中可以看出,mux庫非常輕量,能很好的與標準庫net/http結合使用。

我們還可以使用正則表達式限定變量的模式。ISBN 有固定的模式,現在使用的模式大概是這樣:978-7-111-55842-2(這就是《Go 程序設計語言》一書的 ISBN),即 3 個數字 - 1 個數字 - 3 個數字 - 5 個數字 - 1 個數字,用正則表達式表示爲\d{3}-\d-\d{3}-\d{5}-\d。在變量名後添加一個:分隔變量和正則表達式:

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

靈活的匹配方式

mux提供了豐富的匹配請求的方式。相比之下,net/http只能指定具體的路徑,稍顯笨拙。

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

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

上面的路由只接受域名github.io或其子域名的請求,例如我的博客地址darjun.github.io就是它的一個子域名。指定域名時可以使用正則表達式,上面第二行代碼限制子域名的第一部分必須是若干個字母或數字。

指定路徑前綴:

// 只處理路徑前綴爲`/books/`的請求
r.PathPrefix("/books/")

指定請求的方法:

// 只處理 GET/POST 請求
r.Methods("GET""POST")

使用的協議(HTTP/HTTPS):

// 只處理 https 的請求
r.Schemes("https")

首部:

// 只處理首部 X-Requested-With 的值爲 XMLHTTPRequest 的請求
r.Headers("X-Requested-With""XMLHTTPRequest")

查詢參數(即 URL 中?後的部分):

// 只處理查詢參數包含key=value的請求
r.Queries("key""value")

最後我們可以組合這些條件:

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

除此之外,mux還允許自定義匹配器。自定義的匹配器就是一個類型爲func(r *http.Request, rm *RouteMatch) bool的函數,根據請求r中的信息判斷是否能否匹配成功。http.Request結構中包含了非常多的信息:HTTP 方法、HTTP 版本號、URL、首部等。例如,如果我們要求只處理 HTTP/1.1 的請求可以這麼寫:

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

需要注意的是,mux會根據路由註冊的順序依次匹配。所以,通常是將特殊的路由放在前面,一般的路由放在後面。如果反過來了,特殊的路由就不會被匹配到了:

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

子路由

有時候對路由進行分組管理,能讓程序模塊更清晰,更易於維護。現在網站擴展業務,加入了電影相關信息。我們可以定義兩個子路由分別管理:

r := mux.NewRouter()
bs := r.PathPrefix("/books").Subrouter()
bs.HandleFunc("/", BooksHandler)
bs.HandleFunc("/{isbn}", BookHandler)

ms := r.PathPrefix("/movies").Subrouter()
ms.HandleFunc("/", MoviesHandler)
ms.HandleFunc("/{imdb}", MovieHandler)

子路由一般通過路徑前綴來限定,r.PathPrefix()會返回一個*mux.Route對象,調用它的Subrouter()方法創建一個子路由對象*mux.Router,然後通過該對象的HandleFunc/Handle方法註冊處理函數。

電影沒有類似圖書的 ISBN 國際統一標準,只有一個民間 “準標準”:IMDB。我們採用豆瓣電影中的信息:

定義電影的結構:

type Movie struct {
  IMDB        string `json:"imdb"`
  Name        string `json:"name"`
  PublishedAt string `json:"published_at"`
  Duration    uint32 `json:"duration"`
  Lang        string `json:"lang"`
}

加載:

var (
  mapMovies map[string]*Movie
  slcMovies []*Movie
)

func init() {
  mapMovies = make(map[string]*Movie)
  slcMovies = make([]*Movie, 0, 1)

  data,  := ioutil.ReadFile("../../data/movies.json")
  json.Unmarshal(data, &slcMovies)
  for _, movie := range slcMovies {
    mapMovies[movie.IMDB] = movie
  }
}

使用子路由的方式,還可以將各個部分的路由分散到各自的模塊去加載,在文件book.go中定義一個InitBooksRouter()方法負責註冊圖書相關的路由:

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

在文件movie.go中定義一個InitMoviesRouter()方法負責註冊電影相關的路由:

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

main.go的主函數中:

func main() {
  r := mux.NewRouter()
  InitBooksRouter(r)
  InitMoviesRouter(r)

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

需要注意的是,子路由匹配是需要包含路徑前綴的,也就是說/books/才能匹配BooksHandler

構造路由 URL

我們可以爲一個路由起一個名字,例如:

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

上面的路由中有參數,我們可以傳入參數值來構造一個完整的路徑:

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

返回的是一個*url.URL對象,其路徑部分爲/books/978-7-111-55842-2。這同樣適用於主機名和查詢參數:

r := mux.Router()
r.Host("{name}.github.io").
 Path("/books/{isbn}").
 HandlerFunc(BookHandler).
 Name("book")

url, err := r.Get("book").URL("name""darjun""isbn""978-7-111-55842-2")

路徑中所有的參數都需要指定,並且值需要滿足指定的正則表達式(如果有的話)。運行輸出:

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

可以調用URLHost()只生成主機名部分,URLPath()只生成路徑部分。

中間件

mux定義了中間件類型MiddlewareFunc

type MiddlewareFunc func(http.Handler) http.Handler

所有滿足該類型的函數都可以作爲mux的中間件使用,通過調用路由對象*mux.RouterUse()方法應用中間件。如果看過我上一篇文章《Go 每日一庫之 net/http(基礎和中間件)》應該對這種中間件不陌生了。編寫中間件一般會將原處理器傳入,中間件中會手動調用原處理函數,然後在前後增加通用處理邏輯:

func loggingMiddleware(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    log.Println(r.RequestURI)
    next.ServeHTTP(w, r)
  })
}

我在上篇文章中寫的 3 箇中間件可以直接使用,這就是兼容net/http的好處:

func main() {
  logger = log.New(os.Stdout, "[goweb]", log.Lshortfile|log.LstdFlags)

  r := mux.NewRouter()
  // 直接使用上一篇文章中定義的中間件
  r.Use(PanicRecover, WithLogger, Metric)
  InitBooksRouter(r)
  InitMoviesRouter(r)

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

如果不手動調用原處理函數,那麼原處理函數就不會執行,這可以用來在校驗不通過時直接返回錯誤。例如,網站需要登錄才能訪問,而 HTTP 是一個無狀態的協議。所以發明了 Cookie 機制用於在客戶端和服務器之間記錄一些信息。

我們在登錄成功之後生成一個鍵爲token的 Cookie 表示已登錄成功,我們可以編寫一箇中間件來出來這塊邏輯,如果 Cookie 不存在或者非法,則重定向到登錄界面:

func login(w http.ResponseWriter, r *http.Request) {
  ptTemplate.ExecuteTemplate(w, "login.tpl", nil)
}

func doLogin(w http.ResponseWriter, r *http.Request) {
  r.ParseForm()
  username := r.Form.Get("username")
  password := r.Form.Get("password")
  if username != "darjun" || password != "handsome" {
    http.Redirect(w, r, "/login", http.StatusFound)
    return
  }

  token := fmt.Sprintf("user, username, password)
  data := base64.StdEncoding.EncodeToString([]byte(token))
  http.SetCookie(w, &http.Cookie{
    Name:     "token",
    Value:    data,
    Path:     "/",
    HttpOnly: true,
    Expires:  time.Now().Add(24 * time.Hour),
  })
  http.Redirect(w, r, "/", http.StatusFound)
}

上面爲了記錄登錄狀態,我將登錄的用戶名和密碼組合成username=xxx&password=xxx形式的字符串,對這個字符串進行base64編碼,然後設置到 Cookie 中。Cookie 有效期爲 24 小時。同時爲了安全只允許 HTTP 訪問此 Cookie(JS 腳本不可訪問)。當然這種方式安全性很低,這裏只是爲了演示。登錄成功之後重定向到/

爲了展示登錄界面,我創建了幾個template模板文件,使用html/template解析:

登錄展示頁面:

// login.tpl
<form action="/login" method="post">
  <label>Username:</label>
  <input ><br>
  <label>Password:</label>
  <input ><br>
  <button type="submit">登錄</button>
</form>

主頁面

<ul>
  <li><a href="/books/">圖書</a></li>
  <li><a href="/movies/">電影</a></li>
</ul>

同時也創建了圖書和電影的頁面:

// movies.tpl
<ol>
  {{ range . }}
  <li>
    <p>書名: <a href="/movies/{{ .IMDB }}">{{ .Name }}</a></p>
    <p>上映日期: {{ .PublishedAt }}</p>
    <p>時長: {{ .Duration }}分</p>
    <p>語言: {{ .Lang }}</p>
  </li>
  {{ end }}
</ol>
// movie.tpl
<p>IMDB: {{ .IMDB }}</p>
<p>電影名: {{ .Name }}</p>
<p>上映日期: {{ .PublishedAt }}</p>
<p>時長: {{ .Duration }}分</p>
<p>語言: {{ .Lang }}</p>

圖書頁面類似。接下來要解析模板:

var (
  ptTemplate *template.Template
)

func init() {
  var err error
  ptTemplate, err = template.New("").ParseGlob("./tpls/*.tpl")
  if err != nil {
    log.Fatalf("load templates failed:%v", err)
  }
}

訪問對應的頁面邏輯:

func MoviesHandler(w http.ResponseWriter, r *http.Request) {
  ptTemplate.ExecuteTemplate(w, "movies.tpl", slcMovies)
}

func MovieHandler(w http.ResponseWriter, r *http.Request) {
  movie, ok := mapMovies[mux.Vars(r)["imdb"]]
  if !ok {
    http.NotFound(w, r)
    return
  }

  ptTemplate.ExecuteTemplate(w, "movie.tpl", movie)
}

執行對應的模板,傳入電影列表或某個具體的電影信息即可。現在頁面沒有限制訪問,我們來編寫一箇中間件限制只有登錄用戶才能訪問,未登錄用戶訪問時跳轉到登錄界面:

func authenticateMiddleware(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    cookie, err := r.Cookie("token")
    if err != nil {
      // no cookie
      http.Redirect(w, r, "/login", http.StatusFound)
      return
    }

    data, _ := base64.StdEncoding.DecodeString(cookie.Value)
    values, _ := url.ParseQuery(string(data))
    if values.Get("username") != "dj" && values.Get("password") != "handsome" {
      // failed
      http.Redirect(w, r, "/login", http.StatusFound)
      return
    }

    next.ServeHTTP(w, r)
  })
}

再次強調,這裏只是爲了演示,這種驗證方式安全性很低。

然後,我們讓booksmovies子路由應用中間件authenticateMiddleware(需要登錄驗證),而login子路由不用:

func InitBooksRouter(r *mux.Router) {
  bs := r.PathPrefix("/books").Subrouter()
  // 這裏
  bs.Use(authenticateMiddleware)
  bs.HandleFunc("/", BooksHandler)
  bs.HandleFunc("/{isbn}", BookHandler)
}

func InitMoviesRouter(r *mux.Router) {
  ms := r.PathPrefix("/movies").Subrouter()
  // 這裏
  ms.Use(authenticateMiddleware)
  ms.HandleFunc("/", MoviesHandler)
  ms.HandleFunc("/{id}", MovieHandler)
}

func InitLoginRouter(r *mux.Router) {
  ls := r.PathPrefix("/login").Subrouter()
  ls.Methods("GET").HandlerFunc(login)
  ls.Methods("POST").HandlerFunc(doLogin)
}

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

$ go run .

訪問localhost:8080/movies/,會重定向到localhost:8080/login。輸入用戶名darjun,密碼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/darjun/go-daily-lib

我的博客:https://darjun.github.io

歡迎關注我的微信公衆號【GoUpUp】,共同學習,一起進步~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aCj8VjYtcthVOJNfWefFBA