動態

詳情 返回 返回

萬字解析 Go 官方結構化日誌包 slog - 動態 詳情

首發地址:https://mp.weixin.qq.com/s/v1nh_WnXq1V8z0WpICdcfA

slog 日誌包是 Go 語言中的一個結構化日誌庫,旨在提供一個簡單而強大的日誌系統。因為標準日誌庫 log 過於簡陋,社區中經常有人吐槽,Go 官方也承認了這一點,於是 Go 團隊成員 Jonathan Amsterdam 操刀設計了新的日誌庫 slog,其放在 log/slog 目錄中。

slog 設計之初大量參考了社區中現有日誌包方案,相比於 log,主要解決了兩個問題:

  • log 不支持日誌級別。
  • log 日誌不是結構化的。

這兩個問題都能在 slog 中得到解決,本文就來帶大家詳解 slog 用法及設計。

NOTE:
如果你對標準庫 log 不夠熟悉,可以參考我的文章:《深入探究 Go log 標準庫》。

slog 快速入門

快速開始

slog 使用非常簡單,導入 log/slog 後即可使用:

package main

import "log/slog"

func main() {
    slog.Debug("debug message")
    slog.Info("info message")
    slog.Warn("warn message")
    slog.Error("error message")
}

執行示例代碼,輸出結果如下:

$ go run main.go
2024/06/23 10:20:38 INFO info message
2024/06/23 10:20:38 WARN warn message
2024/06/23 10:20:38 ERROR error message

slog 日誌默認輸出到 os.Stdout

可以發現 Debug 日誌並沒有輸出,説明 slog 日誌默認級別為 Info

slog 默認僅支持 DebugInfoWarnError 這 4 種日誌級別,後文會演示如何增加自定義日誌級別。

附加屬性

slog 支持在 msg 後傳入無限多個 key/value 鍵值對來附加額外的屬性:

slog.Debug("debug message", "hello", "world")
slog.Info("info message", "hello", "world")
slog.Warn("warn message", "hello", "world")
slog.Error("error message", "hello", "world")

執行示例代碼,輸出結果如下:

$ go run main.go
2024/06/23 10:21:33 INFO info message hello=world
2024/06/23 10:21:33 WARN warn message hello=world
2024/06/23 10:21:33 ERROR error message hello=world

可以發現,傳遞給日誌方法的鍵值對會以 key=value 格式輸出。

Context 版本日誌方法

slog 的日誌方法都存在 XxxContext 版本,使用示例:

ctx := context.Background()
slog.DebugContext(ctx, "debug message", "hello", "world")
slog.InfoContext(ctx, "info message", "hello", "world")
slog.WarnContext(ctx, "warn message", "hello", "world")
slog.ErrorContext(ctx, "error message", "hello", "world")

執行示例代碼,輸出結果如下:

$ go run main.go
2024/06/23 10:22:29 INFO info message hello=world
2024/06/23 10:22:29 WARN warn message hello=world
2024/06/23 10:22:29 ERROR error message hello=world

輸出結果與不使用 Context 版本的日誌方法相同。

事實上,它們的代碼主邏輯是一樣的:

// Info calls [Logger.Info] on the default logger.
func Info(msg string, args ...any) {
    Default().log(context.Background(), LevelInfo, msg, args...)
}

// InfoContext calls [Logger.InfoContext] on the default logger.
func InfoContext(ctx context.Context, msg string, args ...any) {
    Default().log(ctx, LevelInfo, msg, args...)
}

無論是 Xxx 還是 XxxContext 版本的日誌方法,底層都會調用 slog.Logger.log 方法,只不過 Xxx 日誌方法的 context 是在方法內部構造的,XxxContext 日誌方法的 context 是通過參數傳入的。

修改日誌級別

我們可以將 slog 日誌級別修改為 Debug

slog.SetLogLoggerLevel(slog.LevelDebug)
slog.Debug("debug message", "hello", "world")
slog.Info("info message", "hello", "world")
slog.Warn("warn message", "hello", "world")
slog.Error("error message", "hello", "world")

執行示例代碼,輸出結果如下:

$ go run main.go
2024/06/23 10:23:31 DEBUG debug message hello=world
2024/06/23 10:23:31 INFO info message hello=world
2024/06/23 10:23:31 WARN warn message hello=world
2024/06/23 10:23:31 ERROR error message hello=world

可以發現,slog 修改日誌級別還是非常方便的。

獲取當前日誌級別

既然可以修改日誌級別,那麼我們是否也可以獲取當前日誌級別呢?

很遺憾,slog 沒有為我們提供一個方法可以便捷的獲取日誌級別。

不過,slog 的 Logger 對象有一個 Enabled 方法,可以用來判斷給定的日誌級別是否被開啓。

那麼我們就可以將日誌級別由低到高依次傳給 Enabled 方法來判斷當前日誌級別是否啓用,只要當前日誌級別已經啓用,就説明 slog 開啓的最低日誌級別是當前日誌級別。

示例代碼如下:

var currentLevel slog.Level = -10
for _, level := range []slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError} {
    r := slog.Default().Enabled(context.Background(), level)
    if r {
        currentLevel = level
        break
    }
}
fmt.Printf("current log level: %v\n", currentLevel)

代碼中初始化 currentLevel 用來記錄當前日誌級別,類型為 slog.Level,初始值為 -10。這之所以能生效,是因為其實 slog.Level 本身就是 int 類型。

slog 默認支持的幾種日誌級別定義如下:

type Level int

