Files
loggerx/format.go
T
2026-09-14 00:14:30 +08:00

334 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loggerx
import (
"bytes"
"context"
"encoding/json"
"fmt"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
// resolveBasePath 计算进程工作目录(用于把绝对路径裁成相对路径)
// 只在 NewLogger 里算一次:filepath.Abs 要走系统调用,而每条日志都会用到
func resolveBasePath() string {
p, err := filepath.Abs("./")
if err != nil {
return ""
}
return strings.ReplaceAll(p, "\\", "/")
}
// basePath 进程工作目录
// 值放在共享指针里,Channel()/WriteAsync() 拷贝出的实例才能看到同一个结果
func (l *Logger) basePath() string {
if l.basePathVal == nil {
return ""
}
return *l.basePathVal
}
// levelOf 事件名 -> 级别
func levelOf(event string) Level {
switch event {
case "debug":
return LevelDebug
case "warn":
return LevelWarn
case "error":
return LevelError
default:
return LevelInfo
}
}
func (l *Logger) logger(ctx context.Context, event string, v ...any) {
// 级别过滤:低于设定级别的日志直接丢弃,连 JSON 都不序列化
if levelOf(event) < l.option.minLevel {
return
}
// 调用方可能是 log 包(Logger.Write -> logger),所以这里取第 2 层
pc, file, line, ok := runtime.Caller(2)
var funcName string
if ok && pc != 0 {
if fn := runtime.FuncForPC(pc); fn != nil {
funcName = strings.TrimPrefix(filepath.Ext(fn.Name()), ".")
}
file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), l.basePath())
}
nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000")
// ctx 允许为 nil,不能直接调 Value
var traceId string
if ctx != nil {
traceId, _ = ctx.Value(l.option.traceField).(string)
}
// error 转成带堆栈的字符串(%+v 会带上 pkg/errors 的调用栈)
// 注意:这里会写进调用方传入的切片,因此先复制一份,不改调用方的内存
args := v
for idx, val := range args {
if _, isErr := val.(error); isErr {
if &args[0] == &v[0] {
args = append([]any(nil), v...)
}
args[idx] = fmt.Sprintf("%+v", val)
}
}
var gid string
if l.option.isGid {
gid = getGID()
}
fd := FormatData{
Level: event,
Time: nowTime,
File: file + ":" + strconv.Itoa(line),
Func: funcName,
Gid: gid,
Content: args,
TraceId: traceId,
Expand: l.option.expandData,
}
line1 := l.marshalLine(fd, nowTime, file, line, gid, traceId)
// 一条日志 = 一行完整合法的 JSON(+ 换行),
// 这样 Fluent Bit / Vector / Loki / jq 等采集端可以直接解析。
// 旧实现写成 "\n[info]{...}":既不是 JSON 行,也没有行尾换行
if _, err := l.write(event, line1); err != nil {
l.reportError(fmt.Errorf("loggerx: 写入日志失败: %w", err))
}
if l.option.errorToInfo && event == "error" {
if _, err := l.write("info", line1); err != nil {
l.reportError(fmt.Errorf("loggerx: 写入 info 日志失败: %w", err))
}
}
}
// marshalLine 按当前配置(格式 + 前缀)渲染一条日志,返回以换行结尾的一行
//
// 前缀/换行都 append 进目标切片,不额外分配一个容器再拷贝:
// 这条路径每条日志都走,多一次分配就会体现在吞吐上
func (l *Logger) marshalLine(fd FormatData, nowTime, file string, line int, gid, traceId string) []byte {
prefix := l.option.prefix
var body []byte
if l.option.format == FormatText {
body = marshalText(fd)
} else {
body = marshalLog(fd, nowTime, file, line, l.option.escapeHTML, gid, traceId)
body = bytes.TrimRight(body, "\n")
}
out := make([]byte, 0, len(prefix)+len(body)+1)
out = append(out, prefix...)
out = append(out, body...)
return append(out, '\n')
}
// marshalText 把一条日志渲染成单行紧凑文本
// 形如:level=info time=2026-01-02 15:04:05.000000 file=/a.go:12 func=main gid=7 content=hi
// 值里含空格/引号/换行时用 %q 包起来,保证「一行一条」且不歧义
//
// 用 append 到 []byte 而不是 strings.Builder + Sprintf
// 文本格式的意义就是便宜,走反射/格式化会把省下的 JSON 成本又花回去
func marshalText(fd FormatData) []byte {
var buf []byte
buf = append(buf, "level="...)
buf = append(buf, fd.Level...)
buf = appendTextField(buf, "time", fd.Time)
buf = appendTextField(buf, "file", fd.File)
if fd.Func != "" {
buf = appendTextField(buf, "func", fd.Func)
}
if fd.Gid != "" {
buf = appendTextField(buf, "gid", fd.Gid)
}
if fd.TraceId != "" {
buf = appendTextField(buf, "traceId", fd.TraceId)
}
buf = append(buf, " content="...)
buf = appendContent(buf, fd.Content)
for _, k := range sortedKeys(fd.Expand) {
buf = appendTextField(buf, k, fd.Expand[k])
}
return buf
}
// appendTextField 追加一个 key=value 字段,必要时加引号
func appendTextField(buf []byte, key, val string) []byte {
buf = append(buf, ' ')
buf = append(buf, key...)
buf = append(buf, '=')
if val == "" {
return append(buf, `""`...)
}
if strings.ContainsAny(val, " \t\"'=\n\r") {
return strconv.AppendQuote(buf, val)
}
return append(buf, val...)
}
// appendContent 渲染 content(多个参数用空格分隔)
// 单个值走快速路径,避免 fmt 的反射开销
func appendContent(buf []byte, v any) []byte {
switch val := v.(type) {
case nil:
return append(buf, `""`...)
case []any:
for i, item := range val {
if i > 0 {
buf = append(buf, ' ')
}
buf = appendValue(buf, item)
}
if len(val) == 0 {
buf = append(buf, `""`...)
}
return buf
case []string:
if len(val) == 0 {
return append(buf, `""`...)
}
return append(buf, strings.Join(val, " ")...)
case string:
return appendValue(buf, val)
default:
return appendValue(buf, val)
}
}
// appendValue 渲染单个值,需要时加引号
func appendValue(buf []byte, v any) []byte {
var s string
switch val := v.(type) {
case string:
s = val
case error:
s = val.Error()
case fmt.Stringer:
s = val.String()
case int:
return strconv.AppendInt(buf, int64(val), 10)
case int64:
return strconv.AppendInt(buf, val, 10)
case bool:
return strconv.AppendBool(buf, val)
default:
s = fmt.Sprintf("%v", v)
}
if s == "" {
return append(buf, `""`...)
}
if strings.ContainsAny(s, " \t\"'=\n\r") {
return strconv.AppendQuote(buf, s)
}
return append(buf, s...)
}
// sortedKeys 让 map 输出顺序稳定,便于 diff 与测试
func sortedKeys(m map[string]string) []string {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// marshalLog 序列化一条日志为 JSON(返回以换行结尾)
//
// json 序列化失败时(chan/func 等不可序列化类型,或循环引用)不能静默丢弃,
// 否则会留下一行没有内容的坏记录,需要降级成可读文本
func marshalLog(fd FormatData, nowTime, file string, line int, escapeHTML bool, gid, traceId string) []byte {
var (
b []byte
err error
)
if escapeHTML {
b, err = json.Marshal(fd)
} else {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
err = encoder.Encode(fd)
b = bytes.TrimRight(buf.Bytes(), "\n")
}
if err == nil {
return append(b, '\n')
}
// 降级:逐字段尝试,失败的内容用 %+v 兜底
fallback := FormatData{
Level: fd.Level,
Time: nowTime,
File: file + ":" + strconv.Itoa(line),
Func: fd.Func,
Gid: gid,
TraceId: traceId,
Content: stringifyValues(fd.Content),
Expand: fd.Expand,
}
if fb, ferr := json.Marshal(fallback); ferr == nil {
return append(fb, '\n')
}
return []byte(fmt.Sprintf("{\"level\":%q,\"time\":%q,\"content\":%q,\"marshal_error\":%q}\n",
fd.Level, nowTime, fmt.Sprintf("%+v", fd.Content), err.Error()))
}
// stringifyValues 把不可序列化的值转成字符串
func stringifyValues(v any) []string {
switch val := v.(type) {
case nil:
return nil
case []any:
out := make([]string, 0, len(val))
for _, item := range val {
out = append(out, fmt.Sprintf("%+v", item))
}
return out
case []string:
return val
default:
return []string{fmt.Sprintf("%+v", val)}
}
}
// getGID 获取当前 goroutine 的 ID
func getGID() string {
b := make([]byte, 64)
b = b[:runtime.Stack(b, false)]
b = bytes.TrimPrefix(b, []byte("goroutine "))
idx := bytes.IndexByte(b, ' ')
if idx < 0 {
// 解析失败时不要 panic
return "0"
}
return string(b[:idx])
}
// FormatData 一条日志的落盘结构
type FormatData struct {
Level string `json:"level,omitempty"`
Time string `json:"time,omitempty"`
File string `json:"file,omitempty"`
Func string `json:"func,omitempty"`
Gid string `json:"gid,omitempty"`
Content interface{} `json:"content,omitempty"`
TraceId string `json:"traceId,omitempty"`
Expand map[string]string `json:"expand,omitempty"`
}