本文 AI 產出,尚未審核

Golang 測試與除錯

主題:日誌記錄(log、Zap、Logrus)


簡介

在開發 Go 應用程式時,日誌(logging)是除錯與監控的第一道防線。無論是本機除錯、CI/CD 測試,或是上線後的服務可觀測性,都離不開適當的日誌輸出。

Go 內建的 log 套件提供最基本的功能,足以滿足簡單工具或腳本;但在大型分散式系統中,我們往往需要結構化、等級化、效能佳的日誌框架,這時 zaplogrus 兩個社群廣受歡迎的套件就派上用場。

本文將從 概念、實作範例、常見陷阱與最佳實踐 三個層面,帶領讀者快速掌握在 Go 專案中選擇與使用日誌的技巧,適合剛入門的初學者,也能為中階開發者提供實務參考。


核心概念

1️⃣ 標準庫 log

log 是 Go 標準函式庫的一部份,提供 簡單的文字日誌,預設寫入 os.Stderr。它支援四種等級(PrintPrintfPrintlnFatalPanic),但沒有正式的等級概念,需要自行在訊息前加上標籤。

程式碼範例 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.Stdoutos.Stderr 或檔案)與 前綴
  • log.LstdFlags 會自動加上日期與時間,log.Lshortfile 顯示呼叫位置。
  • FatalPanic 會額外觸發程式結束或 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,
	)
}

說明

  • InfowErrorw 等方法接受 訊息 + 任意數量的鍵值對,最終會序列化成 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‑valueWithError 是常用的快捷方式。
  • 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
}

說明

  • Hooklogrus 的擴充點,可在 每筆日誌寫入前後 執行自訂邏輯(例如寫入檔案、發送到 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


常見陷阱與最佳實踐

  1. 忘記呼叫 Sync()

    • zap 以及 logrus 的緩衝寫入如果在程式異常退出前未執行 Sync(),最後幾筆日誌會遺失。
    • 最佳實踐:在 main()defer 或測試結束時統一呼叫 logger.Sync()
  2. 在高併發環境使用 log.Printf

    • 標準庫的 log 每次呼叫都會分配字串,若在千級請求下會產生大量 GC。
    • 最佳實踐:在高流量服務改用 zapSugaredLogger(或 zap.Logger)以降低分配。
  3. 過度使用 FatalPanic

    • log.Fatal 直接呼叫 os.Exit(1),會跳過 defer,導致資源未釋放。
    • 最佳實踐:在服務中盡量使用錯誤回傳與統一的錯誤處理機制,僅在不可恢復的啟動錯誤時才使用 Fatal
  4. 日誌等級混亂

    • 把所有訊息都寫成 Info,會讓搜尋關鍵錯誤變得困難。
    • 最佳實踐:遵循 DEBUG < INFO < WARN < ERROR < DPANIC < PANIC < FATAL 的層級,並在 CI 中檢查等級使用情況。
  5. 在日誌中直接輸出敏感資訊

    • 包含密碼、金鑰或個人資料會違反 GDPR/CCPA。
    • 最佳實踐:使用 zapzapcore.NewSamplerlogrus 的 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 或 zapzapcore.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 夠簡單、足以應付小工具;但在需要 高效能、結構化、易於集中管理 的服務中,zaplogrus 提供了更完整的功能與彈性。

  • 選擇原則
    1. 開發階段logruszap.NewDevelopment(),快速看到可讀的訊息。
    2. 生產環境:優先考慮 zap(效能 + JSON),或在已有 logrus 生態時加上適當 Hook。
    3. 資源受限:若不想引入外部套件,直接使用 log 並自行實作 JSON 格式。

最後,記得 統一等級、避免敏感資訊、確保 Sync,並配合 log aggregation(ELK、Stackdriver、Loki)打造完整的可觀測系統。掌握這些概念與實作技巧,你的 Go 應用將在除錯與維運上更得心應手。祝開發順利!