Golang 大補帖:我那個「偶爾」會拿出來用的私藏工具與套件清單!(Part 2)

Golang 大補帖:我那個「偶爾」會拿出來用的私藏工具與套件清單!(Part 2)

TL;DR

嗨各位!我又來了!

延續上一篇我們聊到的 Golang 組態管理和日誌技巧,相信大家已經對這些基礎工具有了初步認識。不過,Golang 的強大可不只如此!一個成熟的專案,勢必會涉及到更複雜的 Web 服務搭建、資料庫操作、效能優化、良好的架構組織以及不可或缺的程式碼測試。

本系列文章投影片
Amazingly Simple Graphic Design Software – Canva
Amazingly Simple Graphic Design Software – Canva

所以,在這篇文章裡,我們將更深入地探索幾個關鍵領域,包括:

  • Web 框架
    如何利用 Gin 建立高效且穩固的 API 服務。
  • HTTP Client
    如何利用 Resty 透過簡短的程式碼,發起 HTTP 請求。

準備好了嗎?讓我們一起進入 Golang 的進階世界,看看如何運用這些實用技巧和範例,讓你的專案更上一層樓!

Web 框架:打造高效的 API 與服務

在 Golang 中開發 Web 應用程式,選擇一個合適的框架能大幅加速開發進程。一個設計精良、功能完善的 Web 框架,不只是一個能處理 HTTP 請求的工具,它更像是一整套為 Web 服務量身打造的「工具箱」。一個好用的 Web 框架,通常會具備以下幾個核心功能,這些功能共同構成了開發高效、穩定 Web 服務的基礎:

  • 路由 (Router)
    這是 Web 框架最基本也最重要的功能,負責將傳入的 HTTP 請求導向到正確的處理函數(Handler)。一個強大的路由器應該支援各種 HTTP 方法(GET, POST, PUT, DELETE 等)、路徑參數、正則表達式匹配等。
  • 請求數據驗證 (Validate Request Data)
    確保客戶端發送的請求數據符合預期格式和業務規則,是防止惡意輸入和保證應用程式穩定的關鍵。好的框架會提供便捷的機制來定義驗證規則。
  • 請求數據綁定 (Bind Request Data)
    自動將 HTTP 請求中的數據(例如 JSON、表單數據、URL 查詢參數)解析並綁定到 Go 語言的 struct 或變數中,大大簡化了數據處理的過程。
  • HTTP 伺服器設定 (HTTP Server Settings)
    提供靈活的配置選項,讓開發者能夠調整伺服器的行為,例如監聽的端口、超時時間、TLS/SSL 設定等。
  • 優雅關閉 (Graceful Shutdown)
    在應用程式需要停止或重啟時,能夠平穩地處理現有請求並釋放資源,而不是直接中斷連接,這對於生產環境的服務穩定性至關重要。
  • 訪問日誌 (Access Log)
    自動記錄每個 HTTP 請求的詳細資訊,如請求路徑、方法、狀態碼、響應時間、客戶端 IP 等,這對於監控、分析和故障排除非常有幫助。
  • 中間件 (Middleware)
    提供一種機制,允許你在請求被處理之前或之後,插入額外的處理邏輯。常見的中間件功能包括身份驗證、日誌記錄、錯誤處理、跨域資源共享 (CORS) 等。
  • 測試支援 (Testing)
    一個好的框架會讓 Web 服務的測試變得容易,提供方便的工具或模式來模擬 HTTP 請求,並驗證響應,從而確保程式碼的正確性。

Golang 主流 Web 框架一覽與比較

在 Golang 生態系中,Web 框架的選擇非常豐富,每個框架都有其獨特的優勢和適用場景。以下列出幾個目前非常流行且廣泛使用的 Web 框架,並簡要比較它們的特點和受歡迎程度:

  • Gin
    • 官方網站https://gin-gonic.com
    • GitHubhttps://github.com/gin-gonic/gin
    • 簡介:Gin 以其極致的性能和簡潔的 API 聞名,是 Golang 中最受歡迎的 Web 框架之一。它基於 httprouter 實現,擁有高性能的路由,並提供豐富的內建中間件(如日誌、恢復、驗證等)。對於構建 RESTful API 和微服務,Gin 提供了非常高效且開發友好的體驗。
    • 受歡迎程度:極高,是 Golang 專案中應用最廣泛的框架之一。
  • Iris
    • 官方網站https://www.iris-go.com
    • GitHubhttps://github.com/kataras/iris
    • 簡介:Iris 是一個全功能的 Web 框架,號稱是 Golang 中最快的 Web 框架之一。它提供了比 Gin 更多的開箱即用功能,包括會話管理、WebSocket、MVC 模式、模板引擎等。Iris 旨在提供一個完整的解決方案,減少對第三方庫的依賴。
    • 受歡迎程度:高,擁有龐大的用戶群和活躍的社區。
  • Echo
    • 官方網站https://echo.labstack.com
    • GitHubhttps://github.com/labstack/echo
    • 簡介:Echo 以其高性能、輕量級和可擴展性而受到青睞。它提供一個簡單且高度優化的 HTTP 路由器,並支持各種中間件。Echo 的設計哲學是最小核心,但提供足夠的彈性來構建任何規模的 Web 應用。
    • 受歡迎程度:高,是 Gin 之外另一個非常受歡迎的輕量級選擇。
  • Beego
    • 官方網站https://doc.beego.com/en-US/beego/developing
    • GitHubhttps://github.com/beego/beego
    • 簡介:Beego 是一個全棧式的 MVC 框架,類似於 Python 的 Django 或 Ruby on Rails。它提供了路由、ORM、會話管理、緩存、日誌、配置文件讀取等幾乎所有 Web 開發所需的功能,旨在幫助開發者快速構建應用程式。
    • 受歡迎程度:中高,在一些需要快速搭建全棧應用的場景中非常受歡迎,尤其在中國大陸地區有較高的採用率。
  • Gorilla Mux
    • 官方網站https://gorilla.github.io
    • GitHubhttps://github.com/gorilla/mux
    • 簡介:Gorilla Mux 是 Gorilla 工具包中的一個強大 URL 路由器和分派器。它不是一個完整的框架,而是專注於路由功能,可以與 Go 標準庫的 net/http 包無縫集成。對於偏好自定義組件、不希望框架過於干預的開發者來說,Gorilla Mux 是個不錯的選擇。
    • 受歡迎程度:高,作為標準庫的擴展,廣泛應用於對性能和靈活性有較高要求的專案。
  • go-chi/chi
    • 官方網站https://go-chi.io
    • GitHubhttps://github.com/go-chi/chi
    • 簡介chi 是一個輕量級、快速且可組合的 HTTP 路由器,它完全兼容 Go 的 net/http 標準庫。chi 強調中間件的組合性,並提供了清晰的路由定義方式,非常適合構建微服務或簡單的 API。
    • 受歡迎程度:中高,近年來在追求輕量和標準庫兼容的開發者中越來越受歡迎。

深入探討 Gin 框架

鑑於 Gin 在性能、易用性和社群活躍度方面的優異表現,本篇文章將主要以 Gin 框架為例,深入探討其核心功能、實用技巧與最佳實踐。透過 Gin 的介紹,您將能更好地理解一個高效 Web 框架是如何運作的,並掌握快速構建穩定 Web 服務的技能。

範例:快速開始

用 Gin 建立一個最簡單的 Web 服務,並啟動一個 HTTP 伺服器來監聽請求,程式碼量驚人地少。你甚至只需要不到 10 行程式碼,就能讓一個功能齊全的 Web 服務跑起來!

Quickstart

