Go語言RESTful JSON API建立

WalkerQiao發表於2019-02-16

Go語言開發RESTFul JSON API

RESTful API在Web專案開發中廣泛使用,本文針對Go語言如何一步步實現RESTful JSON API進行講解, 另外也會涉及到RESTful設計方面的話題。

也許我們之前有使用過各種各樣的API, 當我們遇到設計很糟糕的API的時候,簡直感覺崩潰至極。希望通過本文之後,能對設計良好的RESTful API有一個初步認識。

JSON API是什麼?

JSON之前,很多網站都通過XML進行資料交換。如果在使用過XML之後,再接觸JSON, 毫無疑問,你會覺得世界多麼美好。這裡不深入JSON API的介紹,有興趣可以參考jsonapi

基本的Web伺服器

從根本上講,RESTful服務首先是Web服務。 因此我們可以先看看Go語言中基本的Web伺服器是如何實現的。下面例子實現了一個簡單的Web伺服器,對於任何請求,伺服器都響應請求的URL回去。

package main

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

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
    })

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

上面基本的web伺服器使用Go標準庫的兩個基本函式HandleFunc和ListenAndServe。

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    DefaultServeMux.HandleFunc(pattern, handler)
}

func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}

執行上面的基本web服務,就可以直接通過瀏覽器訪問http://localhost:8080來訪問。

> go run basic_server.go

新增路由

雖然標準庫包含有router, 但是我發現很多人對它的工作原理感覺很困惑。 我在自己的專案中使用過各種不同的第三方router庫。 最值得一提的是Gorilla Web ToolKit的mux router。

另外一個流行的router是來自Julien Schmidt的叫做httprouter的包。

package main

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

    "github.com/gorilla/mux"
)

func main() {
    router := mux.NewRouter().StrictSlash(true)
    router.HandleFunc("/", Index)

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

func Index(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
}

要執行上面的程式碼,首先使用go get獲取mux router的原始碼:

> go get github.com/gorilla/mux

上面程式碼建立了一個基本的路由器,給請求”/”賦予Index處理器,當客戶端請求http://localhost:8080/的時候,就會執行Index處理器。

如果你足夠細心,你會發現之前的基本web服務訪問http://localhost:8080/abc能正常響應: `Hello, “/abc”`, 但是在新增了路由之後,就只能訪問http://localhost:8080了。 原因很簡單,因為我們只新增了對”/”的解析,其他的路由都是無效路由,因此都是404。

建立一些基本的路由

既然我們加入了路由,那麼我們就可以再新增更多路由進來了。

假設我們要建立一個基本的ToDo應用, 於是我們的程式碼就變成下面這樣:

package main

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

    "github.com/gorilla/mux"
)

func main() {
    router := mux.NewRouter().StrictSlash(true)
    router.HandleFunc("/", Index)
    router.HandleFunc("/todos", TodoIndex)
    router.HandleFunc("/todos/{todoId}", TodoShow)

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

func Index(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Welcome!")
}

func TodoIndex(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Todo Index!")
}

func TodoShow(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    todoId := vars["todoId"]
    fmt.Fprintln(w, "Todo Show:", todoId)
}

在這裡我們新增了另外兩個路由: todos和todos/{todoId}。

這就是RESTful API設計的開始。

請注意最後一個路由我們給路由後面新增了一個變數叫做todoId。

這樣就允許我們傳遞id給路由,並且能使用具體的記錄來響應請求。

基本模型

路由現在已經就緒,是時候建立Model了,可以用model傳送和檢索資料。在Go語言中,model可以使用結構體來實現,而其他語言中model一般都是使用類來實現。

package main

import (
    "time"
)

type Todo struct {
    Name      string
    Completed bool
    Due       time.Time
}

type Todos []Todo

上面我們定義了一個Todo結構體,用於表示待做項。 另外我們還定義了一種型別Todos, 它表示待做列表,是一個陣列,或者說是一個分片。

稍後你就會看到這樣會變得非常有用。

返回一些JSON

我們有了基本的模型,那麼我們可以模擬一些真實的響應了。我們可以為TodoIndex模擬一些靜態的資料列表。

package main

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

    "github.com/gorilla/mux"
)

// ...

