优化一些问题&修复一些BUG
This commit is contained in:
@@ -8,11 +8,14 @@ import (
|
|||||||
"github.com/yuninks/loggerx/middleware"
|
"github.com/yuninks/loggerx/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 运行: go run ./example/gin_server
|
||||||
// curl --location '127.0.0.1:8080/ping'
|
// curl --location '127.0.0.1:8080/ping'
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
log := loggerx.NewLogger(ctx, loggerx.SetToConsole())
|
log := loggerx.NewLogger(ctx, loggerx.SetToConsole())
|
||||||
|
// 进程退出前落盘并释放文件句柄
|
||||||
|
defer log.Close()
|
||||||
|
|
||||||
g := gin.Default()
|
g := gin.Default()
|
||||||
|
|
||||||
@@ -25,5 +28,4 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
g.Run(":8080")
|
g.Run(":8080")
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ func main() {
|
|||||||
loggerx.SetEscapeHTML(false),
|
loggerx.SetEscapeHTML(false),
|
||||||
// loggerx.SetExpandData("ddd", "dddd"),
|
// loggerx.SetExpandData("ddd", "dddd"),
|
||||||
)
|
)
|
||||||
|
defer log.Close()
|
||||||
log.WriteAsync().Info(ctx, "{ \"a\": 1, \"b\": 2 }")
|
log.WriteAsync().Info(ctx, "{ \"a\": 1, \"b\": 2 }")
|
||||||
log.Info(ctx, "哈哈哈2")
|
log.Info(ctx, "哈哈哈2")
|
||||||
log.Info(ctx, "哈哈哈2")
|
log.Info(ctx, "哈哈哈2")
|
||||||
|
|||||||
+151
-48
@@ -1,7 +1,9 @@
|
|||||||
package loggerx
|
package loggerx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,99 +11,200 @@ import (
|
|||||||
|
|
||||||
// 文件操作
|
// 文件操作
|
||||||
|
|
||||||
|
// 每个文件的写缓冲大小:越大越省系统调用,代价是崩溃时可能丢最后一批日志
|
||||||
|
const fileBufSize = 32 * 1024
|
||||||
|
|
||||||
|
// logFile 一个已打开的日志文件句柄
|
||||||
|
// 自己管理写缓冲(不用 bufio):bufio.Writer 在底层短写时会永久置位内部错误状态,
|
||||||
|
// 之后所有写入和 Flush 都会失败,已攒下的一批日志会整批丢掉
|
||||||
|
type logFile struct {
|
||||||
|
file *os.File
|
||||||
|
buf []byte
|
||||||
|
pending int
|
||||||
|
fileName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write 写入缓冲,满了就落盘一次
|
||||||
|
func (f *logFile) Write(b []byte) (int, error) {
|
||||||
|
written := 0
|
||||||
|
for len(b) > 0 {
|
||||||
|
if f.pending == len(f.buf) {
|
||||||
|
if err := f.Flush(); err != nil {
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := copy(f.buf[f.pending:], b)
|
||||||
|
f.pending += n
|
||||||
|
written += n
|
||||||
|
b = b[n:]
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFull 保证把整块数据写进文件(允许底层短写,循环补齐)
|
||||||
|
func (f *logFile) writeFull(p []byte) error {
|
||||||
|
for len(p) > 0 {
|
||||||
|
n, err := f.file.Write(p)
|
||||||
|
p = p[n:]
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 把缓冲写入文件(不做 fsync)
|
||||||
|
func (f *logFile) Flush() error {
|
||||||
|
if f.pending == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := f.writeFull(f.buf[:f.pending]); err != nil {
|
||||||
|
// 保留未写成功的部分,下次继续
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.pending = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync 把缓冲刷到文件并 fsync
|
||||||
|
func (f *logFile) Sync() error {
|
||||||
|
var errs []error
|
||||||
|
if err := f.Flush(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
if f.file != nil {
|
||||||
|
if err := f.file.Sync(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return joinErrors(errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 落盘并关闭文件
|
||||||
|
func (f *logFile) Close() error {
|
||||||
|
return joinErrors([]error{f.Sync(), f.file.Close()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinErrors 汇总错误,Go 1.20+ 用 errors.Join
|
||||||
|
func joinErrors(errs []error) error {
|
||||||
|
var real []error
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
real = append(real, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(real) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Join(real...)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取最新的文件名
|
// 获取最新的文件名
|
||||||
func (l *Logger) nowFileName(event string) string {
|
func (l *Logger) nowFileName(event string) string {
|
||||||
// ioc, _ := time.LoadLocation("Asia/Shanghai")
|
|
||||||
// timeDir := fmt.Sprint(time.Now().In(ioc).Format("2006/01/02/15")) // 2006-01-02 15:04:05
|
|
||||||
|
|
||||||
prefix := ""
|
prefix := ""
|
||||||
|
|
||||||
switch l.option.fileSplit {
|
switch l.option.fileSplit {
|
||||||
case FileSplitTimeA:
|
case FileSplitTimeA:
|
||||||
// (年/月/日/时)
|
// (年/月/日/时)
|
||||||
prefix = fmt.Sprint(time.Now().In(l.option.timeZone).Format("2006/01/02/15")) // 2006-01-02 15:04:05
|
prefix = time.Now().In(l.option.timeZone).Format("2006/01/02/15")
|
||||||
case FileSplitTimeB:
|
case FileSplitTimeB:
|
||||||
// (年/月/日)
|
// (年/月/日)
|
||||||
prefix = fmt.Sprint(time.Now().In(l.option.timeZone).Format("2006/01/02")) // 2006-01-02 15:04:05
|
prefix = time.Now().In(l.option.timeZone).Format("2006/01/02")
|
||||||
case FileSplitTimeC:
|
case FileSplitTimeC:
|
||||||
// (年/月-日)
|
// (年/月-日)
|
||||||
prefix = fmt.Sprint(time.Now().In(l.option.timeZone).Format("2006/01-02")) // 2006-01-02 15:04:05
|
prefix = time.Now().In(l.option.timeZone).Format("2006/01-02")
|
||||||
case FileSplitTimeD:
|
case FileSplitTimeD:
|
||||||
// (年-月-日-时)
|
// (年-月-日-时)
|
||||||
prefix = fmt.Sprint(time.Now().In(l.option.timeZone).Format("2006-01-02-15")) // 2006-01-02 15:04:05
|
prefix = time.Now().In(l.option.timeZone).Format("2006-01-02-15")
|
||||||
case FileSplitTimeE:
|
case FileSplitTimeE:
|
||||||
// (年-月-日)
|
// (年-月-日)
|
||||||
prefix = fmt.Sprint(time.Now().In(l.option.timeZone).Format("2006-01-02")) // 2006-01-02 15:04:05
|
prefix = time.Now().In(l.option.timeZone).Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
prefix = prefix + "_"
|
prefix = prefix + "_"
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeDir := fmt.Sprint(time.Now().Local().Format("2006/01/02")) // 2006-01-02 15:04:05
|
|
||||||
if l.channel != "" {
|
if l.channel != "" {
|
||||||
prefix = l.channel + "/" + prefix
|
prefix = l.channel + "/" + prefix
|
||||||
}
|
}
|
||||||
path := l.option.dir + "/" + prefix + event + ".log"
|
return l.option.dir + "/" + prefix + event + ".log"
|
||||||
// fmt.Println(filepath.Abs(path))
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新建文件
|
// 新建文件
|
||||||
func (l *Logger) getFile(event string, isRefresh bool) (*os.File, error) {
|
func (l *Logger) getFile(event string) (*logFile, error) {
|
||||||
f := l.loadFile(event)
|
key := fileKey{channel: l.channel, event: event}
|
||||||
if f != nil && !isRefresh {
|
|
||||||
|
if f := l.loadFile(key); f != nil {
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
// 双检:可能在等锁期间已经被别的 goroutine 建好了
|
||||||
|
if f := l.loadFileLocked(key); f != nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
fileName := l.nowFileName(event)
|
fileName := l.nowFileName(event)
|
||||||
|
|
||||||
dir, _ := filepath.Split(fileName) // 识别目录与文件
|
// 识别目录与文件,创建多层目录(已存在不报错)
|
||||||
os.MkdirAll(dir, os.ModePerm) // 创建多层目录,如果存在不会报错
|
if dir := filepath.Dir(fileName); dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 打开该文件,如果不存在则创建
|
|
||||||
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 打开失败,尝试创建
|
return nil, fmt.Errorf("打开日志文件失败 %s: %w", fileName, err)
|
||||||
fmt.Println("打开日志文件失败")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 关闭原来的
|
|
||||||
if f != nil {
|
|
||||||
closeFile(f)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l.filePath.Store(l.channel, &filePath{
|
lf := &logFile{
|
||||||
file: file,
|
file: file,
|
||||||
|
buf: make([]byte, fileBufSize),
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
})
|
}
|
||||||
|
l.filePath[key] = lf
|
||||||
|
|
||||||
return file, nil
|
return lf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载文件
|
// 加载文件(读锁)
|
||||||
func (l *Logger) loadFile(event string) *os.File {
|
func (l *Logger) loadFile(key fileKey) *logFile {
|
||||||
val, ok := l.filePath.Load(l.channel)
|
l.mu.RLock()
|
||||||
if !ok {
|
defer l.mu.RUnlock()
|
||||||
return nil
|
return l.loadFileLocked(key)
|
||||||
}
|
|
||||||
f := val.(*filePath)
|
|
||||||
if f == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if f.fileName != l.nowFileName(event) {
|
|
||||||
// 原来的文件需关闭
|
|
||||||
closeFile(f.file)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return f.file
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭文件
|
// loadFileLocked 调用方必须已持有锁
|
||||||
func closeFile(f *os.File) error {
|
func (l *Logger) loadFileLocked(key fileKey) *logFile {
|
||||||
f.Sync()
|
f, ok := l.filePath[key]
|
||||||
return f.Close()
|
if !ok || f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建
|
||||||
|
if f.fileName != l.nowFileName(key.event) {
|
||||||
|
delete(l.filePath, key)
|
||||||
|
f.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// close 关闭所有文件句柄(Close 专用)
|
||||||
|
func (l *Logger) close() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for key, f := range l.filePath {
|
||||||
|
errs = append(errs, f.Close())
|
||||||
|
delete(l.filePath, key)
|
||||||
|
}
|
||||||
|
return joinErrors(errs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,84 +7,142 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (l *Logger) logger(ctx context.Context, event string, v ...any) {
|
func (l *Logger) logger(ctx context.Context, event string, v ...any) {
|
||||||
pc, file, line, _ := runtime.Caller(2)
|
// 调用方可能是 log 包(Logger.Write -> logger),所以这里取第 2 层
|
||||||
// fmt.Println("runtime.Caller", pc, file, line, ok)
|
pc, file, line, ok := runtime.Caller(2)
|
||||||
|
|
||||||
basePath, _ := filepath.Abs("./")
|
var funcName string
|
||||||
basePath = strings.ReplaceAll(basePath, "\\", "/")
|
if ok && pc != 0 {
|
||||||
// fmt.Println("basePath", basePath)
|
if fn := runtime.FuncForPC(pc); fn != nil {
|
||||||
|
funcName = strings.TrimPrefix(filepath.Ext(fn.Name()), ".")
|
||||||
file = strings.TrimPrefix(file, basePath)
|
}
|
||||||
|
if basePath, err := filepath.Abs("./"); err == nil {
|
||||||
funcName := runtime.FuncForPC(pc).Name()
|
basePath = strings.ReplaceAll(basePath, "\\", "/")
|
||||||
funcName = filepath.Ext(funcName)
|
file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), basePath)
|
||||||
funcName = strings.TrimPrefix(funcName, ".")
|
}
|
||||||
|
}
|
||||||
// by, _ := json.Marshal(v)
|
|
||||||
|
|
||||||
nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000")
|
nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000")
|
||||||
|
|
||||||
traceId, _ := ctx.Value(l.option.traceField).(string)
|
// ctx 允许为 nil,不能直接调 Value
|
||||||
|
var traceId string
|
||||||
// writeStr := "[" + event + "]" + nowTime + " " + file + ":" + fmt.Sprintf("%d", line) + " " + funcName + " gid:" + getGID() + " " + traceId + " @data@: " + string(by) + "\n\n"
|
if ctx != nil {
|
||||||
|
traceId, _ = ctx.Value(l.option.traceField).(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// error 转成带堆栈的字符串(%+v 会带上 pkg/errors 的调用栈)
|
||||||
for idx, val := range v {
|
for idx, val := range v {
|
||||||
if _, ok := val.(error); ok {
|
if _, isErr := val.(error); isErr {
|
||||||
v[idx] = fmt.Sprintf("%+v", val)
|
v[idx] = fmt.Sprintf("%+v", val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var gid string
|
||||||
|
if l.option.isGid {
|
||||||
|
gid = getGID()
|
||||||
|
}
|
||||||
|
|
||||||
fd := FormatData{
|
fd := FormatData{
|
||||||
Time: nowTime,
|
Time: nowTime,
|
||||||
File: file + ":" + fmt.Sprintf("%d", line),
|
File: file + ":" + strconv.Itoa(line),
|
||||||
Func: funcName,
|
Func: funcName,
|
||||||
Gid: getGID(),
|
Gid: gid,
|
||||||
Content: v,
|
Content: v,
|
||||||
TraceId: traceId,
|
TraceId: traceId,
|
||||||
Expand: l.option.expandData,
|
Expand: l.option.expandData,
|
||||||
}
|
}
|
||||||
|
|
||||||
if event == "error" {
|
fdb := marshalLog(fd, nowTime, file, line, l.option.escapeHTML, gid, traceId)
|
||||||
// fd.Stack = string(debug.Stack())
|
|
||||||
}
|
|
||||||
|
|
||||||
var fdb []byte
|
fdb = append([]byte("\n["+event+"]"), fdb...)
|
||||||
if l.option.escapeHTML {
|
|
||||||
fdb, _ = json.Marshal(fd)
|
_, _ = l.write(event, fdb)
|
||||||
|
|
||||||
|
if l.option.errorToInfo && event == "error" {
|
||||||
|
_, _ = l.write("info", fdb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalLog 序列化日志内容
|
||||||
|
// json 序列化失败时(chan/func 等不可序列化类型,或循环引用)不能静默丢弃,
|
||||||
|
// 否则只会留下一行没有内容的 "["+event+"]",需要降级成可读的文本
|
||||||
|
func marshalLog(fd FormatData, nowTime, file string, line int, escapeHTML bool, gid, traceId string) []byte {
|
||||||
|
var (
|
||||||
|
b []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if escapeHTML {
|
||||||
|
b, err = json.Marshal(fd)
|
||||||
} else {
|
} else {
|
||||||
// 非转义
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
encoder := json.NewEncoder(&buf)
|
encoder := json.NewEncoder(&buf)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
encoder.Encode(fd)
|
err = encoder.Encode(fd)
|
||||||
fdb = buf.Bytes()
|
b = bytes.TrimRight(buf.Bytes(), "\n")
|
||||||
}
|
}
|
||||||
fdb = bytes.TrimRight(fdb, "\n")
|
if err == nil {
|
||||||
|
return b
|
||||||
ff := []byte("\n[" + event + "]")
|
|
||||||
fdb = append(ff, fdb...)
|
|
||||||
|
|
||||||
l.write(event, fdb)
|
|
||||||
|
|
||||||
if l.option.errorToInfo && event == "error" {
|
|
||||||
l.write("info", fdb)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// log.Println("" + string(by))
|
// 降级:逐字段尝试,失败的内容用 %+v 兜底
|
||||||
|
fallback := FormatData{
|
||||||
|
Time: nowTime,
|
||||||
|
File: file + ":" + strconv.Itoa(line),
|
||||||
|
Func: fd.Func,
|
||||||
|
Gid: gid,
|
||||||
|
TraceId: traceId,
|
||||||
|
Content: stringifyValues(fd.Content),
|
||||||
|
Expand: fd.Expand,
|
||||||
|
}
|
||||||
|
if fb, ferr := json.Marshal(fallback); ferr == nil {
|
||||||
|
return fb
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(fmt.Sprintf(`{"time":%q,"content":%q,"marshal_error":%q}`,
|
||||||
|
nowTime, fmt.Sprintf("%+v", fd.Content), err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringifyValues 把不可序列化的值转成字符串
|
||||||
|
func stringifyValues(v any) []string {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(val))
|
||||||
|
for _, item := range val {
|
||||||
|
out = append(out, fmt.Sprintf("%+v", item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return []string{fmt.Sprintf("%+v", val)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getGID 获取当前 goroutine 的 ID
|
||||||
|
func getGID() string {
|
||||||
|
b := make([]byte, 64)
|
||||||
|
b = b[:runtime.Stack(b, false)]
|
||||||
|
b = bytes.TrimPrefix(b, []byte("goroutine "))
|
||||||
|
idx := bytes.IndexByte(b, ' ')
|
||||||
|
if idx < 0 {
|
||||||
|
// 解析失败时不要 panic
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return string(b[:idx])
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormatData struct {
|
type FormatData struct {
|
||||||
Time string `json:"time,omitempty"`
|
Time string `json:"time,omitempty"`
|
||||||
File string `json:"file,omitempty"`
|
File string `json:"file,omitempty"`
|
||||||
Func string `json:"func,omitempty"`
|
Func string `json:"func,omitempty"`
|
||||||
Gid string `json:"gid,omitempty"`
|
Gid string `json:"gid,omitempty"`
|
||||||
Content interface{} `json:"content,omitempty"`
|
Content interface{} `json:"content,omitempty"`
|
||||||
TraceId string `json:"traceId,omitempty"`
|
TraceId string `json:"traceId,omitempty"`
|
||||||
Stack string `json:"stack,omitempty"`
|
Stack string `json:"stack,omitempty"`
|
||||||
Expand map[string]string `json:"expand,omitempty"`
|
Expand map[string]string `json:"expand,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-37
@@ -5,13 +5,11 @@ package loggerx
|
|||||||
// desc: 日志封装类
|
// desc: 日志封装类
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
// "sync_log/global"
|
// "sync_log/global"
|
||||||
@@ -22,16 +20,37 @@ import (
|
|||||||
// 需要实现io.Writer接口
|
// 需要实现io.Writer接口
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
filePath *sync.Map // filePath
|
filePath map[fileKey]*logFile // 每个 (channel,event) 一个句柄
|
||||||
mu *sync.Mutex
|
mu *sync.RWMutex // 保护 filePath
|
||||||
|
writeMu *sync.Mutex // 串行化文件写入(bufio 不是并发安全的)
|
||||||
option loggerOption
|
option loggerOption
|
||||||
channel string
|
channel string
|
||||||
writeType writeType // 是否异步落盘,这里作用范围是本条,优先判断这里
|
writeType writeType // 是否异步落盘,这里作用范围是本条,优先判断这里
|
||||||
|
|
||||||
|
closeOnce *sync.Once
|
||||||
|
done chan struct{} // 通用关闭信号(delete / ctx 相关 goroutine 用)
|
||||||
|
workerDone chan struct{} // 异步消费 goroutine 退出信号
|
||||||
|
closing chan struct{} // 异步投递关闭信号:只关一次
|
||||||
|
|
||||||
|
// 异步队列相关的整块状态都挂在指针后面:Logger 会被 Channel()/WriteAsync()
|
||||||
|
// 浅拷贝,如果状态直接放在 Logger 里,拷贝出的实例就会各看各的
|
||||||
|
// (曾经因此让 Close 看不到真正的队列,白等一场、最后一条日志丢失)
|
||||||
|
async *asyncCtl
|
||||||
}
|
}
|
||||||
|
|
||||||
type filePath struct {
|
// asyncCtl 异步队列的共享控制块:所有拷贝共享同一份
|
||||||
file *os.File
|
type asyncCtl struct {
|
||||||
fileName string
|
mu sync.Mutex
|
||||||
|
ch chan cacheData
|
||||||
|
closed bool // 只能读写于 mu 保护下
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileKey 文件缓存的键
|
||||||
|
// 必须同时包含 channel 与 event:文件名由两者共同决定,只按 channel 缓存会
|
||||||
|
// 导致 info/error 互相顶掉句柄,每次写入都 close+open 一次文件
|
||||||
|
type fileKey struct {
|
||||||
|
channel string
|
||||||
|
event string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLogger(ctx context.Context, opts ...Option) *Logger {
|
func NewLogger(ctx context.Context, opts ...Option) *Logger {
|
||||||
@@ -46,11 +65,16 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
l := &Logger{
|
l := &Logger{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
filePath: &sync.Map{},
|
filePath: make(map[fileKey]*logFile),
|
||||||
mu: &sync.Mutex{},
|
mu: &sync.RWMutex{},
|
||||||
option: opt,
|
writeMu: &sync.Mutex{},
|
||||||
writeType: writeTypeDefault,
|
option: opt,
|
||||||
|
writeType: writeTypeDefault,
|
||||||
|
closeOnce: &sync.Once{},
|
||||||
|
done: make(chan struct{}),
|
||||||
|
workerDone: make(chan struct{}),
|
||||||
|
async: &asyncCtl{},
|
||||||
}
|
}
|
||||||
|
|
||||||
log.SetOutput(l)
|
log.SetOutput(l)
|
||||||
@@ -66,21 +90,54 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
|
|||||||
go l.delete()
|
go l.delete()
|
||||||
|
|
||||||
// 强制刷盘
|
// 强制刷盘
|
||||||
|
// 用 done 而不是只监听 ctx:ctx 为 Background 时 Close 也要能把 goroutine 收回去
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
select {
|
||||||
l.MustSync()
|
case <-ctx.Done():
|
||||||
|
l.MustSync()
|
||||||
|
case <-l.done:
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制刷盘
|
// 关闭日志:关闭异步队列、落盘并关闭所有文件句柄
|
||||||
func (l *Logger) MustSync() {
|
// 重复调用安全,调用后本实例的后台 goroutine 会全部退出
|
||||||
l.filePath.Range(func(key, value any) bool {
|
func (l *Logger) Close() error {
|
||||||
f := value.(*filePath)
|
var err error
|
||||||
f.file.Sync()
|
l.closeOnce.Do(func() {
|
||||||
return true
|
// 1. 停投递 + 等消费协程把队列里已入队的任务全部写完
|
||||||
|
l.drainAsync()
|
||||||
|
|
||||||
|
// 2. 在写锁内完成最后一次刷盘与关闭
|
||||||
|
// 此后不会再有写入(投递已关闭,同步写入也要先抢到这把锁)
|
||||||
|
l.writeMu.Lock()
|
||||||
|
syncErr := l.MustSync()
|
||||||
|
closeErr := l.close()
|
||||||
|
l.writeMu.Unlock()
|
||||||
|
err = joinErrors([]error{syncErr, closeErr})
|
||||||
|
|
||||||
|
// 3. 最后再发通用关闭信号,让 delete / ctx 相关 goroutine 退出
|
||||||
|
close(l.done)
|
||||||
})
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制刷盘:只 flush 缓存数据,文件继续可写
|
||||||
|
func (l *Logger) MustSync() error {
|
||||||
|
l.mu.RLock()
|
||||||
|
files := make([]*logFile, 0, len(l.filePath))
|
||||||
|
for _, f := range l.filePath {
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
l.mu.RUnlock()
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, f := range files {
|
||||||
|
errs = append(errs, f.Sync())
|
||||||
|
}
|
||||||
|
return joinErrors(errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Channel(ch string) (r *Logger) {
|
func (l *Logger) Channel(ch string) (r *Logger) {
|
||||||
@@ -147,32 +204,20 @@ func (l *Logger) Warnf(ctx context.Context, format string, v ...any) {
|
|||||||
l.logger(ctx, "warn", s)
|
l.logger(ctx, "warn", s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加固定的内容
|
|
||||||
// func (l *Logger) ContextWithFields(ctx context.Context, v ...any) {
|
|
||||||
// l.logger(ctx, "add", v...)
|
|
||||||
// }
|
|
||||||
// func (l *Logger) Field(key,val string) {
|
|
||||||
// l.logger(nil, "add", key,val)
|
|
||||||
// }
|
|
||||||
|
|
||||||
func getGID() string {
|
|
||||||
b := make([]byte, 64)
|
|
||||||
b = b[:runtime.Stack(b, false)]
|
|
||||||
b = bytes.TrimPrefix(b, []byte("goroutine "))
|
|
||||||
b = b[:bytes.IndexByte(b, ' ')]
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件夹权限
|
// 验证文件夹权限
|
||||||
// 根文件夹如果不存在则创建
|
// 根文件夹如果不存在则创建
|
||||||
func checkDir(dir string) bool {
|
func checkDir(dir string) bool {
|
||||||
if _, err := os.Stat(dir); err != nil {
|
if _, err := os.Stat(dir); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
log.Println("创建文件夹失败", err)
|
log.Println("创建文件夹失败", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
// 目标存在但不可访问(权限不足等),此时返回 false 才是这个函数的本意
|
||||||
|
log.Println("日志文件夹不可用", dir, err)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,35 +10,48 @@ import (
|
|||||||
// 监听dir文件夹的所有文件,循环查找是否有超过days天的文件,删除掉
|
// 监听dir文件夹的所有文件,循环查找是否有超过days天的文件,删除掉
|
||||||
|
|
||||||
func (l *Logger) delete() {
|
func (l *Logger) delete() {
|
||||||
|
|
||||||
tick := time.NewTicker(time.Hour)
|
tick := time.NewTicker(time.Hour)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
err := l.walkAndDel()
|
// 后台清理不能把整个进程带崩
|
||||||
if err != nil {
|
func() {
|
||||||
fmt.Println(err)
|
defer func() {
|
||||||
}
|
if r := recover(); r != nil {
|
||||||
|
fmt.Println("清理日志文件异常:", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := l.walkAndDel(); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
case <-l.ctx.Done():
|
case <-l.ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-l.done:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) walkAndDel() error {
|
func (l *Logger) walkAndDel() error {
|
||||||
|
// 没设置删除天数(<=0 表示不删除)
|
||||||
|
if l.option.days <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().AddDate(0, 0, -l.option.days)
|
||||||
|
|
||||||
err := filepath.Walk(l.option.dir, func(path string, info os.FileInfo, err error) error {
|
err := filepath.Walk(l.option.dir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 单个文件出错不应该中断整个遍历
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 没设置删除天数
|
|
||||||
if l.option.days <= 0 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断最后修改时间是否大于3天
|
// 最后修改时间在保留期内,跳过
|
||||||
if info.ModTime().After(time.Now().AddDate(0, 0, -l.option.days)) {
|
if info.ModTime().After(deadline) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,14 +59,18 @@ func (l *Logger) walkAndDel() error {
|
|||||||
if !isEmptyDir(path) {
|
if !isEmptyDir(path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
} else if filepath.Ext(path) != ".log" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
ext := filepath.Ext(path)
|
|
||||||
if ext != ".log" {
|
// 删除文件 / 空目录
|
||||||
|
if rerr := os.Remove(path); rerr != nil {
|
||||||
|
// 正被写入的文件在 Windows 上删不掉,等下一轮再试,不要终止遍历
|
||||||
|
fmt.Println("删除文件失败", path, rerr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// 删除文件
|
|
||||||
fmt.Println("删除文件", path)
|
fmt.Println("删除文件", path)
|
||||||
return os.Remove(path)
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|||||||
+122
-35
@@ -2,71 +2,158 @@ package loggerx
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"log"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 异步队列容量,跟随实例
|
||||||
|
const asyncQueueSize = 1000
|
||||||
|
|
||||||
|
// Close 等待异步日志落盘的最长时间
|
||||||
|
const closeDrainTimeout = 5 * time.Second
|
||||||
|
|
||||||
// 写入,需要判断同步还是异步
|
// 写入,需要判断同步还是异步
|
||||||
func (l *Logger) write(event string, b []byte) (n int, err error) {
|
func (l *Logger) write(event string, b []byte) (n int, err error) {
|
||||||
if l.toAsync(event, b) {
|
if l.toAsync(event, b) {
|
||||||
// fmt.Println("异步写入")
|
|
||||||
return len(b), nil
|
return len(b), nil
|
||||||
}
|
}
|
||||||
return l.store(event, b)
|
return l.store(event, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实际的存储
|
// 实际的存储
|
||||||
|
// 必须满足 io.Writer 契约:成功时 n == len(b),否则调用方(log / io.MultiWriter)
|
||||||
|
// 会认为发生了短写并把日志吞掉
|
||||||
func (l *Logger) store(event string, b []byte) (n int, err error) {
|
func (l *Logger) store(event string, b []byte) (n int, err error) {
|
||||||
|
|
||||||
if l.option.isPrintFile {
|
if l.option.isPrintFile {
|
||||||
f, err := l.getFile(event, false)
|
// 串行化写入:句柄缓冲区不是并发安全的,
|
||||||
|
// 异步消费协程与同步调用可能同时写同一个句柄
|
||||||
|
l.writeMu.Lock()
|
||||||
|
n, err = l.storeFile(event, b)
|
||||||
|
l.writeMu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err = f.Write(b)
|
|
||||||
if err == nil && n < len(b) {
|
|
||||||
err = io.ErrShortWrite
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
// 强制更新 & 再次写入
|
|
||||||
f, err := l.getFile(event, true)
|
|
||||||
if err == nil {
|
|
||||||
f.Write(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(l.option.drivers) > 0 {
|
// 驱动(控制台等)的写入失败不改变对调用方的契约
|
||||||
io.MultiWriter(l.option.drivers...).Write(b)
|
_, _ = l.writeDrivers(b)
|
||||||
}
|
|
||||||
return n, err
|
return len(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var chanStore = make(chan cacheData, 1000)
|
// storeFile 写入日志文件
|
||||||
var chanOnce = sync.Once{}
|
func (l *Logger) storeFile(event string, b []byte) (int, error) {
|
||||||
|
f, err := l.getFile(event)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := f.Write(b)
|
||||||
|
if err == nil && n < len(b) {
|
||||||
|
err = io.ErrShortWrite
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入失败:丢弃这个句柄,落到磁盘后按最新文件名重开一次再写
|
||||||
|
// 只重试一次,避免原实现在短写时反复重开文件
|
||||||
|
l.discardFile(event, f)
|
||||||
|
if nf, nerr := l.getFile(event); nerr == nil {
|
||||||
|
if n2, err2 := nf.Write(b); err2 == nil && n2 == len(b) {
|
||||||
|
return n2, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入额外的驱动(控制台 / 自定义 writer)
|
||||||
|
func (l *Logger) writeDrivers(b []byte) (int, error) {
|
||||||
|
if len(l.option.drivers) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return io.MultiWriter(l.option.drivers...).Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// discardFile 关闭文件并从缓存中移除,使下次写入重新打开
|
||||||
|
func (l *Logger) discardFile(event string, f *logFile) {
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
key := fileKey{channel: l.channel, event: event}
|
||||||
|
if cur, ok := l.filePath[key]; ok && cur == f {
|
||||||
|
delete(l.filePath, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步队列的任务
|
||||||
type cacheData struct {
|
type cacheData struct {
|
||||||
logger *Logger
|
Event string
|
||||||
Event string
|
Data []byte
|
||||||
Data []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toAsync 尝试异步写入
|
||||||
|
// 返回 true 表示已经交给异步队列,返回 false 表示需要同步写入
|
||||||
func (l *Logger) toAsync(event string, b []byte) bool {
|
func (l *Logger) toAsync(event string, b []byte) bool {
|
||||||
chanOnce.Do(func() {
|
|
||||||
go func() {
|
|
||||||
for val := range chanStore {
|
|
||||||
val.logger.store(val.Event, val.Data)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
})
|
|
||||||
|
|
||||||
if l.writeType == writeTypeSync || // 指定同步模式
|
if l.writeType == writeTypeSync || // 指定同步模式
|
||||||
(l.writeType == writeTypeDefault && l.option.writeType != writeTypeAsync) { // 默认同步模式
|
(l.writeType == writeTypeDefault && l.option.writeType != writeTypeAsync) { // 默认同步模式
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为了避免丢失,还是要阻塞等待
|
// 整段「检查开关 + 入队」都在同一把锁内:Close 也拿这把锁来停止投递,
|
||||||
chanStore <- cacheData{l, event, b}
|
// 这样 Close 拿到锁时就能确定「要么这条还没入队、要么已经完整入队」。
|
||||||
|
// 否则消费协程可能先看到空队列就退出,把还在路上的这条日志整条丢掉。
|
||||||
|
// 队列满时这里会阻塞,但消费者是独立 goroutine 且不需要这把锁,不会死锁
|
||||||
|
l.async.mu.Lock()
|
||||||
|
defer l.async.mu.Unlock()
|
||||||
|
|
||||||
|
if l.async.closed {
|
||||||
|
// 已开始关闭:退化为同步写入
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if l.async.ch == nil {
|
||||||
|
l.async.ch = make(chan cacheData, asyncQueueSize)
|
||||||
|
go l.asyncWorker(l.async.ch)
|
||||||
|
}
|
||||||
|
l.async.ch <- cacheData{Event: event, Data: b}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asyncWorker 消费异步队列,直到队列被关闭
|
||||||
|
func (l *Logger) asyncWorker(q chan cacheData) {
|
||||||
|
defer close(l.workerDone)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Println("loggerx: 异步写入协程异常:", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for val := range q {
|
||||||
|
_, _ = l.store(val.Event, val.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAsync 停止投递、关闭队列,并等消费协程把剩余任务全部写完
|
||||||
|
func (l *Logger) drainAsync() {
|
||||||
|
// 持锁关闭投递:此后 toAsync 一律退化为同步写入
|
||||||
|
l.async.mu.Lock()
|
||||||
|
l.async.closed = true
|
||||||
|
q := l.async.ch
|
||||||
|
l.async.mu.Unlock()
|
||||||
|
|
||||||
|
if q == nil {
|
||||||
|
// 从未启用过异步写入,没有后台 goroutine 需要等
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭队列并等消费者把缓冲里的任务全部处理完。
|
||||||
|
// 这一步必须真的等到 workerDone:否则 Close 会在消费协程还在写缓冲时
|
||||||
|
// 就刷盘并关闭文件,最后几条日志会连着句柄一起丢掉
|
||||||
|
close(q)
|
||||||
|
select {
|
||||||
|
case <-l.workerDone:
|
||||||
|
case <-time.After(closeDrainTimeout):
|
||||||
|
log.Println("loggerx: 等待异步日志落盘超时")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user