const (
    LevelDebug Level = -4
    LevelInfo  Level = 0
    LevelWarn  Level = 4
    LevelError Level = 8
)

可以發現這幾個日誌級別並不是連續的,這是 slog 團隊經過深思熟慮後的結果。故意這樣設計,是為了方便我們增加自定義日誌級別。使我們可以在任意兩個日誌級別之間定義自己的日誌級別(後文會有講解)。

執行示例代碼,輸出結果如下:

$ go run main.go
current log level: DEBUG

currentLevel 初始值為 -10 而不是最低的日誌級別 LevelDebug,可以證明這段獲取當前日誌級別的示例代碼的確是有效的,而不是因為默認值設為 LevelDebug 打印結果才是 DEBUG

結構化日誌

雖然我們説 slog 是結構化的日誌包,但其實前文示例中打印的日誌結果,並不是結構化的。

接下來看看 slog 支持的真正結構化日誌輸出長什麼樣。

JSONHandler

slog 支持 JSON 結構化日誌。

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))
l.Debug("debug message", "hello", "world")

我們可以通過 slog.New 方法創建一個自定義的 *slog.Logger

slog.New 接收一個 slog.Handler 對象(slog.Handler 是一個接口,後文會詳細講解)。

slog 內置了兩個 slog.Handler 對象,其中一個就是 *slog.JSONHandler,可以使用 slog.NewJSONHandler 來創建。

slog.NewJSONHandler 接收兩個參數,傳入的 os.Stdout 是一個 io.Writer 接口,用來指定日誌輸出位置。

*slog.HandlerOptions 是一個結構體,AddSource 設為 true 可以輸出記錄日誌的位置,Level 用來指定日誌級別,ReplaceAttr 屬性後文再來講解。

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:25:34.880089+08:00",
    "level": "DEBUG",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 71
    },
    "msg": "debug message",
    "hello": "world"
}
NOTE:
slog 默認輸出的 JSON 格式日誌是沒有換行和縮進的,例如:
{"time":"2024-06-23T10:25:34.880089+08:00","level":"DEBUG","source":{"function":"main.main","file":"/workspace/projects/blog-go-example/log/slog/main.go","line":71},"msg":"debug message","hello":"world"}
為了展示效果更佳清晰,這裏輸出的 JSON 格式日誌是我經過美化處理的。後文示例也會如此。

這就是 JSON 格式的結構化日誌輸出。

TextHandler

slog 內置的另一個 slog.Handler 對象是 *slog.TextHandler,可以將日誌輸出為 key=value 結構:

l := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))
l.Debug("debug message", "hello", "world")

執行示例代碼,輸出結果如下:

$ go run main.go
time=2024-06-23T10:25:34.880+08:00 level=DEBUG source=/workspace/projects/blog-go-example/log/slog/main.go:80 msg="debug message" hello=world

一般來説,TextHandler 可以作為開發/測試環境的日誌輸出格式,方便查看;JSONHandler 可以作為生產環境的日誌輸出格式,方便日誌工具採集。

NOTE:
程序中可以通過環境變量來獲取當前程序所處環境是開發、測試還是生產,以此來決定使用哪個 Handler

使用自定義 logger 替換默認 logger

前文已經演示瞭如何通過 slog.New 方法創建一個自定義的 *slog.Logger

我們可以使用自定義的 logger 來替換掉 slog 默認的 logger 對象,方便使用。

示例如下:

slog.Info("info message", "hello", "world")
log.Println("normal log")

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))
slog.SetDefault(l)

slog.Info("info message", "hello", "world")
// log 也被修改了
log.Println("normal log")

使用 slog.SetDefault(l) 可以非常方便的用自定義的 *slog.Logger 對象 l 取代默認 logger 對象。

然後就可以像使用默認 logger 一樣調用 slog.Info 使用自定義 *slog.Logger 記錄日誌了。

並且,slog.SetDefault(l)log 庫同樣生效。

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:27:18.946947+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 95
    },
    "msg": "info message",
    "hello": "world"
}
{
    "time": "2024-06-23T10:27:18.946957+08:00",
    "level": "DEBUG",
    "msg": "normal log"
}
NOTE:
這裏 log.Println("normal log") 輸出的 JSON 日誌也是經過美化處理的。

將 slog.Logger 轉換為 log.Logger

既然 slog.SetDefault(l)log 庫有影響,則説明 *slog.Logger 對象可以被轉換成 *log.Logger 對象。

轉換示例代碼如下:

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

logLogger := slog.NewLogLogger(l.Handler(), slog.LevelInfo)
logLogger.Println("normal log") // 輸出日誌級別跟隨 slog.LevelInfo 設置

slog.NewLogLogger 可以創建一個 *log.Logger 對象,而它的參數分別是 slog.Handler 對象和日誌級別。

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:30:07.99423+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 109
    },
    "msg": "normal log"
}

可以發現,通過標準庫 *log.Logger 對象 logLogger 輸出的日誌依然是 JSON 格式。

使用寬鬆類型可能出現不匹配的屬性鍵值對

前文有介紹 slog 支持在 msg 後傳入無限多個 key/value 鍵值對來附加額外的屬性。

但是,這裏存在一個坑!如果 key/value 不是成對出現,則輸出日誌會得到意想不到的結果:

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

l.Info("info message", "hello") // "!BADKEY":"hello"

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:30:53.200792+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 120
    },
    "msg": "info message",
    "!BADKEY": "hello"
}

