Go Programming Blueprints by Mat Ryer

Michael Lynch

Mat Ryer 著《Go Programming Blueprints》

原文由 Michael Lynch 發布,訂閱此部落格

我是 Mat Ryer 作品的粉絲,他的部落格文章對我寫 Go 的方式產生了深遠的影響。這本書我覺得好壞參半。有些章節非常精彩,讓我學到寶貴的 Go 課程,有些則讓人感到乏味,而且過於糾結在第三方函式庫的細枝末節。整體來說,我還是會推薦給自認是 Go 初學者或中階開發者的讀者。


我喜歡的地方

  • 範例應用程式種類多元,很好地在貼近真實的情境中展示了 Go 的特性。
  • 書中收錄了非常優雅的 Go 程式碼,讓我學到好幾種新的慣用寫法。
  • 以有趣的方式運用 Go 標準函式庫。
  • 終於讓我搞懂了以前一直無法理解的 HTTP context。
  • 提供無 DRM 限制的格式。

我不喜歡的地方

  • 大多數範例都聚焦於高度可擴展的應用,而非我平常撰寫的單一伺服器 Go 應用。
  • 這本書過度依賴重量級的 Google 函式庫(例如 Google Maps、OAuth、gRPC、AppEngine)。
    • 許多範例深入探討特定函式庫的細節,而非與 Go 相關的解法核心。
  • 推薦了幾種極度不安全的軟體實務:
  • 文字編輯粗糙,程式碼中的錯誤檢查也不確實。
    • 書中充斥著大量粗心的文法與程式碼錯誤。
    • 使用者已 提交修正,但多年來一直被忽略。
  • 在許多可用原生 JavaScript 就能同樣甚至更好地完成的地方,卻用 jQuery 把範例複雜化了。
  • Bash 腳本範例給人一種草率的感覺。
  • 整本書的程式碼品質不一致。
    • 有些範例優雅直觀,有些則像是初稿。
  • 有兩個獨立的 GitHub 儲存庫:一個 來自作者,一個 來自出版社
  • 書中雖有在 Windows 上執行範例的說明,但感覺像是未經測試的附帶補充。
  • 有些範例因為第三方相依套件已經消失而無法再編譯。

重點收穫

Go 語言與標準函式庫技巧

Signal channels

  • Signal channels 是在 Go 中實作執行緒安全的事件的慣用方式。
  • Signal channels 其實就只是型別為 struct{}chan
    • Signal channels 不會傳遞任何資料——它們只是用來表示某個事件已經發生。
    • Twitter votes 應用程式就是使用 signal channels 的好例子,用來:
      1. 讓客戶端中斷伺服器。
      2. 指示背景處理程序何時完成工作。

time.Ticker

我以前從沒看過 time.Ticker 這個型別,還曾不小心 自己重新實作了一個類似的版本。它是一種以固定時間間隔執行程式碼的簡單方法:

for range time.NewTicker(5 * time.Minute).C {
  // Execute this code every five minutes.
}

我在 PicoShare 中使用 time.Ticker 來排程定期的資料庫維護

flags.Duration 驚人地靈活

  • flags.Duration 原生就支援不同的時間單位,例如 55s10m
    • 也就是說,當你把 flags.Duration 當作命令列旗標使用時,你的命令列介面就可以接受像 --interval 10m 這樣的旗標,而 flags 套件會自動幫你解析成 time.Duration

將測試套件與正式程式碼分離

  • 在與正式程式碼不同的套件中撰寫測試,能產生更好的測試。
    • 例如,在同一個目錄下,為 foo 套件撰寫名為 foo_test 套件的測試。
    • 通常 Go 的工具不允許同一個資料夾中有多個套件,但對測試做了例外。
  • 獨立的 _test 套件確保測試只能存取正式套件中公開的成員。
    • 這會促使測試去驗證對外可見的行為,而非內部實作細節。

把函式參數放在參數列表的最後面

