Files
dygou_go/internal/logger/logger.go
T
yml2213 b72a58fd57 重构: 日志系统结构化,消除字符串反解析
旧链路: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 过滤
2026-07-29 12:52:26 +08:00

377 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logger
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"douyu_go/internal/constants"
"douyu_go/internal/types"
)
const (
asyncLogBufferSize = 8192
logTimeLayout = "2006/01/02 15:04:05.000"
logEntryTimeLayout = "2006-01-02 15:04:05.000"
)
// minLevel 日志最低等级,低于此等级的日志不输出。0=info, 1=warn, 2=error。
var minLevel atomic.Int32
var douyuOffset atomic.Int64
// SetMinLevel 设置日志最低显示等级。
func SetMinLevel(level string) {
minLevel.Store(int32(levelPriority(level)))
}
// SetDouyuOffset 设置斗鱼服务端时间与本地时间的偏移量,用于日志双时间显示。
func SetDouyuOffset(offset time.Duration) {
douyuOffset.Store(int64(offset))
}
func levelPriority(level string) int {
switch strings.ToLower(level) {
case "debug":
return -1
case "info":
return 0
case "warn":
return 1
case "error":
return 2
default:
return 0
}
}
// AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。
type AsyncLogWriter struct {
dst io.Writer
ch chan []byte
wg sync.WaitGroup
closed uint32
dropped uint64
}
// NewAsyncLogWriter 创建异步日志写入器。
func NewAsyncLogWriter(dst io.Writer, buffer int) *AsyncLogWriter {
if buffer <= 0 {
buffer = 1024
}
w := &AsyncLogWriter{
dst: dst,
ch: make(chan []byte, buffer),
}
w.wg.Add(1)
go w.run()
return w
}
// Write 异步写日志;队列满时丢弃,避免抢购请求被磁盘 I/O 阻塞。
func (w *AsyncLogWriter) Write(p []byte) (int, error) {
if w == nil {
return len(p), nil
}
if atomic.LoadUint32(&w.closed) == 1 {
return len(p), nil
}
buf := append([]byte(nil), p...)
select {
case w.ch <- buf:
default:
atomic.AddUint64(&w.dropped, 1)
}
return len(p), nil
}
func (w *AsyncLogWriter) run() {
defer w.wg.Done()
for p := range w.ch {
_, _ = w.dst.Write(p)
}
if dropped := atomic.LoadUint64(&w.dropped); dropped > 0 {
_, _ = fmt.Fprintln(w.dst, formatLogLine(time.Now(), "日志", "warn", "", fmt.Sprintf("日志队列已丢弃 %d 条,已优先保护抢购链路", dropped)))
}
}
// Close 关闭写入器,等待队列清空。
func (w *AsyncLogWriter) Close() error {
if w == nil {
return nil
}
if atomic.CompareAndSwapUint32(&w.closed, 0, 1) {
close(w.ch)
w.wg.Wait()
}
return nil
}
// sink 日志输出汇集点,把结构化日志条目分发到内存、文件和终端。
type sink struct {
async *AsyncLogWriter // 文件异步写入(无文件时为 nil,仅写内存+终端)
store *MemoryLogStore
stdout bool
}
// write 分发一条日志:内存同步写入,文件异步写入,终端尽力写入。
func (s *sink) write(entry types.LogEntry) {
s.store.Append(entry)
if s.async != nil {
_, _ = s.async.Write([]byte(entry.Message + "\n"))
}
if s.stdout {
_, _ = os.Stdout.Write([]byte(entry.Message + "\n"))
}
}
var globalSink *sink
// InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。
// 终端输出通过 sink 同步写入(Windows GUI 应用无控制台时自动跳过)。
func InitLogging(store *MemoryLogStore, logDir string) func() {
log.SetFlags(0)
log.SetPrefix("")
if logDir == "" {
logDir = constants.DefaultLogDir
}
// 解析为绝对路径,避免打包后工作目录不同导致日志写到意外位置
logDir = resolveAbsolutePath(logDir)
if err := os.MkdirAll(logDir, 0755); err != nil {
globalSink = &sink{store: store, stdout: true}
LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err)
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 {
globalSink = &sink{store: store, stdout: true}
LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err)
return func() {}
}
store.SetFilePath(filePath)
globalSink = &sink{
async: NewAsyncLogWriter(file, asyncLogBufferSize),
store: store,
stdout: true,
}
LogEvent("日志", "success", "日志文件已启用: %s", filePath)
return func() {
if globalSink != nil && globalSink.async != nil {
_ = globalSink.async.Close()
}
_ = file.Close()
}
}
// LogEvent 输出带分类和级别的结构化文本日志。低于当前最低等级的日志会被丢弃。
func LogEvent(category, level, format string, args ...any) {
logStructuredEvent(category, level, "", format, args...)
}
// LogAccountEvent 输出带账号标签的结构化日志,便于前端按账号观察抢购链路。
func LogAccountEvent(category, level, account, format string, args ...any) {
logStructuredEvent(category, level, account, format, args...)
}
func logStructuredEvent(category, level, account, format string, args ...any) {
if category == "" {
category = "系统"
}
if level == "" {
level = "info"
}
if levelPriority(level) < int(minLevel.Load()) {
return
}
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 {
ts := now.In(constants.CST).Format(logTimeLayout)
offset := time.Duration(douyuOffset.Load())
var prefix string
if offset != 0 {
dyTime := now.Add(offset)
prefix = fmt.Sprintf("%s(D%02d:%02d:%02d.%03d) ", ts,
dyTime.Hour(), dyTime.Minute(), dyTime.Second(), dyTime.Nanosecond()/1e6)
} else {
prefix = ts + " "
}
if account != "" {
return fmt.Sprintf("%s[%s][%s][账号:%s] %s", prefix, category, level, account, message)
}
return fmt.Sprintf("%s[%s][%s] %s", prefix, category, level, message)
}
// LogMeta 是从日志文本中解析出的结构化信息。
type LogMeta struct {
Time string
Category string
Level string
Account string
}
// ParseLogLine 从标准日志行中提取毫秒时间、分类、级别和账号。
func ParseLogLine(line string) LogMeta {
meta := LogMeta{
Time: parseLogTime(line),
}
idx := strings.Index(line, "[")
if idx < 0 {
meta.Account = parseAccountFromText(line)
return meta
}
category, rest, ok := cutBracket(line[idx:])
if !ok {
meta.Account = parseAccountFromText(line)
return meta
}
level, rest, ok := cutBracket(rest)
if !ok || !IsLogLevel(level) {
meta.Category = category
meta.Account = parseAccountFromText(line)
return meta
}
meta.Category = category
meta.Level = level
rest = strings.TrimLeft(rest, " \t")
tag, _, ok := cutBracket(rest)
if ok && strings.HasPrefix(tag, "账号:") {
meta.Account = strings.TrimSpace(strings.TrimPrefix(tag, "账号:"))
}
if meta.Account == "" {
meta.Account = parseAccountFromText(line)
}
return meta
}
func parseLogTime(line string) string {
if len(line) < len(logTimeLayout) {
return ""
}
raw := line[:len(logTimeLayout)]
t, err := time.ParseInLocation(logTimeLayout, raw, constants.CST)
if err != nil {
return ""
}
return t.Format(logEntryTimeLayout)
}
func parseAccountFromText(line string) string {
for _, marker := range []string{"账号=", "账号:"} {
idx := strings.Index(line, marker)
if idx < 0 {
continue
}
start := idx + len(marker)
rest := strings.TrimSpace(line[start:])
end := strings.IndexFunc(rest, func(r rune) bool {
return r == ' ' || r == '\t' || r == ']' || r == ',' || r == ',' || r == '|' || r == ';' || r == ';'
})
if end >= 0 {
rest = rest[:end]
}
return strings.TrimSpace(rest)
}
return ""
}
// ParseLogMeta 从标准日志行中提取类似 [网络][warn] 的分类和级别。
func ParseLogMeta(line string) (string, string) {
meta := ParseLogLine(line)
return meta.Category, meta.Level
}
func cutBracket(s string) (string, string, bool) {
if !strings.HasPrefix(s, "[") {
return "", s, false
}
end := strings.Index(s, "]")
if end <= 1 {
return "", s, false
}
return s[1:end], s[end+1:], true
}
// IsLogLevel 判断是否为有效日志级别。
func IsLogLevel(level string) bool {
switch strings.ToLower(level) {
case "success", "error", "warn", "info", "debug":
return true
default:
return false
}
}
// LogDebug 输出 debug 级别日志,仅在日志等级设为 debug 时可见。
func LogDebug(category, format string, args ...any) {
LogEvent(category, "debug", format, args...)
}
// resolveAbsolutePath 将相对路径解析为绝对路径。
// 如果路径已经是绝对路径则直接返回;否则基于可执行文件所在目录解析。
func resolveAbsolutePath(path string) string {
if filepath.IsAbs(path) {
return path
}
// 先尝试基于当前工作目录解析
if abs, err := filepath.Abs(path); err == nil {
return abs
}
// 兜底:基于可执行文件所在目录
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), path)
}
return path
}
// DetectLogLevel 根据日志文本粗略分类,方便前端染色。
func DetectLogLevel(line string) string {
lower := strings.ToLower(line)
switch {
case strings.Contains(lower, "[debug]"):
return "debug"
case strings.Contains(lower, "[success]"):
return "success"
case strings.Contains(lower, "[error]"):
return "error"
case strings.Contains(lower, "[warn]"):
return "warn"
case strings.Contains(lower, "[info]"):
return "info"
case strings.Contains(line, "成功"):
return "success"
case strings.Contains(line, "失败") || strings.Contains(line, "错误") || strings.Contains(lower, "error") || strings.Contains(lower, "panic"):
return "error"
case strings.Contains(line, "降级") || strings.Contains(line, "跳过") || strings.Contains(line, "不存在") || strings.Contains(lower, "warn"):
return "warn"
default:
return "info"
}
}