Go Programming Blueprints(《Go 程式設計藍圖》)— Mat Ryer(馬特·萊爾)著

- 我的評分:8 / 10
- 購買連結
我是馬特·萊爾作品的粉絲,他的部落格文章對我撰寫 Go 的方式產生了深遠的影響。我覺得這本書表現好壞參半。有些章節非常精彩,讓我學到寶貴的 Go 課程,有些章節則讓人感到枯燥,過於糾結在第三方函式庫的細節上。整體而言,我仍會向自認是初學者或中階的 Go 程式設計師推薦這本書。
我喜歡的部分
- 範例應用程式種類多元,能在貼近真實的情境中很好地展示 Go 的特性。
- 書中收錄了非常優雅的 Go 程式碼,讓我學到好幾個新的慣用語法模式。
- 以有趣的方式運用 Go 標準函式庫。
- 終於讓我搞懂了以往一直無法理解的 HTTP context。
- 提供無 DRM 限制的格式。
我不喜歡的部分
- 大多數範例聚焦於高度可擴展的應用程式,而非我平常撰寫的單一伺服器 Go 應用程式。
- 本書過度依賴厚重的 Google 函式庫(例如 Google Maps、OAuth、gRPC、AppEngine)。
- 許多範例深入探討特定函式庫的細節,而非與 Go 相關的解決方案核心。
- 推薦了幾個極度不安全的軟體實務:
- 建議開發者在不確定該指派何種權限時,將
0777作為預設的位元遮罩。 - 未防範目錄遍歷(directory traversal)攻擊,導致範例應用程式出現任意寫入漏洞,可能造成遠端程式碼執行。
- 未防範針對使用者上傳功能的簡易阻斷服務攻擊。
- 建議開發者在不確定該指派何種權限時,將
- 文字編輯與程式碼中的錯誤檢查都很粗糙。
- 存在大量粗心的文法與程式碼錯誤。
- 使用者已提交修正,但多年來一直被忽略。
- 在原生 JavaScript 就能同樣甚至更好地完成的地方,卻用 jQuery 把範例複雜化。
- Bash 腳本範例顯得草率。
- 整本書的程式碼品質不一致。
- 有些範例優雅且直覺,有些則像是初稿。
- 有兩個獨立的 GitHub 儲存庫:一個來自作者,一個來自出版社。
- 作者的儲存庫似乎才是正確的版本。
- 書中提供了在 Windows 上執行範例的說明,但感覺像是未經測試的附加內容。
- 部分範例因第三方相依套件已消失而無法再編譯。
重點整理
Go 語言與標準函式庫技巧
Signal channels(訊號通道)
- Signal channels 是在 Go 中實作執行緒安全事件的慣用方式。
- Signal channels 只是一個型別為
struct{}的chan- Signal channels 不傳遞任何資料——它們只會發出事件已發生的訊號。
- Twitter 票選應用程式是使用 signal channels 的良好範例,可用於:
- 讓客戶端中斷伺服器。
- 指示背景處理程序何時完成工作。
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原生支援不同的時間單位,例如55s或10m。- 也就是說,當你將
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.Minute 是 updateValue 的引數,但它出現在整個 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 而只需修改一個檔案。
萊爾使用輔助函式 decode 和 respond 來隱藏編碼細節,讓你的路由處理器看起來像這樣:
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)
}接著,decode 和 respond 分別處理 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)來擴展系統
- 結合可水平擴展服務的精彩範例。
- 使用 NSQ 來發布訊息。
- 使用 Twitter streaming API 從 Twitter 讀取即時資料。
- 使用 MongoDB 來儲存資料。
- 看到一個高度可擴展卻由簡單元件組成的系統,非常酷。
- 使用 HTTP 連線中的自訂傳輸函式來自訂底層 TCP 連線的低階行為。
- 如何覆寫預設的訊號處理器,以便在應用程式從作業系統接收到
SIGINT或SIGTERM訊號時執行自訂清理工作的良好範例。
隨機一篇部落格