示例中除了 msg 外,僅存在一個為 hellokey,並沒有傳入對應的 value

此時 slog 不會報錯,但輸出日誌結果 "!BADKEY": "hello",提示我們 key/value 數量不匹配。

為了避免這種錯誤發生,我們可以使用 go vet 工具來進行檢查:

$ go vet .      
# github.com/jianghushinian/blog-go-example/log/slog
# [github.com/jianghushinian/blog-go-example/log/slog]
./main.go:120:3: call to slog.Logger.Info missing a final value

可以看到,go vet 能夠發現代碼中 slog 輸出的日誌屬性中 key/value 數量不匹配問題。

使用強類型緩解可能出現不匹配的屬性鍵值對

我們可以使用 slog 提供的強類型 key/value 來緩解以上問題:

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

l.Info("info message", slog.String("hello", "world"), slog.Int("status", 200))

slog 提供了常見的基礎類型方法,可以傳入對應的 key/value 對。

使用 slog.Stringslog.Int 等可以避免不匹配的 key/value

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:32:23.420762+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 135
    },
    "msg": "info message",
    "hello": "world",
    "status": 200
}

但是,使用強類型方法,依然不能限制我們傳入普通的字符串類型 key/value

我們還是可能寫出如下代碼:

l.Info("info message", slog.String("hello", "world"), slog.Int("status", 200), "extra") // "!BADKEY":"extra"

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:32:23.420787+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 136
    },
    "msg": "info message",
    "hello": "world",
    "status": 200,
    "!BADKEY": "extra"
}

利用 LogAttrs 限制必須使用強類型,避免出現 !BADKEY

這個時候,我們還有一種解決方案,就是使用 LogAttrs 來輸出日誌:

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

l.LogAttrs(
    context.Background(),
    slog.LevelInfo,
    "info message",
    slog.String("hello", "world"),
    slog.Int("status", 405),
    slog.Any("err", errors.New("http method not allowed")), // error 類型可以使用 slog.Any 輸出
    // "extra","text", // 編譯不通過,類型不匹配
)

使用 LogAttrs 方法記錄日誌,用起來比 DebugInfo 等略顯繁瑣。不過,它限制只能傳遞 slog.Stringslog.Int 這種強類型,如果傳遞普通字符串,則編譯不通過。

如果要輸出的 value 類型為 error,可以使用 slog.Any 輸出。slog 支持的其他類型我就不一一列出來了,交給你自己去探索吧。

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:33:27.820493+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 147
    },
    "msg": "info message",
    "hello": "world",
    "status": 405,
    "err": "http method not allowed"
}

屬性分組

我們可以使用 slog.Group 為一組 key/value 屬性進行分組。

多説無益,我寫個示例你就懂了。

JSONHandler

這是使用 JSONHandler 的屬性分組示例:

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

l.Info(
    "info message",
    slog.Group("user", "name", "root", slog.Int("age", 20)),
)

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:35:03.301692+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 167
    },
    "msg": "info message",
    "user": {
        "name": "root",
        "age": 20
    }
}

可以發現,slog.Group 第一個參數為分組名稱 user,接下來傳遞的屬性鍵值對都屬於這個分組。

TextHandler

使用 TextHandler 的屬性分組示例:

l := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

l.Info(
    "info message",
    slog.Group("user", "name", "root", slog.Int("age", 20)),
)

執行示例代碼,輸出結果如下:

$ go run main.go
time=2024-06-23T10:35:03.301+08:00 level=INFO source=/workspace/projects/blog-go-example/log/slog/main.go:180 msg="info message" user.name=root user.age=20

有別於 JSONHandler,這裏輸出的屬性結果是 user.name 形式,而非嵌套形式。

使用子 logger

可以使用 With 方法附加自定義屬性到一個新的 *slog.Logger 對象。

這個新得到的 *slog.Logger 對象使用方式不變,但其所有日誌記錄都會攜帶統一的附加屬性,非常適合簡化代碼。

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))
// 附加自定義屬性
sl := l.With("requestId", "10191529-bc34-4efe-95e4-ecac7321773a")
sl.Debug("debug message")
sl.Info("info message")

我們為新的 *slog.Logger 對象 sl 附加了 requestId,這在 Web 開發中非常常用,可以用來追蹤整個請求鏈。

接下來使用 sl 輸出的日誌都會攜帶這個 requestId 屬性。

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:35:53.966953+08:00",
    "level": "DEBUG",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 195
    },
    "msg": "debug message",
    "requestId": "10191529-bc34-4efe-95e4-ecac7321773a"
}
{
    "time": "2024-06-23T10:35:53.966972+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 196
    },
    "msg": "info message",
    "requestId": "10191529-bc34-4efe-95e4-ecac7321773a"
}

為子 logger 屬性分組

logger 對象同樣支持屬性分組,示例代碼如下:

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

sl := l.WithGroup("user").With("requestId", "10191529-bc34-4efe-95e4-ecac7321773a")
sl.Debug("debug message", "name", "admin")
sl.Info("info message", "name", "admin")

使用 WithGroup 方法可以對子 logger 屬性進行分組,這裏同時使用了 With 又得到一個新的子 logger 對象。

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:37:01.62481+08:00",
    "level": "DEBUG",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 208
    },
    "msg": "debug message",
    "user": {
        "requestId": "10191529-bc34-4efe-95e4-ecac7321773a"
        "name": "admin"
    }
}
{
    "time": "2024-06-23T10:37:01.625249+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 209
    },
    "msg": "info message",
    "user": {
        "requestId": "10191529-bc34-4efe-95e4-ecac7321773a",
        "name": "admin"
    }
}