func TodoIndex(w http.ResponseWriter, r *http.Request) {
    todos := Todos{
        Todo{Name: "Write presentation"},
        Todo{Name: "Host meetup"},
    }

    json.NewEncoder(w).Encode(todos)
}

// ...

現在我們建立了一個靜態的Todos分片來響應客戶端請求。注意,如果你請求http://localhost:8080/todos, 就會得到下面的響應:

[
    {
        "Name": "Write presentation",
        "Completed": false,
        "Due": "0001-01-01T00:00:00Z"
    },
    {
        "Name": "Host meetup",
        "Completed": false,
        "Due": "0001-01-01T00:00:00Z"
    }
]

更好的Model

對於經驗豐富的老兵來說,你可能已經發現了一個問題。響應JSON的每個key都是首字母答寫的,雖然看起來微不足道,但是響應JSON的key首字母大寫不是習慣的做法。 那麼下面教你如何解決這個問題:

type Todo struct {
    Name      string    `json:"name"`
    Completed bool      `json:"completed"`
    Due       time.Time `json:"due"`
}

其實很簡單,就是在結構體中新增標籤屬性, 這樣可以完全控制結構體如何編排(marshalled)成JSON。

拆分程式碼

到目前為止,我們所有程式碼都在一個檔案中。顯得雜亂, 是時候拆分程式碼了。我們可以將程式碼按照功能拆分成下面多個檔案。

我們準備建立下面的檔案,然後將相應程式碼移到具體的程式碼檔案中:

  • main.go: 程式入口檔案。
  • handlers.go: 路由相關的處理器。
  • routes.go: 路由。
  • todo.go: todo相關的程式碼。
package main

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

    "github.com/gorilla/mux"
)

func Index(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Welcome!")
}

func TodoIndex(w http.ResponseWriter, r *http.Request) {
    todos := Todos{
        Todo{Name: "Write presentation"},
        Todo{Name: "Host meetup"},
    }

    if err := json.NewEncoder(w).Encode(todos); err != nil {
        panic(err)
    }
}

func TodoShow(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    todoId := vars["todoId"]
    fmt.Fprintln(w, "Todo show:", todoId)
}
package main

import (
    "net/http"

    "github.com/gorilla/mux"
)

type Route struct {
    Name        string
    Method      string
    Pattern     string
    HandlerFunc http.HandlerFunc
}

type Routes []Route

func NewRouter() *mux.Router {

    router := mux.NewRouter().StrictSlash(true)
    for _, route := range routes {
        router.
            Methods(route.Method).
            Path(route.Pattern).
            Name(route.Name).
            Handler(route.HandlerFunc)
    }

    return router
}

var routes = Routes{
    Route{
        "Index",
        "GET",
        "/",
        Index,
    },
    Route{
        "TodoIndex",
        "GET",
        "/todos",
        TodoIndex,
    },
    Route{
        "TodoShow",
        "GET",
        "/todos/{todoId}",
        TodoShow,
    },
}
package main

import "time"

type Todo struct {
    Name      string    `json:"name"`
    Completed bool      `json:"completed"`
    Due       time.Time `json:"due"`
}

type Todos []Todo
package main

import (
    "log"
    "net/http"
)

func main() {

    router := NewRouter()

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

更好的Routing

我們重構的過程中,我們建立了一個更多功能的routes檔案。 這個新檔案利用了一個包含多個關於路由資訊的結構體。 注意,這裡我們可以指定請求的型別,例如GET, POST, DELETE等等。

輸出Web日誌

在拆分的路由檔案中,我也包含有一個不可告人的動機。稍後你就會看到,拆分之後很容易使用另外的函式來修飾http處理器。

首先我們需要有對web請求打日誌的能力,就像很多流行web伺服器那樣的。 在Go語言中,標準庫裡邊沒有web日誌包或功能, 因此我們需要自己建立。

package logger

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

func Logger(inner http.Handler, name string) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        inner.ServeHTTP(w, r)

        log.Printf(
            "%s	%s	%s	%s",
            r.Method,
            r.RequestURI,
            name,
            time.Since(start),
        )
    })
}

上面我們定義了一個Logger函式,可以給handler進行包裝修飾。

