// Package logging 是桌面程序自带的日志设施。 // // 日志同时写两处:磁盘上的 app.log(长期留存、方便排查)和内存里的 // 环形缓冲(最近若干行)。界面上的「日志」按钮直接读这份缓冲, // 因此程序完全不需要控制台窗口就能查看运行日志。 package logging import ( "context" "fmt" "log/slog" "os" "path/filepath" "strconv" "strings" "sync" ) // Buffer 是保存最近若干行日志的环形缓冲。 type Buffer struct { mu sync.Mutex lines []string cap int seq uint64 } // NewBuffer 创建一个最多保留 capacity 行的缓冲。 func NewBuffer(capacity int) *Buffer { if capacity <= 0 { capacity = 2000 } return &Buffer{lines: make([]string, 0, capacity), cap: capacity} } // Append 追加一行日志,超过容量时丢弃最旧的一行。 func (b *Buffer) Append(line string) { b.mu.Lock() defer b.mu.Unlock() if len(b.lines) == b.cap { copy(b.lines, b.lines[1:]) b.lines = b.lines[:len(b.lines)-1] } b.lines = append(b.lines, line) b.seq++ } // Text 返回缓存中的全部日志文本。 func (b *Buffer) Text() string { b.mu.Lock() defer b.mu.Unlock() if len(b.lines) == 0 { return "" } return strings.Join(b.lines, "\n") } // Lines 返回缓存中日志行的副本。 func (b *Buffer) Lines() []string { b.mu.Lock() defer b.mu.Unlock() out := make([]string, len(b.lines)) copy(out, b.lines) return out } // Seq 返回内容版本号:每写入一行或清空一次就加一。 // 界面靠它判断内容有没有变化,避免每次都重建整段文本。 func (b *Buffer) Seq() uint64 { b.mu.Lock() defer b.mu.Unlock() return b.seq } // Clear 清空缓存。 func (b *Buffer) Clear() { b.mu.Lock() defer b.mu.Unlock() b.lines = b.lines[:0] b.seq++ } // Writer 把日志同时写进文件和内存缓冲。 type Writer struct { mu sync.Mutex file *os.File buf *Buffer } // New 返回一个只写内存的写入器,用于日志文件打不开时的兜底。 func New(buf *Buffer) *Writer { return &Writer{buf: buf} } // Open 打开(追加写入)日志文件并与 buf 绑定。 // 打开失败时返回一个仍然可用的写入器(只写内存)和错误。 func Open(path string, buf *Buffer) (*Writer, error) { if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return New(buf), fmt.Errorf("创建日志目录失败: %w", err) } } f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return New(buf), fmt.Errorf("打开日志文件失败: %w", err) } return &Writer{file: f, buf: buf}, nil } // Write 实现 io.Writer。slog 的每条记录是一次 Write 调用,这里按行落盘并缓存。 func (w *Writer) Write(p []byte) (int, error) { line := strings.TrimRight(string(p), "\r\n") if line == "" { return len(p), nil } w.mu.Lock() defer w.mu.Unlock() if w.file != nil { if _, err := w.file.WriteString(line + "\n"); err != nil { return len(p), err } } if w.buf != nil { w.buf.Append(line) } return len(p), nil } // Close 关闭日志文件。 func (w *Writer) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.file == nil { return nil } err := w.file.Close() w.file = nil return err } // handler 是自定义的 slog.Handler:把每条记录格式化成一行, // 再交给 Writer 同时落盘并缓存,供界面直接查看。 type handler struct { w *Writer level slog.Level attrs []slog.Attr groups []string } // NewHandler 创建一个把日志写进 w 的 slog 处理器。 // level 以下级别的记录会被丢弃。 func NewHandler(w *Writer, level slog.Level) slog.Handler { return &handler{w: w, level: level} } func (h *handler) Enabled(_ context.Context, l slog.Level) bool { return l >= h.level } func (h *handler) Handle(_ context.Context, r slog.Record) error { var sb strings.Builder sb.WriteString(r.Time.Format("2006-01-02 15:04:05.000")) sb.WriteString(" ") sb.WriteString(levelLabel(r.Level)) sb.WriteString(" ") sb.WriteString(r.Message) var rec []slog.Attr r.Attrs(func(a slog.Attr) bool { rec = append(rec, a) return true }) // 记录里的属性按当前组栈展开;WithAttrs 存下来的属性已经展开过。 for _, a := range flatten(h.groups, rec) { appendAttr(&sb, a) } for _, a := range h.attrs { appendAttr(&sb, a) } sb.WriteString("\n") _, err := h.w.Write([]byte(sb.String())) return err } // WithAttrs 返回一个带上额外属性的处理器。 func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { if len(attrs) == 0 { return h } nh := *h nh.attrs = append(append([]slog.Attr(nil), h.attrs...), flatten(h.groups, attrs)...) return &nh } // WithGroup 返回一个把后续属性放进命名组的处理器。 func (h *handler) WithGroup(name string) slog.Handler { if name == "" { return h } nh := *h nh.groups = append(append([]string(nil), h.groups...), name) return &nh } // flatten 把带组的属性展开成扁平的键值对,键名带上组前缀。 func flatten(groups []string, attrs []slog.Attr) []slog.Attr { out := make([]slog.Attr, 0, len(attrs)) for _, a := range attrs { if a.Value.Kind() == slog.KindGroup { g := a.Value.Group() if len(g) == 0 { continue } sub := groups if a.Key != "" { sub = append(append([]string(nil), groups...), a.Key) } out = append(out, flatten(sub, g)...) continue } if a.Key == "" { continue } key := a.Key if len(groups) > 0 { key = strings.Join(groups, ".") + "." + key } out = append(out, slog.Attr{Key: key, Value: a.Value}) } return out } // appendAttr 把一个扁平属性写进日志行。 func appendAttr(sb *strings.Builder, a slog.Attr) { sb.WriteString(" ") sb.WriteString(a.Key) sb.WriteString("=") sb.WriteString(formatValue(a.Value)) } // formatValue 把属性值格式化成一行日志里好读的样子。 func formatValue(v slog.Value) string { v = v.Resolve() switch v.Kind() { case slog.KindString: s := v.String() if strings.ContainsAny(s, " \t\"=") { return strconv.Quote(s) } return s case slog.KindTime: return v.Time().Format("15:04:05.000") case slog.KindDuration: return v.Duration().String() default: return fmt.Sprintf("%v", v.Any()) } } // levelLabel 返回定宽的级别标签。 func levelLabel(l slog.Level) string { switch { case l >= slog.LevelError: return "ERROR" case l >= slog.LevelWarn: return "WARN " case l >= slog.LevelInfo: return "INFO " default: return "DEBUG" } }