可以發現,使用 With 附加的屬性和調用 Debuginfo 方法附加的屬性都被分組到了 user 中。

實現 slog.LogValuer 接口,隱藏敏感信息

有時候,我們可能要在日誌中記錄某個模型。

比如這裏有一個 User 模型:

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name"`
    Password string `json:"password"`
}

如果直接將 User 實例對象傳給 slog 進行記錄,那麼 password 屬性也會被記錄,這通常並不是我們想要的。

在使用 slog 以前,我的做法一般是為 User 模型定義一個 SecureString 方法,然後返回脱敏後的字符串,這樣在記錄日誌時,可以將 user.SecureString() 結果傳給日誌記錄器。

func (u *User) SecureString() string {
    u.Password = ""
    res, _ := json.Marshal(u)
    return string(res)
}

不過,slog 為我們提供了 slog.LogValuer 接口,一個對象只要實現這個接口,就可以直接傳遞給 slog 進行記錄。

slog.LogValuer 接口定義如下:

type LogValuer interface {
    LogValue() Value
}

所以,我們可以為 User 實現一個 LogValue 方法:

// LogValue implements slog.LogValuer interface
// slog.Value 不可比較: https://jianghushinian.cn/2024/06/15/how-to-make-structures-incomparable-in-go/
func (u *User) LogValue() slog.Value {
    return slog.GroupValue(
        slog.Int("id", u.ID),
        slog.String("name", u.Name),
    )
}

LogValue 方法返回 slog.Value 類型。

NOTE:
slog.Value 類型不可比較,可以參考我的另一篇文章:《在 Go 中如何讓結構體不可比較?》。

現在,我們直接將 User 實例傳遞給 slog 的日誌記錄方法看看效果:

l := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    AddSource:   true,            // 記錄日誌位置
    Level:       slog.LevelDebug, // 設置日誌級別
    ReplaceAttr: nil,
}))

user := &User{
    ID:       123,
    Name:     "jianghushinian",
    Password: "pass",
}
l.Info("info message", "user1", user)  // *User 未實現 slog.LogValuer 接口
l.Info("info message", "user2", *user) // User 未實現 slog.LogValuer 接口,所以無法隱藏敏感信息

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:38:03.64827+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 225
    },
    "msg": "info message",
    "user1": {
        "id": 123,
        "name": "jianghushinian"
    }
}
{
    "time": "2024-06-23T10:38:03.648291+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 226
    },
    "msg": "info message",
    "user2": {
        "id": 123,
        "name": "jianghushinian",
        "password": "pass"
    }
}

值得注意的是,這裏我為指針類型 *User 實現了 slog.LogValuer 接口,但值類型 User 並沒有實現 slog.LogValuer 接口。所以記錄日誌時 User 實例指針可以隱藏 password,但 User 實例並不能。

slog 是如何設計的

slog 是 Go 日誌生態中的後起之秀,其設計之初可以參考的流行日誌庫有很多,比如 logruszapzerolog 等。所以我們能在 slog 中看到其他日誌庫的影子,尤其是 zap

NOTE:
如果你想深入瞭解 logrus 可以參考我的文章:《Go 第三方 log 庫之 logrus 使用》。
如果你想深入瞭解 zap 可以參考我的文章:《Go 第三方 log 庫之 zap 使用》、《如何基於 zap 封裝一個更好用的日誌庫》。

slog 核心組件有 3 個,分別是 LoggerRecord 以及 Handler

Logger 是一個結構體,面向用户側,其提供了 DebugInfo 等方法用於記錄日誌,定義如下:

type Logger struct {
    handler Handler // for structured logging
}

// Debug logs at [LevelDebug].
func (l *Logger) Debug(msg string, args ...any) {
    l.log(context.Background(), LevelDebug, msg, args...)
}

// DebugContext logs at [LevelDebug] with the given context.
func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) {
    l.log(ctx, LevelDebug, msg, args...)
}

// Info logs at [LevelInfo].
func (l *Logger) Info(msg string, args ...any) {
    l.log(context.Background(), LevelInfo, msg, args...)
}

// InfoContext logs at [LevelInfo] with the given context.
func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) {
    l.log(ctx, LevelInfo, msg, args...)
}

func (l *Logger) log(ctx context.Context, level Level, msg string, args ...any) {
    if !l.Enabled(ctx, level) {
        return
    }
    var pc uintptr
    if !internal.IgnorePC {
        var pcs [1]uintptr
        // skip [runtime.Callers, this function, this function's caller]
        runtime.Callers(3, pcs[:])
        pc = pcs[0]
    }
    r := NewRecord(time.Now(), level, msg, pc)
    r.Add(args...)
    if ctx == nil {
        ctx = context.Background()
    }
    _ = l.Handler().Handle(ctx, r)
}
...

可以發現,Logger 結構體定義非常簡單,其內部唯一一個屬性就是 Handler

Record 是一條日誌條目,一個 Record 實例就代表了一條日誌記錄,定義如下:

// A Record holds information about a log event.
type Record struct {
    // The time at which the output method (Log, Info, etc.) was called.
    Time time.Time

    // The log message.
    Message string

    // The level of the event.
    Level Level

    ...
}

其中 Time 是當前這條日誌記錄的時間,Message 是日誌消息,Level 是日誌級別。

Handler 是一個接口,用於處理 Logger 產生的日誌條目 Record,定義如下:

// A Handler handles log records produced by a Logger.
type Handler interface {
    // Enabled reports whether the handler handles records at the given level.
    Enabled(context.Context, Level) bool

    // Handle handles the Record.
    Handle(context.Context, Record) error

    // WithAttrs returns a new Handler whose attributes consist of
    // both the receiver's attributes and the arguments.
    WithAttrs(attrs []Attr) Handler

    // WithGroup returns a new Handler with the given group appended to
    // the receiver's existing groups.
    WithGroup(name string) Handler
}

slog 3 大核心組件導圖如下:

image.png

這就是 slog 最核心的設計了。

從 slog 架構邏輯上講,其中 Logger 被稱為前端Handler 被稱為後端,而 Record 就是連接二者的橋樑。

記錄一條日誌流程如下:

image.png

用户調用前端 Logger 提供的日誌記錄方法 Info 記錄一條日誌,Info 方法會調用一個私有方法 loglog 方法內部會使用 NewRecord 創建一個日誌條目 Record,最終,Logger 會調用其嵌入的 Handler 對象的 Handle 方法解析 Record 並執行日誌記錄邏輯。

現在是不是對 slog 的理解更加清晰了。

定製 Logger

瞭解了 slog 的日誌設計,接下來我們就可以基於 slog.Logger 定製屬於自己的 Logger 對象了。

我們要自定義的 Logger 對象需要支持 3 個功能:自定義日誌級別、動態調整日誌級別、以及正確輸出日誌位置。

自定義日誌級別

自定義的 Logger 對象首先要支持自定義日誌級別:

type Level = slog.Level

const (
    LevelDebug = slog.LevelDebug
    LevelTrace = slog.Level(-2) // 自定義日誌級別
    LevelInfo  = slog.LevelInfo
    LevelWarn  = slog.LevelWarn
    LevelError = slog.LevelError
)

這裏我們為 slog.Level 定義了一個別名 Level,並且在 LevelDebug 以及 LevelInfo 之間自定義了一個日誌級別,其值為 -2

因為 LevelDebug 值為 -4LevelInfo 值為 0,所以我們最多可以在二者之間自定義 3 個日誌級別 -3-2-1

動態設置日誌級別

自定義 Logger 結構體對象如下:

type Logger struct {
    l   *slog.Logger
    lvl *slog.LevelVar // 用來動態調整日誌級別
}

Logger 內部包含了 *slog.Logger 對象,以及 *slog.LevelVar 對象。

其中 *slog.LevelVar 類型支持通過 *slog.LevelVar.Set(Level) 動態調整日誌級別。

Logger 構造函數如下:

func New(level slog.Level) *Logger {
    var lvl slog.LevelVar
    lvl.Set(level)

    h := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,

        Level: &lvl, // 支持動態設置日誌級別

        // 修改日誌中的 Attr 鍵值對(即日誌記錄中附加的 key/value)
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            if a.Key == slog.LevelKey {
                level := a.Value.Any().(slog.Level)
                levelLabel := level.String()

                switch level {
                case LevelTrace:
                    // NOTE: 如果不設置,默認日誌級別打印為 "level":"DEBUG+2"
                    levelLabel = "TRACE"
                }

                a.Value = slog.StringValue(levelLabel)
            }

            return a
        },
    }))

    return &Logger{l: h, lvl: &lvl}
}

自定義 Logger 使用 JSONHandler 作為默認的 Handler,這裏終於用上了 ReplaceAttr 屬性。

*slog.HandlerOptionsReplaceAttr 屬性接收一個函數,第一個參數是日誌分組 groups,即我們通過 slog.Group 指定的組,第二個參數是 slog.Attr 類型,它其實就是日誌條目中包含的所有附加屬性 key/value

所以,其實每記錄一條日誌,都會多次調用這個方法,並將日誌條目對應的分組和屬性傳遞進來,方便我們進行修改,並返回最終修改後的屬性。

這也就給了我們一個機會,可以判斷當前日誌條目的級別,而自定義日誌級別輸出結果如何,完全掌握在我們自己手中。

根據前文的示例講解,你應該也能發現,日誌條目中的時間和級別等屬性,都是 slog 自動加上去的。而屬性的 key 其實被定義為了常量:

// Keys for "built-in" attributes.
const (
    // TimeKey is the key used by the built-in handlers for the time
    // when the log method is called. The associated Value is a [time.Time].
    TimeKey = "time"
    // LevelKey is the key used by the built-in handlers for the level
    // of the log call. The associated value is a [Level].
    LevelKey = "level"
    // MessageKey is the key used by the built-in handlers for the
    // message of the log call. The associated value is a string.
    MessageKey = "msg"
    // SourceKey is the key used by the built-in handlers for the source file
    // and line of the log call. The associated value is a *[Source].
    SourceKey = "source"
)

slog 內置了這 4 個常量作為屬性的 key,所以在 ReplaceAttr 方法中,我們可以通過 if a.Key == slog.LevelKey 判斷這個屬性是否為日誌級別。

如果是日誌級別,並且日誌級別為自定義的 LevelTrace,則設置其字符串形式為 TRACE

而動態調整日誌級別其實是 slog 自帶的功能,我們只需要代理一下 *slog.LevelVar.Set 方法即可:

// SetLevel 動態調整日誌級別
func (l *Logger) SetLevel(level Level) {
    l.lvl.Set(level)
}

設置 caller skip

自定義 Logger 還要解決一個重要問題。