讓我們來看看這個「Hello World」級別的 Gin 範例 (內容參考以上官方範例):

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	// 1. 建立一個 Gin 引擎實例,帶有預設的中間件 (如 Logger 和 Recovery)
	r := gin.Default()

	// 2. 定義一個 GET 請求的路由:當收到 "/ping" 路徑的 GET 請求時
	r.GET("/ping", func(c *gin.Context) {
		// 3. 在路由處理函數中,設定 HTTP 狀態碼為 200 (OK)
		//    並以 JSON 格式返回響應體 {"message": "pong"}
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
	})

	// 4. 運行伺服器,預設監聽在 0.0.0.0:8080 端口
	//    此行會阻塞程式,直到伺服器停止
	r.Run()
}

接著只需執行 go run your_main_file.go,你的 Gin Web 服務就會在 8080 端口上跑起來。你可以打開瀏覽器訪問 http://localhost:8080/ping,或者使用 curl http://localhost:8080/ping,你將會看到 { "message": "pong" } 的 JSON 回應。

範例:路徑參數與路由群組

作為一個高效的 Web 框架,Gin 在路由方面提供了非常彈性且強大的功能,讓你可以輕鬆定義複雜的路徑結構和組織你的 API。其中,路徑參數 (Path Parameters)路由群組 (Router Group) 是兩個非常實用的特性。

在設計 RESTful API 時,經常需要從 URL 路徑中提取動態的參數,例如 /users/123 中的 123 代表用戶 ID。Gin 允許你通過 :參數名 的方式來定義帶有參數的路由,並透過 c.Param("參數名") 來獲取這些參數。

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	router := gin.Default()

	// 這個處理器將匹配 /user/john 但不匹配 /user/ 或 /user/john/sand
	router.GET("/user/:name", func(c *gin.Context) {
		name := c.Param("name") // 從路徑中獲取名為 "name" 的參數
		c.String(http.StatusOK, "Hello %s", name) // 回傳字符串,顯示獲取到的名字
	})

	// 然而,這個處理器將匹配 /user/john 和 /user/john/sand
	// 如果沒有其他路由匹配 /user/john,它將重定向到 /user/john/
	router.GET("/user/:name/*action", func(c *gin.Context) {
		name := c.Param("name")       // 獲取名為 "name" 的參數
		action := c.Param("action")   // 獲取名為 "action" 的參數 (可以包含斜線)
		message := name + " is " + action // 組合訊息
		c.String(http.StatusOK, message) // 回傳組合後的訊息
	})

	router.Run(":8080")
}
範例:處理 Query String (查詢字符串)

Query String 通常用於 GET 請求,以 ?key=value&anotherKey=anotherValue 的形式附在 URL 後面。Gin 提供了 c.Query()c.DefaultQuery() 方法來輕鬆獲取這些參數。

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	router := gin.Default()

	// 這個請求將響應 URL 匹配 /welcome?firstname=Jane&lastname=Doe
	router.GET("/welcome", func(c *gin.Context) {
		// c.DefaultQuery() 嘗試從查詢字符串中獲取 "firstname"
		// 如果不存在,則使用預設值 "Guest"
		firstname := c.DefaultQuery("firstname", "Guest")

		// c.Query() 獲取 "lastname",如果不存在則返回空字符串
		// 這是 c.Request.URL.Query().Get("lastname") 的快捷方式
		lastname := c.Query("lastname")

		c.String(http.StatusOK, "Hello %s %s", firstname, lastname)
	})

	router.Run(":8080")
}
範例:處理 Request Body (請求體) 與數據驗證

POST、PUT 等請求通常會將數據放在 Request Body 中,常見的格式有 JSON、XML、表單數據等。Gin 提供了強大的數據綁定 (Data Binding) 功能,能夠自動將 Request Body 中的數據解析並綁定到你定義的 Go Struct 中,極大地簡化了數據處理的流程。

更棒的是,Gin 還整合了 go-playground/validator 這個非常流行的數據驗證庫,讓你可以在 Struct 字段上使用 binding 標籤 (tag) 來定義驗證規則,實現自動化的數據驗證。

package main

import (
	"net/http"
    
	"github.com/gin-gonic/gin"
)

// User 定義一個用戶的結構,包含綁定標籤和驗證標籤
type User struct {
	User     string `form:"user" json:"user" xml:"user" binding:"required"`     // user 字段,必須存在
	Password string `form:"password" json:"password" xml:"password" binding:"required"` // password 字段,必須存在
}

func main() {
	router := gin.Default()

	// 範例:綁定 JSON 數據 (user: "manu", password: "123")
	router.POST("/loginJSON", func(c *gin.Context) {
		var jsonUser User
		// c.ShouldBindJSON 嘗試將請求體綁定到 jsonUser Struct
		// 如果綁定失敗 (例如格式錯誤或 required 字段缺失),則返回錯誤
		if err := c.ShouldBindJSON(&jsonUser); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		// 數據驗證成功後,進行業務邏輯判斷
		if jsonUser.User != "manu" || jsonUser.Password != "123" {
			c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"})
			return
		}

		c.JSON(http.StatusOK, gin.H{"status": "you are logged in by JSON"})
	})

	// 範例:綁定 XML 數據
	/*
	<root>
	  <user>manu</user>
	  <password>123</password>
	</root>
	*/
	router.POST("/loginXML", func(c *gin.Context) {
		var xmlLogin User
		// c.ShouldBindXML 嘗試將請求體綁定到 xmlLogin Struct
		if err := c.ShouldBindXML(&xmlLogin); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		if xmlLogin.User != "manu" || xmlLogin.Password != "123" {
			c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"})
			return
		}

		c.JSON(http.StatusOK, gin.H{"status": "you are logged in by XML"})
	})

	router.Run(":8080")
}

如果想要了解更多更多不同種數據驗證格式個方式可以參考以下的連結:

validator package - github.com/go-playground/validator/v10 - Go Packages
範例:優雅關閉 (Graceful Shutdown):確保服務平穩停止

在開發和測試階段,我們通常會直接中斷 Web 服務的運行。但在生產環境中,粗暴地停止服務可能會導致正在處理中的請求中斷、資料丟失,或留下未關閉的資源。這時候,優雅關閉 (Graceful Shutdown) 就顯得尤為重要了。

優雅關閉指的是在接收到停止訊號時,服務能夠:

  1. 停止接收新的請求。
  2. 等待所有正在處理中的請求完成。
  3. 在設定的超時時間內,安全地關閉所有資源(如數據庫連接、緩存連接等)。

Gin 本身是基於 Go 標準庫的 net/http 伺服器,因此我們可以利用 Go 標準庫提供的 http.Servercontext 機制來實現優雅關閉。

請看以下範例程式碼,它展示了如何在 Gin 應用程式中實現優雅關閉:

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/gin-gonic/gin"
)