如果你的函式接受函式作為參數,請把它們放在參數列表的最後面。否則,閱讀者很難分辨哪個引數是屬於內層函式、哪個是屬於外層函式的。

不佳的參數順序

假設你有一個名為 updateValue 的函式,它會輪詢某個值的變更並定期更新本地副本,因此需要接受一個 SetValFn

type SetValFn func(key, value string) bool

如果 SetValFn 參數是第一個引數,在函式定義中看起來一切正常:

func updateValue(setFn SetValFn, interval time.Duration) {
  for range time.NewTicker(interval).C {
    value := fetchValue()
    setFn("somekey", value)
  }
}

但當要呼叫 updateValue 時,呼叫端就會變得難以閱讀:

updateValue(func(key, value string) bool {
  if err := DB.SetKey(key, value); err != nil {
    return false
  }
  return true
}, 5*time.Minute) // Which function call is this for?

微妙之處在於,5*time.MinuteupdateValue 的一個引數,但它出現在整個 SetValFn 內嵌函式定義之後,因此很難注意到它與 updateValue 的關聯。

較佳的參數順序

上述範例更好的改寫方式,就是確保把函式引數放在列表的最後:

// Reorder arguments so that SetValFn is last
func updateValue(interval time.Duration, setFn SetValFn) {

這樣一來,在呼叫端就能更清楚看出兩個引數都是屬於 updateValue 的:

updateValue(5*time.Minute, func(key, value string) bool {
  if err := DB.SetKey(key, value); err != nil {
    return false
  }
  return true
})

在程式碼中優先考慮「視線清晰度」

書中有提到「視線清晰度(line of sight)」的概念,不過我認為 Ryer 在他的部落格上解釋得更好。

如果程式碼中存在深層的巢狀結構與條件判斷,就會變得難以閱讀;而當條件分支相距很遠時,也很難維持對上下文的掌握。Ryer 主張以讓邏輯盡量靠近螢幕左側的方式來組織程式碼。

視線不佳的範例

當視線不佳時,邏輯會被深深巢狀,條件區塊也會很大:

if something.OK() {
  something.Lock()
  defer something.Unlock()
  err := something.Do()
  if err == nil {
    stop := StartTimer()
    defer stop()
    log.Println("working...")
    doWork(something)
    <-something.Done()
    log.Println("finished")
    return nil
  } else {
    return err
  }
} else {
  return errors.New("something not ok")
}

視線良好的範例

要改善視線清晰度,你可以反轉條件判斷的邏輯,在發生錯誤時提早返回,然後讓其餘邏輯保持在條件之外:

if !something.OK() {  // flipped
  return errors.New("something not ok")
}
something.Lock()
defer something.Unlock()
err := something.Do()
if err != nil {       // flipped
  return err
}
stop := StartTimer()
defer stop()

log.Println("working...")
doWork(something)
<-something.Done()
log.Println("finished")
return nil

在 HTTP 處理器中使用 context

我以業餘性質做 Go 網頁程式設計已經五年了,直到讀了這本書才真正理解在 HTTP 處理器中使用 context.Context 的意義。第 6 章提供了很好的解釋,但我在此試著做個摘要。

假設你的網頁應用程式要求使用者在每次 HTTP 請求中都提供 API 金鑰。它可以是標頭、URL 查詢參數或 Cookie,但為求簡單,我們就假設它是查詢參數。你預期使用者會用像 /foo?key=abc123 這樣的金鑰來呼叫你的 API。而你想透過確保每個請求都有正確的 API 金鑰來保護所有端點。

要做到這點,你可以建立一個 HTTP 中介層(middleware)函式。中介層函式會以鏈狀方式運作,因此多個中介層函式可以依序處理同一個 HTTP 請求。中介層函式會透過 context.Context 將資料傳遞給後續的 HTTP 處理器。

為了強制驗證 API 金鑰,我們首先需要在 Context 物件中建立一個用來儲存 API 金鑰的鍵:

type contextKey struct {
  name string
}

var contextKeyAPIKey = &contextKey{"api-key"}

基於我仍無法完全理解的原因,這個鍵需要是一個包含字串的 struct,而不是單純的字串。

更新(2023-01-02):一開始我很困惑為什麼 contextKey 是一個包含字串的 struct,而不是單純的字串。書中 Ryer 解釋這樣的設計可以避免與具有相同值的其他鍵發生衝突,但我當時不懂為什麼開發者不能乾脆避免為不同用途重複使用相同的鍵。Matthew Riley 為我釐清了這個行為,讓我明白區域型別可以避免跨套件的衝突,而單純的字串則無法做到。

如果你使用像 const contextKeyToken := "token" 這樣的 context 鍵,而另一個處理相同請求的套件也使用了 "token" 這個鍵,那麼你們就會互相覆蓋對方的 context 值。透過在你的套件內定義一個自訂的區域型別,你就能確保 Context 不會把其他套件的 token 視為與你相同的鍵,因為它們的型別不同。

既然已經定義好 context 鍵,接著就可以像這樣建立一個中介層函式:

func withAPIKey(fn http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    key := r.URL.Query().Get("key")
    if key != "abc123" {
      http.Error(w, "Invalid API key", http.StatusUnauthorized)
      return
    }
    // Add the API key to the request context.
    ctx := context.WithValue(r.Context(), contextKeyAPIKey, key)
    fn(w, r.WithContext(ctx))
  }
}

