重构: 日志系统结构化,消除字符串反解析

旧链路: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:
yml2213
2026-07-29 12:52:26 +08:00
parent 7c2c5a61a6
commit b72a58fd57
3 changed files with 120 additions and 67 deletions
+11 -39
View File
@@ -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
View File
@@ -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 {
+69
View File
@@ -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") // 恢复,避免影响后续测试
}