Golang:使用 httprouter 構建 API 服務器

https://medium.com/@gauravsingharoy/build-your-first-api-server-with-httprouter-in-golang-732b7b01f6ab
做者:Gaurav Singha Roy
譯者:oopsguy.comgit

10 個月前,我成爲了一名 Gopher,沒有後悔。像許多其餘 gopher 同樣,我很快發現簡單的語言特性對於快速構建快速、可擴展的軟件很是有用。當我剛開始學習 Go 時,我正在搗鼓不一樣的路由器,它能夠當作 API 服務器使用。若是你跟我同樣有 Rails 背景,你也可能會在構建 Web 框架提供的全部功能方面遇到困難。回到路由器話題,我發現了 3 個是很是有用的好東西: Gorilla muxhttprouterbone(按性能從低到高)。即使 bone 的性能最高且有更簡單的 handler 簽名,但對我來講,它仍然不夠成熟,沒法應用於生產環境中。所以,我最終使用了 httprouter。在本教程中,我將使用 httprouter 構建一個簡單的 REST API 服務器。github

若是你想偷懶,只想獲取源碼,則能夠在這裏[4]直接克隆個人 github 倉庫。golang

讓咱們開始吧。首先建立一個基本端點:json

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprint(w, "Welcome!\n")
}

func main() {
    router := httprouter.New()
    router.GET("/", Index)

    log.Fatal(http.ListenAndServe(":8080", router))
}

在上面的代碼段中,Index 是一個 handler 函數,須要傳入三個參數。以後,該 handler 將在 main 函數中被註冊到 GET / 路徑上。如今編譯並運行你的程序,轉到 http:// localhost:8080 來查看你的 API 服務器。點擊這裏[1]獲取當前代碼。api

咱們可讓 API 變得複雜一點。如今有一個名爲 Book 的實體,能夠把 ISDN 字段做爲惟一標識。讓咱們建立更多的 handler,即分表表明着 Index 和 Show 動做的 GET /books 和 GET /books/:isdnmain.go 文件此時以下:服務器

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprint(w, "Welcome!\n")
}

type Book struct {
    // The main identifier for the Book. This will be unique.
    ISDN   string `json:"isdn"`
    Title  string `json:"title"`
    Author string `json:"author"`
    Pages  int    `json:"pages"`
}

type JsonResponse struct {
    // Reserved field to add some meta information to the API response
    Meta interface{} `json:"meta"`
    Data interface{} `json:"data"`
}

type JsonErrorResponse struct {
    Error *ApiError `json:"error"`
}

type ApiError struct {
    Status int16  `json:"status"`
    Title  string `json:"title"`
}

// A map to store the books with the ISDN as the key
// This acts as the storage in lieu of an actual database
var bookstore = make(map[string]*Book)

// Handler for the books index action
// GET /books
func BookIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    books := []*Book{}
    for _, book := range bookstore {
        books = append(books, book)
    }
    response := &JsonResponse{Data: &books}
    w.Header().Set("Content-Type", "application/json; charset=UTF-8")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(response); err != nil {
        panic(err)
    }
}

// Handler for the books Show action
// GET /books/:isdn
func BookShow(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
    isdn := params.ByName("isdn")
    book, ok := bookstore[isdn]
    w.Header().Set("Content-Type", "application/json; charset=UTF-8")
    if !ok {
        // No book with the isdn in the url has been found
        w.WriteHeader(http.StatusNotFound)
        response := JsonErrorResponse{Error: &ApiError{Status: 404, Title: "Record Not Found"}}
        if err := json.NewEncoder(w).Encode(response); err != nil {
            panic(err)
        }
    }
    response := JsonResponse{Data: book}
    if err := json.NewEncoder(w).Encode(response); err != nil {
        panic(err)
    }
}

func main() {
    router := httprouter.New()
    router.GET("/", Index)
    router.GET("/books", BookIndex)
    router.GET("/books/:isdn", BookShow)

    // Create a couple of sample Book entries
    bookstore["123"] = &Book{
        ISDN:   "123",
        Title:  "Silence of the Lambs",
        Author: "Thomas Harris",
        Pages:  367,
    }

    bookstore["124"] = &Book{
        ISDN:   "124",
        Title:  "To Kill a Mocking Bird",
        Author: "Harper Lee",
        Pages:  320,
    }

    log.Fatal(http.ListenAndServe(":8080", router))
}

你若是如今嘗試請求 GET https:// localhost:8080/books,將獲得如下響應:app

