重构: 日志系统结构化,消除字符串反解析
旧链路:LogEvent→log.Print→logFileWriter→MemoryLogStore.Write 再用 ParseLogLine 字符串切割把分类/级别/账号反解析回来,格式一调就崩。 新链路:LogEvent 直构 LogEntry(Time/Level/Category/Account 直接填值), 经 sink 分发到内存(同步)/文件(异步)/终端。Message 仍用 formatLogLine 渲染保持文件文本格式与前端清理逻辑兼容。 - MemoryLogStore 新增 Append(entry) 替代 Write 的字符串解析路径 - 删除 logFileWriter,新增 sink 统一管理三路输出 - 删除 log.SetOutput/log.Print 依赖 - 保留 ParseLogLine/DetectLogLevel/formatLogLine(AsyncLogWriter 丢弃 统计与测试仍用),但 log_store 不再调用 - 新增 TestStructuredLogging 覆盖 LogEvent→sink→Append 链路与 minLevel 过滤
This commit is contained in:
@@ -1,15 +1,12 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
// MemoryLogStore 内存日志缓冲,实现 io.Writer 接口。
|
||||
// MemoryLogStore 内存日志缓冲,供前端增量拉取。
|
||||
type MemoryLogStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
@@ -29,44 +26,19 @@ func NewMemoryLogStore(max int) *MemoryLogStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Write 实现 io.Writer,把标准 log 输出转存为内存日志。
|
||||
func (s *MemoryLogStore) Write(p []byte) (int, error) {
|
||||
text := strings.TrimRight(string(p), "\r\n")
|
||||
if text == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Append 追加一条结构化日志,由内部分配自增 ID。
|
||||
// 超过容量时丢弃最旧的条目,保留最近的 max 条。
|
||||
func (s *MemoryLogStore) Append(entry types.LogEntry) types.LogEntry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
meta := ParseLogLine(line)
|
||||
category, level := meta.Category, meta.Level
|
||||
if level == "" {
|
||||
level = DetectLogLevel(line)
|
||||
}
|
||||
logTime := meta.Time
|
||||
if logTime == "" {
|
||||
logTime = time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
s.nextID++
|
||||
s.entries = append(s.entries, types.LogEntry{
|
||||
ID: s.nextID,
|
||||
Time: logTime,
|
||||
Level: level,
|
||||
Category: category,
|
||||
Account: meta.Account,
|
||||
Message: line,
|
||||
})
|
||||
if len(s.entries) > s.max {
|
||||
copy(s.entries, s.entries[len(s.entries)-s.max:])
|
||||
s.entries = s.entries[:s.max]
|
||||
}
|
||||
s.nextID++
|
||||
entry.ID = s.nextID
|
||||
s.entries = append(s.entries, entry)
|
||||
if len(s.entries) > s.max {
|
||||
copy(s.entries, s.entries[len(s.entries)-s.max:])
|
||||
s.entries = s.entries[:s.max]
|
||||
}
|
||||
return len(p), nil
|
||||
return entry
|
||||
}
|
||||
|
||||
// SetFilePath 保存当前日志文件路径。
|
||||
|
||||
+40
-28
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -112,29 +113,28 @@ func (w *AsyncLogWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// logFileWriter 同时写入文件、内存和终端。
|
||||
// 终端写入失败时静默忽略(Windows GUI 应用无控制台)。
|
||||
type logFileWriter struct {
|
||||
file *os.File
|
||||
store *MemoryLogStore
|
||||
// sink 日志输出汇集点,把结构化日志条目分发到内存、文件和终端。
|
||||
type sink struct {
|
||||
async *AsyncLogWriter // 文件异步写入(无文件时为 nil,仅写内存+终端)
|
||||
store *MemoryLogStore
|
||||
stdout bool
|
||||
}
|
||||
|
||||
func (w *logFileWriter) Write(p []byte) (int, error) {
|
||||
// 写入文件
|
||||
if _, err := w.file.Write(p); err != nil {
|
||||
return 0, err
|
||||
// write 分发一条日志:内存同步写入,文件异步写入,终端尽力写入。
|
||||
func (s *sink) write(entry types.LogEntry) {
|
||||
s.store.Append(entry)
|
||||
if s.async != nil {
|
||||
_, _ = s.async.Write([]byte(entry.Message + "\n"))
|
||||
}
|
||||
// 写入内存
|
||||
if _, err := w.store.Write(p); err != nil {
|
||||
return 0, err
|
||||
if s.stdout {
|
||||
_, _ = os.Stdout.Write([]byte(entry.Message + "\n"))
|
||||
}
|
||||
// 尝试写入终端(失败时忽略,Windows GUI 应用无控制台)
|
||||
_, _ = os.Stdout.Write(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
var globalSink *sink
|
||||
|
||||
// InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。
|
||||
// 终端输出通过 logFileWriter 同步写入(Windows GUI 应用无控制台时自动跳过)。
|
||||
// 终端输出通过 sink 同步写入(Windows GUI 应用无控制台时自动跳过)。
|
||||
func InitLogging(store *MemoryLogStore, logDir string) func() {
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
@@ -147,29 +147,30 @@ func InitLogging(store *MemoryLogStore, logDir string) func() {
|
||||
logDir = resolveAbsolutePath(logDir)
|
||||
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
async := NewAsyncLogWriter(store, asyncLogBufferSize)
|
||||
log.SetOutput(async)
|
||||
globalSink = &sink{store: store, stdout: true}
|
||||
LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err)
|
||||
return func() { _ = async.Close() }
|
||||
return func() {}
|
||||
}
|
||||
|
||||
filePath := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405")+".log")
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
async := NewAsyncLogWriter(store, asyncLogBufferSize)
|
||||
log.SetOutput(async)
|
||||
globalSink = &sink{store: store, stdout: true}
|
||||
LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err)
|
||||
return func() { _ = async.Close() }
|
||||
return func() {}
|
||||
}
|
||||
|
||||
store.SetFilePath(filePath)
|
||||
// 使用 logFileWriter 同时写入文件、内存和终端(终端失败时静默忽略)
|
||||
multi := &logFileWriter{file: file, store: store}
|
||||
async := NewAsyncLogWriter(multi, asyncLogBufferSize)
|
||||
log.SetOutput(async)
|
||||
globalSink = &sink{
|
||||
async: NewAsyncLogWriter(file, asyncLogBufferSize),
|
||||
store: store,
|
||||
stdout: true,
|
||||
}
|
||||
LogEvent("日志", "success", "日志文件已启用: %s", filePath)
|
||||
return func() {
|
||||
_ = async.Close()
|
||||
if globalSink != nil && globalSink.async != nil {
|
||||
_ = globalSink.async.Close()
|
||||
}
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
@@ -194,7 +195,18 @@ func logStructuredEvent(category, level, account, format string, args ...any) {
|
||||
if levelPriority(level) < int(minLevel.Load()) {
|
||||
return
|
||||
}
|
||||
log.Print(formatLogLine(time.Now(), category, level, account, fmt.Sprintf(format, args...)))
|
||||
now := time.Now()
|
||||
message := fmt.Sprintf(format, args...)
|
||||
entry := types.LogEntry{
|
||||
Time: now.In(constants.CST).Format(logEntryTimeLayout),
|
||||
Level: level,
|
||||
Category: category,
|
||||
Account: account,
|
||||
Message: formatLogLine(now, category, level, account, message),
|
||||
}
|
||||
if globalSink != nil {
|
||||
globalSink.write(entry)
|
||||
}
|
||||
}
|
||||
|
||||
func formatLogLine(now time.Time, category, level, account, message string) string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -79,3 +80,71 @@ func TestParseLogMeta(t *testing.T) {
|
||||
t.Errorf("ParseLogMeta = (%q, %q), want ('网络', 'warn')", cat, lvl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStructuredLogging 验证 LogEvent→sink→MemoryLogStore 结构化链路:
|
||||
// LogEntry 的 category/level/account 应直接由调用方填入,而非字符串反解析。
|
||||
func TestStructuredLogging(t *testing.T) {
|
||||
store := NewMemoryLogStore(100)
|
||||
cleanup := InitLogging(store, t.TempDir())
|
||||
defer func() {
|
||||
cleanup()
|
||||
globalSink = nil
|
||||
}()
|
||||
|
||||
LogEvent("抢购", "success", "兑换成功: %s", "商品A")
|
||||
LogAccountEvent("抢购", "warn", "testuser", "冷却: %ds", 5)
|
||||
|
||||
entries := store.Since(0)
|
||||
// InitLogging 自身产生 1 条"日志文件已启用",加上上述 2 条,共 3 条
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("Since(0) = %d entries, want 3", len(entries))
|
||||
}
|
||||
if entries[0].Category != "日志" {
|
||||
t.Errorf("entry0 Category = %q, want '日志' (InitLogging 启动日志)", entries[0].Category)
|
||||
}
|
||||
|
||||
e1 := entries[1]
|
||||
if e1.ID != 2 {
|
||||
t.Errorf("entry1 ID = %d, want 2", e1.ID)
|
||||
}
|
||||
if e1.Category != "抢购" {
|
||||
t.Errorf("entry1 Category = %q, want '抢购'", e1.Category)
|
||||
}
|
||||
if e1.Level != "success" {
|
||||
t.Errorf("entry1 Level = %q, want 'success'", e1.Level)
|
||||
}
|
||||
if e1.Account != "" {
|
||||
t.Errorf("entry1 Account = %q, want empty", e1.Account)
|
||||
}
|
||||
if e1.Time == "" {
|
||||
t.Error("entry1 Time is empty")
|
||||
}
|
||||
if !strings.Contains(e1.Message, "[抢购][success]") {
|
||||
t.Errorf("entry1 Message = %q, want contains '[抢购][success]'", e1.Message)
|
||||
}
|
||||
if !strings.Contains(e1.Message, "兑换成功: 商品A") {
|
||||
t.Errorf("entry1 Message = %q, want contains '兑换成功: 商品A'", e1.Message)
|
||||
}
|
||||
|
||||
e2 := entries[2]
|
||||
if e2.ID != 3 {
|
||||
t.Errorf("entry2 ID = %d, want 3", e2.ID)
|
||||
}
|
||||
if e2.Account != "testuser" {
|
||||
t.Errorf("entry2 Account = %q, want 'testuser'", e2.Account)
|
||||
}
|
||||
if !strings.Contains(e2.Message, "[账号:testuser]") {
|
||||
t.Errorf("entry2 Message = %q, want contains '[账号:testuser]'", e2.Message)
|
||||
}
|
||||
if !strings.Contains(e2.Message, "冷却: 5s") {
|
||||
t.Errorf("entry2 Message = %q, want contains '冷却: 5s'", e2.Message)
|
||||
}
|
||||
|
||||
// 验证 minLevel 过滤:设为 error 后 info 日志不应进入内存
|
||||
SetMinLevel("error")
|
||||
LogEvent("测试", "info", "这条应被过滤")
|
||||
if got := store.Since(e2.ID); len(got) != 0 {
|
||||
t.Errorf("after SetMinLevel(error), Since(%d) = %d entries, want 0", e2.ID, len(got))
|
||||
}
|
||||
SetMinLevel("info") // 恢复,避免影响后续测试
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user