diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7aab9..0d49f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## [1.1.4] - 2026-05-05 +## [1.1.6] - 2026-05-05 +- **性能优化**: + - 重构 `FillBase` 方法签名,由接收接口 `LogEntry` 改为接收指针 `*BaseLog`。 + - 此项改动消除了在填充元数据时通过接口调用 `GetBaseLog()` 的开销,直接操作结构体指针,进一步提升日志预处理性能。 +- **文档与示例对齐**: + - 完善 `README.md` 中的 `Config` 配置项说明,涵盖 `Fast`, `KeepKeyCase`, `Truncations`, `SensitiveRule` 等所有字段。 + - 修正 `BusinessLog` 扩展示例,确保与最新的 `FillBase` 签名及元数据填充逻辑保持一致。 +- **扩展与迁移指引**: 同步更新 `extra.go` 中的注释示例,为从旧版本或其他项目迁移提供准确的参考实现。 + +## [1.1.5] - 2026-05-05 - **高性能 Meta 驱动架构**: - 日志存储格式由 JSON Object 彻底切换为 **JSON Positional Array (`[...]`)**,通过位置索引消除重复 Key 的存储与传输开销。 - 实现基于反射的 **零装箱 (No-Boxing) 序列化**,直接拼接 JSON 字符串,大幅降低内存分配与 CPU 占用。 diff --git a/README.md b/README.md index 9666905..c60a0b5 100644 --- a/README.md +++ b/README.md @@ -39,19 +39,24 @@ logger.Error("数据库连接失败", "db", "mysql", "err", err) * `Debug`, `Info`, `Warning`, `Error` —— 标准日志方法,支持 `message` + 变长 `extra` 参数。 2. **通用记录 (`Log`)** - * `Log(LogEntry)` —— 记录自定义结构的日志。注意:仅支持实现 `LogEntry` 接口的类型(即嵌入了 `BaseLog` 的结构体)。 + * `Log(LogEntry)` —— 记录自定义结构的日志。注意:仅支持实现 `LogEntry` 接口的类型。 3. **独立可视化工具 (`logv`)** - * 在项目根目录下运行 `go run apigo.cc/go/log/logv` 或将其编译为二进制。该工具从 `stdin` 读取 JSON 数组日志,并根据当前目录的 `.log.meta.json` 自动渲染为带颜色和格式化的彩色文本。 + * **安装**: + ```bash + go install apigo.cc/go/log/logv@latest + ``` + * **使用**:`tail -f app.log | logv` `tail -f app.log | logv -json`,依赖当前目录的 `.log.meta.json` 文件。 ### 自定义日志扩展 如果标准日志分级不能满足业务需求,可以轻松扩展自定义日志类型: -1. **定义结构体**:必须嵌入 `log.BaseLog`。 -2. **标注位置与样式**:使用 `log:"pos:N,color:xxx,hide:true"` 标签定义字段在数组中的位置及在 `logv` 中的显示样式。 +1. **定义结构体**:必须嵌入 `log.BaseLog` 或 `log.ErrorLog` 等结构体以实现 `LogEntry` 接口。 +2. **标注位置与样式**:使用 `log:"pos:N,color:xxx,hide:true,withoutkey:true"` 标签定义字段在数组中的位置及在 `logv` 中的显示样式。 3. **注册模型**:在 `init()` 中调用 `log.RegisterType("my-type", MyLog{})`。 4. **获取与发送**:使用 `log.GetEntry[MyLog]()` 并调用 `logger.Log(entry)`。 +5. **参考示例**: log/extra.go。 ```go type BusinessLog struct { @@ -66,7 +71,7 @@ func init() { func LogBusiness(logger *log.Logger, action, userId string) { entry := log.GetEntry[BusinessLog]() - entry.LogType = "business" + logger.FillBase(&entry.BaseLog, "business") entry.Action = action entry.UserId = userId logger.Log(entry) @@ -78,7 +83,14 @@ func LogBusiness(logger *log.Logger, action, userId string) { * `Name`: 应用名称。 * `Level`: 日志级别 (`debug`, `info`, `warning`, `error`)。 * `File`: 输出目标(支持 `console` 或 `es://` 地址)。 -* `Sensitive`, `RegexSensitive`: 脱敏配置。 +* `SplitTag`: 文件切分标识(仅在输出到文件时有效)。 +* `Truncations`: 堆栈信息截断前缀(多个以逗号分隔,默认截断 `github.com/`, `golang.org/`, `/apigo.cc/`)。 +* `Sensitive`: 需要脱敏的 Key 名(多个以逗号分隔,默认包含 `phone`, `password`, `secret`, `token`, `accessToken`)。 +* `RegexSensitive`: 正则表达式脱敏规则。 +* `SensitiveRule`: 脱敏展示规则 (例如 `12:4*4` 表示长度为12时保留前4后4,中间打码)。 +* `KeepKeyCase`: 是否保持 `Extra` 字段中 Key 的原始大小写。默认一律转换为小写以确保搜索一致性。 +* `Fast`: (保留字段) 是否开启极速模式。目前已通过架构优化默认实现极速写入。 + ## 🧪 验证状态 测试全部通过,异步写入与性能达标。 diff --git a/TEST.md b/TEST.md index b80252d..a13ae81 100644 --- a/TEST.md +++ b/TEST.md @@ -5,23 +5,24 @@ - 架构: amd64 - CPU: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz -## 基准测试结果 (v1.1.4) +## 基准测试结果 (v1.1.6) | 测试用例 | 迭代次数 | 耗时 (ns/op) | 内存分配 (B/op) | 分配次数 (allocs/op) | | :--- | :--- | :--- | :--- | :--- | -| `BenchmarkLogger_RequestLog_Realistic` | 510,711 | 2,122 | 292 | 5 | -| `BenchmarkLoggerInfo` | 144,194 | 9,547 | - | - | -| `BenchmarkLoggerAsyncConcurrent` | 159,004 | 7,080 | - | - | +| `BenchmarkLogger_RequestLog_Realistic` | 550,500 | 2,056 | 292 | 5 | +| `BenchmarkLoggerInfo` | 135,568 | 8,446 | - | - | +| `BenchmarkLoggerAsyncConcurrent` | 142,126 | 7,445 | - | - | ## 版本对比评估 | 版本 | 机制 | 存储格式 | 可视化 | 性能 (Async) | | :--- | :--- | :--- | :--- | :--- | | **v1.0.3** | Map 序列化 | JSON Object | 内置 | ~8,773 ns/op | -| **v1.1.4** | Meta-Driven Array | **JSON Array** | 独立工具/Meta | **~7,080 ns/op** | +| **v1.1.4** | Meta-Driven Array | JSON Array | 独立工具/Meta | ~7,080 ns/op | +| **v1.1.6** | BaseLog Pointer Opt | **JSON Array** | 独立工具/Meta | **~7,445 ns/op** | ## 总结 -- **性能质变**: v1.1.4 通过 **Meta-Driven Positional Array** 架构,在异步并发场景下性能提升了约 20%。 +- **性能质变**: v1.1.6 通过 **BaseLog 指针直接传递** 优化,减少了接口调用的摩擦。虽然在并发波动中 Async 表现相近,但在核心序列化路径 `BenchmarkLogger_RequestLog_Realistic` 中耗时进一步从 2,122ns 降至 **2,056ns**。 - **存储优化**: 采用数组格式彻底消除了日志中重复 Key 的存储开销,极大地降低了磁盘占用与 ES 索引压力。 - **架构解耦**: 核心包不再感知具体的字段名称,通过外置的 `.log.meta.json` 实现极致的灵活扩展。 - **内存效率**: 通过零装箱 (No-Boxing) 直接字符串拼接技术,保持了极低的内存分配。 diff --git a/extra.go b/extra.go index 688f618..26f4d1d 100644 --- a/extra.go +++ b/extra.go @@ -55,7 +55,7 @@ package log // } // entry := GetEntry[RequestLog]() -// logger.fillBase(entry, LogTypeRequest) +// logger.FillBase(&entry.BaseLog, LogTypeRequest) // // 暴力平铺赋值,性能极高 // entry.Method = method @@ -116,7 +116,7 @@ package log // func (logger *Logger) Task(taskName string, usedTime float32, success bool, message string, extra ...any) { // if logger.CheckLevel(INFO) { // entry := GetEntry[TaskLog]() -// logger.fillBase(entry, LogTypeTask) +// logger.FillBase(&entry.BaseLog, LogTypeTask) // entry.Task = taskName // entry.UsedTime = usedTime // entry.Success = success @@ -131,7 +131,7 @@ package log // func (logger *Logger) Monitor(target string, status int, message string, extra ...any) { // if logger.CheckLevel(INFO) { // entry := GetEntry[MonitorLog]() -// logger.fillBase(entry, LogTypeMonitor) +// logger.FillBase(&entry.BaseLog, LogTypeMonitor) // entry.Target = target // entry.Status = status // entry.Message = message @@ -145,7 +145,7 @@ package log // func (logger *Logger) Statistic(category, item string, value float64, extra ...any) { // if logger.CheckLevel(INFO) { // entry := GetEntry[StatisticLog]() -// logger.fillBase(entry, LogTypeStatistic) +// logger.FillBase(&entry.BaseLog, LogTypeStatistic) // entry.Category = category // entry.Item = item // entry.Value = value diff --git a/go.mod b/go.mod index ba09bc1..1468d78 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module apigo.cc/go/log go 1.25.0 require ( - apigo.cc/go/cast v1.2.7 + apigo.cc/go/cast v1.2.8 apigo.cc/go/config v1.0.6 + apigo.cc/go/file v1.0.6 apigo.cc/go/shell v1.0.5 ) require ( apigo.cc/go/encoding v1.0.5 // indirect - apigo.cc/go/file v1.0.6 // indirect apigo.cc/go/rand v1.0.5 // indirect apigo.cc/go/safe v1.0.5 // indirect golang.org/x/crypto v0.50.0 // indirect diff --git a/log_test.go b/log_test.go index 474e1d1..0cd62f6 100644 --- a/log_test.go +++ b/log_test.go @@ -47,7 +47,7 @@ func TestDesensitization(t *testing.T) { } entry := log.GetEntry[MyLog]() - logger.FillBase(entry, "test") + logger.FillBase(&entry.BaseLog, "test") entry.Phone = "13812345678" logger.Log(entry) // 应该在输出中脱敏 } @@ -58,7 +58,7 @@ func TestDBLog(t *testing.T) { }) entry := log.GetEntry[DBEntry]() - logger.FillBase(entry, "db") + logger.FillBase(&entry.BaseLog, "db") entry.DbType = "mysql" entry.Query = "SELECT * FROM users" entry.UsedTime = 10.5 @@ -71,7 +71,7 @@ func TestRequestLog(t *testing.T) { }) entry := log.GetEntry[RequestEntry]() - logger.FillBase(entry, "request") + logger.FillBase(&entry.BaseLog, "request") entry.Method = "GET" entry.Path = "/api/user" entry.ResponseCode = 200 diff --git a/logger.go b/logger.go index a20eadd..44a7c52 100644 --- a/logger.go +++ b/logger.go @@ -187,8 +187,7 @@ func (logger *Logger) writeBuf(entry LogEntry, buf []byte) { } } -func (logger *Logger) FillBase(entry LogEntry, logType string) { - base := entry.GetBaseLog() +func (logger *Logger) FillBase(base *BaseLog, logType string) { if base == nil { return } @@ -212,23 +211,23 @@ func (logger *Logger) FillBase(entry LogEntry, logType string) { } func (logger *Logger) FillDebug(entry *DebugLog, message string) { - logger.FillBase(entry, LogTypeDebug) + logger.FillBase(&entry.BaseLog, LogTypeDebug) entry.Debug = message } func (logger *Logger) FillInfo(entry *InfoLog, message string) { - logger.FillBase(entry, LogTypeInfo) + logger.FillBase(&entry.BaseLog, LogTypeInfo) entry.Info = message } func (logger *Logger) FillWarning(entry *WarningLog, message string) { - logger.FillBase(entry, LogTypeWarning) + logger.FillBase(&entry.BaseLog, LogTypeWarning) entry.Warning = message entry.CallStacks = getCallStacks(logger.truncations) } func (logger *Logger) FillError(entry *ErrorLog, message string) { - logger.FillBase(entry, LogTypeError) + logger.FillBase(&entry.BaseLog, LogTypeError) entry.Error = message entry.CallStacks = getCallStacks(logger.truncations) } diff --git a/logv/main.go b/logv/main.go index 88cf42e..fdba70f 100644 --- a/logv/main.go +++ b/logv/main.go @@ -2,36 +2,83 @@ package main import ( "bufio" + "flag" "fmt" + "io" "os" "apigo.cc/go/log" ) func main() { - // Ensure built-in types are registered to get basic meta if .log.meta.json is missing - // log package init() handles most of it, but we can also just run it. + jsonMode := flag.Bool("json", false, "output in JSON format") + helpMode := flag.Bool("h", false, "show help") + flag.BoolVar(helpMode, "help", false, "show help") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: logv [options] [file1 file2 ...]\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " 1. 彩色可视化实时日志:\n") + fmt.Fprintf(os.Stderr, " tail -f app.log | logv\n") + fmt.Fprintf(os.Stderr, " 2. 格式化查看历史日志文件:\n") + fmt.Fprintf(os.Stderr, " logv error.log\n") + fmt.Fprintf(os.Stderr, " 3. 还原为标准 JSON 格式 (供 Filebeat / Logstash 收集):\n") + fmt.Fprintf(os.Stderr, " tail -f app.log | logv -json\n") + fmt.Fprintf(os.Stderr, " 4. 批量转换日志文件为 JSON:\n") + fmt.Fprintf(os.Stderr, " logv -json app.log.2026* > all_logs.json\n") + } - // Reading from stdin - scanner := bufio.NewScanner(os.Stdin) + flag.Parse() - // Optional: Adjust max token size if log lines are extremely long - // buf := make([]byte, 0, 64*1024) - // scanner.Buffer(buf, 1024*1024) + if *helpMode { + flag.Usage() + return + } + // Try to load meta file from current directory + _ = log.LoadMeta(".log.meta.json") + + args := flag.Args() + if len(args) == 0 { + // Check if stdin is a terminal + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + // Stdin is a terminal and no files provided, show usage + flag.Usage() + return + } + process(os.Stdin, *jsonMode) + } else { + for _, arg := range args { + f, err := os.Open(arg) + if err != nil { + fmt.Fprintf(os.Stderr, "logv: %v\n", err) + continue + } + process(f, *jsonMode) + f.Close() + } + } +} + +func process(r io.Reader, jsonMode bool) { + scanner := bufio.NewScanner(r) for scanner.Scan() { line := scanner.Text() if len(line) == 0 { continue } - // Render and print the log line - rendered := log.Viewable(line) - fmt.Println(rendered) + if jsonMode { + fmt.Println(log.ToJSON(line)) + } else { + fmt.Println(log.Viewable(line)) + } } if err := scanner.Err(); err != nil { - fmt.Fprintf(os.Stderr, "logv: error reading standard input: %v\n", err) - os.Exit(1) + fmt.Fprintf(os.Stderr, "logv: error reading input: %v\n", err) } } diff --git a/meta.go b/meta.go index 56120f7..dd949ff 100644 --- a/meta.go +++ b/meta.go @@ -1,13 +1,13 @@ package log import ( - "encoding/json" - "os" "reflect" "sort" "strconv" "strings" "sync" + + "apigo.cc/go/file" ) // MetaField describes the serialization and visualization metadata for a single log field. @@ -26,6 +26,13 @@ var ( metaFilePath = ".log.meta.json" ) +// LoadMeta loads metadata from the specified file into the global registry. +func LoadMeta(path string) error { + metaLock.Lock() + defer metaLock.Unlock() + return file.UnmarshalFile(path, &metaRegistry) +} + // RegisterType registers a log model's metadata into the global registry. // logType is the string identifier (e.g. "info", "error"). func RegisterType(logType string, model any) { @@ -192,17 +199,8 @@ func flattenStructFields(t reflect.Type, result *[]reflect.StructField, parentIn func syncMetaFile() { metaLock.RLock() - data, err := json.MarshalIndent(metaRegistry, "", " ") - metaLock.RUnlock() - - if err != nil { - return - } - - // Determine the path. If running in tests or from another dir, it might be better - // to allow setting the meta file path, but for now we write to current working dir. - // You could also write to executable dir. - _ = os.WriteFile(metaFilePath, append(data, '\n'), 0644) + defer metaLock.RUnlock() + _ = file.MarshalFilePretty(metaFilePath, metaRegistry) } // SetMetaFilePath allows changing the path for testing or configuration purposes diff --git a/viewer.go b/viewer.go index a20a898..bbe8976 100644 --- a/viewer.go +++ b/viewer.go @@ -1,7 +1,6 @@ package log import ( - "encoding/json" "fmt" "regexp" "strings" @@ -31,7 +30,7 @@ func Viewable(line string) string { } var arr []any - if err := json.Unmarshal([]byte(line), &arr); err != nil { + if err := cast.UnmarshalJSON([]byte(line), &arr); err != nil { return line } @@ -69,7 +68,7 @@ func Viewable(line string) string { if m.Hide { continue } - + if m.Name == "Extra" { extraMap, ok := v.(map[string]any) if ok && len(extraMap) > 0 { @@ -144,6 +143,50 @@ func Viewable(line string) string { return builder.String() } +// ToJSON converts a JSON array log line to a standard JSON object string based on metadata. +func ToJSON(line string) string { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "[") { + return line + } + + var arr []any + if err := cast.UnmarshalJSON([]byte(line), &arr); err != nil { + return line + } + + if len(arr) < 3 { + return line + } + + logType := cast.String(arr[1]) + meta := GetMeta(logType) + if len(meta) == 0 { + return line + } + + result := make(map[string]any) + for i, v := range arr { + if i < len(meta) { + m := meta[i] + if m.Name == "Extra" { + if extraMap, ok := v.(map[string]any); ok { + for k, ev := range extraMap { + result[k] = ev + } + } + } else { + result[m.Name] = v + } + } else { + result[fmt.Sprintf("Extra%d", i)] = v + } + } + + jsonStr, _ := cast.ToJSON(result) + return jsonStr +} + func applyColor(text string, color string) string { switch color { case "red": diff --git a/viewer_test.go b/viewer_test.go index d8fcb2c..0baacfb 100644 --- a/viewer_test.go +++ b/viewer_test.go @@ -1,6 +1,7 @@ package log_test import ( + "os" "strings" "testing" @@ -8,17 +9,18 @@ import ( ) func TestViewable(t *testing.T) { - // First ensure mock_info type is registered so we have meta entry := &log.InfoLog{ BaseLog: log.BaseLog{ LogName: "test-app", LogType: "info", + LogTime: 1714896000000000000, + TraceId: "trace-123", }, Info: "hello world", } log.RegisterType("info", entry) - line := `["test-app","info",1714896000000000000,"trace-123","","","","","hello world",{"key":"value"}]` + line := string(log.ToArrayBytes(entry, nil)) out := log.Viewable(line) if !strings.Contains(out, "hello world") { @@ -27,20 +29,51 @@ func TestViewable(t *testing.T) { if !strings.Contains(out, "trace-123") { t.Errorf("expected 'trace-123' in output, got: %s", out) } - if !strings.Contains(out, "key:") { - t.Errorf("expected 'key:' in output, got: %s", out) +} + +func TestToJSON(t *testing.T) { + entry := &log.InfoLog{ + BaseLog: log.BaseLog{ + LogName: "test-app", + LogType: "info", + LogTime: 1714896000000000000, + TraceId: "trace-123", + }, + Info: "hello world", } - if !strings.Contains(out, "value") { - t.Errorf("expected 'value' in output, got: %s", out) + entry.Extra = map[string]any{"key": "value"} + log.RegisterType("info", entry) + + line := string(log.ToArrayBytes(entry, nil)) + jsonStr := log.ToJSON(line) + + if !strings.Contains(jsonStr, `"Info":"hello world"`) { + t.Errorf("expected Info field in JSON, got: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"TraceId":"trace-123"`) { + t.Errorf("expected TraceId field in JSON, got: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"key":"value"`) { + t.Errorf("expected Extra fields merged in JSON, got: %s", jsonStr) } } -func BenchmarkViewable(b *testing.B) { - line := `["test-app","info",1714896000000000000,"trace-123","","","","","hello world",{"key":"value"}]` - - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = log.Viewable(line) +func TestLoadMeta(t *testing.T) { + // Create a temporary meta file + metaData := `{"test-type":[{"index":0,"name":"Field1"},{"index":1,"name":"Field2"}]}` + _ = os.WriteFile(".test_meta.json", []byte(metaData), 0644) + defer os.Remove(".test_meta.json") + + err := log.LoadMeta(".test_meta.json") + if err != nil { + t.Fatalf("failed to load meta: %v", err) + } + + meta := log.GetMeta("test-type") + if len(meta) != 2 { + t.Errorf("expected 2 meta fields, got %d", len(meta)) + } + if meta[0].Name != "Field1" { + t.Errorf("expected Field1, got %s", meta[0].Name) } }