{
    "meta": null,
    "data": [
        {
            "isdn": "123",
            "title": "Silence of the Lambs",
            "author": "Thomas Harris",
            "pages": 367
        },
        {
            "isdn": "124",
            "title": "To Kill a Mocking Bird",
            "author": "Harper Lee",
            "pages": 320
        }
    ]
}

咱們在 main 函數中硬編碼了這兩個 book 實體。點擊這裏[2]獲取當前代碼。框架

讓咱們來重構一下代碼。到目前爲止,全部的代碼都放置在同一個文件中:main.go。咱們能夠把它們劃分移到不一樣的文件中。此時咱們有一個目錄:ide

.
├── handlers.go
├── main.go
├── models.go
└── responses.go

咱們把全部與 JSON 響應相關的結構體移動到 responses.go,將 handler 函數移動到 Handlers.go,將 Book 結構體移動到 models.go。點擊這裏[3]查看當前代碼。如今,咱們跳過來寫一些測試。在 Go 中,*_test.go 文件是用做測試用途。所以讓咱們建立一個 handlers_test.go函數

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/julienschmidt/httprouter"
)

func TestBookIndex(t *testing.T) {
    // Create an entry of the book to the bookstore map
    testBook := &Book{
        ISDN:   "111",
        Title:  "test title",
        Author: "test author",
        Pages:  42,
    }
    bookstore["111"] = testBook
    // A request with an existing isdn
    req1, err := http.NewRequest("GET", "/books", nil)
    if err != nil {
        t.Fatal(err)
    }
    rr1 := newRequestRecorder(req1, "GET", "/books", BookIndex)
    if rr1.Code != 200 {
        t.Error("Expected response code to be 200")
    }
    // expected response
    er1 := "{\"meta\":null,\"data\":[{\"isdn\":\"111\",\"title\":\"test title\",\"author\":\"test author\",\"pages\":42}]}\n"
    if rr1.Body.String() != er1 {
        t.Error("Response body does not match")
    }
}

// Mocks a handler and returns a httptest.ResponseRecorder
func newRequestRecorder(req *http.Request, method string, strPath string, fnHandler func(w http.ResponseWriter, r *http.Request, param httprouter.Params)) *httptest.ResponseRecorder {
    router := httprouter.New()
    router.Handle(method, strPath, fnHandler)
    // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
    rr := httptest.NewRecorder()
    // Our handlers satisfy http.Handler, so we can call their ServeHTTP method
    // directly and pass in our Request and ResponseRecorder.
    router.ServeHTTP(rr, req)
    return rr
}

咱們使用 httptest 包的 Recorder 來 mock handler。一樣,你也能夠爲 handler BookShow 編寫測試用例。

讓咱們稍微作些重構。咱們仍然把全部路由都定義在了 main 函數中,handler 看起來有點臃腫,須要作點 DRY。咱們仍然在終端中輸出一些日誌消息,而且能夠添加一個 BookCreate handler 來建立一個新的 Book。

首先先解決 handlers.go

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "io/ioutil"
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprint(w, "Welcome!\n")
}

// Handler for the books Create action
// POST /books
func BookCreate(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
    book := &Book{}
    if err := populateModelFromHandler(w, r, params, book); err != nil {
        writeErrorResponse(w, http.StatusUnprocessableEntity, "Unprocessible Entity")
        return
    }
    bookstore[book.ISDN] = book
    writeOKResponse(w, book)
}

// Handler for the books index action
// GET /books
func BookIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    books := []*Book{}
    for _, book := range bookstore {
        books = append(books, book)
    }
    writeOKResponse(w, books)
}

// Handler for the books Show action
// GET /books/:isdn
func BookShow(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
    isdn := params.ByName("isdn")
    book, ok := bookstore[isdn]
    if !ok {
        // No book with the isdn in the url has been found
        writeErrorResponse(w, http.StatusNotFound, "Record Not Found")
        return
    }
    writeOKResponse(w, book)
}

// Writes the response as a standard JSON response with StatusOK
func writeOKResponse(w http.ResponseWriter, m interface{}) {
    w.Header().Set("Content-Type", "application/json; charset=UTF-8")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(&JsonResponse{Data: m}); err != nil {
        writeErrorResponse(w, http.StatusInternalServerError, "Internal Server Error")
    }
}

// Writes the error response as a Standard API JSON response with a response code
func writeErrorResponse(w http.ResponseWriter, errorCode int, errorMsg string) {
    w.Header().Set("Content-Type", "application/json; charset=UTF-8")
    w.WriteHeader(errorCode)
    json.
        NewEncoder(w).
        Encode(&JsonErrorResponse{Error: &ApiError{Status: errorCode, Title: errorMsg}})
}