在定義路由時,用 withAPIKey 中介層把請求處理器包起來:

mux := http.NewServeMux()
mux.HandleFunc("/foo", withAPIKey(s.handleFoo))

withAPIKey 中介層保證了請求中的 API 金鑰是有效且存在的。如果在 withAPIKey 下游的任何請求處理器需要存取 API 金鑰,就可以呼叫這個輔助函式:

func APIKey(ctx context.Context) string {
  k := ctx.Value(contextKeyAPIKey)
  if k == nil {
    panic("no API key in request")
  }
  key, ok := k.(string)
  if !ok {
    panic("API key in request is not a string")
  }
  return key
}

handleFoo 處理器位於 withAPIKey 中介層的下游,因此它可以從請求的 context 中存取 API 金鑰:

func (s *Server) handleFoo(w http.ResponseWriter, r *http.Request) {
  log.Printf("handling /foo, API key=%v", APIKey(r.Context()))
}

HTTP 輔助函式

Mat Ryer 的 HTTP 編碼輔助模式

Ryer 主張將編碼格式抽象化,讓 HTTP 處理器不必關心交換格式。這樣一來,如果你的介面原本使用 JSON,日後想改成 protobuf 時,就只需要修改一個檔案。

Ryer 使用 decoderespond 這兩個輔助函式來隱藏編碼細節,讓你的路由處理器看起來像這樣:

func handleFooPost(w http.ResponseWriter, r *http.Request) {
  var payload struct {
    Username string `json:"username"`
    DisplayName string `json:"displayName"`
  }
  if err := decode(r, &payload); err != nil {
    respondErr(ctx, w, r, err, http.StatusBadRequest)
    return
  }

  // Do something with the request.

  response := struct {
      ID string `json:"id"`
    }{
      ID: "1234",
    }
  respond(ctx, w, r, response, http.StatusOK)
}

decoderespond 則分別負責處理 JSON 的反序列化與序列化:

// decode parses JSON from an HTTP request body.
func decode(r *http.Request, v interface{}) error {
  err := json.NewDecoder(r.Body).Decode(v)
  if err != nil {
    return err
  }
  if valid, ok := v.(interface {
    OK() error
  }); ok {
    err = valid.OK()
    if err != nil {
      return err
    }
  }
  return nil
}

