2026-09-13 21:53:37 +08:00
|
|
|
package loggerx_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"compress/gzip"
|
|
|
|
|
"context"
|
2026-09-14 00:14:30 +08:00
|
|
|
"encoding/json"
|
2026-09-13 21:53:37 +08:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/yuninks/loggerx"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ---------- 功能 1:定时刷盘 ----------
|
|
|
|
|
|
|
|
|
|
// 开启定时刷盘后,未 Close 也能在磁盘上看到日志
|
|
|
|
|
func TestFlushIntervalPersistsWithoutClose(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(),
|
|
|
|
|
loggerx.SetDir(dir),
|
|
|
|
|
loggerx.SetFlushInterval(30*time.Millisecond),
|
|
|
|
|
)
|
|
|
|
|
defer l.Close()
|
|
|
|
|
|
|
|
|
|
l.Info(context.Background(), "FLUSHED-BY-TIMER")
|
|
|
|
|
|
|
|
|
|
// 等若干次刷盘周期;不调用 MustSync,也不 Close
|
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
if strings.Contains(readAllLogs(t, dir), "FLUSHED-BY-TIMER") {
|
|
|
|
|
return // 成功:定时器把它刷下去了
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
t.Fatal("定时刷盘没生效:未 Close 时磁盘上看不到日志")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 不开定时刷盘时,日志应该仍留在内存缓冲里(证明上面的测试不是假通过)
|
|
|
|
|
func TestNoFlushIntervalKeepsInBuffer(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
|
|
|
|
|
defer l.Close()
|
|
|
|
|
|
|
|
|
|
l.Info(context.Background(), "STILL-BUFFERED")
|
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
if strings.Contains(readAllLogs(t, dir), "STILL-BUFFERED") {
|
|
|
|
|
t.Skip("本次写入正好触发了缓冲落盘,跳过该对照")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 定时刷盘不能丢日志、也不能让 Close 之后 goroutine 残留
|
|
|
|
|
func TestFlushIntervalNoLossAndStops(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(),
|
|
|
|
|
loggerx.SetDir(dir),
|
|
|
|
|
loggerx.SetFlushInterval(10*time.Millisecond),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const n = 3000
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
l.Infof(context.Background(), "FL-%d", i)
|
|
|
|
|
}
|
|
|
|
|
if err := l.Close(); err != nil {
|
|
|
|
|
t.Fatalf("Close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content := readAllLogs(t, dir)
|
|
|
|
|
missing := 0
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
if !strings.Contains(content, fmt.Sprintf(`FL-%d"`, i)) {
|
|
|
|
|
missing++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if missing > 0 {
|
|
|
|
|
t.Errorf("定时刷盘场景丢了 %d 条日志", missing)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 功能 2:按大小切割 + 压缩 ----------
|
|
|
|
|
|
|
|
|
|
// 超过大小上限后应该滚动出带序号的归档文件,并压缩成 .gz
|
|
|
|
|
func TestSizeSplitRollsAndCompresses(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(),
|
|
|
|
|
loggerx.SetDir(dir),
|
|
|
|
|
loggerx.SetSizeSplit(8*1024), // 8KB 一个文件
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const n = 800 // 每条约 130 字节 => 约 104KB,应滚出十来个文件
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
l.Infof(context.Background(), "SPLIT-%d-%s", i, strings.Repeat("x", 60))
|
|
|
|
|
}
|
|
|
|
|
if err := l.Close(); err != nil {
|
|
|
|
|
t.Fatalf("Close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
|
|
|
|
|
gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz"))
|
|
|
|
|
t.Logf("滚动结果: %d 个 .log, %d 个 .gz", len(files), len(gzs))
|
|
|
|
|
|
|
|
|
|
if len(gzs) == 0 {
|
|
|
|
|
t.Fatal("没有产生任何压缩归档 .gz")
|
|
|
|
|
}
|
|
|
|
|
// 每个未压缩文件都不应明显超过上限(留一点余量给缓冲/单条超长)
|
|
|
|
|
for _, f := range files {
|
|
|
|
|
st, err := os.Stat(f)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("stat %s: %v", f, err)
|
|
|
|
|
}
|
|
|
|
|
if st.Size() > 8*1024+2048 {
|
|
|
|
|
t.Errorf("%s 大小 %d 超过上限过多", filepath.Base(f), st.Size())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 归档文件名应带序号
|
|
|
|
|
for _, g := range gzs {
|
|
|
|
|
if !strings.Contains(filepath.Base(g), "_") {
|
|
|
|
|
t.Errorf("归档文件名缺少序号: %s", filepath.Base(g))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 压缩归档要能被解开,且内容是完整合法的日志(不能丢条)
|
|
|
|
|
func TestCompressedArchiveIsReadable(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(),
|
|
|
|
|
loggerx.SetDir(dir),
|
|
|
|
|
loggerx.SetSizeSplit(4*1024),
|
|
|
|
|
)
|
|
|
|
|
const n = 400
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
l.Infof(context.Background(), "GZ-%d", i)
|
|
|
|
|
}
|
|
|
|
|
if err := l.Close(); err != nil {
|
|
|
|
|
t.Fatalf("Close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz"))
|
|
|
|
|
if len(gzs) == 0 {
|
|
|
|
|
t.Fatal("没有产生 .gz 归档")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 压缩归档 + 当前未压缩文件一起统计,必须一条不漏
|
|
|
|
|
content := readAllGz(t, dir) + readAllLogs(t, dir)
|
2026-09-14 00:14:30 +08:00
|
|
|
// 落到磁盘的每条记录都应是合法 JSON 行(便于采集端解析)
|
|
|
|
|
if !jsonLinesValid(t, content) {
|
|
|
|
|
t.Errorf("归档内容不是合法 JSON 行: %.160q", content)
|
2026-09-13 21:53:37 +08:00
|
|
|
}
|
|
|
|
|
missing := 0
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
if !strings.Contains(content, fmt.Sprintf(`GZ-%d"`, i)) {
|
|
|
|
|
missing++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if missing > 0 {
|
|
|
|
|
t.Errorf("压缩归档丢失 %d / %d 条日志", missing, n)
|
|
|
|
|
}
|
|
|
|
|
t.Logf("共 %d 个 .gz 归档,解压后日志完整(%d 条)", len(gzs), n)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 关闭压缩时,归档应保持未压缩的 .log 形态
|
|
|
|
|
func TestSizeSplitWithoutCompress(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(),
|
|
|
|
|
loggerx.SetDir(dir),
|
|
|
|
|
loggerx.SetSizeSplit(4*1024),
|
|
|
|
|
loggerx.SetCompress(false),
|
|
|
|
|
)
|
|
|
|
|
for i := 0; i < 300; i++ {
|
|
|
|
|
l.Infof(context.Background(), "RAW-%d", i)
|
|
|
|
|
}
|
|
|
|
|
if err := l.Close(); err != nil {
|
|
|
|
|
t.Fatalf("Close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz"))
|
|
|
|
|
if len(gzs) != 0 {
|
|
|
|
|
t.Errorf("SetCompress(false) 仍然产生了 %d 个 .gz", len(gzs))
|
|
|
|
|
}
|
|
|
|
|
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
|
|
|
|
|
if len(files) < 2 {
|
|
|
|
|
t.Errorf("期望滚出多个归档文件,实际 %d 个", len(files))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 滚动过程中不能丢日志:所有序号都应在 .log 或 .gz 里找得到
|
|
|
|
|
func TestSizeSplitNoLoss(t *testing.T) {
|
|
|
|
|
for round := 0; round < 3; round++ {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(),
|
|
|
|
|
loggerx.SetDir(dir),
|
|
|
|
|
loggerx.SetSizeSplit(8*1024),
|
|
|
|
|
)
|
|
|
|
|
const n = 600
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
l.Infof(context.Background(), "NOLOSS-%d", i)
|
|
|
|
|
}
|
|
|
|
|
if err := l.Close(); err != nil {
|
|
|
|
|
t.Fatalf("第 %d 轮 Close: %v", round, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content := readAllLogs(t, dir) + readAllGz(t, dir)
|
|
|
|
|
missing := 0
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
if !strings.Contains(content, fmt.Sprintf(`NOLOSS-%d"`, i)) {
|
|
|
|
|
missing++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if missing > 0 {
|
|
|
|
|
t.Errorf("第 %d 轮:按大小切割丢了 %d 条日志", round, missing)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 单条日志就超过上限时不能死循环
|
|
|
|
|
func TestSizeSplitHugeSingleEntry(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
l := loggerx.NewLogger(context.Background(),
|
|
|
|
|
loggerx.SetDir(dir),
|
|
|
|
|
loggerx.SetSizeSplit(1024), // 上限比单条还小
|
|
|
|
|
)
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
defer close(done)
|
|
|
|
|
l.Info(context.Background(), strings.Repeat("H", 8192))
|
|
|
|
|
l.Info(context.Background(), "after-huge")
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
case <-time.After(10 * time.Second):
|
|
|
|
|
t.Fatal("单条超长日志导致卡死(可能是滚动死循环)")
|
|
|
|
|
}
|
|
|
|
|
if err := l.Close(); err != nil {
|
|
|
|
|
t.Fatalf("Close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(readAllLogs(t, dir)+readAllGz(t, dir), "after-huge") {
|
|
|
|
|
t.Error("超长日志之后的那条日志丢失了")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 重启场景:文件已经满了,新进程开起来应该归档旧文件而不是永远写同一个
|
|
|
|
|
func TestSizeSplitOnRestart(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
opts := []loggerx.Option{loggerx.SetDir(dir), loggerx.SetSizeSplit(4 * 1024)}
|
|
|
|
|
|
|
|
|
|
l1 := loggerx.NewLogger(context.Background(), opts...)
|
|
|
|
|
for i := 0; i < 200; i++ {
|
|
|
|
|
l1.Infof(context.Background(), "RUN1-%d", i)
|
|
|
|
|
}
|
|
|
|
|
if err := l1.Close(); err != nil {
|
|
|
|
|
t.Fatalf("l1.Close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
l2 := loggerx.NewLogger(context.Background(), opts...)
|
|
|
|
|
for i := 0; i < 200; i++ {
|
|
|
|
|
l2.Infof(context.Background(), "RUN2-%d", i)
|
|
|
|
|
}
|
|
|
|
|
if err := l2.Close(); err != nil {
|
|
|
|
|
t.Fatalf("l2.Close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content := readAllLogs(t, dir) + readAllGz(t, dir)
|
|
|
|
|
for i := 0; i < 200; i++ {
|
|
|
|
|
if !strings.Contains(content, fmt.Sprintf(`RUN2-%d"`, i)) {
|
|
|
|
|
t.Fatalf("重启后 RUN2-%d 丢失", i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 归档序号不应互相覆盖:RUN1 与 RUN2 都应该在
|
|
|
|
|
if !strings.Contains(content, `RUN1-0"`) {
|
|
|
|
|
t.Error("第一轮运行的日志被覆盖了")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 00:14:30 +08:00
|
|
|
// jsonLinesValid 校验每行都是合法 JSON(空行忽略)
|
|
|
|
|
func jsonLinesValid(t *testing.T, content string) bool {
|
|
|
|
|
t.Helper()
|
|
|
|
|
lines := strings.Split(strings.TrimSpace(content), "\n")
|
|
|
|
|
if len(lines) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
if line == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var v map[string]any
|
|
|
|
|
if err := json.Unmarshal([]byte(line), &v); err != nil {
|
|
|
|
|
t.Logf("第 %d 行不是合法 JSON: %v\n%s", i+1, err, line)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if v["level"] == nil {
|
|
|
|
|
t.Logf("第 %d 行缺少 level 字段: %s", i+1, line)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 21:53:37 +08:00
|
|
|
// readAllGz 解开目录下所有 .gz 归档
|
|
|
|
|
func readAllGz(t *testing.T, dir string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
gzs, _ := filepath.Glob(filepath.Join(dir, "*.gz"))
|
|
|
|
|
for _, g := range gzs {
|
|
|
|
|
f, err := os.Open(g)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("打开 %s: %v", g, err)
|
|
|
|
|
}
|
|
|
|
|
zr, err := gzip.NewReader(f)
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = f.Close()
|
|
|
|
|
t.Fatalf("%s 不是合法 gzip: %v", g, err)
|
|
|
|
|
}
|
|
|
|
|
b, err := io.ReadAll(zr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("解压 %s: %v", g, err)
|
|
|
|
|
}
|
|
|
|
|
_ = zr.Close()
|
|
|
|
|
_ = f.Close()
|
|
|
|
|
sb.Write(b)
|
|
|
|
|
}
|
|
|
|
|
return sb.String()
|
|
|
|
|
}
|