不知道你有沒有注意,前文在介紹 slog是如何設計的 時候,給出的 slog.Logger 源碼定義中,不管是 Debug 還是 Info 方法,其實它們內部都調用了 slog.Logger.log 方法。所以 slog.Logger.log 方法是 slog.Logger 的核心方法。

再次回顧下 slog.Logger.log 方法的定義:

// log is the low-level logging method for methods that take ...any.
// It must always be called directly by an exported logging method
// or function, because it uses a fixed call depth to obtain the pc.
func (l *Logger) log(ctx context.Context, level Level, msg string, args ...any) {
    if !l.Enabled(ctx, level) {
        return
    }
    var pc uintptr
    if !internal.IgnorePC {
        var pcs [1]uintptr
        // skip [runtime.Callers, this function, this function's caller]
        runtime.Callers(3, pcs[:])
        pc = pcs[0]
    }
    r := NewRecord(time.Now(), level, msg, pc)
    r.Add(args...)
    if ctx == nil {
        ctx = context.Background()
    }
    _ = l.Handler().Handle(ctx, r)
}

這個方法中,先通過 if !l.Enabled(ctx, level) 判斷當前日誌級別是否啓用,如果沒啓用則丟棄這條日誌,否則繼續執行。

這裏有一行重點代碼 runtime.Callers(3, pcs[:]),這行是專門用來準確記錄日誌位置的,在構造 Handler 時如果傳入的 *slog.HandlerOptions 對象開啓了 AddSource 選項,就會使用這裏的邏輯記錄日誌的正確位置。

runtime.Callers 能夠獲取調用堆棧的程序計數器,3 表示跳過前 3 個調用者,包括 runtime.Callers 自身、當前的 log 函數和它的直接調用者 DebugInfo 等。

遺憾的是,這裏 3 是寫死的魔法數字,不是通過參數傳遞進來的。因為我們還會對 slog 的日誌記錄函數進行包裝,所以,我們自定義的 Logger 對象在記錄日誌時無法獲得準確的日誌位置。

比如定義 Debug 方法如下:

func (l *Logger) Debug(msg string, args ...any) {
    // 不會走 *customlog.Logger.log() 調用,會走 *slog.Logger.log() 調用
    l.l.Debug(msg, args...)
}

這裏直接代理到 *slog.Logger.Debug 方法。

現在如果你使用 New 函數創建一個自定義 Logger 對象,然後調用 *Logger.Debug 方法記錄日誌,將得到錯誤的日誌位置。

為了解決這個問題,正確的做法是,我們可以重寫 slog.Logger.log 方法:

func (l *Logger) log(ctx context.Context, level slog.Level, msg string, args ...any) {
    if !l.l.Enabled(ctx, level) {
        return
    }
    var pc uintptr
    var pcs [1]uintptr
    // skip [runtime.Callers, this function, this function's caller]
    // NOTE: 這裏修改 skip 為 4,*slog.Logger.log 源碼中 skip 為 3
    runtime.Callers(4, pcs[:])
    pc = pcs[0]
    r := slog.NewRecord(time.Now(), level, msg, pc)
    r.Add(args...)
    if ctx == nil {
        ctx = context.Background()
    }
    _ = l.l.Handler().Handle(ctx, r)
}

這裏幾乎是 slog.Logger.log 方法的拷貝,只不過將 runtime.Callers(3, pcs[:]) 中的 3 換成了 4

因為我們自定義的日誌記錄方法包裝了 slog.Logger 對應的日誌記錄方法,這就多了一層調用,所以 runtime.Callersskip 參數值就需要加 1

NOTE:
你可能糾結於 if !internal.IgnorePC { 這個判斷條件被我們忽略了,沒關係,這個判斷實際上是為了在性能測試時關閉記錄日誌位置的功能,以此提升性能。所以,關閉忽略它並不影響我們的程序功能。

然後定義幾個常用的日誌記錄方法:

func (l *Logger) Info(msg string, args ...any) {
    // l.l.Info(msg, args...)
    l.Log(context.Background(), LevelInfo, msg, args...)
}

// Trace 自定義的日誌級別
func (l *Logger) Trace(msg string, args ...any) {
    l.Log(context.Background(), LevelTrace, msg, args...)
}

func (l *Logger) Warn(msg string, args ...any) {
    // l.l.Warn(msg, args...)
    l.Log(context.Background(), LevelWarn, msg, args...)
}

func (l *Logger) Error(msg string, args ...any) {
    // l.l.Error(msg, args...)
    l.Log(context.Background(), LevelError, msg, args...)
}

func (l *Logger) Log(ctx context.Context, level slog.Level, msg string, args ...any) {
    l.log(ctx, level, msg, args...)
}

現在自定義 Logger 對象暴露的頂層方日誌記錄方法,都會調用我們自定義的 *Logger.log 方法,而非 *slog.Logger.log 方法。也就解決了日誌記錄位置不準確的問題。

完整 Logger 代碼實現

至此,我們得到了自定義日誌包的完整代碼如下:

package customlog

import (
    "context"
    "log/slog"
    "os"
    "runtime"
    "time"
)

type Level = slog.Level

const (
    LevelDebug = slog.LevelDebug
    LevelTrace = slog.Level(-2) // 自定義日誌級別
    LevelInfo  = slog.LevelInfo
    LevelWarn  = slog.LevelWarn
    LevelError = slog.LevelError
)

type Logger struct {
    l   *slog.Logger
    lvl *slog.LevelVar // 用來動態調整日誌級別
}

func New(level slog.Level) *Logger {
    var lvl slog.LevelVar
    lvl.Set(level)

    h := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,

        // Level:     level, // 靜態設置日誌級別
        Level: &lvl, // 支持動態設置日誌級別

        // 修改日誌中的 Attr 鍵值對(即日誌記錄中附加的 key/value)
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            if a.Key == slog.LevelKey {
                level := a.Value.Any().(slog.Level)
                levelLabel := level.String()

                switch level {
                case LevelTrace:
                    // NOTE: 如果不設置,默認日誌級別打印為 "level":"DEBUG+2"
                    levelLabel = "TRACE"
                }

                a.Value = slog.StringValue(levelLabel)
            }

            // NOTE: 可以在這裏修改時間輸出格式
            // if a.Key == slog.TimeKey {
            //     if t, ok := a.Value.Any().(time.Time); ok {
            //         a.Value = slog.StringValue(t.Format(time.DateTime))
            //     }
            // }

            return a
        },
    }))

    return &Logger{l: h, lvl: &lvl}
}