func main() {
	router := gin.Default() // 建立一個 Gin 引擎實例,帶有預設中間件
	router.GET("/", func(c *gin.Context) {
		time.Sleep(5 * time.Second)                   // 模擬一個需要較長時間處理的請求
		c.String(http.StatusOK, "Welcome Gin Server") // 回傳歡迎訊息
	})

	// 建立一個標準的 HTTP 伺服器實例
	srv := &http.Server{
		Addr:    ":8080", // 伺服器監聽的地址和端口
		Handler: router,  // 將 Gin 路由器設定為伺服器的處理器
	}

	// 在一個新的 goroutine 中啟動 HTTP 伺服器
	// 這樣主 goroutine 就可以繼續執行,監聽停止訊號
	go func() {
		// ListenAndServe 會阻塞,直到伺服器關閉或發生錯誤
		// http.ErrServerClosed 是伺服器優雅關閉時會返回的錯誤,可以忽略
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen: %s\n", err) // 如果不是優雅關閉錯誤,則紀錄致命錯誤並退出
		}
	}()

	// 等待中斷訊號來優雅地關閉伺服器,並設定 5 秒的超時
	quit := make(chan os.Signal, 1) // 建立一個緩衝大小為 1 的 channel,用於接收訊號
	// signal.Notify 監聽指定的系統訊號:
	// syscall.SIGINT (Ctrl+C)
	// syscall.SIGTERM (kill 命令預設發送的訊號)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit // 阻塞在此處,直到接收到訊號

	log.Println("Shutdown Server ...") // 接收到訊號,開始關閉伺服器

	// 建立一個帶有 5 秒超時的上下文
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel() // 確保在函數退出時取消上下文,釋放資源

	// 調用伺服器的 Shutdown 方法進行優雅關閉
	// 它會停止接收新請求,並等待現有請求在上下文超時前完成
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatal("Server Shutdown:", err) // 如果關閉過程中發生錯誤,則紀錄致命錯誤
	}

	// 捕獲上下文超時訊號
	select {
	case <-ctx.Done(): // 如果上下文因超時而結束
		log.Println("timeout of 5 seconds.") // 紀錄超時訊息
	}

	log.Println("Server exiting") // 伺服器已成功退出
}
範例:Gin Web 服務的測試:確保 API 品質

測試是任何健壯應用程式不可或缺的一部分,對於 Web 服務來說更是如此。為你的 Gin 路由和處理函數編寫單元測試,可以確保你的 API 能夠按照預期工作,並在程式碼修改時及時發現潛在問題。Gin 本身提供了良好的測試支持,結合 Go 標準庫的 testing 包和一些實用工具,我們可以輕鬆地為 Web 服務編寫測試。

以下範例展示了如何使用 Go 的 net/http/httpteststretchr/testify 庫來測試 Gin 的路由。為了更好地組織代碼,我們會將主應用程式邏輯和測試邏輯分別放在兩個文件中。

main.go 檔案:

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

// User 結構,用於綁定請求體
type User struct {
	Username string `json:"username"`
	Gender   string `json:"gender" binding:"required"`
}

// setupRouter 函數,用於建立並配置 Gin 路由器
// 將路由定義從 main 函數中抽離,方便測試時重複使用
func setupRouter() *gin.Engine {
	r := gin.Default() // 建立帶預設中間件的 Gin 引擎

	// 定義 GET /ping 路由
	r.GET("/ping", func(c *gin.Context) {
		c.String(http.StatusOK, "pong")
	})

	// 定義 POST /useradd 路由,用於處理用戶添加
	r.POST("/useradd", func(c *gin.Context) {
		var user User
		// 綁定 JSON 請求體到 User 結構
		if err := c.BindJSON(&user); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) // 綁定失敗則返回 400 錯誤
			return
		}
		c.JSON(http.StatusOK, user) // 成功綁定後,回傳綁定後的用戶數據
	})
	return r
}

func main() {
	r := setupRouter() // 獲取配置好的 Gin 路由器
	r.Run(":8080")     // 啟動 Gin 服務,監聽 8080 端口
}

main_test.go 檔案:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
)

// TestPingRoute 測試 GET /ping 路由
func TestPingRoute(t *testing.T) {
	// 獲取 Gin 路由器實例 (從 main.go 中的 setupRouter 函數)
	router := setupRouter()

	// 建立一個響應記錄器,用於捕獲 HTTP 響應
	w := httptest.NewRecorder()
	// 建立一個新的 HTTP GET 請求,路徑為 "/ping",請求體為 nil
	req, _ := http.NewRequest(http.MethodGet, "/ping", nil)

	// 讓 Gin 路由器處理這個請求,響應會寫入 w
	router.ServeHTTP(w, req)

	// 使用 testify/assert 庫進行斷言
	// 斷言 HTTP 狀態碼為 200 (OK)
	assert.Equal(t, http.StatusOK, w.Code)
	// 斷言響應體為 "pong"
	assert.Equal(t, "pong", w.Body.String())
}

// TestPostUserAdd 測試 POST /useradd 路由
func TestPostUserAdd(t *testing.T) {
	// 獲取 Gin 路由器實例
	router := setupRouter()

	// 建立響應記錄器
	w := httptest.NewRecorder()

	// 準備要發送的 JSON 數據
	exampleUser := User{
		Username: "testuser",
		Gender:   "male",
	}
	// 將 Struct 轉換為 JSON 字符串
	userJson, _ := json.Marshal(exampleUser)

	// 建立一個新的 HTTP POST 請求,路徑為 "/useradd",請求體為 JSON 字符串
	req, _ := http.NewRequest(http.MethodPost, "/useradd", strings.NewReader(string(userJson)))
	// 設定請求頭部的 Content-Type 為 application/json,非常重要!
	req.Header.Set("Content-Type", "application/json")

	// 讓 Gin 路由器處理這個請求
	router.ServeHTTP(w, req)

	// 斷言 HTTP 狀態碼為 200 (OK)
	assert.Equal(t, http.StatusOK, w.Code)

	// 將響應體解析回 User Struct,以便驗證其內容
	var receivedUser User
	json.Unmarshal(w.Body.Bytes(), &receivedUser)

	// 斷言響應中的 username 和 gender 是否與預期相符
	assert.Equal(t, "testuser", receivedUser.Username)
	assert.Equal(t, "male", receivedUser.Gender)
}

// TestPostUserAddInvalidData 測試 POST /useradd 處理無效數據的情況
func TestPostUserAddInvalidData(t *testing.T) {
	router := setupRouter()
	w := httptest.NewRecorder()

	// 發送無效的 JSON 數據 (缺少 required 字段或格式錯誤)
	invalidJson := `{"username": "testuser"}` // 缺少 gender
	req, _ := http.NewRequest(http.MethodPost, "/useradd", strings.NewReader(invalidJson))
	req.Header.Set("Content-Type", "application/json")

	router.ServeHTTP(w, req)

	// 斷言 HTTP 狀態碼為 400 (Bad Request),表示數據綁定失敗
	assert.Equal(t, http.StatusBadRequest, w.Code)

	// 也可以進一步驗證返回的錯誤訊息
	var response map[string]string
	json.Unmarshal(w.Body.Bytes(), &response)
	// 假設 Gin 綁定錯誤會返回包含 "error" 鍵的 JSON 訊息
	assert.Contains(t, response, "error")
}

如何運行測試:

在專案根目錄下,打開終端機並運行:

go test

如果想查看更詳細的測試輸出,可以使用:

go test -v

確保你的專案結構如下:

your_project/
  ├── main.go
  └── main_test.go

透過使用 Gin 的測試工具,你可以有效地為 Gin 應用程式編寫可靠的單元測試,確保每個 API 端點都能夠穩定運行。這對於維護和迭代複雜的 Web 服務至關重要。

HTTP Client:Golang 如何發起外部請求

在開發微服務或需要與第三方 API 互動的應用程式時,發起 HTTP 請求是核心功能之一。Golang 提供了標準庫來處理 HTTP 請求,同時也有許多優秀的第三方庫讓這項工作更加便捷和強大。本節我們將比較幾種常見的發起 HTTP 請求的方式。

Go 標準庫 net/http

Golang 的 net/http 包提供了構建 HTTP 客戶端最基礎也是最核心的能力。你可以使用它來創建請求、設定頭部、發送請求並處理響應。

優點:

  • 原生支持:無需引入第三方庫,直接使用 Go 語言內建功能。
  • 高度靈活:你可以精確控制請求的每一個細節。

