优化一些问题&修复一些BUG
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 修复2:io.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())
|
||||
}
|
||||
|
||||
// ---------------- 修复4:nil 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user