优化一些问题&修复一些BUG

This commit is contained in:
yun
2026-09-13 20:41:27 +08:00
parent 75efec48bf
commit ab11fa71a0
8 changed files with 876 additions and 183 deletions
+380
View File
@@ -0,0 +1,380 @@
package loggerx_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/yuninks/loggerx"
)
// newTestLogger 统一构造测试实例,测试结束关闭句柄
func newTestLogger(t *testing.T, opts ...loggerx.Option) (*loggerx.Logger, string) {
t.Helper()
dir := t.TempDir()
opts = append([]loggerx.Option{loggerx.SetDir(dir)}, opts...)
l := loggerx.NewLogger(context.Background(), opts...)
t.Cleanup(func() {
if err := l.Close(); err != nil {
t.Errorf("Close 返回错误: %v", err)
}
})
return l, dir
}
// syncAndRead 先刷盘再读取(写入走 bufio,落盘后才能看到)
func syncAndRead(t *testing.T, l *loggerx.Logger, dir, event string) string {
t.Helper()
if err := l.MustSync(); err != nil {
t.Fatalf("MustSync: %v", err)
}
return readLog(t, dir, event)
}
// tailOf 取字符串末尾 n 个字节,用于失败时打印现场
func tailOf(s string, n int) string {
if len(s) <= n {
return s
}
return s[len(s)-n:]
}
// readLog 读取切割后的日志文件(文件名可能带日期前缀)
func readLog(t *testing.T, dir, event string) string {
t.Helper()
files, err := filepath.Glob(filepath.Join(dir, "*_"+event+".log"))
if err != nil {
t.Fatalf("glob %s: %v", event, err)
}
if len(files) == 0 {
files, _ = filepath.Glob(filepath.Join(dir, event+".log"))
}
var sb strings.Builder
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s: %v", f, err)
}
sb.Write(b)
}
return sb.String()
}
// ---------------- 修复1:文件句柄缓存键必须包含 event ----------------
// info/error 交替写入后,各自文件的内容必须完整且不串台
func TestEventHandlerIsolation(t *testing.T) {
l, dir := newTestLogger(t)
const n = 50
for i := 0; i < n; i++ {
l.Infof(context.Background(), "INFO-%d", i)
l.Errorf(context.Background(), "ERR-%d", i)
}
info, errl := syncAndRead(t, l, dir, "info"), syncAndRead(t, l, dir, "error")
if got := strings.Count(info, "INFO-"); got != n {
t.Errorf("info.log 条数 = %d, 期望 %d", got, n)
}
if got := strings.Count(errl, "ERR-"); got != n {
t.Errorf("error.log 条数 = %d, 期望 %d", got, n)
}
if strings.Contains(info, "ERR-") {
t.Error("info.log 中混入了 error 日志")
}
if strings.Contains(errl, "INFO-") {
t.Error("error.log 中混入了 info 日志")
}
}
// 不同 channel 也必须各自独立
func TestChannelIsolation(t *testing.T) {
l, dir := newTestLogger(t)
l.Channel("c1").Info(context.Background(), "IN-C1")
l.Channel("c2").Info(context.Background(), "IN-C2")
if s := syncAndRead(t, l, dir, "info"); strings.Contains(s, "IN-C2") {
t.Error("根 channel 的日志里混入了 c2 的内容")
}
// channel 的日志在子目录里,同样带时间前缀
c1, err := filepath.Glob(filepath.Join(dir, "c1", "*_info.log"))
if err != nil || len(c1) == 0 {
t.Fatalf("没有找到 c1 的日志文件: %v", err)
}
b, err := os.ReadFile(c1[0])
if err != nil {
t.Fatalf("读取 c1 日志: %v", err)
}
if !strings.Contains(string(b), "IN-C1") {
t.Errorf("c1 日志内容不正确: %s", string(b))
}
}
// 事件交替写入的性能:不能因为切换事件而反复重开文件
func BenchmarkInfoOnly(b *testing.B) {
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(b.TempDir()))
defer l.Close()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
func BenchmarkInfoErrorInterleaved(b *testing.B) {
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(b.TempDir()))
defer l.Close()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
l.Errorf(ctx, "hello %d", i)
}
}
// ---------------- 修复2io.Writer 契约 ----------------
func TestWriteSatisfiesIOWriterContract(t *testing.T) {
cases := []struct {
name string
opts []loggerx.Option
}{
{"默认(写文件)", nil},
{"只写驱动(不写文件)", []loggerx.Option{loggerx.SetPrintFile(false), loggerx.SetToConsole()}},
{"文件+附加驱动", []loggerx.Option{loggerx.SetExtraDriver(io.Discard)}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
l, _ := newTestLogger(t, c.opts...)
b := []byte("hello io.Writer\n")
n, err := l.Write(b)
if err != nil {
t.Fatalf("Write 返回错误: %v", err)
}
if n != len(b) {
t.Fatalf("Write 返回 n=%d, 期望 len(b)=%d(违反 io.Writer 契约)", n, len(b))
}
})
}
}
// 通过标准库 log 写入也必须成功:log 在短写时会报 io.ErrShortWrite
func TestStdLogWriteNoShortWriteError(t *testing.T) {
l, dir := newTestLogger(t)
// log.SetOutput 是全局状态,恢复现场
prev := log.Writer()
defer log.SetOutput(prev)
log.SetOutput(l)
if err := log.Output(2, "via standard log"); err != nil {
t.Fatalf("标准 log 写入失败: %v", err)
}
if s := syncAndRead(t, l, dir, "info"); !strings.Contains(s, "via standard log") {
t.Errorf("日志内容未落盘: %s", s)
}
}
// ---------------- 修复3:句柄释放与刷盘 ----------------
// Close 之后文件必须能被删除(Windows 上句柄没关是删不掉的)
func TestCloseReleasesFileHandles(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
l.Info(context.Background(), "before close")
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
if len(files) == 0 {
t.Fatal("没有生成日志文件")
}
for _, f := range files {
if err := os.Remove(f); err != nil {
t.Errorf("Close 后仍无法删除 %s: %v", filepath.Base(f), err)
}
}
}
// Close 必须先把缓存里的内容刷到磁盘
func TestCloseFlushesBufferedData(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
l.Info(context.Background(), "buffered-content")
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
if len(files) != 1 {
t.Fatalf("期望 1 个日志文件, 实际 %d", len(files))
}
b, err := os.ReadFile(files[0])
if err != nil {
t.Fatalf("读取: %v", err)
}
if !strings.Contains(string(b), "buffered-content") {
t.Errorf("Close 后内容丢失: %q", string(b))
}
}
// 重复 Close 必须安全
func TestCloseIsIdempotent(t *testing.T) {
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(t.TempDir()))
if err := l.Close(); err != nil {
t.Fatalf("首次 Close: %v", err)
}
if err := l.Close(); err != nil {
t.Fatalf("重复 Close: %v", err)
}
}
// 异步写入 + Close:队列里的内容不能丢
func TestCloseDrainsAsyncQueue(t *testing.T) {
for _, n := range []int{10, 100, 197, 198, 199, 200, 201, 250} {
t.Run(fmt.Sprintf("n=%d", n), func(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetWriteAsync())
al := l.WriteAsync()
for i := 0; i < n; i++ {
al.Infof(context.Background(), "ASYNC-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
var sb strings.Builder
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s: %v", f, err)
}
sb.Write(b)
}
content := sb.String()
missing := make([]string, 0)
for i := 0; i < n; i++ {
if !strings.Contains(content, fmt.Sprintf(`ASYNC-%d"`, i)) {
missing = append(missing, fmt.Sprint(i))
}
}
if len(missing) > 0 {
cnt := strings.Count(content, "[info]")
t.Errorf("丢 %d 条: %s | 实际日志行数=%d n=%d 结尾=%q", len(missing), strings.Join(missing, ","), cnt, n, tailOf(content, 160))
}
})
}
}
// 用 context.Background 创建实例,Close 后后台 goroutine 必须退出
func TestCloseStopsGoroutines(t *testing.T) {
before := runtime.NumGoroutine()
loggers := make([]*loggerx.Logger, 0, 10)
for i := 0; i < 10; i++ {
loggers = append(loggers, loggerx.NewLogger(context.Background(), loggerx.SetDir(t.TempDir())))
}
for _, l := range loggers {
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Errorf("Close 后 goroutine 未回收: before=%d after=%d", before, runtime.NumGoroutine())
}
// ---------------- 修复4nil ctx / 序列化失败 ----------------
func TestNilContextDoesNotPanic(t *testing.T) {
l, dir := newTestLogger(t)
// nolint:staticcheck // 故意传 nil,验证不 panic
l.Info(nil, "nil ctx")
if s := syncAndRead(t, l, dir, "info"); !strings.Contains(s, "nil ctx") {
t.Errorf("nil ctx 时日志未写入: %q", s)
}
}
// 不可序列化的内容不能产出空行 / 坏 JSON
func TestUnmarshalableContentProducesValidJSON(t *testing.T) {
l, dir := newTestLogger(t)
l.Info(context.Background(), make(chan int), func() {}, map[string]any{"ok": 1})
l.Infof(context.Background(), "后面这条必须还在")
content := syncAndRead(t, l, dir, "info")
lines := strings.Split(strings.TrimSpace(content), "\n")
if len(lines) != 2 {
t.Fatalf("期望 2 行日志, 实际 %d 行: %q", len(lines), content)
}
for i, line := range lines {
body := strings.TrimPrefix(line, "[info]")
if body == "" {
t.Fatalf("第 %d 行是空的 [info] 裸行(序列化失败被静默丢弃)", i+1)
}
var v map[string]any
if err := json.Unmarshal([]byte(body), &v); err != nil {
t.Fatalf("第 %d 行不是合法 JSON: %v\n内容: %s", i+1, err, body)
}
if i == 0 && v["content"] == nil {
t.Error("序列化失败时 content 丢失,无法定位问题")
}
}
}
// 并发写入:数据不能串、不能丢
func TestConcurrentWriteIntegrity(t *testing.T) {
l, dir := newTestLogger(t)
var wg sync.WaitGroup
const perLevel = 300
for i := 0; i < perLevel; i++ {
wg.Add(3)
go func(i int) { defer wg.Done(); l.Infof(context.Background(), "INFO-%d", i) }(i)
go func(i int) { defer wg.Done(); l.Errorf(context.Background(), "ERR-%d", i) }(i)
go func(i int) { defer wg.Done(); l.Channel("cc").Infof(context.Background(), "CC-%d", i) }(i)
}
wg.Wait()
if got := strings.Count(syncAndRead(t, l, dir, "info"), "INFO-"); got != perLevel {
t.Errorf("info 条数 = %d, 期望 %d", got, perLevel)
}
if got := strings.Count(syncAndRead(t, l, dir, "error"), "ERR-"); got != perLevel {
t.Errorf("error 条数 = %d, 期望 %d", got, perLevel)
}
// cc 是子 channel,日志落在 cc/ 子目录
ccFiles, _ := filepath.Glob(filepath.Join(dir, "cc", "*_info.log"))
if len(ccFiles) == 0 {
t.Fatal("没有找到 cc 的日志文件")
}
var ccb strings.Builder
for _, f := range ccFiles {
b, _ := os.ReadFile(f)
ccb.Write(b)
}
if got := strings.Count(ccb.String(), "CC-"); got != perLevel {
t.Errorf("cc 条数 = %d, 期望 %d", got, perLevel)
}
}