Goのloggerを引き回す際に皆さんはどのような手法を取っていますか?
- グローバル変数にloggerのインスタンスを入れておく
- contextにloggerのインスタンスを入れておく
- トレースIDなどを入れたloggerを適宜作ってcontextに格納する
- 構造体のフィールドにloggerのインスタンスを入れておく(DI)
などなど、ソフトウェアの規模や特性を鑑みて各自使い分けているかと思います。
ところで、私は 2. 3. の手法があまり好きではありませんでした。単純に面倒だし美しくありません。contextに入れる、取り出すだけでも数行のコードを毎度書く必要があってダルいな〜と思っていました。けれども、ログにトレースIDなどを入れたいだろうなあと思い、Webサーバの実装においては 2. を渋々選択していました。
さて、Go 1.21ではslogパッケージが登場しました。slogは単に構造化ログが扱えるというだけでなく、使いやすかったりトレースと統合できたりということも考慮されて設計されています。
slogパッケージではslog.SetDefault(l *Logger)
という関数が用意されており、デフォルトのロガーを登録しておけます。実装としては大体グローバル変数に格納する手法と一緒で、複数のgoroutineから呼ばれることを考慮してsync/atomicを用いているようです。*1
さらに、ログを出力する際にはslog.InfoContext(ctx context.Context, msg string, args ...any)
のようにcontextを渡すことができる関数が用意されており、loggerのハンドラーは出力の際にcontextの値を利用することができます。
私はこの2つを見て、contextによって構造化ログに含まれる特定の値(トレースIDなど)を変えたいという目的程度であればloggerごとcontextに入れる実装はやらなくて良いと解釈しました。以下のようにcontextからrecordに付与したい情報を取得するようなHandle()
を実装したHandlerを使ってslogのインスタンスを作り、SetDefault()
でデフォルトロガーに設定すれば良いのです。こうすることで、ログを出すためだけにわざわざcontextからloggerを取得する必要がなくなります。
type LogWithTraceHandler struct { inner slog.Handler } func NewLogWithTraceHandler(inner slog.Handler) LogWithTraceHandler { return LogWithTraceHandler{inner} } func (h *LogWithTraceHandler) Handle(ctx context.Context, r slog.Record) error { sc := trace.SpanContextFromContext(ctx) if sc.IsValid() { r.AddAttrs( slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String()), ) } return h.inner.Handle(ctx, r) } func main() { logger := NewLogWithTraceHandler(slog.NewJSONHandler(os.Stderr, nil)) slog.SetDefault(logger) // あとは context に SpanContext を詰めて slog.InfoContext(ctx, "message", ...) で呼び出し }
(コードはブラウザ上で適当に書いたので動かないかもしれません。机上論。)
もちろん、あるcontextの中ではloggerの実装ごと変えてしまいたいというユースケースがないとは言いませんし、そういう場合にはcontextにloggerの実体を格納すると良いでしょう。しかし、典型的なWebサーバを作るという目的において、自分だったらslog.SetDefault()
にセットしたloggerを使うので十分かなと現時点では考えています。
2024-07-08 追記: 机上論じゃないバージョンです