這是Go語言中非常標準的慣用方式。其實也是函數語言程式設計的慣用方式。 非常有效,我們只需要將Handler傳入該函式, 然後它會將傳入的handler包裝一下,新增web日誌和耗時統計功能。

應用Logger修飾器

要應用Logger修飾符, 我們可以建立router, 我們只需要簡單的將我們所有的當前路由都包到其中, NewRouter函式修改如下:

func NewRouter() *mux.Router {

    router := mux.NewRouter().StrictSlash(true)
    for _, route := range routes {
        var handler http.Handler

        handler = route.HandlerFunc
        handler = Logger(handler, route.Name)

        router.
            Methods(route.Method).
            Path(route.Pattern).
            Name(route.Name).
            Handler(handler)
    }

    return router
}

現在再次執行我們的程式,我們就可以看到日誌大概如下:

2014/11/19 12:41:39 GET /todos  TodoIndex       148.324us

這個路由檔案太瘋狂…讓我們重構它吧

路由routes檔案現在已經變得稍微大了些, 下面我們將它分解成多個檔案:

  • routes.go
  • router.go
package main

import "net/http"

type Route struct {
    Name        string
    Method      string
    Pattern     string
    HandlerFunc http.HandlerFunc
}

type Routes []Route

var routes = Routes{
    Route{
        "Index",
        "GET",
        "/",
        Index,
    },
    Route{
        "TodoIndex",
        "GET",
        "/todos",
        TodoIndex,
    },
    Route{
        "TodoShow",
        "GET",
        "/todos/{todoId}",
        TodoShow,
    },
}
package main

import (
    "net/http"

    "github.com/gorilla/mux"
)

func NewRouter() *mux.Router {
    router := mux.NewRouter().StrictSlash(true)
    for _, route := range routes {
        var handler http.Handler
        handler = route.HandlerFunc
        handler = Logger(handler, route.Name)

        router.
            Methods(route.Method).
            Path(route.Pattern).
            Name(route.Name).
            Handler(handler)

    }
    return router
}

另外再承擔一些責任

到目前為止,我們已經有了一些相當好的樣板程式碼(boilerplate), 是時候重新審視我們的處理器了。我們需要稍微多的責任。 首先修改TodoIndex,新增下面兩行程式碼:

func TodoIndex(w http.ResponseWriter, r *http.Request) {
    todos := Todos{
        Todo{Name: "Write presentation"},
        Todo{Name: "Host meetup"},
    }
    w.Header().Set("Content-Type", "application/json; charset=UTF-8")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(todos); err != nil {
        panic(err)
    }
}

這裡發生了兩件事。 首先,我們設定了響應型別並告訴客戶端期望接受JSON。第二,我們明確的設定了響應狀態碼。

Go語言的net/http伺服器會嘗試為我們猜測輸出內容型別(然而並不是每次都準確的), 但是既然我們已經確切的知道響應型別,我們總是應該自己設定它。

稍等片刻,我們的資料庫在哪裡?

很明顯,如果我們要建立RESTful API, 我們需要一些用於儲存和檢索資料的地方。然而,這個是不是本文的範圍之內, 因此我們將簡單的建立一個非常簡陋的模擬資料庫(非執行緒安全的)。

我們建立一個repo.go檔案,內容如下:

package main

import "fmt"

var currentId int

var todos Todos

// Give us some seed data
func init() {
    RepoCreateTodo(Todo{Name: "Write presentation"})
    RepoCreateTodo(Todo{Name: "Host meetup"})
}

func RepoFindTodo(id int) Todo {
    for _, t := range todos {
        if t.Id == id {
            return t
        }
    }
    // return empty Todo if not found
    return Todo{}
}

func RepoCreateTodo(t Todo) Todo {
    currentId += 1
    t.Id = currentId
    todos = append(todos, t)
    return t
}
func RepoDestroyTodo(id int) error {
    for i, t := range todos {
        if t.Id == id {
            todos = append(todos[:i], todos[i+1:]...)
            return nil
        }
    }
    return fmt.Errorf("Could not find Todo with id of %d to delete", id)
}

給Todo新增ID

我們建立了模擬資料庫,我們使用並賦予id, 因此我們相應的也需要更新我們的Todo結構體。

