Compare commits

..

2 Commits
v1.5.9 ... main

11 changed files with 290 additions and 44 deletions

View File

@ -1,5 +1,21 @@
# Changelog # Changelog
## [1.5.11] - 2026-08-24
- **Log Viewer 元数据修复**:
- Meta 持久化改为合并磁盘已有类型,避免独立 `logv` 启动时用基础日志类型覆盖应用的 `api_call``request` 等业务 Meta。
- 修复业务日志在 `logv` 中退化为无颜色、无字段映射的数组输出。
- **依赖更新**: 对齐 `shell v1.5.6`
## [1.5.10] - 2026-08-24
- **默认配置扩展**:
- 新增 `SetDefaultConfig`,允许应用提供内置默认配置,并继续由配置文件和环境变量覆盖。
- 最终有效配置未变化时复用现有 `DefaultLogger`,避免重复初始化写入资源。
- **切分文件可靠性**:
- 修复默认 logger 初始化早于 writer service 时写入 nil 文件映射导致的 panic。
- 文件 logger 初始化时自动创建父目录。
- split 文件日志在异步 writer 启动前同步落盘,避免启动日志回退到终端。
- **依赖更新**: 对齐 `cast v1.5.5``config v1.5.4``file v1.5.6``id v1.5.7``shell v1.5.5``encoding v1.5.6``rand v1.5.4``safe v1.5.3`
## [1.5.9] - 2026-07-18 ## [1.5.9] - 2026-07-18
- **依赖校验修复**: 重新整理模块 checksum并对齐 `x/crypto v0.52.0``x/sys v0.45.0` - **依赖校验修复**: 重新整理模块 checksum并对齐 `x/crypto v0.52.0``x/sys v0.45.0`

View File