缺點:

  • 程式碼冗長:對於常見的操作(如設定 JSON 請求體、處理錯誤響應、處理重定向等),需要撰寫較多的 boilerplate code(樣板程式碼)。
  • 缺乏進階功能:標準庫本身不提供開箱即用的重試機制、請求超時配置、請求/響應中間件、代理設置等進階功能。若要實現這些,需要自己手動編寫大量額外邏輯。
  • 錯誤處理複雜:處理各種網路錯誤、HTTP 狀態碼、JSON 解析錯誤等,需要較為細緻的錯誤判斷。

以下是一個使用 net/http 標準庫發起 GET 請求的簡單範例:

package main

import (
	"fmt"
	"io"
	"net/http"
	"time"
)

func main() {
	// 1. 建立一個 http.Client 實例
	// 建議使用 Client 實例來管理連接池和超時設置,而不是每次都使用 http.Get/Post
	client := &http.Client{
		Timeout: 10 * time.Second, // 設定請求超時
	}

	// 2. 建立一個新的 HTTP GET 請求
	req, err := http.NewRequest("GET", "https://gobyexample.com", nil) // nil 表示沒有請求體
	if err != nil {
		fmt.Printf("建立請求失敗: %v\n", err)
		return
	}

	// 3. (可選) 設定請求頭部
	req.Header.Add("Accept", "application/json")
	req.Header.Add("User-Agent", "Go-HTTP-Client/1.0")

	// 4. 發送請求
	resp, err := client.Do(req) // 使用 client.Do() 發送請求
	if err != nil {
		fmt.Printf("發送請求失敗: %v\n", err)
		return
	}
	defer resp.Body.Close() // 確保響應體在函數結束時關閉,避免資源洩漏

	// 5. 檢查 HTTP 響應狀態碼
	if resp.StatusCode != http.StatusOK {
		fmt.Printf("收到非預期狀態碼: %d %s\n", resp.StatusCode, resp.Status)
		return
	}

	// 6. 讀取響應體內容
	body, err := io.ReadAll(resp.Body) // 讀取所有響應內容
	if err != nil {
		fmt.Printf("讀取響應體失敗: %v\n", err)
		return
	}

	fmt.Println("響應狀態:", resp.Status)
	fmt.Println("響應頭部:", resp.Header)
	fmt.Println("響應體長度:", len(body))
	fmt.Println("響應體內容:", string(body)) // 如果內容很大,不建議直接打印
}

可以看到,即使是一個簡單的 GET 請求,也需要包含錯誤檢查、響應體關閉等標準流程。當涉及到 JSON 請求體、表單提交、文件上傳、重試邏輯、中斷請求等更複雜的場景時,程式碼會迅速膨脹且變得難以維護。這就是為什麼在實際專案中,許多開發者會選擇功能更豐富的第三方 HTTP Client 庫。

第三方 HTTP Client 庫的優勢

相較於標準庫,第三方 HTTP Client 庫通常提供了更簡潔的 API、更豐富的功能和更好的開發體驗。它們會將許多常見的繁瑣操作封裝起來,讓你可以更專注於業務邏輯。主要優勢包括:

  • 鏈式調用 API:使得請求構建更流暢、程式碼更易讀。
  • 自動 JSON/XML 序列化與反序列化:直接傳遞 Go Struct 作為請求體或響應接收器。
  • 自動處理常見頭部:如 Content-Type, Accept 等。
  • 內建重試機制:可配置的重試策略,處理暫時性網路錯誤。
  • 請求/響應中間件:允許你在請求發送前或響應接收後插入自定義邏輯(如日誌記錄、加密解密、錯誤處理)。
  • 更好的錯誤處理:將不同類型的錯誤統一化,方便處理。
  • 進階功能:如代理設定、TLS 配置、Cookie 管理、Multipart Form 數據發送等。

流行第三方 HTTP Client 庫比較:imroc/reqgo-resty/resty

在 Golang 社群中,有兩個非常受歡迎的第三方 HTTP Client 庫:imroc/reqgo-resty/resty。它們都提供了比標準庫更友好的 API 和更強大的功能,但各有側重。

go-resty/resty

  • GitHub 連結https://github.com/go-resty/resty
  • 簡介resty 是一個受到 Ruby RestClientPython Requests 啟發的 Go 語言 HTTP Client 庫。它提供了非常簡潔且富有表達力的鏈式 API,旨在讓 HTTP 請求的構建和發送像寫英文句子一樣流暢。resty 內建了對 JSON 和 XML 數據的自動處理,支援重試、中間件、文件上傳、多部分表單等功能。
  • 特點
    • 易用性極高:API 設計直觀,學習曲線平緩。
    • 功能豐富:涵蓋了大部分日常開發所需的 HTTP Client 功能。
    • 自動序列化/反序列化:能自動處理 JSON、XML、表單數據的編碼和解碼。
    • 重試機制:內建可配置的重試邏輯,可以設定重試次數、延遲策略等。
    • Request Middleware / Response Middleware:允許開發者在請求生命週期的不同階段插入自定義邏輯。
  • 受歡迎程度:極高,在許多 Golang 專案中被廣泛應用。

imroc/req

  • GitHub 連結https://github.com/imroc/req
  • 簡介req 是一個相對較新但發展迅速的 HTTP Client 庫,它旨在提供一個更現代、更具 Python Requests 風格的 API。req 在設計上追求簡潔與效率,並提供了一些額外的便利功能,例如方便的錯誤處理、響應體的自動解析、以及類似 requests.Session 的會話管理。
  • 特點
    • 現代 API 設計:在某些方面可能比 resty 更為簡潔和符合直覺。
    • 內建 JSON 日誌:方便調試和日誌記錄。
    • 更細緻的錯誤處理:提供更多關於錯誤的上下文資訊。
    • 自動壓縮與解壓縮:支持 Gzip、Deflate 等。
    • Hook 機制:提供了類似中間件的 hook 功能。
  • 受歡迎程度:高,近年來迅速崛起,受到越來越多開發者的青睞。

比較與選擇

  • API 風格resty 的鏈式調用非常經典和穩定;req 則更貼近 Python Requests 的簡潔風格,有時候可能更直觀。
  • 功能完整性:兩者都提供了大部分常用功能,resty 的生態和文檔相對成熟,req 則在不斷創新和完善。
  • 性能:對於大多數應用來說,兩者的性能差異可以忽略不計。
  • 社區活躍度:兩者都非常活躍,但 resty 的歷史更久,用戶基數更大。

綜合考量,go-resty/resty 憑藉其成熟的 API 設計、強大的功能集以及龐大的用戶基礎,依然是許多 Golang 專案中穩定且高效的 HTTP Client 首選。因此,在接下來的內容中,我們將主要以 go-resty/resty 為例,深入探討其常用功能和實用技巧,幫助你更高效地進行 HTTP 請求操作。

go-resty/resty:簡潔高效的 HTTP 請求實踐

go-resty/resty 的設計哲學是讓 HTTP 請求的發送變得直觀且富有表達力,它將許多底層的複雜性抽象化,使得開發者可以專注於請求的內容和目的。正如你提供的圖片所示,使用 resty 發起一個 GET 請求,程式碼非常簡潔,同時還能自動獲取豐富的響應資訊和請求追蹤數據,這對於調試和監控來說極為方便。

範例:快速開始

以下是一個使用 resty 發起 GET 請求並獲取詳細響應資訊的範例:

package main

import (
	"encoding/json"
	"fmt"
	"time"

	"github.com/go-resty/resty/v2"
)