package main

import "time"

type Todo struct {
    Id        int       `json:"id"`
    Name      string    `json:"name"`
    Completed bool      `json:"completed"`
    Due       time.Time `json:"due"`
}

type Todos []Todo

更新我們的TodoIndex

要使用資料庫,我們需要在TodoIndex中檢索資料。修改程式碼如下:

func TodoIndex(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json; charset=UTF-8")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(todos); err != nil {
        panic(err)
    }
}

POST JSON

到目前為止,我們只是輸出JSON, 現在是時候進入儲存一些JSON了。

在routes.go檔案中新增如下路由:

Route{
    "TodoCreate",
    "POST",
    "/todos",
    TodoCreate,
},

Create路由

func TodoCreate(w http.ResponseWriter, r *http.Request) {
    var todo Todo
    body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576))
    if err != nil {
        panic(err)
    }
    if err := r.Body.Close(); err != nil {
        panic(err)
    }
    if err := json.Unmarshal(body, &todo); err != nil {
        w.Header().Set("Content-Type", "application/json; charset=UTF-8")
        w.WriteHeader(422) // unprocessable entity
        if err := json.NewEncoder(w).Encode(err); err != nil {
            panic(err)
        }
    }

    t := RepoCreateTodo(todo)
    w.Header().Set("Content-Type", "application/json; charset=UTF-8")
    w.WriteHeader(http.StatusCreated)
    if err := json.NewEncoder(w).Encode(t); err != nil {
        panic(err)
    }
}

首先我們開啟請求的body。 注意我們使用io.LimitReader。這樣是保護伺服器免受惡意攻擊的好方法。假設如果有人想要給你伺服器傳送500GB的JSON怎麼辦?

我們讀取body以後,我們解構Todo結構體。 如果失敗,我們作出正確的響應,使用恰當的響應碼422, 但是我們依然使用json響應回去。 這樣可以允許客戶端理解有錯發生了, 而且有辦法知道到底發生了什麼錯誤。

最後,如果所有都通過了,我們就響應201狀態碼,表示請求建立的實體已經成功建立了。 我們同樣還是響應回代表我們建立的實體的json, 它會包含一個id, 客戶端可能接下來需要用到它。

POST一些JSON

我們現在有了偽repo, 也有了create路由,那麼我們需要post一些資料。 我們使用curl通過下面的命令來達到這個目的:

curl -H "Content-Type: application/json" -d `{"name": "New Todo"}` http://localhost:8080/todos

如果你再次通過http://localhost:8080/todos訪問,大概會得到下面的響應:

[
    {
        "id": 1,
        "name": "Write presentation",
        "completed": false,
        "due": "0001-01-01T00:00:00Z"
    },
    {
        "id": 2,
        "name": "Host meetup",
        "completed": false,
        "due": "0001-01-01T00:00:00Z"
    },
    {
        "id": 3,
        "name": "New Todo",
        "completed": false,
        "due": "0001-01-01T00:00:00Z"
    }
]

我們還沒有做的事情

雖然我們已經有了很好的開端,但是還有很多事情沒有做:

  • 版本控制: 如果我們需要修改API, 結果完全改變了怎麼辦? 可能我們需要在我們的路由開頭加上/v1/prefix?
  • 授權: 除非這些都是公開/免費API, 我們可能還需要授權。 建議學習JSON web tokens的東西。

eTag – 如果你正在構建一些需要擴充套件的東西,你可能需要實現eTag。

還有什麼?

對於所有專案來說,開始都很小,但是很快就變得失控了。但是如果我們想要將它帶到另外一個層次, 讓他生產就緒, 還有一些額外的事情需要做:

  • 大量重構(refactoring).
  • 為這些檔案建立幾個包,例如一些JSON助手、修飾符、處理器等等。
  • 測試, 使得,你不能忘記這點。這裡我們沒有做任何測試。對於生產系統來說,測試是必須的。

原始碼

https://github.com/corylanou/…

總結

對我來說,最重要的,需要記住的是我們要建立一個負責任的API。 傳送適當的狀態碼,header等,這些是API廣泛採用的關鍵。我希望本文能讓你儘快開始自己的API。

參考連結

相關文章