334 lines
8.7 KiB
Go
334 lines
8.7 KiB
Go
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"`
|
||
}
|