func main() {
	// 1. 建立一個 Resty Client 實例
	client := resty.New()

	// 2. 設定 Client 的一些全局配置 (可選)
	client.SetTimeout(5 * time.Second)                    // 設定 Client 的超時時間
	client.SetHeader("User-Agent", "Resty-Go-Client/1.0") // 設定全局的 User-Agent 頭部

	// 3. 發起一個 GET 請求
	// Request() 方法用於建立一個 Request 實例,然後可以鏈式調用各種設定方法
	// Get() 方法用於發送 GET 請求
	resp, err := client.R().Get("https://httpbin.org/get")

	// 4. 處理錯誤
	if err != nil {
		fmt.Printf("發送請求失敗: %v\n", err)
		return
	}

	// 5. 輸出響應資訊 (開箱即用的詳細 debug 資訊)
	fmt.Println("=== Response Info ===")
	fmt.Printf("Error: %v\n", resp.Error())            // 獲取響應中的錯誤 (如果有的話)
	fmt.Printf("Status Code: %d\n", resp.StatusCode()) // HTTP 狀態碼
	fmt.Printf("Status: %s\n", resp.Status())          // HTTP 狀態文字
	fmt.Printf("Proto: %s\n", resp.Proto())            // HTTP 協議版本
	fmt.Printf("Time: %s\n", resp.Time())              // 請求發送和響應接收的總耗時
	fmt.Printf("Received At: %s\n", resp.ReceivedAt()) // 響應接收的時間
	fmt.Printf("Body: %s\n", resp.String())            // 響應體內容 (轉換為字符串)
	fmt.Printf("Size: %d bytes\n", resp.Size())        // 響應體大小

	fmt.Println("\n=== Response Headers ===")
	for k, v := range resp.Header() { // 遍歷響應頭部
		fmt.Printf("%s: %s\n", k, v)
	}

	// 6. 輸出請求追蹤資訊 (Trace Info) - 對於性能分析和調試非常有幫助
	// resty 會自動記錄請求的各個階段的耗時
	fmt.Println("\n=== Request Trace Info ===")
	trace := resp.Request.TraceInfo()                         // 獲取請求的追蹤資訊
	fmt.Printf("DNSLookup: %s\n", trace.DNSLookup)            // DNS 解析耗時
	fmt.Printf("ConnTime: %s\n", trace.ConnTime)              // TCP 連接建立耗時
	fmt.Printf("TCPConnTime: %s\n", trace.TCPConnTime)        // TCP 連接建立耗時 (與 ConnTime 相同)
	fmt.Printf("TLSHandshake: %s\n", trace.TLSHandshake)      // TLS 握手耗時 (HTTPS)
	fmt.Printf("ServerTime: %s\n", trace.ServerTime)          // 服務器處理請求耗時
	fmt.Printf("ResponseTime: %s\n", trace.ResponseTime)      // 從請求發送完成到接收到第一個字節響應的耗時
	fmt.Printf("TotalTime: %s\n", trace.TotalTime)            // 整個請求的總耗時 (從請求開始到響應接收完成)
	fmt.Printf("IsConnReused: %t\n", trace.IsConnReused)      // 連接是否被重用
	fmt.Printf("IsConnWasIdle: %t\n", trace.IsConnWasIdle)    // 連接是否空閒
	fmt.Printf("ConnIdleTime: %s\n", trace.ConnIdleTime)      // 連接空閒時間
	fmt.Printf("RequestAttempts: %d\n", trace.RequestAttempt) // 請求重試次數
	fmt.Printf("RemoteAddr: %s\n", trace.RemoteAddr)          // 遠端服務器地址

	// 7. 處理響應體內容 (JSON 或 XML)
	// resty 可以自動解析 JSON 或 XML 到 Struct
	var data map[string]any
	// 將響應體 JSON 解析到 map
	if err := json.Unmarshal(resp.Body(), &data); err != nil {
		fmt.Printf("解析 JSON 失敗: %v\n", err)
		return
	}
	fmt.Println("\nParsed JSON Data:", data)
}

運行上述程式碼,你會看到 resty 在發送一個簡單的 GET 請求後,不僅提供了 HTTP 狀態碼、響應體等基本資訊,更開箱即用地列出了:

  • 詳細的響應資訊:包括請求耗時、接收時間、錯誤信息等。
  • 完整的響應頭部:清晰展示服務器返回的所有 HTTP 頭部。
  • 精準的請求追蹤資訊 (Trace Info):這是 resty 的一個亮點。它會自動測量 DNS 解析、TCP 連接、TLS 握手、服務器處理、響應接收等各個階段的耗時。這些細粒度的時間數據對於性能調優和問題排查(例如判斷是網路延遲、DNS 問題還是服務器響應慢)提供了極大的便利,而無需額外編寫複雜的監控代碼。

這種「所見即所得」的簡潔 API 和豐富的調試信息,使得 resty 在日常開發中,無論是快速驗證 API 接口,還是深入分析請求性能瓶頸,都成為一個非常得力的助手。

接下來,除了簡單的 GET 請求,resty 在處理各種複雜的請求參數方面也做得非常出色,無論是 URL 查詢參數、路徑參數、請求體數據(JSON、XML、表單、二進制)還是文件上傳,它都提供了簡潔直觀的 API。

範例:設置 Query String 參數

Query String 參數通常用於 GET 請求,以 ?key1=value1&key2=value2 的形式附加在 URL 後面。resty 提供了兩種設置 Query String 的方式:SetQueryParams()SetQueryString()

SetQueryString(string):直接傳入一個已經手動編碼好的 Query String 字符串。當你已經有完整的 Query String 時使用,但需要注意手動編碼的正確性。

package main

import (
	"fmt"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 使用 SetQueryString 直接設置 Query String 字符串
	// 這個請求將發送到 /show_product?productid=232&template=fresh_sample&cat=resty&source=google&kw=buy a lot more
	resp, err := client.R().
		SetQueryString("productid=232&template=fresh_sample&cat=resty&source=google&kw=buy a lot more").
		SetHeader("Accept", "application/json").
		SetAuthToken("BCS9490051884F7EAC75BD037F019E008FBC59490051884F7EAC75BD037F019E008F").
		Get("https://httpbin.org/get") // 請求 httpbin.org/get 以查看 Query String 效果

	if err != nil {
		fmt.Printf("請求失敗: %v\n", err)
		return
	}
	fmt.Println("SetQueryString 響應:", resp.String())
}

SetQueryParams(map[string]string):接收一個 map[string]string,將鍵值對自動編碼為 Query String。這是最推薦的方式,因為它會自動處理 URL 編碼。

package main

import (
	"fmt"
	"time"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 使用 SetQueryParams 設置多個 Query String 參數
	// 會生成類似 /search_result?page_no=1&limit=20&sort=name&order=asc&random=...
	resp, err := client.R().
		SetQueryParams(map[string]string{ //
			"page_no": "1",
			"limit":   "20",
			"sort":    "name",
			"order":   "asc",
			// 加入一個動態參數,例如時間戳加隨機數
			"random": fmt.Sprintf("%d", time.Now().UnixNano()/100),
		}).
		SetHeader("Accept", "application/json").
		SetAuthToken("BCS9490051884F7EAC75BD037F019E008FBC59490051884F7EAC75BD037F019E008F").
		Get("https://httpbin.org/get") // 請求 httpbin.org/get 以查看 Query String 效果

	if err != nil {
		fmt.Printf("請求失敗: %v\n", err)
		return
	}
	fmt.Println("SetQueryParams 響應:", resp.String())
}
範例:設置 Path Parameters (路徑參數)

Path Parameters 通常用於 RESTful API,例如 /users/{userId}/details 中的 {userId}resty 提供了 SetPathParams() 方法來處理這些動態的路徑片段。

package main

