改進官方 Go RESTful API 教學的程式碼
摘要:本文描述我如何重新實作官方 Go 教學「Developing a RESTful API with Go and Gin」中的程式碼。我的版本新增了幾個功能、修正了一些問題、加入了測試,並且只使用 Go 標準函式庫。
最近我讀了新的 Tutorial: Developing a RESTful API with Go and Gin,相較於 Go 其他優秀的文件,這份教學似乎有一些品質上的問題,而且令我覺得奇怪的是,官方文件竟然使用第三方函式庫(Gin)而不是推廣標準函式庫。
所以我決定只用標準函式庫重寫程式碼,並修正因缺少鎖定而導致的並行問題。我也加入了一些功能,例如輸入驗證和更完善的錯誤處理——這些我認為是任何「真正的」網路服務都應該具備的功能。
重寫完成後,我在 Go 的開發者郵件論壇 提出了這些問題,而 Go 的技術負責人 Russ Cox 回覆如下:
這是我們規劃中幾個運用 Go 第三方套件生態系的教學裡的第一篇。目的是突顯那些被廣泛使用、能簡化常見使用情境的套件。
我想這也說得通,尤其是在現在有了 Go modules 的情況下。當我提到我在程式碼中看到的具體問題時,他表示他們比較傾向保持教學原樣。他的回覆很有幫助:
我認為這些都超出了這份特定教學的範圍。真正的系統根本不會使用記憶體內資料庫,所以記憶體內資料庫缺少鎖定似乎不是什麼大問題。專輯驗證等問題也是如此。教學的目標是簡短且聚焦於說明特定概念,在這個案例中就是以 JSON 為基礎的 RESTful API。它刻意省略了真實系統中會有的所有輸入驗證、身分驗證和其他複雜細節。你提到的這些都是值得強調的好觀點,我很感謝你花時間寫這篇部落格文章,但如果加入這份特定教學中,反而會偏離原本聚焦的重點。
這樣的說法也算合理。不過,我仍然認為教學不應該包含錯誤,所以我很希望看到他們修正這個並行問題,或至少將其標註為簡化的處理。對於許多靠複製範例程式碼來學習的初學者來說,輕描淡寫地帶過重要細節是有風險的。因此,我認為我們在這類程式碼中可以樹立更好的典範。
我在下面討論我在自己版本中所做的改進。完整原始碼請見 GitHub 上的 benhoyt/web-service-stdlib。
改進項目
以下是我在原始程式碼中發現、並在自己版本中修改或改進的地方(附有下方詳細章節的連結):
- 標準函式庫。 如前所述,原始版本使用了 Gin 網頁框架。我的版本則只使用標準函式庫的套件。
- 驗證。 原始版本對「新增專輯」輸入完全沒有做驗證(除了確認是 JSON 之外),因此很容易就能新增一個 ID 為空、價格為負數等的專輯。我已改為進行一些基本的驗證,並向客戶端回傳可解析的驗證錯誤。
- 專輯 ID 唯一性。 現有程式碼會毫無問題地加入 ID 重複的專輯,而
/albums/:id端點只會回傳第一筆。這似乎有問題:或許應該改用PUT /albums/:id來直接更新指定 ID 的專輯,或是繼續使用POST /albums但對重複情況回傳錯誤——我選擇了後者。 - 並行處理。 原始程式碼中的全域
albums切片在讀寫時沒有加鎖,因此無法同時被存取,若嘗試這麼做就會引發 panic。我了解這只是個範例,真正的資料庫不會有這個問題,但要加上簡單的 mutex 鎖來讓程式碼變得安全其實非常簡單。我的版本加入了鎖,也加入了測試來確保沒有 race condition。 - 十進位金額。 專輯的
Price欄位是float64。用二進位浮點數來儲存和處理金額不是個好主意,因為二進位浮點數無法精確表示十進位小數,對其進行運算可能會引入捨入誤差。我已將Price欄位改為以整數的「分」為單位(即「定點數」)。 - JSON 錯誤。 Gin 路由器的預設 Not Found 錯誤會回傳
Content-Type: text/plain,因此這些錯誤回傳的是純文字而非 JSON。不過,getAlbumByID中明確回傳的 Not Found 則是回傳 JSON。同樣地,Gin 的BindJSON在收到無效輸入時也不會回傳 JSON 錯誤。我的版本則讓所有錯誤都以 JSON 回傳。 - 方法不符。 Gin(至少在預設設定下)當 URL 正確但 HTTP 方法不符時,會回傳 404 Not Found 而非 405 Method Not Allowed。我已修正為在這些情況下回傳標準的 405 狀態碼。
- 測試。 原始版本沒有任何測試。這沒關係,因為這本來就不是教學的目的。但使用 Go 的
httptest函式庫來測試 HTTP handler 其實相當容易,我已為所有功能(包含錯誤情況)加入了測試。 - 資料庫介面。 我為資料庫方法定義了一個明確的介面(可回傳如
ErrDoesNotExist等已定義的錯誤),並搭配一個與原始版本類似的記憶體內實作。 - 關注點分離。 在原始版本中,「資料庫」程式碼與 handler 程式碼交織在一起。部分由於使用了資料庫介面,在我的版本中,資料庫程式碼與 HTTP handler 程式碼完全分離,讓錯誤處理等測試變得更容易,未來需要時也能更輕鬆地替換成真正的資料庫。
我的版本程式碼明顯多了不少(約 300 行程式碼而非 50 行,另外還有約 300 行測試程式碼),但這主要是因為新增了額外功能。我認為我的版本展示了更為穩健、更易於維護的程式碼。
接下來讓我們更深入地看看上述每一個重點。
標準函式庫
Gin 提供了 URL 路由(包含 URL 參數)和幾個 JSON 序列化函式。在我的版本中,我寫了一些簡單的路由程式碼,並加入了幾個自訂的 JSON 輔助函式。
我在其他地方曾深入探討過在 Go 中不同的 HTTP 路由作法,但在這裡路由非常單純,所以我使用了 regex switch 作法的簡化版本,用正則表達式來解析 /albums/:id 路由。因此在這裡甚至不需要用到標準函式庫的 http.ServeMux。
/albums/:id 路由就算不用正則表達式也能算單純,但用正則表達式處理邊界情況會稍微簡單一些:確保 ID 至少有一個字元且不包含斜線。
我的程式碼也會處理 HTTP 方法,包含正確處理 405 Method Not Allowed。以下是完整的路由程式碼:
// Regex to match "/albums/:id" (id must be one or more non-slash chars).
var reAlbumsID = regexp.MustCompile(`^/albums/([^/]+)$`)
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
s.log.Printf("%s %s", r.Method, path)
var id string
switch {
case path == "/albums":
switch r.Method {
case "GET":
s.getAlbums(w, r)
case "POST":
s.addAlbum(w, r)
default:
w.Header().Set("Allow", "GET, POST")
s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
}
case match(path, reAlbumsID, &id):
switch r.Method {
case "GET":
s.getAlbumByID(w, r, id)
default:
w.Header().Set("Allow", "GET")
s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
}
default:
s.jsonError(w, http.StatusNotFound, ErrorNotFound, nil)
}
}有點冗長,但非常清晰明確,也避免了必須設定第三方路由器才能讓錯誤以 JSON 回傳並正確回傳 405 的麻煩。
Gin 讓程式碼變短的另一個地方是它的 IndentedJSON 和 BindJSON 輔助函式,分別用來序列化和反序列化 JSON。幸好,只用標準的 encoding/json 套件來處理 JSON 就非常容易。我寫了幾個小型的輔助函式來封裝這些操作並處理錯誤:
// writeJSON marshals v to JSON and writes it to the response, handling
// errors as appropriate. It also sets the Content-Type header to
// "application/json".
func (s *Server) writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
s.log.Printf("error marshaling JSON: %v", err)
http.Error(w, `{"error":"`+ErrorInternal+`"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, err = w.Write(b)
if err != nil {
// Very unlikely to happen, but log any error (not much more we can do)
s.log.Printf("error writing JSON: %v", err)
}
}
// readJSON reads the request body and unmarshals it from JSON, handling
// errors as appropriate. It returns true on success; the caller should
// return from the handler early if it returns false.
func (s *Server) readJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
b, err := io.ReadAll(r.Body)
if err != nil {
s.log.Printf("error reading JSON body: %v", err)
s.jsonError(w, http.StatusInternalServerError, ErrorInternal, nil)
return false
}
err = json.Unmarshal(b, v)
if err != nil {
data := map[string]interface{}{"message": err.Error()}
s.jsonError(w, http.StatusBadRequest, ErrorMalformedJSON, data)
return false
}
return true
}我本來也可以使用 json.Encoder 直接串流寫入回應。不過,錯誤處理會有點棘手:如果發生 JSON 序列化錯誤,而 Encoder.Encode 已經寫入部分內容到回應中,就無法再回傳非 200 的 HTTP 狀態碼。在 Album 這個案例中,因為結構非常單純,發生錯誤的機率很低(甚至不可能?),但在一般情況下,JSON 編碼是有可能回傳錯誤的,所以我先將結構序列化為 []byte。
同樣地,在反序列化時你也可以使用 json.Decoder 直接從請求主體讀取——不過,那其實是為串流所設計的。
請注意,對於 Internal Server Error,我們是將 err 值記錄到日誌中——它可能包含敏感(或過多)的資訊,所以應該記錄下來,而不是包含在回應中。
驗證
這是網路安全——或者說任何軟體——最基本的原則之一:永遠要驗證使用者輸入。沒有驗證的話,服務的使用者可能會新增一張沒有 ID、沒有標題或歌手名稱,或價格為負數(或大到不合理)的專輯。
我在新增專輯的端點中加入了幾行驗證程式碼,並設計了一種結構化的方式來回傳驗證錯誤,讓客戶端能顯示有用的錯誤訊息。以下是完整的驗證程式碼:
// Validate the input and build a map of validation issues
type validationIssue struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
issues := make(map[string]interface{})
if album.ID == "" {
issues["id"] = validationIssue{"required", ""}
}
if album.Title == "" {
issues["title"] = validationIssue{"required", ""}
}
if album.Artist == "" {
issues["artist"] = validationIssue{"required", ""}
}
if album.Price < 0 || album.Price >= 100000 {
issues["price"] = validationIssue{"out-of-range",
"price must be between 0 and $1000"}
}
if len(issues) > 0 {
s.jsonError(w, http.StatusBadRequest, ErrorValidation, issues)
return
}在這個案例中,我允許價格為零,因為我認為零代表「沒有價格」,例如免費或不適用(像是家庭收藏目錄)。
我們不需要帶有特定領域語言的框架,只要用簡單的 if 敘述來檢查需要的項目就好。我們建立一個以欄位名稱為索引的問題對應表,如果有任何驗證問題,就將其包含在 JSON 錯誤中回傳給呼叫端。以下是驗證錯誤回應的範例:
$ curl http://localhost:8080/albums -d '{"price":-1}'
{
"status": 400,
"error": "validation",
"data": {
"artist": {
"error": "required"
},
"id": {
"error": "required"
},
"price": {
"error": "out-of-range",
"message": "price must be between 0 and $1000"
},
"title": {
"error": "required"
}
}
}對於更大的網路服務,我可能會將其再標準化一些,並視需要為結構加入 Validate() map[string]ValidationIssue 這類方法。不過,有時候同一個結構在不同情境下會需要不同的驗證,所以或許這種保持簡單的作法就已經很好了。
專輯 ID 的唯一性
如前所述,原始程式碼在你新增 ID 重複的專輯時不會回傳錯誤,例如:
$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums
[
...
{
"id": "foo",
"title": "",
"artist": "",
"price": 0
},
{
"id": "foo",
"title": "",
"artist": "",
"price": 0
}
]我已修正這個問題,讓「資料庫」會拒絕已存在的 ID。在這種情況下,資料庫的 AddAlbum 方法會回傳 ErrAlreadyExists,而 handler 程式碼會檢查這個錯誤並回應 409 Conflict:
// Database method:
func (d *MemoryDatabase) AddAlbum(album Album) error {
d.lock.Lock()
defer d.lock.Unlock()
if _, ok := d.albums[album.ID]; ok {
return ErrAlreadyExists
}
d.albums[album.ID] = album
return nil
}
// Handler error checking:
func (s *Server) addAlbum(w http.ResponseWriter, r *http.Request) {
// ... JSON parsing and validation ...
err := s.db.AddAlbum(album)
if errors.Is(err, ErrAlreadyExists) {
s.jsonError(w, http.StatusConflict, ErrorAlreadyExists, nil)
return
} else if err != nil {
s.log.Printf("error adding album ID %q: %v", album.ID, err)
s.jsonError(w, http.StatusInternalServerError, ErrorDatabase, nil)
return
}
s.writeJSON(w, http.StatusCreated, album)
}更新:如一位留言者所指出的,讓資料庫自動產生唯一的專輯 ID,而不是由使用者自行設定,會是更好的作法。
並行處理
原始程式碼在你嘗試存取 GET 端點的同時有人正在 POST 新增專輯時,會發生資料競爭。顯然,使用 SQL 資料庫就能解決這個問題,因為這類資料庫本身就具備並行安全。但要在存取記憶體內結構時加上 mutex 鎖其實並不難。
在這個案例中,我使用 sync.RWMutex,因為專輯被瀏覽的頻率幾乎肯定會比新增的頻率高很多。因此我在讀取時加上 RLock/RUnlock,在寫入時則使用 Lock/Unlock。
或許更有趣的是,我加入了一個測試,如果沒有加鎖,在 Go 的 race detector 下就會失敗——想看看效果的話,可以試著把加鎖和解鎖的呼叫註解掉,然後執行 go test -race。
這個測試會啟動一堆 goroutine,每一個都會對三個端點發出請求,包含讀取和寫入:
func TestConcurrentRequests(t *testing.T) {
server := newTestServer()
for i := 0; i < 100; i++ {
go func(i int) {
result := serve(t, server, newRequest(t, "GET", "/albums", nil))
ensureStatus(t, result, http.StatusOK)
albumID := "c" + strconv.Itoa(i)
body := `{"id": "` + albumID + `", "title": "T", "artist": "A"}`
result = serve(t, server, newRequest(t, "POST", "/albums", strings.NewReader(body)))
ensureStatus(t, result, http.StatusCreated)
result = serve(t, server, newRequest(t, "GET", "/albums/"+albumID, nil))
ensureStatus(t, result, http.StatusOK)
}(i)
}
}十進位金額
一般來說,用二進位浮點數來儲存和處理金額是個不好的主意——你無法精確儲存十進位小數(分),而且在對這些數值進行運算時誤差會不斷累積。
為了解決這個問題,我將專輯的 Price 欄位從 float64 改為 int,這樣就能精確地以整數的分來儲存。這是精確儲存金額常見的方式之一。另一種方式則是使用十進位運算函式庫,例如 shopspring/decimal。
JSON 錯誤
對 API 客戶端來說,如果網路服務永遠都回傳 JSON,即使是 Not Found 這類錯誤也會比較友善。這樣客戶端就可以用單一的程式路徑,永遠將回應解碼為 JSON。
在我的版本中,我讓所有錯誤都以 JSON 回傳,使用一個小型的 jsonError 輔助函式,它會呼叫前面提到的 writeJSON 輔助函式:
// jsonError writes a structured error as JSON to the response, with
// optional structured data in the "data" field.
func (s *Server) jsonError(w http.ResponseWriter, status int,
error string, data map[string]interface{}) {
response := struct {
Status int `json:"status"`
Error string `json:"error"`
Data map[string]interface{} `json:"data,omitempty"`
}{
Status: status,
Error: error,
Data: data,
}
s.writeJSON(w, status, response)
}通常「data」欄位是空的,但對於 Bad Request 錯誤來說,提供給呼叫端更多關於哪裡出錯的資訊會很有用(例如上面展示的驗證程式碼)。
Error 欄位是幾個已定義的 JSON 錯誤碼常數之一,例如 ErrorValidation。
方法不符
這是個非常簡單的細節,但 Gin(以教學程式碼使用的預設設定來說)當 URL 正確但找不到對應方法時,會回傳 404 Not Found 而非 405 Method Not Allowed。
如路由程式碼所示,我已將其改為在這些情況下回傳 HTTP 405 狀態碼。
測試
我為伺服器加入了許多測試:這些測試涵蓋所有端點,以及錯誤行為、驗證問題等等。
透過 go test -coverprofile 來看測試涵蓋率,顯示我已經測試了除了最精簡的 main 函式和 writeJSON 中難以測試的錯誤處理部分(在實務上幾乎不可能發生)之外的所有程式碼。一般來說,我認為追求 100% 測試涵蓋率並不是合理的目標,但在這裡能如此輕易地涵蓋這麼多程式碼,感覺很不錯。
這些測試都遵循相同的基本模式:建立一個測試用伺服器,透過 httptest.ResponseRecorder 執行一個或多個請求,然後確認回應是否正確——包含狀態碼和 JSON 資料。
我實作了幾個測試輔助函式(標記為 T.Helper),用來建立新請求、處理單一請求、解析 JSON 回應等等。這些輔助函式每一行都不多,但能大幅減少測試中的重複樣板程式碼。
以下是一個測試範例,以及 ensureStatus 輔助函式:
func TestGetAlbums(t *testing.T) {
server := newTestServer()
result := serve(t, server, newRequest(t, "GET", "/albums", nil))
ensureStatus(t, result, http.StatusOK)
var got []testAlbum
unmarshalResponse(t, result, &got)
want := []testAlbum{
{ID: "a1", Title: "9th Symphony", Artist: "Beethoven", Price: 795},
{ID: "a2", Title: "Hey Jude", Artist: "The Beatles", Price: 2000},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("bad response: got vs want:\n%#v\n%#v", got, want)
}
}
func ensureStatus(t *testing.T, response *http.Response, want int) {
t.Helper()
if response.StatusCode != want {
t.Fatalf("bad status code: got %d, want %d", response.StatusCode, want)
}
}請注意,我並沒有單獨測試伺服器所使用的 MemoryDatabase 實作。相反地,它的功能是作為整體伺服器測試的一部分來進行測試。在可行的情況下,使用記憶體內的假物件、避免記錄「mock」呼叫的麻煩,是一種簡單且更不容易出錯的測試寫法。
在這些測試中還有幾個有趣的地方:
- 表格驅動子測試的範例:
TestGetAlbum。 - 上面提到的並行測試:
TestConcurrentRequests。 - 使用
errorDatabase模擬物件來測試 handler 在資料庫發生錯誤時是否正確回傳 500 Internal Server Error:TestDatabaseErrors。
資料庫介面
Go 的介面既強大又有點獨特:你可以實作一個具體型別,例如帶有各種存取方法的資料庫結構,而實作本身不需要宣告它實作或繼承了什麼。只要寫程式碼就好。
接著,使用資料庫的物件——在這個案例中是 Server——會定義一個只包含它所需方法的介面(這很可能只是實作方法中的一部分)。在我們的例子中,它看起來像這樣:
// Server is the album HTTP server.
type Server struct {
db Database
log *log.Logger
}
// Database is the interface used by the server to load and store albums.
type Database interface {
// GetAlbums returns a copy of all albums, sorted by ID.
GetAlbums() ([]Album, error)
// GetAlbumsByID returns a single album by ID, or ErrDoesNotExist if
// an album with that ID does not exist.
GetAlbumByID(id string) (Album, error)
// AddAlbum adds a single album, or ErrAlreadyExists if an album with
// the given ID already exists.
AddAlbum(album Album) error
}
var (
ErrDoesNotExist = errors.New("does not exist")
ErrAlreadyExists = errors.New("already exists")
)如你所見,Server 擁有一個 Database,它可能是像我所定義的 MemoryDatabase 那樣的記憶體內資料庫,也可能是位於磁碟上,或是使用外部的 SQL 資料庫。或者,它也可能是像我們在 TestDatabaseErrors 中用來測試資料庫錯誤處理、永遠回傳錯誤的 errorDatabase。
定義一個好的介面需要一些 API 設計的考量。我一開始沒有加入錯誤回傳值,AddAlbum 函式只會回傳一個「是否真的有新增?」的布林值。不過,真正的資料庫需要回傳錯誤,所以我們一開始就做好完善的錯誤處理會比較好。
請注意 GetAlbumByID 和 AddAlbum 的文件註解中,如何描述在專輯不存在(或已存在)時會回傳的特殊錯誤值。這讓 handler 可以檢測這個錯誤值(使用 == 或 errors.Is),並向呼叫端回傳適當的 HTTP 狀態碼。
在更大的專案中,Server 和 Database 很可能會定義在 server 套件中,而 MemoryDatabase 則可能定義在另一個獨立的 testdb 套件中。為了簡單起見(這個專案只有幾百行程式碼),我把所有東西都放在同一個 main.go 檔案中。在 Go 中有個不錯的經驗法則:只有在真正需要時,才將東西拆分到不同的套件中。
資料庫實作
對於我的資料庫實作,我仍像原始教學一樣使用簡單的記憶體內資料庫。不過,它現在是透過結構來實作(以符合上述的 Database 介面),並且我加入了鎖定來修正那些並行問題。以下是完整的程式碼:
// MemoryDatabase is a Database implementation that uses a simple
// in-memory map to store the albums.
type MemoryDatabase struct {
lock sync.RWMutex
albums map[string]Album
}
// NewMemoryDatabase creates a new in-memory database.
func NewMemoryDatabase() *MemoryDatabase {
return &MemoryDatabase{albums: make(map[string]Album)}
}
func (d *MemoryDatabase) GetAlbums() ([]Album, error) {
d.lock.RLock()
defer d.lock.RUnlock()
// Make a copy of the albums map (as a slice)
albums := make([]Album, 0, len(d.albums))
for _, album := range d.albums {
albums = append(albums, album)
}
// Sort by ID so we return them in a defined order
sort.Slice(albums, func(i, j int) bool {
return albums[i].ID < albums[j].ID
})
return albums, nil
}
func (d *MemoryDatabase) GetAlbumByID(id string) (Album, error) {
d.lock.RLock()
defer d.lock.RUnlock()
album, ok := d.albums[id]
if !ok {
return Album{}, ErrDoesNotExist
}
return album, nil
}
func (d *MemoryDatabase) AddAlbum(album Album) error {
d.lock.Lock()
defer d.lock.Unlock()
if _, ok := d.albums[album.ID]; ok {
return ErrAlreadyExists
}
d.albums[album.ID] = album
return nil
}除了 mutex 之外,與原始作法唯一顯著的差異是改用以 ID 為索引的 map 而非 slice 來儲存專輯。這讓透過 ID 查詢的時間複雜度為常數。
然而,由於 Go 的 map 沒有固定的走訪順序,我讓 GetAlbums 依 ID 排序,以確保它以一致的順序回傳專輯。原始程式碼(或許是無意的?)是以從舊到新的順序回傳。如果使用真正的資料庫,你可能會使用 ORDER BY 子句,依某些對使用者有意義的條件來排序,例如標題。
關注點分離
這一點相當自然地源自資料庫介面:在原始程式碼中,像 JSON 序列化這類的 HTTP handler 程式碼與資料庫程式碼混雜在一起。資料庫介面強制實現了關注點分離,讓測試資料庫錯誤處理變得更容易。等到需要時,要替換成真正的資料庫也會變得非常直接——只要新增一個 SQLDatabase 結構,並用 SQL 查詢來實作它的方法即可。
結論
重寫並嘗試改進這份程式碼是個有趣的練習,希望你也從中獲得樂趣或學到一些東西。我由衷希望它變得更穩健、更易於維護,也避免了學習和更新第三方相依套件所帶來的麻煩。
完整原始碼請見 GitHub 上的 benhoyt/web-service-stdlib。
如果你有任何回饋,或對改進我的程式碼或本文有任何建議,歡迎告訴我!
隨機一篇部落格
留言
登入後參與討論