// respond serializes response data to JSON in the body of an HTTP request.
func respond(ctx context.Context, w http.ResponseWriter, r *http.Request, v interface{}, code int) {
  var buf bytes.Buffer
  err := json.NewEncoder(&buf).Encode(v)
  if err != nil {
    respondErr(ctx, w, r, err, http.StatusInternalServerError)
    return
  }
  w.Header().Set("Content-Type", "application/json; charset=utf-8")
  w.WriteHeader(code)
  _, err = buf.WriteTo(w)
  if err != nil {
    log.Errorf(ctx, "respond: %s", err)
  }
}

我如何改良 Ryer 的編碼輔助模式

我喜歡 Ryer 的輔助函式概念,但我認為它為了太少的好處付出了過高的抽象成本。你有多常為了不同的編碼方式而重寫整個網頁應用程式呢?

而且,反正抽象還是會洩漏,因為路由處理器即便理論上不該知道格式,卻仍必須在 struct 中指定 JSON 標籤。

我也不喜歡用 JSON 來寫錯誤訊息,因為 Go HTTP 堆疊中的大多數元件在失敗時都是回傳純文字錯誤,所以如果錯誤是 JSON 格式,客戶端就必須同時處理格式正確的 JSON 和純文字兩種錯誤。直接一律用純文字發送錯誤訊息會更簡單。

對於成功的 JSON 回應,我會使用一個名為 respondJSON 的函式,像這樣:

func respondJSON(w http.ResponseWriter, data interface{}) {
  w.WriteHeader(http.StatusOK)
  w.Header().Set("Content-Type", "application/json")
  if err := json.NewEncoder(w).Encode(data); err != nil {
    log.Fatalf("failed to encode JSON response: %v", err)
  }
}

而 JSON 解碼我就直接 inline 處理,所以我的 handleFooPost 會長得像這樣:

func handleFooPost(w http.ResponseWriter, r *http.Request) {
  var payload struct {
    Username string `json:"username"`
    DisplayName string `json:"displayName"`
  }
  if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
    http.Error(w, "JSON is invalid", http.StatusBadRequest)
    return
  }

  // Do something with the request.

  respondJSON(w, struct {
      ID string `json:"id"`
    }{
      ID: "1234",
    })
}

我最後雖然會重複那段 json.NewDecoder(r.Body).Decode(&payload) 的程式碼,但它只有一行,所以影響不大。

對客戶端隱藏內部 struct 細節

網頁開發中一個影響所有語言的陷阱就是意外的資料外洩。假設你有一個用來表示使用者資料的內部 struct:

type User struct {
  Username string `json:"username"`
  DisplayName string `json:"displayName"`
}

你想公開一個像 /user?id=1234 這樣的 JSON API,於是你寫了類似這樣的程式碼:

func handleUserGet(w http.ResponseWriter, r *http.Request) {
  user, err := loadUser(r.URL.Query().Get("id"))
  if err != nil {
    http.Error(w, "Failed to load user", http.StatusInternalServerError)
    return
  }

  respondJSON(w, user)
}

當使用者查詢 /user 路由時,他們會得到該使用者的公開資訊:

curl https://example.com/user?id=1234
{
  "username": "alice123",
  "displayName": "Alice"
}

到目前為止都很好。但一個月後,你發現想調整內部 struct 以傳遞更多資料,例如使用者的電子郵件和密碼雜湊:

type User struct {
  Username string `json:"username"`
  DisplayName string `json:"displayName"`
  Email string `json:"email"`               // Add these for
  PasswordHash string `json:"passwordHash"` // internal operations.
}

即使你完全沒動到 handleUserGet,現在當使用者呼叫 /user 路由時,卻會拿到大量新資訊:

curl https://example.com/users?id=1234
{
  "username": "alice123",
  "displayName": "Alice",
  "email": "[email protected]",
  "passwordHash": "$2a$10$J5zqqeQgH80ScyOSeCNCD.1V3ApJ1ULYMwMEhOjG6j4SM1mqL84YO"
}

糟糕!你剛剛把所有人的電子郵件和密碼雜湊都外洩了。

