From 01c3012a8a0b9f12bbc63a43544dafef50bd5966 Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Thu, 9 Jul 2026 20:36:52 +0800 Subject: [PATCH] feat(log): support installing a custom logger via SetLogger Add log.SetLogger / tableau.SetLogger to let callers plug in their own Printf-style logger (e.g. *zap.SugaredLogger, or a thin adapter around slog/logrus) as tableau's log destination, via a new customdriver. Once installed, subsequent log.Init calls (triggered internally by GenProto/GenConf) no longer override the custom driver, only updating the log level used for LevelEnabled. Also rename the built-in Logger struct to sugaredLogger to avoid naming collision with the new exported Logger interface, and trim a debug log in load.go that printed the entire patched message via %s. --- load/load.go | 2 +- log/driver/customdriver/customdriver.go | 100 +++++++++++++++ log/log.go | 47 ++++++- log/log_test.go | 2 +- log/logger.go | 95 ++++---------- log/logger_test.go | 4 +- log/setlogger_test.go | 160 ++++++++++++++++++++++++ tableau.go | 17 +++ 8 files changed, 349 insertions(+), 78 deletions(-) create mode 100644 log/driver/customdriver/customdriver.go create mode 100644 log/setlogger_test.go diff --git a/load/load.go b/load/load.go index 8bd840f8..e7b611dd 100644 --- a/load/load.go +++ b/load/load.go @@ -120,7 +120,7 @@ func loadMessagerWithPatch(msg proto.Message, path string, fmt format.Format, pa default: return xerrors.Newf("unknown patch type: %v", patch) } - log.Debugf("patched(%s) %s by %v: %s", patch, name, patchPaths, msg) + log.Debugf("patched(%s) %s by %v", patch, name, patchPaths) return nil } diff --git a/log/driver/customdriver/customdriver.go b/log/driver/customdriver/customdriver.go new file mode 100644 index 00000000..ffe0ad12 --- /dev/null +++ b/log/driver/customdriver/customdriver.go @@ -0,0 +1,100 @@ +// Package customdriver adapts a user-provided [Logger] to the +// driver.Driver interface, so that tableau's log output can be routed into +// an external logging system. +package customdriver + +import ( + "fmt" + "strings" + + "github.com/tableauio/tableau/log/core" + "github.com/tableauio/tableau/log/driver" +) + +var _ driver.Driver = (*CustomDriver)(nil) + +// Logger is a minimal Printf-style logging interface, satisfied directly by +// most loggers (e.g. *zap.SugaredLogger) or via a thin adapter (e.g. slog). +type Logger interface { + Debugf(format string, args ...any) + Infof(format string, args ...any) + Warnf(format string, args ...any) + Errorf(format string, args ...any) + DPanicf(format string, args ...any) + Panicf(format string, args ...any) + Fatalf(format string, args ...any) +} + +// CustomDriver forwards core.Record entries to a user-provided Logger. +type CustomDriver struct { + logger Logger +} + +// New creates a CustomDriver that forwards log records to logger. +func New(logger Logger) *CustomDriver { + return &CustomDriver{logger: logger} +} + +func (d *CustomDriver) Name() string { + return "custom" +} + +// GetLevel always returns core.DebugLevel; filtering is delegated to logger. +func (d *CustomDriver) GetLevel(logger string) core.Level { + return core.DebugLevel +} + +// Print normalizes r (from a Xxx/Xxxf/Xxxw call) into one message and +// forwards it via the corresponding Xxxf method. +func (d *CustomDriver) Print(r *core.Record) { + msg := formatRecord(r) + switch r.Level { + case core.DebugLevel: + d.logger.Debugf("%s", msg) + case core.InfoLevel: + d.logger.Infof("%s", msg) + case core.WarnLevel: + d.logger.Warnf("%s", msg) + case core.ErrorLevel: + d.logger.Errorf("%s", msg) + case core.DPanicLevel: + d.logger.DPanicf("%s", msg) + case core.PanicLevel: + d.logger.Panicf("%s", msg) + case core.FatalLevel: + d.logger.Fatalf("%s", msg) + } +} + +// formatRecord renders r's format/args and any key-value pairs into one message. +func formatRecord(r *core.Record) string { + var msg string + if r.Format != nil && *r.Format != "" { + msg = fmt.Sprintf(*r.Format, r.Args...) + } else if len(r.Args) > 0 { + msg = fmt.Sprint(r.Args...) + } + if len(r.KVs) > 0 { + if msg != "" { + msg += " " + } + msg += formatKVs(r.KVs) + } + return msg +} + +// formatKVs renders kvs as "key1=value1 key2=value2". +func formatKVs(kvs []any) string { + var b strings.Builder + for i := 0; i < len(kvs); i += 2 { + if i > 0 { + b.WriteByte(' ') + } + if i+1 < len(kvs) { + fmt.Fprintf(&b, "%v=%v", kvs[i], kvs[i+1]) + } else { + fmt.Fprintf(&b, "%v=(MISSING)", kvs[i]) + } + } + return b.String() +} diff --git a/log/log.go b/log/log.go index 8da28245..d71b7997 100644 --- a/log/log.go +++ b/log/log.go @@ -6,37 +6,74 @@ package log import ( "fmt" + "sync" "github.com/tableauio/tableau/log/core" "github.com/tableauio/tableau/log/driver" + "github.com/tableauio/tableau/log/driver/customdriver" "github.com/tableauio/tableau/log/driver/zapdriver" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) -var defaultLogger *Logger +// Logger is the minimal Printf-style logging interface expected by +// SetLogger. *zap.SugaredLogger satisfies it directly; other logging +// systems (e.g. slog, logrus) can be plugged in via a thin adapter. +type Logger = customdriver.Logger + +var defaultLogger *sugaredLogger var gOpts *Options var atomicLevel zap.AtomicLevel +// mu guards hasCustomLogger, which is read/written by SetLogger and Init. +var mu sync.Mutex +var hasCustomLogger bool + func init() { - defaultLogger = &Logger{ + defaultLogger = &sugaredLogger{ level: core.DebugLevel, } gOpts = &Options{} atomicLevel = zap.NewAtomicLevelAt(zap.DebugLevel) } +// SetLogger installs a user-provided logger (e.g. *zap.SugaredLogger, or a +// custom adapter around slog/logrus/etc.) as tableau's log destination. +// +// Once set, subsequent calls to Init (triggered internally by +// tableau.GenProto/GenConf via log.Options) will no longer install the +// built-in zap-based driver, so the custom logger keeps taking effect. +func SetLogger(logger Logger) { + mu.Lock() + hasCustomLogger = true + mu.Unlock() + SetDriver(customdriver.New(logger)) +} + +// Init initializes the built-in zap-based logger from opts. +// +// NOTE: if a custom logger has already been installed via SetLogger, Init +// only updates the log level used for LevelEnabled, and leaves the custom +// logger driver untouched. func Init(opts *Options) error { gOpts = opts // remember as global options. + if err := atomicLevel.UnmarshalText([]byte(opts.Level)); err != nil { + return fmt.Errorf("illegal log level: %s", opts.Level) + } + + mu.Lock() + skip := hasCustomLogger + mu.Unlock() + if skip { + return nil + } + logger, err := zapdriver.NewLogger(opts.Mode, opts.Level, opts.Filename, opts.Sink) if err != nil { return err } - if err := atomicLevel.UnmarshalText([]byte(opts.Level)); err != nil { - return fmt.Errorf("illegal log level: %s", opts.Level) - } SetDriver(zapdriver.NewWithLogger(atomicLevel, logger)) return nil } diff --git a/log/log_test.go b/log/log_test.go index 18f25b64..2867da86 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -123,7 +123,7 @@ func Test_logs(t *testing.T) { } func TestMain(m *testing.M) { - defaultLogger = &Logger{ + defaultLogger = &sugaredLogger{ level: core.DebugLevel, // driver: &defaultdriver.DefaultDriver{ // CallerSkip: 1, diff --git a/log/logger.go b/log/logger.go index 9b556c22..4c65f8f3 100644 --- a/log/logger.go +++ b/log/logger.go @@ -7,158 +7,115 @@ import ( "github.com/tableauio/tableau/log/driver" ) -type LoggerIface interface { - // Debug uses fmt.Sprint to construct and log a message. - Debug(args ...any) - - // Info uses fmt.Sprint to construct and log a message. - Info(args ...any) - - // Warn uses fmt.Sprint to construct and log a message. - Warn(args ...any) - - // Error uses fmt.Sprint to construct and log a message. - Error(args ...any) - - // DPanic uses fmt.Sprint to construct and log a message. In development, the - // logger then panics. (See DPanicLevel for details.) - DPanic(args ...any) - - // Panic uses fmt.Sprint to construct and log a message, then panics. - Panic(args ...any) - - // Fatal uses fmt.Sprint to construct and log a message, then calls os.Exit. - Fatal(args ...any) - - // Debugf uses fmt.Sprintf to log a templated message. - Debugf(format string, args ...any) - - // Infof uses fmt.Sprintf to log a templated message. - Infof(format string, args ...any) - - // Warnf uses fmt.Sprintf to log a templated message. - Warnf(format string, args ...any) - - // Errorf uses fmt.Sprintf to log a templated message. - Errorf(format string, args ...any) - - // DPanicf uses fmt.Sprintf to log a templated message. In development, the - // logger then panics. (See DPanicLevel for details.) - DPanicf(format string, args ...any) - - // Panicf uses fmt.Sprintf to log a templated message, then panics. - Panicf(format string, args ...any) - - // Fatalf uses fmt.Sprintf to log a templated message, then calls os.Exit. - Fatalf(format string, args ...any) -} - -type Logger struct { +// sugaredLogger is tableau's built-in logger implementation, which dispatches +// records to a pluggable driver.Driver (default: zap-based; can be replaced +// by a user-provided logger via SetLogger). +type sugaredLogger struct { level core.Level driver driver.Driver } -func (l *Logger) Debug(args ...any) { +func (l *sugaredLogger) Debug(args ...any) { l.log(core.DebugLevel, "", args, nil) } -func (l *Logger) Info(args ...any) { +func (l *sugaredLogger) Info(args ...any) { l.log(core.InfoLevel, "", args, nil) } -func (l *Logger) Warn(args ...any) { +func (l *sugaredLogger) Warn(args ...any) { l.log(core.WarnLevel, "", args, nil) } -func (l *Logger) Error(args ...any) { +func (l *sugaredLogger) Error(args ...any) { l.log(core.ErrorLevel, "", args, nil) } -func (l *Logger) DPanic(args ...any) { +func (l *sugaredLogger) DPanic(args ...any) { l.log(core.DPanicLevel, "%+v", args, nil) // TODO: panic only in development os.Exit(-1) } -func (l *Logger) Panic(args ...any) { +func (l *sugaredLogger) Panic(args ...any) { l.log(core.PanicLevel, "%+v", args, nil) os.Exit(-1) } -func (l *Logger) Fatal(args ...any) { +func (l *sugaredLogger) Fatal(args ...any) { l.log(core.FatalLevel, "%+v", args, nil) os.Exit(-1) } -func (l *Logger) Debugf(format string, args ...any) { +func (l *sugaredLogger) Debugf(format string, args ...any) { l.log(core.DebugLevel, format, args, nil) } -func (l *Logger) Infof(format string, args ...any) { +func (l *sugaredLogger) Infof(format string, args ...any) { l.log(core.InfoLevel, format, args, nil) } -func (l *Logger) Warnf(format string, args ...any) { +func (l *sugaredLogger) Warnf(format string, args ...any) { l.log(core.WarnLevel, format, args, nil) } -func (l *Logger) Errorf(format string, args ...any) { +func (l *sugaredLogger) Errorf(format string, args ...any) { l.log(core.ErrorLevel, format, args, nil) } -func (l *Logger) DPanicf(format string, args ...any) { +func (l *sugaredLogger) DPanicf(format string, args ...any) { l.log(core.DPanicLevel, format, args, nil) // TODO: panic only in development panic("log panic") } -func (l *Logger) Panicf(format string, args ...any) { +func (l *sugaredLogger) Panicf(format string, args ...any) { l.log(core.PanicLevel, format, args, nil) panic("log panic") } -func (l *Logger) Fatalf(format string, args ...any) { +func (l *sugaredLogger) Fatalf(format string, args ...any) { l.log(core.FatalLevel, format, args, nil) os.Exit(-1) } // Debugw logs a message with some additional context. The variadic key-value // pairs are treated as they are in With. -func (l *Logger) Debugw(msg string, keysAndValues ...any) { +func (l *sugaredLogger) Debugw(msg string, keysAndValues ...any) { l.log(core.DebugLevel, msg, nil, keysAndValues) } // Infow logs a message with some additional context. -func (l *Logger) Infow(msg string, keysAndValues ...any) { +func (l *sugaredLogger) Infow(msg string, keysAndValues ...any) { l.log(core.InfoLevel, msg, nil, keysAndValues) } // Warnw logs a message with some additional context. -func (l *Logger) Warnw(msg string, keysAndValues ...any) { +func (l *sugaredLogger) Warnw(msg string, keysAndValues ...any) { l.log(core.WarnLevel, msg, nil, keysAndValues) } // Errorw logs a message with some additional context. -func (l *Logger) Errorw(msg string, keysAndValues ...any) { +func (l *sugaredLogger) Errorw(msg string, keysAndValues ...any) { l.log(core.ErrorLevel, msg, nil, keysAndValues) } // DPanicw logs a message with some additional context. -func (l *Logger) DPanicw(msg string, keysAndValues ...any) { +func (l *sugaredLogger) DPanicw(msg string, keysAndValues ...any) { l.log(core.DPanicLevel, msg, nil, keysAndValues) } // Panicw logs a message with some additional context. -func (l *Logger) Panicw(msg string, keysAndValues ...any) { +func (l *sugaredLogger) Panicw(msg string, keysAndValues ...any) { l.log(core.PanicLevel, msg, nil, keysAndValues) } // Fatalw logs a message with some additional context. -func (l *Logger) Fatalw(msg string, keysAndValues ...any) { +func (l *sugaredLogger) Fatalw(msg string, keysAndValues ...any) { l.log(core.FatalLevel, msg, nil, keysAndValues) } -func (l *Logger) log(lvl core.Level, format string, fmtArgs []any, kvs []any) { +func (l *sugaredLogger) log(lvl core.Level, format string, fmtArgs []any, kvs []any) { // If logging at this level is completely disabled, skip the overhead of // string formatting. // if lvl < DPanicLevel { diff --git a/log/logger_test.go b/log/logger_test.go index 49f550dd..eda23128 100644 --- a/log/logger_test.go +++ b/log/logger_test.go @@ -14,13 +14,13 @@ func TestDefaultLogger_Debugf(t *testing.T) { } tests := []struct { name string - l *Logger + l *sugaredLogger args args }{ // TODO: Add test cases. { name: "test", - l: &Logger{ + l: &sugaredLogger{ level: core.DebugLevel, driver: &defaultdriver.DefaultDriver{}, }, diff --git a/log/setlogger_test.go b/log/setlogger_test.go new file mode 100644 index 00000000..cfcec7aa --- /dev/null +++ b/log/setlogger_test.go @@ -0,0 +1,160 @@ +package log + +import ( + "bytes" + "fmt" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tableauio/tableau/log/driver/customdriver" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +// restoreLoggerState snapshots log package's mutable global state before a +// SetLogger test, and restores it on cleanup, so that tests can run in any +// order without interfering with each other. +func restoreLoggerState(t *testing.T) { + t.Helper() + origDriver := defaultLogger.driver + origOpts := gOpts + origLevel := atomicLevel.Level() + mu.Lock() + origHasCustom := hasCustomLogger + mu.Unlock() + + t.Cleanup(func() { + defaultLogger.driver = origDriver + gOpts = origOpts + atomicLevel.SetLevel(origLevel) + mu.Lock() + hasCustomLogger = origHasCustom + mu.Unlock() + }) +} + +// *zap.SugaredLogger satisfies Logger (Debugf/Infof/.../Fatalf) directly, +// so it can be installed via SetLogger without any adapter. +func TestSetLogger_Zap(t *testing.T) { + restoreLoggerState(t) + + core, observed := observer.New(zapcore.DebugLevel) + SetLogger(zap.New(core).Sugar()) + + Info("hello") + Infow("infow msg", "key1", "value1") + Errorf("errorf msg: %d", 42) + + entries := observed.All() + require.Len(t, entries, 3) + + assert.Equal(t, zapcore.InfoLevel, entries[0].Level) + assert.Equal(t, "hello", entries[0].Message) + + assert.Equal(t, zapcore.InfoLevel, entries[1].Level) + assert.Equal(t, "infow msg key1=value1", entries[1].Message) + + assert.Equal(t, zapcore.ErrorLevel, entries[2].Level) + assert.Equal(t, "errorf msg: 42", entries[2].Message) +} + +func TestSetLogger_ZapPanic(t *testing.T) { + restoreLoggerState(t) + + core, observed := observer.New(zapcore.DebugLevel) + SetLogger(zap.New(core).Sugar()) + + assert.Panics(t, func() { + Panic("boom") + }) + entries := observed.All() + require.Len(t, entries, 1) + assert.Equal(t, zapcore.PanicLevel, entries[0].Level) + assert.Equal(t, "boom", entries[0].Message) +} + +// TestSetLogger_NotOverriddenByInit verifies that once a custom logger is +// installed via SetLogger, a subsequent Init call (as done internally by +// tableau.Generate/GenProto/GenConf) does not replace it with the built-in +// zap-based driver. +func TestSetLogger_NotOverriddenByInit(t *testing.T) { + restoreLoggerState(t) + + core, observed := observer.New(zapcore.DebugLevel) + SetLogger(zap.New(core).Sugar()) + + err := Init(&Options{Mode: "FULL", Level: "INFO", Sink: "CONSOLE"}) + require.NoError(t, err) + assert.Equal(t, "INFO", Level()) + + _, ok := defaultLogger.driver.(*customdriver.CustomDriver) + assert.True(t, ok, "driver should remain the custom one after Init") + + Info("still routed to custom logger") + entries := observed.All() + require.Len(t, entries, 1) + assert.Equal(t, "still routed to custom logger", entries[0].Message) +} + +// slogAdapter adapts a *slog.Logger to the Logger interface, demonstrating +// how to integrate a logging system (such as slog) that doesn't natively +// expose Printf-style methods. Since Logger only requires the 7 Xxxf +// methods, the adapter is a thin, mechanical wrapper. +type slogAdapter struct { + l *slog.Logger +} + +var _ Logger = (*slogAdapter)(nil) + +func newSlogAdapter(l *slog.Logger) *slogAdapter { + return &slogAdapter{l: l} +} + +func (a *slogAdapter) Debugf(format string, args ...any) { a.l.Debug(fmt.Sprintf(format, args...)) } +func (a *slogAdapter) Infof(format string, args ...any) { a.l.Info(fmt.Sprintf(format, args...)) } +func (a *slogAdapter) Warnf(format string, args ...any) { a.l.Warn(fmt.Sprintf(format, args...)) } +func (a *slogAdapter) Errorf(format string, args ...any) { a.l.Error(fmt.Sprintf(format, args...)) } +func (a *slogAdapter) DPanicf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + a.l.Error(msg) + panic(msg) +} +func (a *slogAdapter) Panicf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + a.l.Error(msg) + panic(msg) +} +func (a *slogAdapter) Fatalf(format string, args ...any) { a.l.Error(fmt.Sprintf(format, args...)) } + +func TestSetLogger_Slog(t *testing.T) { + restoreLoggerState(t) + + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + SetLogger(newSlogAdapter(slog.New(handler))) + + Infow("infow msg", "key1", "value1") + Errorf("errorf msg: %d", 42) + + out := buf.String() + assert.Contains(t, out, "level=INFO") + assert.Contains(t, out, `msg="infow msg key1=value1"`) + assert.Contains(t, out, "level=ERROR") + assert.Contains(t, out, "errorf msg: 42") +} + +func TestSetLogger_SlogPanic(t *testing.T) { + restoreLoggerState(t) + + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + SetLogger(newSlogAdapter(slog.New(handler))) + + assert.Panics(t, func() { + Panic("boom") + }) + assert.Contains(t, buf.String(), "boom") +} diff --git a/tableau.go b/tableau.go index af4a82b7..02c4a1dc 100644 --- a/tableau.go +++ b/tableau.go @@ -78,6 +78,23 @@ func SetLang(lang string) error { return localizer.SetLang(lang) } +// SetLogger installs a user-provided logger as tableau's log destination, +// so that log output produced by tableau (including when invoked +// indirectly, e.g. via load.LoadMessagerInDir) can be routed into the +// caller's own logging system. +// +// logger only needs to implement 7 Printf-style methods (Debugf, Infof, +// Warnf, Errorf, DPanicf, Panicf, Fatalf; see log.Logger), so most loggers +// (e.g. *zap.SugaredLogger) satisfy it directly, and others (e.g. slog, +// logrus) can be adapted with a thin wrapper. +// +// Once set, it takes effect immediately and is not overridden by the log +// options (options.Log) passed to Generate/GenProto/GenConf, regardless of +// call order. +func SetLogger(logger log.Logger) { + log.SetLogger(logger) +} + // NewImporter creates a new importer of the specified workbook. func NewImporter(workbookPath string) (importer.Importer, error) { ctx := context.Background()