@ -25,6 +25,12 @@ import "apigo.cc/go/log"
// 默认 logger (通过 log.json 或环境变量配置) // 默认 logger (通过 log.json 或环境变量配置)
func main() { func main() {
// 应用内置预设log.json/log.yml、env 配置和环境变量仍可覆盖
log.SetDefaultConfig(log.Config{
File: "logs/app.log",
SplitTag: ".2006-01-02",
})
// 在微服务场景下动态设置应用名称 // 在微服务场景下动态设置应用名称
log.SetDefaultName("my-microservice") log.SetDefaultName("my-microservice")
@ -82,23 +88,29 @@ export LOG_FILE=console
* `truncations`: 堆栈信息截断前缀(多个以逗号分隔,默认截断 `github.com/`, `golang.org/`, `/apigo.cc/`)。 * `truncations`: 堆栈信息截断前缀(多个以逗号分隔,默认截断 `github.com/`, `golang.org/`, `/apigo.cc/`)。
* `sensitive`: 需要自动脱敏的字段名(多个以逗号分隔,不区分大小写),默认处理 `phone,password,secret,token,accessToken,authorization` * `sensitive`: 需要自动脱敏的字段名(多个以逗号分隔,不区分大小写),默认处理 `phone,password,secret,token,accessToken,authorization`
文件输出会在 logger 初始化时自动创建父目录。使用 `splitTag` 时,异步 writer service 启动前产生的日志会同步写入目标文件,启动后自动切换为异步写入。
## 🛠 API 指南 ## 🛠 API 指南
### 核心功能 ### 核心功能
1. **分级记录** 1. **应用默认配置**
* `SetDefaultConfig(Config)` —— 设置应用内置预设,然后加载配置文件与环境变量覆盖;最终有效配置未变化时复用当前默认 logger。
2. **分级记录**
* `Debug`, `Info`, `Warning`, `Error` —— 标准日志方法,支持 `message` + 变长 `extra` 参数。 * `Debug`, `Info`, `Warning`, `Error` —— 标准日志方法,支持 `message` + 变长 `extra` 参数。
2. **摩擦消除 (`As`)** 3. **摩擦消除 (`As`)**
* `As(v, err)` —— 仿照 `cast.As`,忽略错误并返回零值,但会自动将错误记录到日志中。支持全局调用 (`log.As`) 或实例调用 (`logger.As`)。 * `As(v, err)` —— 仿照 `cast.As`,忽略错误并返回零值,但会自动将错误记录到日志中。支持全局调用 (`log.As`) 或实例调用 (`logger.As`)。
* **优势**: 在类型转换或快速赋值场景下,无需繁琐的 `if err != nil` 判断,同时确保异常被记录。 * **优势**: 在类型转换或快速赋值场景下,无需繁琐的 `if err != nil` 判断,同时确保异常被记录。
3. **通用记录 (`Log`)** 4. **通用记录 (`Log`)**
* `Log(LogEntry)` —— 记录自定义结构的日志。 * `Log(LogEntry)` —— 记录自定义结构的日志。
4. **独立可视化工具 (`logv`)** 5. **独立可视化工具 (`logv`)**
* **安装**: `go install apigo.cc/go/log/logv@latest` * **安装**: `go install apigo.cc/go/log/logv@latest`
* **使用**: `tail -f app.log | logv``tail -f app.log | logv -json` * **使用**: `tail -f app.log | logv``tail -f app.log | logv -json`
* `logv` 从当前目录加载 `.log.meta.json`;基础 Meta 会与应用已有业务 Meta 合并,不会覆盖业务日志类型。
### 自定义日志扩展 (规范) ### 自定义日志扩展 (规范)

24
TEST.md
View File

@ -1,5 +1,27 @@
# Test Results # Test Results
## v1.5.11 验证
- 验证 Meta 同步保留磁盘已有业务类型,并更新当前进程注册的同名类型。
- 验证本地 `logv` 格式化 `request` 日志时恢复时间、字段映射、隐藏规则和 ANSI 颜色。
- 验证 `logv` 运行后 `.log.meta.json` 仍保留 `api_call``request`
- 全量单元测试、竞态测试和基准测试均通过。
## v1.5.10 验证
- 验证 split 文件 logger 在包初始化阶段安全创建,不依赖文件级 `init` 顺序。
- 验证文件 logger 自动创建父目录。
- 验证异步 writer 启动前的 split 日志同步写入目标文件。
- 验证代码默认配置保留未被配置文件覆盖的字段,并由配置文件覆盖已配置字段。
- 验证最终有效配置不变时 `SetDefaultConfig` 复用现有 logger。
- 全量单元测试、竞态测试和基准测试均通过。
### BenchmarkApple M3 Max / arm64
```text
BenchmarkLoggerInfo-16 889536 1361 ns/op
BenchmarkLoggerAsyncConcurrent-16 4128828 286.5 ns/op
BenchmarkLogger_RequestLog_Realistic-16 1000000 1111 ns/op 975 B/op 27 allocs/op
```
## v1.5.9 验证 ## v1.5.9 验证
- 独立模块解析、全量测试与基准测试均通过。 - 独立模块解析、全量测试与基准测试均通过。
@ -63,3 +85,5 @@ ok apigo.cc/go/log 3.301s
- **深度脱敏能力**: `TestDeepDesensitization` 闭环验证了对复杂嵌套结构的脱敏逻辑。 - **深度脱敏能力**: `TestDeepDesensitization` 闭环验证了对复杂嵌套结构的脱敏逻辑。
- **可靠性边界**: `TestLoggerReliability` 模拟了极高压力下的日志丢弃与缓冲策略。 - **可靠性边界**: `TestLoggerReliability` 模拟了极高压力下的日志丢弃与缓冲策略。
- **文件切分**: `TestSplitTag` 实测了基于时间滚动的文件切分能力。 - **文件切分**: `TestSplitTag` 实测了基于时间滚动的文件切分能力。
- **启动期文件写入**: `TestLoggerCore_SplitFileWritesBeforeWriterServiceStarts` 验证异步服务启动前日志直接写入 split 文件。
- **默认配置覆盖**: `TestLoadDefaultConfig``TestSetDefaultConfigKeepsLoggerWhenEffectiveConfigIsUnchanged` 验证代码预设、文件覆盖和无变化复用行为。

View File

@ -1,8 +1,12 @@
package log package log
import ( import (
"os"
"path/filepath"
"strings"
"sync" "sync"
"testing" "testing"
"time"
) )
func TestLoggerCore_Initialization(t *testing.T) { func TestLoggerCore_Initialization(t *testing.T) {
@ -18,6 +22,119 @@ func TestLoggerCore_Initialization(t *testing.T) {
} }
} }
func TestLoggerCore_SplitFileInitialization(t *testing.T) {
logFile := filepath.Join(t.TempDir(), "logs", "server.log")
logger := NewLogger(Config{
File: logFile,
SplitTag: "20060102",
})
if logger.file == nil {
t.Fatal("expected split file writer to be initialized")
}
if _, err := os.Stat(filepath.Dir(logFile)); err != nil {
t.Fatalf("expected parent directory to be created: %v", err)
}
}
func TestLoggerCore_SplitFileWritesBeforeWriterServiceStarts(t *testing.T) {
if WriterService.Running.Load() {
t.Skip("writer service already running")
}
logFile := filepath.Join(t.TempDir(), "logs", "server.log")
logger := NewLogger(Config{
File: logFile,
SplitTag: "20060102",
})
logger.Info("before writer service starts")
logger.file.Run()
data, err := os.ReadFile(logFile + "." + time.Now().Format("20060102"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "before writer service starts") {
t.Fatalf("expected pre-start log to be written to split file, got %q", data)
}
}
func TestLoadDefaultConfig(t *testing.T) {
oldConfig := defaultConfig
oldWorkingDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
defaultConfig = oldConfig
_ = os.Chdir(oldWorkingDir)
})
tempDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tempDir, "log.yml"), []byte("level: warning\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chdir(tempDir); err != nil {
t.Fatal(err)
}
defaultConfig = Config{
Name: "built-in",
Level: "debug",
File: "logs/server.log",
}
conf := loadDefaultConfig()
if conf.Name != "built-in" {
t.Fatalf("expected default name to be retained, got %q", conf.Name)
}
if conf.File != "logs/server.log" {
t.Fatalf("expected default file to be retained, got %q", conf.File)
}
if conf.Level != "warning" {
t.Fatalf("expected file config to override level, got %q", conf.Level)
}
}
func TestSetDefaultConfig(t *testing.T) {
oldConfig := defaultConfig
oldLogger := DefaultLogger
t.Cleanup(func() {
defaultConfig = oldConfig
DefaultLogger = oldLogger
DefaultLogger.RedirectStdLog()
})
SetDefaultConfig(Config{Name: "application-default", Level: "debug"})
if DefaultLogger.config.Name != "application-default" {
t.Fatalf("expected rebuilt default logger name, got %q", DefaultLogger.config.Name)
}
if DefaultLogger.config.Level != "debug" {
t.Fatalf("expected rebuilt default logger level, got %q", DefaultLogger.config.Level)
}
}
func TestSetDefaultConfigKeepsLoggerWhenEffectiveConfigIsUnchanged(t *testing.T) {
oldConfig := defaultConfig
oldLogger := DefaultLogger
t.Cleanup(func() {
defaultConfig = oldConfig
DefaultLogger = oldLogger
DefaultLogger.RedirectStdLog()
})
defaultConfig = Config{}
DefaultLogger = NewLogger(Config{Name: "configured", Level: "info"})
currentLogger := DefaultLogger
SetDefaultConfig(Config{Name: "configured"})
if DefaultLogger != currentLogger {
t.Fatal("expected unchanged effective config to keep the existing logger")
}
}
func TestLoggerCore_Concurrency(t *testing.T) { func TestLoggerCore_Concurrency(t *testing.T) {
conf := Config{ conf := Config{
Name: "concurrent-test", Name: "concurrent-test",

View File

@ -4,18 +4,40 @@ import (
"apigo.cc/go/config" "apigo.cc/go/config"
) )
var DefaultLogger *Logger var (
defaultConfig Config
DefaultLogger *Logger
)
func loadDefaultConfig() Config {
conf := defaultConfig
_ = config.Load(&conf, "log")
return conf
}
func init() { func init() {
RegisterWriterMaker("es", newESWriter) RegisterWriterMaker("es", newESWriter)
RegisterWriterMaker("ess", newESWriter) RegisterWriterMaker("ess", newESWriter)
var conf Config setDefaultLogger(loadDefaultConfig())
_ = config.Load(&conf, "log") }
func setDefaultLogger(conf Config) {
DefaultLogger = NewLogger(conf) DefaultLogger = NewLogger(conf)
DefaultLogger.RedirectStdLog() DefaultLogger.RedirectStdLog()
} }
// SetDefaultConfig sets application defaults, then applies file and environment
// overrides before rebuilding DefaultLogger.
func SetDefaultConfig(conf Config) {
defaultConfig = conf
finalConfig := normalizeConfig(loadDefaultConfig())
if DefaultLogger != nil && DefaultLogger.config == finalConfig {
return
}
setDefaultLogger(finalConfig)
}
// New 创建带有 traceId 的 Logger 副本 // New 创建带有 traceId 的 Logger 副本
func New(traceId string) *Logger { func New(traceId string) *Logger {
return DefaultLogger.New(traceId) return DefaultLogger.New(traceId)

16
go.mod
View File

@ -3,19 +3,19 @@ module apigo.cc/go/log
go 1.25.0 go 1.25.0
require ( require (
apigo.cc/go/cast v1.5.3 apigo.cc/go/cast v1.5.5
apigo.cc/go/config v1.5.3 apigo.cc/go/config v1.5.4
apigo.cc/go/file v1.5.5 apigo.cc/go/file v1.5.6
apigo.cc/go/id v1.5.4 apigo.cc/go/id v1.5.7
apigo.cc/go/shell v1.5.3 apigo.cc/go/shell v1.5.6
) )
require apigo.cc/go/jsmod v1.5.3 // indirect require apigo.cc/go/jsmod v1.5.3 // indirect
require ( require (
apigo.cc/go/encoding v1.5.4 // indirect apigo.cc/go/encoding v1.5.6 // indirect
apigo.cc/go/rand v1.5.3 // indirect apigo.cc/go/rand v1.5.4 // indirect
apigo.cc/go/safe v1.5.2 // indirect apigo.cc/go/safe v1.5.3 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

32
go.sum
View File

@ -1,21 +1,21 @@
apigo.cc/go/cast v1.5.3 h1:jk6VX0rGFhjKtfPhsaV6IKYpiGmORRk9qPTtuNS53tw= apigo.cc/go/cast v1.5.5 h1:DMbfK3uPhPjRaXutj3StIZIkfjFIATSXfuAOeNOd4Fw=
apigo.cc/go/cast v1.5.3/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= apigo.cc/go/cast v1.5.5/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4=
apigo.cc/go/config v1.5.3 h1:peq1FM2xO+vzPHJf8Dwg3DXm8PtFQMfTFKQj6fpoG7A= apigo.cc/go/config v1.5.4 h1:1c/OarGwbz3+6ikurE+a6LJLjtzXvGvbxw3HV/Nk54M=
apigo.cc/go/config v1.5.3/go.mod h1:ZiOAjWa1mQIzszaJZN+kO6YU4GXreng+NxkcK/TAkqQ= apigo.cc/go/config v1.5.4/go.mod h1:oN+D2F8ETIyqKp+Yu8R4PRQlUoqR44o35jAHwLrrAq0=
apigo.cc/go/encoding v1.5.4 h1:Fk8TrveZATyy8SHukC4ZiqdTSp+QIfsRHtt55xmMK7w= apigo.cc/go/encoding v1.5.6 h1:v02swVfbFGidD4QcX2ktuHHbCjdSbOB85fhzAXay+7M=
apigo.cc/go/encoding v1.5.4/go.mod h1:dShEsZ3gKqBINz7TSOYf4e7/fBCqCY9VzlenoGUQUFM= apigo.cc/go/encoding v1.5.6/go.mod h1:Big9q1Zwy4071dXtnrQ3SJDzfa/G7/A60KE/5+M//P8=
apigo.cc/go/file v1.5.5 h1:/+HmDumLu6Qk2KuQL63M9lpgzHTDL+QJ8dStOl7e9gs= apigo.cc/go/file v1.5.6 h1:Y7w3Tyu4e16VuED7rF2pzba+dzGE+hjDnBlIPVHIfzA=
apigo.cc/go/file v1.5.5/go.mod h1:xRVNhctvqOKeBemmcRW/BQfgkc3B+vT/UZVdSc7duUo= apigo.cc/go/file v1.5.6/go.mod h1:9sdW4ylSOA0HWc8Yt8qdnmMf6nn5SUEmjoPKyXpYXIQ=
apigo.cc/go/id v1.5.4 h1:D1Zx9gEZhOgdTgZ4SdmPImhpc9xGiOA33Y+j2MkstzQ= apigo.cc/go/id v1.5.7 h1:Y5Sx6sQBCAdYMCQPTjODZyGMMd1+WRWCy2dWHVq11XQ=
apigo.cc/go/id v1.5.4/go.mod h1:hCTQq+KC1ALWe1FpPERf+W4B6FSulg9FAgOUJDDySiY= apigo.cc/go/id v1.5.7/go.mod h1:fugudFBqfVNakfm91zZzuzU0P4PULzo6sylB8hRMqxA=
apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU= apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU=
apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
apigo.cc/go/rand v1.5.3 h1:O4bPIwyaOWEBCr0nL9A4G4qG48AqiGTCzfPeckm3Ius= apigo.cc/go/rand v1.5.4 h1:eessFBsKQuoOYdzrStldOGw9f4HqbeO87X95bI4jIBQ=
apigo.cc/go/rand v1.5.3/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4= apigo.cc/go/rand v1.5.4/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4=
apigo.cc/go/safe v1.5.2 h1:EnuEOW/SGwf/5A0nw9LnqfKJE071+TIc6ez8HI9R9Lg= apigo.cc/go/safe v1.5.3 h1:9p/BmdlVWLbekpKByZIFC09Qn8Wdhik2eINiwunBxPs=
apigo.cc/go/safe v1.5.2/go.mod h1:2GqCCLLGex4OAhdET3iBWm1R+LIYtmTrvHP8W0iESSw= apigo.cc/go/safe v1.5.3/go.mod h1:Ay8kEPL76DeXH4ifsVTc/3/sfGHlWLQjAp4vi7GA9AI=
apigo.cc/go/shell v1.5.3 h1:pI+u12sy6upoygq+1XXqUlvUboBfH4Q52jRpoJFv56A= apigo.cc/go/shell v1.5.6 h1:2i93lNJ0oy/D5kOmlmOjwVjLPsW7vQBo0trLOF9AAAI=
apigo.cc/go/shell v1.5.3/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY= apigo.cc/go/shell v1.5.6/go.mod h1:Bp73DGKESOISWSIGUtL1dsFo5g4SI10GhgsnLJCTpsw=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"apigo.cc/go/cast" "apigo.cc/go/cast"
"apigo.cc/go/file"
"apigo.cc/go/id" "apigo.cc/go/id"
) )
@ -83,7 +84,7 @@ func RegisterWriterMaker(name string, f func(*Config) Writer) {
writerMakers[name] = f writerMakers[name] = f
} }
func NewLogger(conf Config) *Logger { func normalizeConfig(conf Config) Config {
if conf.Level == "" { if conf.Level == "" {
conf.Level = "info" conf.Level = "info"
} }
@ -97,6 +98,11 @@ func NewLogger(conf Config) *Logger {
if conf.Name == "" { if conf.Name == "" {
conf.Name = getDefaultName() conf.Name = getDefaultName()
} }
return conf
}
func NewLogger(conf Config) *Logger {
conf = normalizeConfig(conf)
logger := Logger{ logger := Logger{
truncations: cast.Split(conf.Truncations, ","), truncations: cast.Split(conf.Truncations, ","),
@ -138,6 +144,7 @@ func NewLogger(conf Config) *Logger {
} }
} }
} else { } else {
file.EnsureParentDir(conf.File)
if conf.SplitTag != "" { if conf.SplitTag != "" {
WriterService.FilesLock.RLock() WriterService.FilesLock.RLock()
logger.file = WriterService.Files[conf.File+conf.SplitTag] logger.file = WriterService.Files[conf.File+conf.SplitTag]
@ -187,7 +194,14 @@ func (logger *Logger) writeBuf(entry LogEntry, buf []byte) {
if logger.writer != nil { if logger.writer != nil {
logger.writer.Log(entry, buf) logger.writer.Log(entry, buf)
} else if logger.file != nil { } else if logger.file != nil {
fmt.Println(Viewable(string(buf))) WriterService.FilesLock.RLock()
if WriterService.Running.Load() {
WriterService.FilesLock.RUnlock()
writeAsync(logPayload{entry: entry, buf: buf, file: logger.file})
return
}
logger.file.Write(time.Now(), buf)
WriterService.FilesLock.RUnlock()
} else if logger.goLogger == nil { } else if logger.goLogger == nil {
fmt.Println(Viewable(string(buf))) fmt.Println(Viewable(string(buf)))
} else { } else {

View File

@ -228,7 +228,7 @@ func flattenStructFields(t reflect.Type, result *[]fieldInfo, parentIndex []int)
func syncMetaFile() { func syncMetaFile() {
metaLock.RLock() metaLock.RLock()
defer metaLock.RUnlock() defer metaLock.RUnlock()
_ = file.MarshalFilePretty(metaFilePath, metaRegistry) _ = file.PatchFile(metaFilePath, metaRegistry)
} }
// SetMetaFilePath allows changing the path for testing or configuration purposes // SetMetaFilePath allows changing the path for testing or configuration purposes

View File

@ -47,8 +47,12 @@ func (l *MockErrorLog) Reset() {
func TestMetaExtraction(t *testing.T) { func TestMetaExtraction(t *testing.T) {
// Setup custom meta file path for testing // Setup custom meta file path for testing
oldMetaFilePath := metaFilePath
SetMetaFilePath(".test.meta.json") SetMetaFilePath(".test.meta.json")
defer os.Remove(".test.meta.json") t.Cleanup(func() {
SetMetaFilePath(oldMetaFilePath)
_ = os.Remove(".test.meta.json")
})
RegisterType("mock_info", MockInfoLog{}) RegisterType("mock_info", MockInfoLog{})
RegisterType("mock_error", MockErrorLog{}) RegisterType("mock_error", MockErrorLog{})
@ -103,3 +107,35 @@ func TestMetaExtraction(t *testing.T) {
t.Errorf("expected at least 2 types in registry, got %d", len(registry)) t.Errorf("expected at least 2 types in registry, got %d", len(registry))
} }
} }
func TestSyncMetaFilePreservesExistingTypes(t *testing.T) {
oldMetaFilePath := metaFilePath
metaPath := ".test.merge.meta.json"
SetMetaFilePath(metaPath)
t.Cleanup(func() {
SetMetaFilePath(oldMetaFilePath)
_ = os.Remove(metaPath)
})
existing := `{"api_call":[{"Index":0,"Name":"Action","Color":"cyan"}]}`
if err := os.WriteFile(metaPath, []byte(existing), 0644); err != nil {
t.Fatal(err)
}
RegisterType("mock_merge", MockInfoLog{})
data, err := os.ReadFile(metaPath)
if err != nil {
t.Fatal(err)
}
var registry map[string][]MetaField
if err := json.Unmarshal(data, &registry); err != nil {
t.Fatal(err)
}
if len(registry["api_call"]) != 1 || registry["api_call"][0].Name != "Action" {
t.Fatalf("existing business metadata was not preserved: %+v", registry["api_call"])
}
if len(registry["mock_merge"]) == 0 {
t.Fatal("newly registered metadata was not written")
}
}

View File

@ -37,9 +37,18 @@ type writerService struct {
var ( var (
// WriterService is the global instance of defaultService. // WriterService is the global instance of defaultService.
WriterService = &writerService{} WriterService = newWriterService()
) )
func newWriterService() *writerService {
service := &writerService{
LogChannel: make(chan logPayload, 10000),
Files: make(map[string]*FileWriter),
}
service.Writers.Store([]Writer{})
return service
}
// ConsoleWriter 控制台写入器 // ConsoleWriter 控制台写入器
type ConsoleWriter struct { type ConsoleWriter struct {
} }
@ -52,10 +61,6 @@ func (w *ConsoleWriter) Run() {
} }
func init() { func init() {
WriterService.LogChannel = make(chan logPayload, 10000)
WriterService.Writers.Store([]Writer{})
WriterService.Files = make(map[string]*FileWriter)
RegisterWriterMaker("console", func(conf *Config) Writer { RegisterWriterMaker("console", func(conf *Config) Writer {
return &ConsoleWriter{} return &ConsoleWriter{}
}) })