// SetLevel 動態調整日誌級別
func (l *Logger) SetLevel(level Level) {
    l.lvl.Set(level)
}

func (l *Logger) Debug(msg string, args ...any) {
    // 不會走 *customlog.Logger.log() 調用,會走 *slog.Logger.log() 調用
    l.l.Debug(msg, args...)
}

func (l *Logger) Info(msg string, args ...any) {
    l.Log(context.Background(), LevelInfo, msg, args...)
}

// Trace 自定義的日誌級別
func (l *Logger) Trace(msg string, args ...any) {
    l.Log(context.Background(), LevelTrace, msg, args...)
}

func (l *Logger) Warn(msg string, args ...any) {
    l.Log(context.Background(), LevelWarn, msg, args...)
}

func (l *Logger) Error(msg string, args ...any) {
    l.Log(context.Background(), LevelError, msg, args...)
}

func (l *Logger) Log(ctx context.Context, level slog.Level, msg string, args ...any) {
    l.log(ctx, level, msg, args...)
}

// log is the low-level logging method for methods that take ...any.
// It must always be called directly by an exported logging method
// or function, because it uses a fixed call depth to obtain the pc.
func (l *Logger) log(ctx context.Context, level slog.Level, msg string, args ...any) {
    if !l.l.Enabled(ctx, level) {
        return
    }
    var pc uintptr
    var pcs [1]uintptr
    // skip [runtime.Callers, this function, this function's caller]
    // NOTE: 這裏修改 skip 為 4,*slog.Logger.log 源碼中 skip 為 3
    runtime.Callers(4, pcs[:])
    pc = pcs[0]
    r := slog.NewRecord(time.Now(), level, msg, pc)
    r.Add(args...)
    if ctx == nil {
        ctx = context.Background()
    }
    _ = l.l.Handler().Handle(ctx, r)
}

使用示例:

package main

import "github.com/jianghushinian/blog-go-example/log/slog/customlog"

...

l := customlog.New(customlog.LevelDebug)
l.Debug("custom debug message", "hello", "world")
l.Trace("custom trace message", "hello", "world")
l.Info("custom info message", "hello", "world")

l.SetLevel(customlog.LevelInfo)
l.Debug("custom debug message", "hello", "world")
l.Trace("custom trace message", "hello", "world")
l.Info("custom info message", "hello", "world")

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:39:28.563559+08:00",
    "level": "DEBUG",
    "source": {
        "function": "github.com/jianghushinian/blog-go-example/log/slog/customlog.(*Logger).Debug",
        "file": "/workspace/projects/blog-go-example/log/slog/customlog/customlog.go",
        "line": 72
    },
    "msg": "custom debug message",
    "hello": "world"
}
{
    "time": "2024-06-23T10:39:28.563785+08:00",
    "level": "TRACE",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 233
    },
    "msg": "custom trace message",
    "hello": "world"
}
{
    "time": "2024-06-23T10:39:28.563815+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 234
    },
    "msg": "custom info message",
    "hello": "world"
}
# 動態調整日誌級別以後輸出
{
    "time": "2024-06-23T10:39:28.56384+08:00",
    "level": "INFO",
    "source": {
        "function": "main.main",
        "file": "/workspace/projects/blog-go-example/log/slog/main.go",
        "line": 239
    },
    "msg": "custom info message",
    "hello": "world"
}

為了對比效果,Debug 方法並沒有改為調用自定義 log 方法,依舊是被代理到 *slog.Logger.Debug 方法。

可以發現,Debug 記錄的日誌輸出位置是錯誤的,並不是 l.Debug("custom debug message", "hello", "world") 這行代碼的位置,而是 Debug 定義的位置。

TraceInfo 日誌級別都能被正確記錄。

特別強調 Trace 日誌中輸出了 "level": "TRACE" 屬性鍵值對。如果我們沒有在 ReplaceAttr 方法中修改 levelLabel 的字符串形式,我們將會得到 "level":"DEBUG+2",即這個日誌級別 slog 不認識,但它知道其值比 LevelDebug2。因為 LevelDebug 值為 -4LevelTrace 值為 -2(為了加深理解,你可以註釋掉相關代碼,再執行下示例程序,看看效果)。

當我們使用 l.SetLevel(customlog.LevelInfo) 動態調整日誌級別以後,僅 Info 級別的日誌會被輸出。

看來我們自定義的 Logger 實現完成了它的 3 個使命。

自定義 Handler