import (
	"fmt"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 設置路徑參數
	// URL 會被組合成 /v1/users/[email protected]/100002/details
	resp, err := client.R().
		SetPathParams(map[string]string{ //
			"userId":       "[email protected]",
			"subAccountId": "100002",
		}).
		Get("https://httpbin.org/v1/users/{userId}/{subAccountId}/details") // 使用大括號佔位符

	if err != nil {
		fmt.Printf("請求失敗: %v\n", err)
		return
	}
	fmt.Println("Path Parameters 響應:", resp.String())
	fmt.Println("組合後的 URL:", resp.Request.URL) // 打印最終的 URL
}

SetPathParams 會自動將 map 中的鍵替換掉 URL 中對應的 {鍵名} 佔位符,非常方便。

範例:設置 Request Body 參數

對於 POST、PUT 等請求,數據通常會放在請求體中。resty 對於不同格式的請求體提供了多種便捷的設置方法,並會根據你設置的內容自動判斷 Content-Type

SetBody(struct)SetBody(map[string]any):直接傳遞 Go Struct 或 mapresty 會自動將其序列化為 JSON 格式,並設定 Content-Type: application/json。這是處理 JSON 數據最便捷的方式。

package main

import (
	"fmt"

	"github.com/go-resty/resty/v2"
)

// 定義一個用於請求體的 Struct
type AuthPayload struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

func main() {
	client := resty.New()

	// POST Struct 作為請求體 (自動轉 JSON)
	authData := AuthPayload{
		Username: "testuser",
		Password: "testpass",
	}
	resp, err := client.R().
		SetBody(authData). // resty 會自動將 Struct 轉為 JSON,並設定 Content-Type: application/json
		Post("https://httpbin.org/post")

	if err != nil {
		fmt.Printf("請求失敗: %v\n", err)
		return
	}
	fmt.Println("POST Struct (JSON) 響應:", resp.String())

	// POST map 作為請求體 (自動轉 JSON)
	mapData := map[string]any{
		"username": "testuser",
		"password": "testpass",
	}
	resp, err = client.R().
		SetBody(mapData). // resty 會自動將 map 轉為 JSON,並設定 Content-Type: application/json
		Post("https://httpbin.org/post")

	if err != nil {
		fmt.Printf("請求失敗: %v\n", err)
		return
	}
	fmt.Println("POST Map (JSON) 響應:", resp.String())
}

SetBody(string)SetBody([]byte):直接設置字符串或字節切片作為請求體。此時 resty 會默認為 Content-Type: text/plain

package main

import (
	"fmt"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// POST JSON 字符串作為請求體
	// resty 會自動設定 Content-Type: application/json
	resp, err := client.R().
		SetHeader("Content-Type", "application/json"). // 如果 client 沒有全局設定,可以手動設定
		SetBody(`{"username": "testuser", "password": "testpass"}`).
		Post("https://httpbin.org/post") // 請求 httpbin.org/post 以查看請求體

	if err != nil {
		fmt.Printf("請求失敗: %v\n", err)
		return
	}
	fmt.Println("POST JSON String 響應:", resp.String())

	// POST 字節數組作為請求體
	resp, err = client.R().
		SetBody([]byte(`{"username": "testuser", "password": "testpass"}`)).
		Post("https://httpbin.org/post")

	if err != nil {
		fmt.Printf("請求失敗: %v\n", err)
		return
	}
	fmt.Println("POST Byte Array 響應:", resp.String())
}
範例:文件上傳 (Multipart Form)

resty 對於文件上傳也提供了非常便捷的 API,支援單個文件、多個文件以及文件與表單字段混合上傳。你可以直接傳入文件路徑、io.Reader 或字節數組。

package main

import (
	"fmt"
	"os"
	"strings"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 創建一個模擬的文本文件用於測試
	err := os.WriteFile("test-file.txt", []byte("這是測試文件的內容。"), 0644)
	if err != nil {
		fmt.Printf("創建測試文件失敗: %v\n", err)
		return
	}
	defer os.Remove("test-file.txt") // 確保測試結束後刪除文件

	// 使用文件路徑上傳單個文件 (Multipart Form)
	// resty 會自動檢測 Content-Type 為 multipart/form-data
	// 並在表單中創建一個文件字段
	resp, err := client.R().
		SetFile("my_file", "test-file.txt"). // "my_file" 是表單字段名,"test-file.txt" 是文件路徑
		Post("https://httpbin.org/post")

	if err != nil {
		fmt.Printf("文件上傳失敗: %v\n", err)
		return
	}
	fmt.Println("單文件上傳響應:", resp.String())

	// 使用 io.Reader 上傳文件 (Multipart Form)
	// 這允許從任何實現 io.Reader 介面的源讀取文件,例如從記憶體中的字節
	profileImgBytes := []byte("這是模擬的圖片二進制數據。")
	notesBytes := []byte("這是一些筆記內容。")

	resp, err = client.R().
		SetFileReader("profile_img", "test-img.png", strings.NewReader(string(profileImgBytes))).
		SetFileReader("notes", "text-file.txt", strings.NewReader(string(notesBytes))).
		SetFormData(map[string]string{ // 同時上傳表單字段
			"first_name": "Jeevanandam",
			"last_name":  "M",
		}).
		Post("https://httpbin.org/post")

	if err != nil {
		fmt.Printf("多文件+表單數據上傳失敗: %v\n", err)
		return
	}
	fmt.Println("多文件+表單數據上傳響應 (io.Reader):", resp.String())

	// 使用多個文件路徑上傳多個文件
	resp, err = client.R().
		SetFiles(map[string]string{
			"profile_img": "test-img.png", // 假設存在這個文件
			"notes":       "test-file.txt",
		}).
		Post("https://httpbin.org/post")

	if err != nil {
		fmt.Printf("多文件上傳失敗: %v\n", err)
		return
	}
	fmt.Println("多文件上傳響應 (路徑):", resp.String())
}

resty 會自動處理 multipart/form-data 的組裝,包括 Content-Type 和邊界 boundary 的設置,極大地簡化了文件上傳的複雜性。

總而言之,go-resty/resty 透過其靈活多樣的參數設置方法,讓 Golang 開發者能夠以極高的效率和清晰度來構建和發送各種複雜的 HTTP 請求,從而顯著提升開發體驗。


在更複雜的應用場景中,我們可能需要在請求發送前進行統一的處理(例如添加認證資訊、日誌記錄),或在響應接收後進行統一的處理(例如解密響應、錯誤統一處理)。resty 提供了強大的中間件 (Middleware) 能力和錯誤鉤子 (OnError Hooks),讓這些橫切關注點的處理變得非常簡潔和靈活。

範例:請求與響應中間件 (Request and Response Middleware)

resty 的中間件機制比傳統的回調 (callback) 方式更為靈活。它允許你在請求發送前 (OnBeforeRequest) 和響應處理後 (OnAfterResponse) 插入自定義邏輯。

    • 添加認證令牌:動態獲取並設置 Authorization 頭部。
    • 日誌記錄:記錄請求的詳細資訊,例如請求 URL、請求頭部、請求體等。
    • 請求修改:在發送前修改請求的任何部分。
    • 響應日誌記錄:記錄響應的狀態碼、響應頭部、響應體等。
    • 錯誤統一處理:根據響應狀態碼或特定錯誤內容進行統一的錯誤處理(例如刷新過期令牌、重試)。
    • 數據解密/解壓縮:對服務器返回的加密或壓縮數據進行處理。

