Go Programming Blueprints by Mat Ryer

Michael Lynch

Go Programming Blueprints(《Go 程式設計藍圖》)— 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 票選應用程式是使用 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(視線清晰度)

書中提到了「line of sight」的概念,但我認為萊爾在他的部落格上解釋得更好。

如果程式碼中存在深層的脈絡與條件巢狀結構,就會變得難以閱讀,而當條件式的分支相距甚遠時,也很難維持脈絡。萊爾主張以讓邏輯盡量靠近螢幕左緣的方式來組織程式碼。

視線清晰度不佳

當 line of sight 不佳時,邏輯會深層巢狀,且條件區塊會很大:

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")
}

視線清晰度良好

要改善 line of sight,可以反轉條件式的邏輯,讓程式在發生錯誤時提早結束,然後將其餘邏輯保留在條件式之外:

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 網頁程式設計作為嗜好已有五年,直到讀了這本書之前,我從未理解 context.Context 在 HTTP 處理器中的用途。第 6 章提供了很好的說明,但我會在此試著摘要。

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

要達成此目的,你可以建立一個 HTTP 中介軟體函式。中介軟體函式會以鏈狀方式運作,因此多個中介軟體函式可以依序處理同一個 HTTP 請求。中介軟體函式會透過 context.Context 將資料傳遞給後續的 HTTP 處理器。

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

type contextKey struct {
  name string
}

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

由於一些我仍無法完全理解的原因,這個鍵必須是一個包含字串的結構,而不是單純的字串。

更新(2023-01-02):我一開始很困惑為什麼 contextKey 是一個包含字串的結構,而不是單純的字串。在書中,萊爾解釋這個決定是為了防止與具有相同值的其他鍵發生衝突,但我不明白為什麼開發者不乾脆避免為不同用途重複使用同一個鍵。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 輔助函式

萊爾的 HTTP 編碼輔助模式

萊爾主張將編碼格式抽象化,讓 HTTP 處理器與交換格式無關。這樣一來,如果你的介面使用 JSON,你可以將其改為 protobuf 而只需修改一個檔案。

萊爾使用輔助函式 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)
  }
}

我如何改寫萊爾的編碼輔助模式

我喜歡萊爾的輔助方法概念,但我認為它為了太少的好處付出了過高的抽象成本。你多久會重寫一次網頁應用程式以使用不同的編碼方案?

此外,你無論如何都會洩漏抽象,因為路由處理器必須在結構中指定 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 解碼,所以我的 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) 程式碼片段,但它只有一行,所以不是什麼大問題。

對客戶端隱藏內部結構細節

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

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"
}

目前為止一切順利。但一個月後,你發現想調整內部結構以傳遞更多資料,例如使用者的電子郵件地址和密碼雜湊:

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 結構中新增欄位時,他們並沒有動到 handleUsersGet,除非他們定期檢查應用程式的原始 HTTP 流量,否則不會注意到資料外洩。

我很擔心在自己的應用程式中犯下這類錯誤,所以我總是很好奇其他人如何處理這個問題。

萊爾的 Public 方法模式

萊爾提議透過為同時具有內部與外部表示的結構新增一個 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")
}

我喜歡萊爾的技巧,我認為如果你在程式碼庫中建立這種慣例,它會運作得很好,但它不是我個人在 Go 中解決此問題最喜歡的方法。

我對萊爾這個技巧的主要疑慮是它違反了封裝性。我偏好讓內部型別盡可能簡單,並盡量減少對客戶端如何使用資料的假設。新增一個 Public 方法意味著該型別預先假設了客戶端將如何使用資料,並迫使所有端點公開相同的欄位。

我偏好的細節隱藏方法

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

通常,我會使用在行內宣告的匿名結構,這樣甚至不需要另一個具名型別:

// 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,
  })
}

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

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

值得一提的有趣章節

使用 WebSockets 的聊天應用程式

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

加入使用者帳號

  • 串連 HTTP 處理器的良好範例。

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

  • 僅這一章就值回書價。
  • Horizontally scaling(水平擴展):透過增加節點來擴展系統,以提升可靠性或效能
  • Vertically scaling(垂直擴展):透過增加個別節點的資源(例如增加 RAM 或 CPU)來擴展系統
  • 結合可水平擴展服務的精彩範例。
  • 看到一個高度可擴展卻由簡單元件組成的系統,非常酷。
  • 使用 HTTP 連線中的自訂傳輸函式來自訂底層 TCP 連線的低階行為。
  • 如何覆寫預設的訊號處理器,以便在應用程式從作業系統接收到 SIGINTSIGTERM 訊號時執行自訂清理工作的良好範例。

原文由 Michael Lynch 發布

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