hn-readcard-go/logger.go

241 lines
5.5 KiB
Go
Raw Permalink Normal View History

2026-08-06 11:23:33 +08:00
package main
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type dailyWriter struct {
dir string
prefix string
mu sync.Mutex
day string
part int
file *os.File
fileSize int64
retention int
maxFileBytes int64
maxTotalBytes int64
lastCleanup time.Time
bytesSinceGC int64
}
func newDailyWriter(dir, prefix string, retention int, maxFileBytes, maxTotalBytes int64) (*dailyWriter, error) {
dir, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
if err = os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
w := &dailyWriter{
dir: dir,
prefix: prefix,
retention: retention,
maxFileBytes: maxFileBytes,
maxTotalBytes: maxTotalBytes,
}
if err = w.openForDayLocked(time.Now()); err != nil {
return nil, err
}
w.cleanupLocked(time.Now())
return w, nil
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
now := time.Now()
if w.file == nil || w.day != now.Format("2006-01-02") {
if err := w.openForDayLocked(now); err != nil {
return 0, err
}
w.cleanupLocked(now)
}
if w.maxFileBytes > 0 && w.fileSize > 0 && w.fileSize+int64(len(p)) > w.maxFileBytes {
if err := w.openNextPartLocked(now); err != nil {
return 0, err
}
w.cleanupLocked(now)
}
n, err := w.file.Write(p)
w.fileSize += int64(n)
w.bytesSinceGC += int64(n)
if err == nil && (w.bytesSinceGC >= 1<<20 || now.Sub(w.lastCleanup) >= time.Minute || (w.maxFileBytes > 0 && w.fileSize >= w.maxFileBytes)) {
w.cleanupLocked(now)
}
return n, err
}
func (w *dailyWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *dailyWriter) openForDayLocked(now time.Time) error {
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
w.day = now.Format("2006-01-02")
w.part = 0
for {
path := w.partPathLocked()
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return w.openPathLocked(path, 0)
}
return err
}
if w.maxFileBytes <= 0 || info.Size() < w.maxFileBytes {
return w.openPathLocked(path, info.Size())
}
w.part++
}
}
func (w *dailyWriter) openNextPartLocked(now time.Time) error {
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
if w.day != now.Format("2006-01-02") {
return w.openForDayLocked(now)
}
w.part++
for {
path := w.partPathLocked()
info, err := os.Stat(path)
if os.IsNotExist(err) {
return w.openPathLocked(path, 0)
}
if err != nil {
return err
}
if w.maxFileBytes <= 0 || info.Size() < w.maxFileBytes {
return w.openPathLocked(path, info.Size())
}
w.part++
}
}
func (w *dailyWriter) partPathLocked() string {
if w.part == 0 {
return filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, w.day))
}
return filepath.Join(w.dir, fmt.Sprintf("%s-%s-%03d.log", w.prefix, w.day, w.part))
}
func (w *dailyWriter) openPathLocked(path string, size int64) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
w.file = file
w.fileSize = size
return nil
}
type logFileInfo struct {
path string
size int64
modTime time.Time
}
func (w *dailyWriter) cleanupLocked(now time.Time) {
w.lastCleanup = now
w.bytesSinceGC = 0
entries, err := os.ReadDir(w.dir)
if err != nil {
return
}
cutoff := now.AddDate(0, 0, -w.retention)
files := make([]logFileInfo, 0, len(entries))
var total int64
currentPath := ""
if w.file != nil {
currentPath, _ = filepath.Abs(w.file.Name())
}
for _, entry := range entries {
if entry.IsDir() || !isManagedLogName(entry.Name()) {
continue
}
path := filepath.Join(w.dir, entry.Name())
info, statErr := entry.Info()
if statErr != nil {
continue
}
if w.retention > 0 && info.ModTime().Before(cutoff) && !samePath(path, currentPath) {
if os.Remove(path) == nil {
continue
}
}
files = append(files, logFileInfo{path: path, size: info.Size(), modTime: info.ModTime()})
total += info.Size()
}
if w.maxTotalBytes <= 0 || total <= w.maxTotalBytes {
return
}
sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) })
for _, file := range files {
if total <= w.maxTotalBytes {
break
}
if samePath(file.path, currentPath) {
continue
}
if os.Remove(file.path) == nil {
total -= file.size
}
}
}
func isManagedLogName(name string) bool {
return strings.HasSuffix(name, ".log") && (strings.HasPrefix(name, "service-") || strings.HasPrefix(name, "crash-"))
}
func samePath(left, right string) bool {
return right != "" && strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
type appLogger struct {
*slog.Logger
writer *dailyWriter
}
func newAppLogger(cfg Config, component string) (*appLogger, error) {
maxFileBytes := int64(cfg.LogMaxFileMB) * 1024 * 1024
maxTotalBytes := int64(cfg.LogMaxTotalMB) * 1024 * 1024
w, err := newDailyWriter(cfg.LogDir, "service-"+component, cfg.LogRetentionDays, maxFileBytes, maxTotalBytes)
if err != nil {
return nil, err
}
handler := slog.NewJSONHandler(io.MultiWriter(os.Stdout, w), &slog.HandlerOptions{Level: slog.LevelInfo})
return &appLogger{Logger: slog.New(handler).With("component", component, "pid", os.Getpid()), writer: w}, nil
}
func (l *appLogger) Close() error {
if l == nil || l.writer == nil {
return nil
}
return l.writer.Close()
}
func discardLogger() *appLogger {
return &appLogger{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}
}