//Populates a model from the params in the Handler
func populateModelFromHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params, model interface{}) error {
    body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576))
    if err != nil {
        return err
    }
    if err := r.Body.Close(); err != nil {
        return err
    }
    if err := json.Unmarshal(body, model); err != nil {
        return err
    }
    return nil
}

我建立了兩個函數,writeOKResponse 用於將 StatusOK 寫入響應,其返回一個 model 或一個 model slice,writeErrorResponse 將在發生預期或意外錯誤時將 JSON 錯誤做爲響應。像任何一個優秀的 gopher 同樣,咱們不該該 panic。我還添加了一個名爲 populateModelFromHandler 的函數,它將內容從 body 中解析成所需的 model(struct)。在這種狀況下,咱們在 BookCreate handler 中使用它來填充一個 Book

如今來看看日誌。咱們簡單地建立一個 Logger 函數,它包裝了 handler 函數,並在執行 handler 函數以前和以後打印日誌消息。

package main

import (
    "log"
    "net/http"
    "time"

    "github.com/julienschmidt/httprouter"
)

// A Logger function which simply wraps the handler function around some log messages
func Logger(fn func(w http.ResponseWriter, r *http.Request, param httprouter.Params)) func(w http.ResponseWriter, r *http.Request, param httprouter.Params) {
    return func(w http.ResponseWriter, r *http.Request, param httprouter.Params) {
        start := time.Now()
        log.Printf("%s %s", r.Method, r.URL.Path)
        fn(w, r, param)
        log.Printf("Done in %v (%s %s)", time.Since(start), r.Method, r.URL.Path)
    }
}

以後來看看路由。首先,在統一一個地方定義全部路由,好比 routes.go

package main

import "github.com/julienschmidt/httprouter"

/*
Define all the routes here.
A new Route entry passed to the routes slice will be automatically
translated to a handler with the NewRouter() function
*/
type Route struct {
    Name        string
    Method      string
    Path        string
    HandlerFunc httprouter.Handle
}

type Routes []Route

func AllRoutes() Routes {
    routes := Routes{
        Route{"Index", "GET", "/", Index},
        Route{"BookIndex", "GET", "/books", BookIndex},
        Route{"Bookshow", "GET", "/books/:isdn", BookShow},
        Route{"Bookshow", "POST", "/books", BookCreate},
    }
    return routes
}

讓咱們建立了一個 NewRouter 函數,它能夠在 main 函數中調用,它讀取上面定義的全部路由,並返回一個可用的 httprouter.Router。所以建立一個文件 router.go。咱們還將使用新建立的 Logger 函數來包裝 handler。

package main

import "github.com/julienschmidt/httprouter"

//Reads from the routes slice to translate the values to httprouter.Handle
func NewRouter(routes Routes) *httprouter.Router {

    router := httprouter.New()
    for _, route := range routes {
        var handle httprouter.Handle

        handle = route.HandlerFunc
        handle = Logger(handle)

        router.Handle(route.Method, route.Path, handle)
    }

    return router
}

你的目錄此時應該像這樣:

.
├── handlers.go
├── handlers_test.go
├── logger.go
├── main.go
├── models.go
├── responses.go
├── router.go
└── routes.go

這裏[4]查看完整代碼。

到這裏,這些代碼應該能夠幫助你開始編寫本身的 API 服務器了。固然,你須要把你的功能放在不一樣的包中,如下目錄結構是一個參照:

.
├── LICENSE
├── README.md
├── handlers
│   ├── books_test.go
│   └── books.go
├── models
│   ├── book.go
│   └── *
├── store
│   ├── *
└── lib
|   ├── *
├── main.go
├── router.go
├── rotes.go

若是你有一個大的單體服務,你還能夠將 handlersmodels 和全部路由功能都放在另外一個名爲 app 的包中。你只要記住,go 不像 Java 或 Scala 那樣能夠有循環的包引用。所以你必須格外注意你的包結構。

這就是所有內容,但願本教程能幫助到你。🍻

  • [1] https://github.com/gsingharoy/httprouter-tutorial/tree/master/part1
  • [2] https://github.com/gsingharoy/httprouter-tutorial/tree/master/part2
  • [3] https://github.com/gsingharoy/httprouter-tutorial/tree/master/part3
  • [4] https://github.com/gsingharoy/httprouter-tutorial/tree/master/part4
  • [Gorilla mux] https://github.com/gorilla/mux
  • [httprouter] https://github.com/julienschmidt/httprouter
  • [bone] https://github.com/go-zoo/bone
相關文章
相關標籤/搜索