既然講解了如何自定義 slog 的前端 Logger,我們不妨看一下如何自定義 slog 的後端 Handler

根據前文的講解,我們知道 Handler 是一個接口,回顧下其定義:

// A Handler handles log records produced by a Logger.
type Handler interface {
    // Enabled reports whether the handler handles records at the given level.
    Enabled(context.Context, Level) bool

    // Handle handles the Record.
    Handle(context.Context, Record) error

    // WithAttrs returns a new Handler whose attributes consist of
    // both the receiver's attributes and the arguments.
    WithAttrs(attrs []Attr) Handler

    // WithGroup returns a new Handler with the given group appended to
    // the receiver's existing groups.
    WithGroup(name string) Handler
}

既然是接口,那麼我們自定義的 Handler 其實只要實現這個接口就行了。

自定義 Handler 代碼實現如下:

package customlog

import (
    "context"
    "io"
    "log/slog"
)

// Handler 自定義日誌後端 slog.Handler
type Handler struct {
    slog.Handler
}

// NewHandler 創建新的日誌後端 handler
func NewHandler(w io.Writer, opts *slog.HandlerOptions) *Handler {
    return &Handler{
        Handler: slog.NewJSONHandler(w, opts),
    }
}

// Enabled 當前日誌級別是否開啓
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
    return h.Handler.Enabled(ctx, level)
}

// Handle 處理日誌記錄,僅在 Enabled() 返回 true 時才會被調用
func (h *Handler) Handle(ctx context.Context, record slog.Record) error {
    record.Add("customlog", "handler")
    return h.Handler.Handle(ctx, record)
}

// WithAttrs 從現有的 handler 創建一個新的 handler,並將新增屬性附加到新的 handler
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return h.Handler.WithAttrs(attrs)
}

// WithGroup 從現有的 handler 創建一個新的 handler,並將指定分組附加到新的 handler
func (h *Handler) WithGroup(name string) slog.Handler {
    return h.Handler.WithGroup(name)
}

在這裏,為了示例足夠簡單,我並沒有做太多的工作,自定義的 Handler 僅代理了 *slog.JSONHandler。並在 Handle 方法中對日誌條目 Record 附加了一對屬性 record.Add("customlog", "handler")

現在,這個自定義 Handler 就可以使用了:

package main

import "github.com/jianghushinian/blog-go-example/log/slog/customlog"

...

l := slog.New(customlog.NewHandler(os.Stdout, nil))
l.Info("info message", "hello", "world")

我們直接將構造的自定義 Handler 傳遞給 slog.New,得到一個新的 *slog.Logger 對象,然後用其記錄一條日誌。

執行示例代碼,輸出結果如下:

$ go run main.go
{
    "time": "2024-06-23T10:40:31.509387+08:00",
    "level": "INFO",
    "msg": "info message",
    "hello": "world",
    "customlog": "handler"
}

沒錯,就是這麼簡單,我們實現了自定義 slog 的後端 Handler

總結

本文對 slog 的常用 API 進行了演示講解。比如附加屬性、結構化日誌、屬性分組等。

接下來我為你介紹了 slog 是如何設計的,slog 包含 3 大核心對象:LoggerRecordHandlerLogger 又被稱為 前端Handler 被稱為 後端,而 Record 用來表示一條日誌。

前端 Logger 直接面向用户側,我們可以對其進行二次封裝,來定製自己的用户 API。

後端 Handler 可以統一日誌處理接口,我們也可以在自定義的 Handler 中很方便的集成如 zapzerolog 等第三方日誌庫。你可以在 [Go Wiki: Resources for slog
](https://tip.golang.org/wiki/Resources-for-slog) 這個列表找到一些靈感。

slog 更多用法就等着你自己去探索了,祝你好運。

如果你比較在意日誌庫性能,這個項目 go-logging-benchmarks 有 Go 流行日誌庫的性能對比,供你參考。

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

延伸閲讀

  • slog 源碼: https://github.com/golang/go/tree/go1.22.0/src/log/slog
  • slog Documentation: https://pkg.go.dev/log/slog@go1.22.0
  • Structured Logging with slog: https://go.dev/blog/slog
  • A Guide to Writing slog Handlers: https://github.com/golang/example/blob/master/slog-handler-guide/guide.md
  • Logging in Go with Slog: The Ultimate Guide: https://betterstack.com/community/guides/logging/logging-in-go/
  • How to Retrieve Log Level with "slog" Package in Go?: https://stackoverflow.com/questions/77504588/how-to-retrieve-log-level-with-slog-package-in-go
  • go-logging-benchmarks: https://github.com/betterstack-community/go-logging-benchmarks
  • slog正式版來了:Go日誌記錄新選擇!: https://tonybai.com/2023/09/01/slog-a-new-choice-for-logging-...
  • 深入探究 Go log 標準庫: https://jianghushinian.cn/2023/03/11/dive-into-the-go-log-sta...
  • Go 第三方 log 庫之 logrus 使用: https://jianghushinian.cn/2023/03/15/use-of-logrus-in-go-thir...
  • Go 第三方 log 庫之 zap 使用: https://jianghushinian.cn/2023/03/19/use-of-zap-in-go-third-p...
  • 如何基於 zap 封裝一個更好用的日誌庫: https://jianghushinian.cn/2023/04/16/how-to-wrap-a-more-user-...
  • 本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/log/slog

聯繫我

  • 公眾號:Go編程世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 博客:https://jianghushinian.cn
user avatar jump_and_jump 頭像
點贊 1 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.