OnAfterResponse:在接收到服務器的響應並進行基本處理(如讀取響應體)之後執行。這對於以下場景非常有用:

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 註冊響應中間件:在每次響應處理後執行
	client.OnAfterResponse(func(c *resty.Client, resp *resty.Response) error {
		log.Printf("AfterResponse: URL=%s, Status=%s, StatusCode=%d, BodySize=%d\n",
			resp.Request.URL, resp.Status(), resp.StatusCode(), resp.Size())

		// 範例:如果響應狀態碼是 401 Unauthorized,則進行特殊處理
		if resp.StatusCode() == http.StatusUnauthorized {
			log.Println("收到 401 Unauthorized,可能需要刷新令牌或重新登入")
			// 這裡可以觸發令牌刷新邏輯或錯誤處理
		}
		return nil // 如果返回 error,會影響後續響應的處理
	})

	resp, err := client.R().Get("https://httpbin.org/status/401") // 模擬 401 響應
	if err != nil {
		fmt.Println("請求失敗:", err)
		return
	}
	fmt.Println("響應體:", resp.String())
}

OnBeforeRequest:在請求發送給伺服器之前執行。這是一個非常適合做以下事情的地方:

package main

import (
	"fmt"
	"log"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 註冊請求中間件:在每次請求發送前執行
	client.OnBeforeRequest(func(c *resty.Client, req *resty.Request) error {
		log.Printf("BeforeRequest: Method=%s, URL=%s, Headers=%+v\n", req.Method, req.URL, req.Header)
		// 範例:動態添加認證頭部
		req.SetAuthToken("your-dynamic-auth-token-here")
		return nil // 如果返回 error,請求將會被中斷
	})

	resp, err := client.R().Get("https://httpbin.org/get")
	if err != nil {
		fmt.Println("請求失敗:", err)
		return
	}
	fmt.Println("響應體:", resp.String())
}
範例:錯誤鉤子 (OnError Hooks)

resty 提供了 OnError 鉤子,專門用於處理請求過程中發生的錯誤。 這些錯誤可能包括:

  • 客戶端未能將請求發送出去,例如連接超時或 TLS 握手失敗。
  • 請求已經重試了最大次數,但仍然失敗。

OnError 鉤子中,你可以獲取到原始的錯誤信息,並且如果服務器有返回響應,你還能從 resty.ResponseError 中獲取到最後一次收到的響應。 這對於日誌記錄、錯誤指標的增量以及特定的錯誤恢復邏輯非常有用。

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 註冊錯誤鉤子
	client.OnError(func(req *resty.Request, err error) {
		log.Printf("OnError Hook Triggered: Request URL=%s, Error=%v\n", req.URL, err)

		// 檢查錯誤是否包含 ResponseError (即服務器有返回響應,但狀態碼非 2xx 或發生其他錯誤)
		if v, ok := err.(*resty.ResponseError); ok {
			log.Printf("ResponseError: Status Code=%d, Body=%s\n", v.Response.StatusCode(), v.Response.String())
			// v.Err 包含原始錯誤
			log.Printf("Original Error: %v\n", v.Err)
		} else {
			// 其他類型的網路錯誤,例如連接超時、DNS 解析失敗等
			log.Printf("Network Error: %v\n", err)
		}

		// 在這裡可以日誌記錄錯誤、增加監控指標、發送警報等
		// 發送一個錯誤通知
		// go sendErrorNotification(req.URL, err)
	})

	// 模擬一個連接超時的請求 (例如請求一個不存在的本地地址,或者非常慢的服務)
	// 注意:實際運行時,這個請求可能會因為防火牆或不存在的服務器而阻塞一段時間
	client.SetTimeout(1 * time.Millisecond) // 故意設定極短超時
	if _, err := client.R().Get("http://localhost:9999/timeout"); err != nil {
		fmt.Printf("主程式中捕獲到錯誤: %v\n", err)
	}

	// 模擬一個非 2xx 狀態碼的響應 (resty 會將非 2xx 響應視為錯誤,並觸發 OnError hook)
	// 但此時會有 ResponseError
	// 模擬 500 Internal Server Error
	if _, err := client.R().Get("https://httpbin.org/status/500"); err != nil {
		fmt.Printf("主程式中捕獲到錯誤: %v\n", err)
	}
}

透過這些中間件和錯誤鉤子,resty 提供了一個非常強大的機制來集中管理 HTTP 請求的生命週期,實現日誌、監控、認證、錯誤處理等通用邏輯,從而大大提升程式碼的整潔度和可維護性。


在實際的網路環境中,由於網路不穩定、服務器瞬時負載過高或暫時性故障,HTTP 請求可能會失敗。為了提高應用程式的健壯性和可靠性,通常需要實現重試 (Retry) 機制。resty 內建了開箱即用的重試功能,讓你可以輕鬆配置請求的重試策略,而無需手動編寫複雜的重試循環。

resty 的重試功能允許你:

  • 設定最大重試次數:定義請求最多可以嘗試多少次。
  • 設定重試間隔:在每次重試之間等待多長時間,支援固定延遲和指數退避 (exponential backoff) 等策略。
  • 定義重試條件:哪些情況下需要重試(例如特定狀態碼、網路錯誤等)。
範例:基本重試配置

你可以透過 SetRetryCount()SetRetryWaitTime() 來設定基本的重試策略。

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	// 設定 Client 的重試策略
	client.SetRetryCount(3)                     // 最多重試 3 次 (總共發送 1 + 3 = 4 次請求)
	client.SetRetryWaitTime(1 * time.Second)    // 每次重試等待 1 秒
	client.SetRetryMaxWaitTime(5 * time.Second) // 如果是指數退避,最長等待時間,預設為 2 秒

	// 註冊一個重試回調函數,用於觀察重試過程
	// resty 的 AddRetryHook 可以在每次重試後執行自定義邏輯
	client.AddRetryHook(func(resp *resty.Response, err error) {
		if err != nil {
			log.Printf("重試中遇到錯誤: %v\n", err)
		} else {
			log.Printf("請求正在重試中... 狀態碼: %d\n", resp.StatusCode())
		}
	})

	// 請求一個可能會失敗的端點 (例如模擬 500 錯誤)
	// httpbin.org/status/500 會直接返回 500
	// resty 預設會在非 nil 錯誤執行時進行重試
	fmt.Println("--- 執行帶重試的請求 (模擬 500 錯誤) ---")
	resp, err := client.R().
		Get("https://httpbin.org/status/500")

	if err != nil {
		fmt.Printf("請求最終失敗: %v\n", err)
	} else {
		fmt.Printf("請求成功 (可能經過重試): 狀態碼 %d\n", resp.StatusCode())
	}

	// 請求一個不存在的地址,模擬網路錯誤
	fmt.Println("\n--- 執行帶重試的請求 (模擬網路錯誤) ---")
	resp, err = client.R().
		Get("http://localhost:9999/unreachable")

	if err != nil {
		fmt.Printf("請求最終失敗 (網路錯誤): %v\n", err)
	} else {
		fmt.Printf("請求成功 (可能經過重試): 狀態碼 %d\n", resp.StatusCode())
	}
}

在上述範例中,當你請求 https://httpbin.org/status/500 時,resty 會因為收到 500 狀態碼而觸發重試。它會嘗試重試 3 次,每次重試之間等待 1 秒。你可以在控制台中觀察到 OnRetry 回調函數的輸出。

範例:自定義重試條件

