標籤:
原文地址http://thenewstack.io/make-a-restful-json-api-go/
這篇文章不僅僅討論如何使用Go構建RESTful的JSON API,同時也會討論如何設計好的RESTful API。如果你曾經遭遇了未遵循良好設計的API,那麼你最終將寫爛代碼來使用這些垃圾API。希望閱讀這篇文章後,你能夠對好的API應該是怎樣的有更多的認識。
JSON API是啥?
在JSON前,XML是一種主流的文字格式設定。筆者有幸XML和JSON都使用過,毫無疑問,JSON是明顯的贏家。本文不會深入涉及JSON API的概念,在jsonapi.org可以找到的詳細的描述。
Sponsor Note
SpringOne2GX是一個專門面向App開發人員、解決方案和資料架構師的會議。議題都是專門針對程式猿(媛),架構師所使用的流行的開源技術,如:Spring IO Projects,Groovy & Grails,Cloud Foundry,RabbitMQ,Redis,Geode,Hadoop and Tomcat等。
一個基本的Web Server
一個RESTful服務本質上首先是一個Web service。下面的是樣本是一個最簡單的Web server,對於任何請求都簡單的直接返回請求連結:
package mainimport ( "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))}
編譯執行這個樣本將運行這個server,監聽8080連接埠。嘗試使用http://localhost:8080訪問server。
增加一個路由
當大多數標準庫開始支援路由,我發現大多數人都搞不清楚它們是如何工作的。我在項目中使用過幾個第三方的router。印象最深的是Gorilla Web Toolkit中的mux router.
另一個比較流行的router是Julien Schmidt貢獻的httprouter
package mainimport ( "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))}
運行上面的樣本,首先需要安裝包“github.com/gorilla/mux”.可以直接使用命令go get遍曆整個source code安裝所有未安裝的依賴包。
譯者註:
也可以使用go get "github.com/gorilla/mux"直接安裝包。
上面的樣本建立了一個簡單的router,增加了一個“/”路由,並分配Index handler響應針對指定的endpoint的訪問。這是你會發現在第一個樣本中還能訪問的如http://localhost:8080/foo這類的連結在這個樣本中不再工作了,這個樣本將只能響應連結http://localhost:8080.
建立更多的基本路由
上一節我們已經有了一個路由,是時候建立更多的路由了。假設我們將要建立一個基本的TODO app。
package mainimport ( "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)}
現在我們又在上一個樣本的基礎上增加了兩個routes,分別是:
- ToDo index route:http://localhost:8080/todos
- ToDo show route: http://localhost:8080/todos/{todoId}
這就是一個RESTful設計的開始。注意,最後一個路由我們增加了一個名為todoId的變數。這將允許我們向route傳遞變數,然後獲得合適的響應記錄。
基本樣式
有了路由後,就可以建立一些基本的TODO樣式用於發送和檢索資料。在一些其他語言中使用類(class)來達到這個目的,Go中使用struct。
package mainimport “time”type Todo struct { Name string Completed tool Due time.time}type Todos []Todo
註:
最後一行定義的類型Todos是Todo的slice。稍後你將會看到怎麼使用它。
返回JSON
基於上面的基本樣式,我們可以類比真實的響應,並基於待用資料列出TodoIndex。
func TodoIndex(w http.ResponseWriter, r *http.Request) { todos := Todos{ Todo{Name: "Write presentation"}, Todo{Name: "Host meetup"}, } json.NewEncoder(w).Encode(todos)}
這樣就建立了一個Todos的靜態slice,並被編碼響應使用者請求。如果這時你訪問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" }]
一個稍微好點的樣式
可能你已經發現了,基於前面的樣式,todos返回的並不是一個標準的JSON資料包(JSON格式定義中不包含大寫字母)。雖然這個問題有那麼一點微不足道,但是我們還是可以解決它:
package mainimport "time"type Todo struct { Name string `json:"name"` Completed bool `json:"completed"` Due time.Time `json:"due"`}type Todos []Todo
上面的程式碼範例在原來的基礎上增加了struct tags,這樣可以指定JSON的編碼格式。
檔案拆分
到此我們需要對這個項目稍微做下重構。現在一個檔案包含了太多的內容。我們將建立如下幾個檔案,並重新組織檔案內容:
- main.go
- handlers.go
- routes.go
- todo.go
handlers.go
package mainimport ( "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)}
routes.go
package mainimport ( "net/http" "github.com/gorilla/mux")type Route struct { Name string Method string Pattern string HandlerFunc http.HandlerFunc}type Routes []Routefunc 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, },}
todo.go
package mainimport "time"type Todo struct { Name string `json:"name"` Completed bool `json:"completed"` Due time.Time `json:"due"`}type Todos []Todo
main.go
package mainimport ( "log" "net/http")func main() { router := NewRouter() log.Fatal(http.ListenAndServe(":8080", router))}
更好的路由
上面重構的一部分就是建立了一個更詳細的route檔案,新檔案中使用了一個struct包含了更多的有關路由的詳細資料。尤其是,我們可以通過這個struct指定請求的動作,如GET、POST、DELETE等。
記錄Web Log
前面的拆分檔案中,我還有一個更長遠的考慮。稍後你將會看到,拆分後我將能夠很輕鬆的使用其他函數裝飾我的http handlers。這一節我們將使用這個功能讓我們的web能夠像其他現代的網站一樣為web訪問請求記Log。在Go中,目前還沒有一個web logging package,也沒有標準庫提供相應的功能。所以我們不得不自己實現一個。
在前面拆分檔案的基礎上,我們建立一個叫logger.go的新檔案,並在檔案中添加如下代碼:
package mainimport ( "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\t%s\t%s\t%s", r.Method, r.RequestURI, name, time.Since(start), ) })}
這樣,如果你訪問http://localhost:8080/todos,你將會看到console中有如下log輸出。
2014/11/19 12:41:39 GET /todos TodoIndex 148.324us
Routes file開始瘋狂…繼續重構
基於上面的拆分,你會發現繼續照著這個節奏發展,routes.go檔案將變得越來越龐大。所以我們繼續拆分這個檔案。將其拆分為如下兩個檔案:
routes.go 迴歸
package mainimport "net/http"type Route struct { Name string Method string Pattern string HandlerFunc http.HandlerFunc}type Routes []Routevar routes = Routes{ Route{ "Index", "GET", "/", Index, }, Route{ "TodoIndex", "GET", "/todos", TodoIndex, }, Route{ "TodoShow", "GET", "/todos/{todoId}", TodoShow, },}
router.go
package mainimport ( "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}
做更多的事情
現在我們已經有了一個不錯的模板,是時候重新考慮我們handlers了,讓handler能做更多的事情。首先我們在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) }}
新增的兩行代碼讓TodoIndex handler多做兩件事。首先返回client期望的json,並告知內容類型。然後明確的設定一個狀態代碼。
Go的net/http server在Header中沒有顯示的說明內容類型時將嘗試為我們猜測內容類型,但是並不是總是那麼準確。所以在我們知道content類型的情況下,我們應該總是自己設定類型。
等等,資料庫在哪兒?
如果我們繼續構造RESTful API,我們需要考慮一個地方用於儲存和檢索資料。但是這超出了本文所討論的範疇,所以這裡簡單的實現了一個粗糙的資料存放區(粗糙到甚至都沒安全執行緒機制)。
建立一個名為repo.go的檔案,代碼如下:
package mainimport "fmt"var currentId intvar todos Todos// Give us some seed datafunc 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
現在我們已經有了一個粗糙的資料庫。我們可以為Todo建立一個ID,用於標識和見識Todo item。資料結構更新如下:
package mainimport "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 handler
資料存放區在資料庫後,不必在handler中產生資料,直接通過ID檢索資料庫即可得到相應內容。修改handler如下:
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) }}
Posting JSON
前面所有的API都是相應GET請求的,只能輸出JSON。這節將增加一個上傳和儲存JSON的API。在routes.go檔案中增加如下route:
Route{ "TodoCreate", "POST", "/todos", TodoCreate,},
The Create endpoint
上面建立了一個新的router,現在為這個新的route建立一個endpoint。在handlers.go檔案增加TodoCreate handler。代碼如下:
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。注意,在擷取body時我們使用了io.LimitReader,這是一個防止你的伺服器被惡意攻擊的好方法。試想如果有人給你發送了一個500GB的json。
讀取body後,將其內容解碼到Todo struct中。如果解碼失敗,我們要做的事情不僅僅是返回一個‘422’這樣的狀態代碼,同時還會返回一段包含錯誤資訊的json。這能夠使用戶端不僅知道有錯誤發生,還能瞭解錯誤發生在哪兒。
最後,如果一切順利,我們將向用戶端返回狀態代碼201,同時我們還向用戶端返回建立的實體內容,這些資訊用戶端在後面的操作中可能會用到。
Post JSON
所有的工作的完成後,我們就可以上傳下json string測試一下了。Sample及返回結果如下所示:
curl -H "Content-Type: application/json" -d ‘{"name":"New Todo"}‘ http://localhost:8080/todosNow, if you go to http://localhost/todos we should see the following response:[ { "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,並且這將導致重大的更改?也許我們可以從為所有的routes添加/v1這樣的首碼開始。
- 身份認證 - 除非這是一個自由/公開的API,否則我們可能需要添加一些認證機制。建議學習JSON web tokens
- eTags - 如果你的構建需要擴充,你可能需要實現eTags
還剩些啥?
所有的項目都是開始的時候很小,但是很快就會發展開始變得失控。如果我想把這件事帶到下一個層級,並準備使其投入生產,則還有如下這些額外的事情需要做:
- 很多的重構
- 將這些檔案封裝成一些package,如JSON helpers,decorators,handlers等等。
- 測試…是的,這個不能忽略。目前我們還沒有做任何的測試,但是對於一個產品,這個是必須的。
如何擷取原始碼
如果你想擷取本文樣本的原始碼,repo地址在這裡:https://github.com/corylanou/tns-restful-json-api
使用Go構建RESTful的JSON API