旧链路: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 过滤
377 lines
9.8 KiB
Go
377 lines
9.8 KiB
Go
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"
|
||
}
|
||
}
|