resty 提供了 AddRetryCondition() 方法,讓你能夠更精確地定義什麼情況下需要重試。你可以基於響應狀態碼、錯誤類型、響應體內容等來自定義重試邏輯。

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	client.SetRetryCount(3)
	client.SetRetryWaitTime(1 * time.Second)

	// 範例:只有在收到 429 (Too Many Requests) 或 503 (Service Unavailable) 時才重試
	client.AddRetryCondition(func(resp *resty.Response, err error) bool {
		// 只有當沒有錯誤,且狀態碼是 429 或 503 時才重試
		if err == nil && (resp.StatusCode() == http.StatusTooManyRequests || resp.StatusCode() == http.StatusServiceUnavailable) {
			log.Printf("自定義重試條件觸發: 收到狀態碼 %d\n", resp.StatusCode())
			return true // 返回 true 表示需要重試
		}
		// 其他情況不重試
		return false
	})

	client.AddRetryHook(func(r *resty.Response, err error) {
		log.Printf("請求正在重試中... URL: %s, 重試次數: %d, 錯誤: %v\n", r.Request.URL, r.Request.Attempt, err)
	})

	// 模擬一個 429 錯誤
	fmt.Println("--- 執行帶自定義重試的請求 (模擬 429 錯誤) ---")
	resp, err := client.R().Get("https://httpbin.org/status/429")

	if err != nil {
		fmt.Printf("請求最終失敗: %v\n", err)
	} else {
		fmt.Printf("請求成功: 狀態碼 %d\n", resp.StatusCode())
	}

	// 模擬一個 400 錯誤 (不會觸發重試,因為不在自定義條件內)
	fmt.Println("\n--- 執行帶自定義重試的請求 (模擬 400 錯誤) ---")
	resp, err = client.R().Get("https://httpbin.org/status/400")

	if err != nil {
		fmt.Printf("請求最終失敗: %v\n", err)
	} else {
		fmt.Printf("請求成功: 狀態碼 %d\n", resp.StatusCode())
	}
}

這個範例展示了如何使用 AddRetryCondition 來自定義重試邏輯,使得 resty 的重試功能更加靈活和符合實際業務需求。

範例:重試間隔策略 (Retry After Callback)

resty 預設會使用指數退避策略 (exponential backoff) 來增加重試間隔。你可以透過 SetRetryAfter() 方法提供一個自定義的回調函數,來計算每次重試的等待時間。這對於實現更精確的重試延遲,例如基於響應中的 Retry-After 頭部或自定義演算法非常有用。

package main

import (
	"errors"
	"fmt"
	"log"
	"time"

	"github.com/go-resty/resty/v2"
)

func main() {
	client := resty.New()

	client.SetRetryCount(3)                      // 最多重試 3 次
	client.SetRetryWaitTime(1 * time.Second)     // 預設的初始等待時間
	client.SetRetryMaxWaitTime(20 * time.Second) // 重試的最大等待時間

	// 自定義重試間隔策略:指數退避並帶有 jitter (隨機抖動)
	// 或者根據特定錯誤返回不重試的錯誤
	client.SetRetryAfter(func(c *resty.Client, resp *resty.Response) (time.Duration, error) {
		// 假設我們在某些情況下不想重試
		if resp != nil && resp.StatusCode() == 403 {
			log.Println("收到 403 Forbidden,不進行重試。")
			return 0, errors.New("權限不足,不重試") // 返回 error 會停止重試鏈
		}

		// 預設的指數退避和抖動
		// resty 內部會根據 SetRetryWaitTime 和 SetRetryMaxWaitTime 計算
		// 這裡可以根據 resp 或 err 自定義計算延遲
		// 範例:如果響應頭部有 Retry-After,則根據它來決定下次重試時間
		retryAfterHeader := resp.Header().Get("Retry-After")
		if retryAfterHeader != "" {
			if duration, err := time.ParseDuration(retryAfterHeader + "s"); err == nil {
				log.Printf("根據 Retry-After 頭部,下次重試延遲 %s\n", duration)
				return duration, nil
			}
		}

		// 如果沒有自定義邏輯,可以讓 resty 處理預設的指數退避
		// 返回 0, nil 讓 resty 使用其內部指數退避邏輯
		log.Printf("使用 resty 預設指數退避策略。當前重試次數: %d\n", resp.Request.Attempt)
		return 0, nil
	})

	client.AddRetryHook(func(resp *resty.Response, err error) {
		log.Printf("請求正在重試中... URL: %s, 重試次數: %d, 錯誤: %v\n", resp.Request.URL, resp.Request.Attempt, err)
	})

	// 請求一個可能會觸發自定義重試間隔的端點
	// 假設一個服務在第 2 次請求後才成功,並模擬 403 錯誤
	// 這裡無法直接模擬服務器端動態行為,但代碼邏輯展示了 SetRetryAfter 的用法。
	fmt.Println("--- 執行帶自定義重試間隔的請求 ---")
	resp, err := client.R().Get("https://httpbin.org/status/403") // 模擬 403 錯誤

	if err != nil {
		fmt.Printf("請求最終失敗: %v\n", err)
	} else {
		fmt.Printf("請求成功: 狀態碼 %d\n", resp.StatusCode())
	}
}

透過 SetRetryAfter(),你可以對重試間隔有更精細的控制,使其更符合網路服務的實際行為和最佳實踐。

重試的最佳實踐:

  • 針對暫時性錯誤:重試機制主要用於處理暫時性的網路問題或服務器瞬時負載。對於永久性錯誤(如 401 Unauthorized, 404 Not Found, 或 403 Forbidden),重試通常是沒有意義的,反而會浪費資源並增加服務器負擔。
  • 設定合理的重試次數和間隔:過多的重試次數或過短的間隔可能導致雪崩效應,增加服務器壓力。建議使用指數退避策略(SetRetryMaxWaitTime 可以配合實現),以避免在服務器故障時持續發送大量請求。
  • 避免在幂等性差的操作上重試:對於非幂等性 (non-idempotent) 的操作(例如創建資源的 POST 請求,如果沒有進行唯一性檢查,重試可能導致重複創建),需要謹慎使用重試,或確保服務器端有防重複提交的機制。

透過 resty 內建的重試功能,你可以顯著提升 HTTP 請求的穩定性,減少因瞬時故障導致的業務中斷,這在微服務架構和依賴外部 API 的應用程式中尤為重要。


最後

首先,我們介紹了強大而高效的 Gin Web 框架。Gin 憑藉其快速的性能和易於使用的 API,成為構建 RESTful API 服務的理想選擇。我們學習了如何設定 Gin 路由器、處理各種 HTTP 請求(GET、POST 等)、解析查詢參數、路徑參數以及請求體(包括 JSON 和 XML 格式的數據綁定)。此外,我們也探討了 Gin 的中間件機制,這使得日誌記錄、身份驗證和錯誤處理等橫切關注點的實現變得輕而易舉,大幅提升了程式碼的組織性和可維護性。

接著,我們轉向了 Golang 中發起外部 HTTP 請求的實踐。我們比較了原生的 net/http 包與兩個廣受歡迎的第三方庫 imroc/reqgo-resty/resty 的優劣。最終,我們選擇了 go-resty/resty 作為主要介紹對象,因為它提供了更簡潔的鏈式 API、豐富的開箱即用調試資訊(如詳細的請求追蹤)、靈活的參數設置方式(Query String、Path Parameters、JSON/Form Body、文件上傳)、強大的請求與響應中間件,以及內建的重試機制。這些特性使得 resty 在處理複雜的 API 互動時,能夠顯著提升開發效率和應用程式的健壯性。

透過 Gin 框架的應用,我們能夠高效地構建穩定的 Web 服務;而借助 resty 庫,我們則能輕鬆可靠地與外部服務進行通信。這兩者的結合,為 Go 語言開發者提供了構建現代微服務和集成應用程式的強大基石。

下一篇文章我們將深入探討 Golang 中的數據庫操作。我們將重點介紹強大的 ORM (Object-Relational Mapping) 框架 GORM,讓你能夠以更 Go 語言化的方式與數據庫進行交互。此外,我們還會介紹輕量級的記憶體快取解決方案 gocache,這對於提升應用程式性能和減少數據庫負載至關重要。敬請期待!