Golang 測試與除錯
主題:日誌記錄(log、Zap、Logrus)
簡介
在開發 Go 應用程式時,日誌(logging)是除錯與監控的第一道防線。無論是本機除錯、CI/CD 測試,或是上線後的服務可觀測性,都離不開適當的日誌輸出。
Go 內建的 log 套件提供最基本的功能,足以滿足簡單工具或腳本;但在大型分散式系統中,我們往往需要結構化、等級化、效能佳的日誌框架,這時 zap 與 logrus 兩個社群廣受歡迎的套件就派上用場。
本文將從 概念、實作範例、常見陷阱與最佳實踐 三個層面,帶領讀者快速掌握在 Go 專案中選擇與使用日誌的技巧,適合剛入門的初學者,也能為中階開發者提供實務參考。
核心概念
1️⃣ 標準庫 log
log 是 Go 標準函式庫的一部份,提供 簡單的文字日誌,預設寫入 os.Stderr。它支援四種等級(Print、Printf、Println、Fatal、Panic),但沒有正式的等級概念,需要自行在訊息前加上標籤。
程式碼範例 1:基本使用
package main
import (
"log"
"os"
)
func main() {
// 設定輸出目的地與前綴字
logger := log.New(os.Stdout, "[myApp] ", log.LstdFlags|log.Lshortfile)
logger.Println("服務啟動")
logger.Printf("連線到 %s:%d", "localhost", 8080)
// Fatal 會先寫入日誌再呼叫 os.Exit(1)
// logger.Fatal("致命錯誤,程式結束")
}
說明
log.New允許自訂 輸出端(os.Stdout、os.Stderr或檔案)與 前綴。log.LstdFlags會自動加上日期與時間,log.Lshortfile顯示呼叫位置。Fatal、Panic會額外觸發程式結束或 panic,使用時要小心。
程式碼範例 2:寫入檔案
func initFileLogger(path string) *log.Logger {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Fatalf("開啟日誌檔案失敗: %v", err)
}
return log.New(f, "", log.Ldate|log.Ltime|log.Lmsgprefix)
}
說明
- 透過
os.OpenFile以 追加模式 開啟檔案,適合長期服務的日誌輪替。 log.Lmsgprefix只保留訊息本身,讓外部工具自行加上時間戳。
2️⃣ zap – 高效能結構化日誌
zap(由 Uber 開發)以 零分配 為設計目標,適合高併發、對效能敏感的服務。它支援 結構化日誌(key‑value),並提供兩種建構器:zap.NewProduction()(預設 JSON)與 zap.NewDevelopment()(較易讀的 console)。
程式碼範例 3:建立 Production logger
package main
import (
"go.uber.org/zap"
)
func main() {
// 建立一個適合生產環境的 logger,輸出為 JSON
logger, err := zap.NewProduction()
if err != nil {
panic(err)
}
defer logger.Sync() // 確保緩衝寫入磁碟
sugar := logger.Sugar() // 提供更簡潔的 API
sugar.Infow("使用者登入",
"userID", 12345,
"ip", "192.168.1.10",
)
sugar.Errorw("資料庫連線失敗",
"host", "db.example.com",
"retry", 3,
)
}
說明
Infow、Errorw等方法接受 訊息 + 任意數量的鍵值對,最終會序列化成 JSON。defer logger.Sync()必須在程式結束前呼叫,否則緩衝區的日誌可能遺失。
程式碼範例 4:自訂 Encoder(文字格式)
func newConsoleLogger() (*zap.Logger, error) {
cfg := zap.NewDevelopmentConfig()
cfg.Encoding = "console" // 改為可讀的文字格式
cfg.EncoderConfig.EncodeTime = zap.TimeEncoderOfLayout("2006-01-02 15:04:05")
return cfg.Build()
}
說明
zap.Config讓你自由調整 編碼方式、時間格式、輸出目的地。console編碼在開發階段更直觀,而json更適合 log aggregation(如 ELK、Stackdriver)。
程式碼範例 5:在測試中使用 zap
func TestSomething(t *testing.T) {
logger, _ := zap.NewDevelopment()
defer logger.Sync()
// 把 logger 注入被測試的物件
svc := NewService(logger)
// 執行測試
if err := svc.Do(); err != nil {
t.Fatalf("Do() 錯誤: %v", err)
}
}
說明
- 在 單元測試 中直接使用
zap.NewDevelopment(),可以即時看到結構化日誌,協助定位錯誤。
3️⃣ logrus – 易用的結構化日誌
logrus 是另一個廣受歡迎的第三方日誌套件,語法類似 log,但內建 Level、Hook、Formatter,適合想快速上手且不追求極致效能的專案。
程式碼範例 6:基本設定
package main
import (
"github.com/sirupsen/logrus"
)
func main() {
// 設定全域 logger
log := logrus.New()
log.SetFormatter(&logrus.JSONFormatter{}) // 輸出 JSON
log.SetLevel(logrus.InfoLevel)
log.WithFields(logrus.Fields{
"module": "auth",
"user": "alice",
}).Info("登入成功")
log.WithError(errors.New("timeout")).Warn("呼叫外部 API 超時")
}
說明
WithFields讓你一次加入多個 key‑value,WithError是常用的快捷方式。JSONFormatter讓日誌直接適配 ELK、Fluentd 等系統。
程式碼範例 7:自訂 Hook(寫入檔案)
type FileHook struct {
Writer io.Writer
LogLevels []logrus.Level
}
func (h *FileHook) Fire(entry *logrus.Entry) error {
line, err := entry.String()
if err != nil {
return err
}
_, err = h.Writer.Write([]byte(line))
return err
}
func (h *FileHook) Levels() []logrus.Level {
return h.LogLevels
}
// 使用方式
func initLogrus() *logrus.Logger {
logger := logrus.New()
file, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
logger.AddHook(&FileHook{
Writer: file,
LogLevels: logrus.AllLevels,
})
return logger
}
說明
Hook是logrus的擴充點,可在 每筆日誌寫入前後 執行自訂邏輯(例如寫入檔案、發送到 Slack)。- 透過
AllLevels讓所有等級的日誌都觸發此 Hook。
4️⃣ 小結:三者比較
| 套件 | 主要特性 | 效能 | 結構化支援 | 設定彈性 |
|---|---|---|---|---|
log |
標準庫、零依賴 | 最高(因為最簡單) | 需自行手寫 JSON 或 key‑value | 低(只能改輸出、前綴) |
zap |
零分配、JSON/Console、支援 Sampling | 極佳(適合高併發) | 原生支援 | 高(EncoderConfig、Sampling、Hook) |
logrus |
友好 API、Hook、Formatter | 中等(相較 zap 稍慢) | 原生支援 | 中等(Formatter、Hook) |
根據需求選擇:快速開發 → logrus;高效能、結構化 → zap;不想額外套件 → log。
常見陷阱與最佳實踐
忘記呼叫
Sync()zap以及logrus的緩衝寫入如果在程式異常退出前未執行Sync(),最後幾筆日誌會遺失。- 最佳實踐:在
main()、defer或測試結束時統一呼叫logger.Sync()。
在高併發環境使用
log.Printf- 標準庫的
log每次呼叫都會分配字串,若在千級請求下會產生大量 GC。 - 最佳實踐:在高流量服務改用
zap的SugaredLogger(或zap.Logger)以降低分配。
- 標準庫的
過度使用
Fatal或Paniclog.Fatal直接呼叫os.Exit(1),會跳過defer,導致資源未釋放。- 最佳實踐:在服務中盡量使用錯誤回傳與統一的錯誤處理機制,僅在不可恢復的啟動錯誤時才使用
Fatal。
日誌等級混亂
- 把所有訊息都寫成
Info,會讓搜尋關鍵錯誤變得困難。 - 最佳實踐:遵循 DEBUG < INFO < WARN < ERROR < DPANIC < PANIC < FATAL 的層級,並在 CI 中檢查等級使用情況。
- 把所有訊息都寫成
在日誌中直接輸出敏感資訊
- 包含密碼、金鑰或個人資料會違反 GDPR/CCPA。
- 最佳實踐:使用
zap的zapcore.NewSampler或logrus的 Hook 進行 過濾,或在WithFields前先做遮蔽。
實際應用場景
| 場景 | 推薦套件 | 為何選擇 |
|---|---|---|
| 小型 CLI 工具 | log |
零依賴、簡單快速 |
| 微服務(Go kit / gRPC) | zap |
高併發、JSON 輸出易於集中式日誌平台(如 Loki、Elastic) |
| Web API(Gin、Echo) | logrus + Hook |
可直接掛載到框架的 middleware,快速加入檔案或 Slack 通知 |
| 測試環境 | zap.NewDevelopment() 或 logrus.New() |
友好的 console 輸出,幫助定位測試失敗原因 |
| 需要日誌輪替 | logrus + lumberjack Hook 或 zap 的 zapcore.AddSync(lumberjack.Logger) |
結合第三方輪替套件,實現自動切檔 |
範例:在 Gin 中整合 Zap
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync()
r := gin.New()
r.Use(ginzap.Ginzap(logger, time.RFC3339, true))
r.Use(ginzap.RecoveryWithZap(logger, true))
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"})
})
r.Run(":8080")
}
說明
ginzap為社群提供的中介層,讓每一次 HTTP 請求自動產生結構化日誌。
總結
日誌是 開發、測試、運維不可或缺的資訊來源。Go 內建的 log 夠簡單、足以應付小工具;但在需要 高效能、結構化、易於集中管理 的服務中,zap 與 logrus 提供了更完整的功能與彈性。
- 選擇原則:
- 開發階段:
logrus或zap.NewDevelopment(),快速看到可讀的訊息。 - 生產環境:優先考慮
zap(效能 + JSON),或在已有logrus生態時加上適當 Hook。 - 資源受限:若不想引入外部套件,直接使用
log並自行實作 JSON 格式。
- 開發階段:
最後,記得 統一等級、避免敏感資訊、確保 Sync,並配合 log aggregation(ELK、Stackdriver、Loki)打造完整的可觀測系統。掌握這些概念與實作技巧,你的 Go 應用將在除錯與維運上更得心應手。祝開發順利!