Goのslogでインデント付きのJSONを出力するハンドラを実装する
JSONの構造化ログを出力するのにslogパッケージはとても便利です。
しかし、slogパッケージで用意されているハンドラではインデントのないJSONしか出力できず、開発時にローカルでログを確認する際は少し不便です。
そのため、本記事では開発環境向けにインデント付きのJSONを出力するハンドラを実装する方法を紹介します。
実装
インデント付きのJSONを出力するハンドラの実装方法はいくつかあると思いますが、本記事では単純にslog.JSONHandlerをラップする方法で実装します。
これは本番環境で使用することを想定するslog.JSONHandlerとの差異を可能な限り無くすためです。
他の実装方法として、レコードをmap[string]anyの形式で保持し、その値をMarshalIndent関数でJSONデータに変換して出力するという実装方法も考えられます。
しかし、こちらの方法だと出力されるJSONのフィールドの並び順がアルファベット順に変わってしまうため、本記事ではこの方法で実装するのを避けました。
slog.Handlerインタフェースは次の4つのメソッドを持ちます。本記事ではJSONIndentHandler構造体を定義し、この構造体がslog.Handlerインタフェースを満たすように実装していきます。
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
JSONIndentHandler構造体
まず、JSONIndentHandler構造体を次のように宣言します。
type JSONIndentHandler struct {
handler slog.Handler
w io.Writer
mu *sync.Mutex
buf *bytes.Buffer
}
handlerフィールドはラップするハンドラを、wフィールドはログの出力先を、muフィールドはログの出力時に排他制御を行うためのミューテックスを保持します。
重要なのはbufフィールドです。本記事の実装ではslog.JSONHandlerが出力した内容をそのままwに書き込まずに、このバッファで一時的に保持します。そして、バッファの内容をインデント付きのJSONデータに再度加工してwに書き込みます。
このJSONIndentHandler構造体を初期化するNewJSONIndentHandler関数は次のように宣言します。
func NewJSONIndentHandler(w io.Writer, opts *slog.HandlerOptions) *JSONIndentHandler {
buf := &bytes.Buffer{}
return &JSONIndentHandler{
handler: slog.NewJSONHandler(buf, opts),
w: w,
mu: &sync.Mutex{},
buf: buf,
}
}
ここから個々のメソッドの実装に入っていきます。
Enableメソッド
Enableメソッドはログレベルからログを出力するかを判断するメソッドです。
Enableメソッドは単純にラップしたハンドラのEnableメソッドを使用するように宣言します。
func (h *JSONIndentHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level)
}
Handleメソッド
Handleメソッドはレコードを受け取り、実際にログを出力するメソッドです。
Handleメソッドは次のように宣言します。
func (h *JSONIndentHandler) Handle(ctx context.Context, record slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
if err := h.handler.Handle(ctx, record); err != nil {
return err
}
encoder := json.NewEncoder(h.w)
encoder.SetIndent("", strings.Repeat(" ", 2))
if err := encoder.Encode(json.RawMessage(h.buf.Bytes())); err != nil {
return fmt.Errorf("failed to encode json log entry: %w", err)
}
h.buf.Reset()
return nil
}
h.handler.Handleメソッドの呼び出しでh.bufにインデントなしのJSONデータが書き込まれます。
そして、そのデータをencoder.Encodeメソッドでインデント付きのJSONデータに変換し、h.wに書き込みます。
最後に書き込み済みのJSONデータはバッファに必要ないので、h.buf.Resetメソッドでバッファから削除します。
重要な点はHandleメソッドの開始時にh.mu.Lockメソッドでロックを取得している点です。
wとbufはハンドラ間で共有されており、この2つのフィールドへの書き込みを単純に行えばデータ競合が発生する可能性があります。
そのため、ミューテックスを使用して排他制御を行なっています。
WithAttrsメソッド、WithGroupメソッド
WithAttrsメソッドは指定した属性を含むログを出力する新しいハンドラを返すメソッドで、WithGroupメソッドは指定したグループでまとめられたログを出力する新しいハンドラを返すメソッドです。
この2つのメソッドも単純にラップしたハンドラのメソッドを使用して、次のように宣言します。
func (h *JSONIndentHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &JSONIndentHandler{
handler: h.handler.WithAttrs(attrs),
w: h.w,
mu: h.mu,
buf: h.buf,
}
}
func (h *JSONIndentHandler) WithGroup(name string) slog.Handler {
return &JSONIndentHandler{
handler: h.handler.WithGroup(name),
w: h.w,
mu: h.mu,
buf: h.buf,
}
}
これでJSONIndentHandlerの実装は完了です。
動作確認
実際にNewJSONIndentHandlerを使ってログを出力してみます。
package main
import (
"log/slog"
"os"
)
func main() {
l := slog.New(NewJSONIndentHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.MessageKey {
a.Key = "message"
}
return a
},
}))
l.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg", "e", "f")
}
上記のプログラムを実行すると次の結果が得られます。
slog.JSONHandlerと同じフィールドの順序で、インデント付きのJSONデータが出力されることが確認できます。
{
"time": "2024-07-01T09:00:00.000000+09:00",
"level": "INFO",
"source": {
"function": "main.main",
"file": "/Users/example/main.go",
"line": 19
},
"message": "msg",
"a": "b",
"G": {
"c": "d",
"H": {
"e": "f"
}
}
}
テスト
実装した独自のハンドラが適切な出力を行うのかのテストを追加してみます。
独自のハンドラをテストするには標準のslogtestパッケージが便利です。
slogtestパッケージには独自のハンドラがslogのハンドラが満たすべき性質を満たすかを確認するテストケースが用意されています。
例としてJSONIndentHandlerのテストは次のように書けます。
package main
import (
"bytes"
"encoding/json"
"log/slog"
"testing"
"testing/slogtest"
)
func TestJSONIndentHandler(t *testing.T) {
var buf bytes.Buffer
newHandler := func(t *testing.T) slog.Handler {
buf.Reset()
return NewJSONIndentHandler(&buf, nil)
}
result := func(t *testing.T) map[string]any {
line := buf.Bytes()
if len(line) == 0 {
return map[string]any{}
}
var m map[string]any
if err := json.Unmarshal(line, &m); err != nil {
t.Fatal(err)
}
return m
}
slogtest.Run(t, newHandler, result)
}
slogtest.Run関数が用意されているテストケースをサブテストで実行する関数で、第2引数、第3引数にnewHandler関数とresult関数を受け取ります。
newHandler関数はハンドラのインスタンスを生成する関数で、result関数はログ出力をmap[string]anyにパースする関数です。
slogtest.Run関数はそれぞれのテストケースで次のようにテストを行います。
-
newHandlerを呼び出してハンドラのインスタンスを生成する - 生成したインスタンスでテストケースを実行する
- テストケースの実行後、
result関数を呼び出して出力した内容をmap[string]anyで取得し、その内容を検証する
これでテストを含めてのJSONIndentHandlerの実装が完了しました。
上記の内容に誤りなどがあればコメントなどで教えていただけると幸いです。
Discussion