我以前做滲透測試時,就在現實世界中發現好幾家公司犯了這種錯誤。這是個很微妙的錯誤,因為從開發者的角度來看,實作 handlerUserGet 時一切都如預期運作。當他們在 User struct 中新增欄位時,並沒有去動 handleUsersGet,所以除非他們平時就會例行檢查應用程式原始的 HTTP 流量,否則不會察覺到資料外洩。

我對在自己的應用程式中犯這類錯誤感到非常擔心,所以總是很好奇其他人是怎麼處理這個問題的。

Ryer 的 Public 方法模式

Ryer 建議透過為同時具有內部與外部表示形式的 struct 加上一個 Public 方法來解決上述問題:

type obj struct {
  value1 string
  value2 string
  value3 string
}

func (o *obj) Public() interface{} {
  return map[string]interface{}{"one": o.value1, "three": o.value3}
}

func TestPublic(t *testing.T) {
  is := is.New(t)

  o := &obj{
    value1: "value1",
    value2: "value2",
    value3: "value3",
  }

  v, ok := meander.Public(o).(map[string]interface{})
  is.Equal(true, ok)
  is.Equal(v["one"], "value1")
  is.Nil(v["two"])
  is.Equal(v["three"], "value3")
}

我喜歡 Mat Ryer 的技巧,也認為如果在程式碼庫中建立起這樣的慣例,它會運作得很好,但它不是我在 Go 中最喜歡的解決方案。

我對 Ryer 技巧的主要疑慮在於它違反了封裝性。我偏好讓內部型別盡可能簡單,並盡量減少對客戶端如何使用資料的假設。加入 Public 方法意味著該型別預先假設了客戶端會如何使用資料,而且會迫使所有端點都暴露相同的欄位。

我偏好的隱藏細節方法

在我的 Go 程式碼中,我偏好為對外資料使用不同的 struct。當我需要將資料發布給外部客戶端時,我會把資料從內部 struct 複製到外部 struct。

通常我會使用直接在行內宣告的匿名 struct,這樣甚至不需要再定義一個具名的型別:

// my internal data
type User struct {
  Username string
  DisplayName string
  Email string
  PasswordHash string
}

func handleUserGet(w http.ResponseWriter, r *http.Request) {
  user, err := loadUser(r.URL.Query().Get("id"))
  if err != nil {
    http.Error(w, "Failed to load user", http.StatusInternalServerError)
    return
  }

  // Copy the fields from User that I want to publish into a new anonymous
  // struct.
  respondJSON(w, struct {
    Username string `json:"username"`
    DisplayName string `json:"displayName"`
  }{
    Username: user.Username,
    DisplayName: user.DisplayName,
  })
}

我偏好這種方法有幾個原因:

  • 多了一層防止意外外洩的保護。
    • 即使有人不小心在對外型別中包含了內部 struct,也不會印出任何東西,因為內部 struct 的欄位沒有 JSON 標籤。
  • 它讓你回傳的資料更加明確。
  • 它讓你對資料有更細緻的控制。
    • 使用 Public 模式時,所有包含該型別的端點都必須以相同格式回傳資料;而使用上述方法時,每個端點都能自行決定要暴露哪些欄位以及用什麼格式。

值得一提的有趣章節

使用 WebSockets 的聊天應用程式

  • 展示如何使用 goroutine 與 WebSockets 的精彩範例。

加入使用者帳號

  • 示範如何串連 HTTP 處理器的好例子。

建構分散式系統與處理彈性資料

  • 單是這一章就值回書價了。
  • 水平擴展:透過增加節點來提升系統的可靠性或效能
  • 垂直擴展:透過增加單一節點的資源來擴展系統(例如增加記憶體或 CPU)
  • 結合水平可擴展服務的精彩範例。
  • 看到一個由簡單元件組成卻具有高度可擴展性的系統,真的很酷。
  • 在 HTTP 連線中使用自訂的 transport 函式,來客製化底層 TCP 連線的低階行為。
  • 示範如何覆寫預設的信號處理器,以便在應用程式收到作業系統的 SIGINTSIGTERM 信號時執行自訂的清理工作。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言