diff --git a/README.md b/README.md index 45d55e3..45f225a 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,14 @@ cd ez-api ## 日志 -- 使用 [zerolog](https://github.com/rs/zerolog) 输出结构化日志,默认 ConsoleWriter。 +- 业务代码统一使用标准库 `log/slog`(`logger.Info("msg", "k", v)` 风格)。 +- 输出后端仍为 [zerolog](https://github.com/rs/zerolog)(通过 `internal/logging` 的 slog handler bridge),默认 ConsoleWriter。 - 通过 `EZ_LOG_LEVEL` 控制控制平面的日志等级,配合异步 DB 写入(LogWriter)一起使用。 +## JSON + +- 项目内 JSON 编解码统一走 `ez-api/internal/jsoncodec`(内部使用 Sonic)。 + ## 设计决策 - **异步日志**: 日志不会立即写入 DB。它们被缓冲在内存中,并分批刷新,以减少 DB IOPS。 diff --git a/cmd/server/main.go b/cmd/server/main.go index 050afc5..5ca1afa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,23 +2,22 @@ package main import ( "context" + "log/slog" "net/http" "os" "os/signal" - "strings" "syscall" "time" _ "github.com/ez-api/ez-api/docs" "github.com/ez-api/ez-api/internal/api" "github.com/ez-api/ez-api/internal/config" + "github.com/ez-api/ez-api/internal/logging" "github.com/ez-api/ez-api/internal/middleware" "github.com/ez-api/ez-api/internal/model" "github.com/ez-api/ez-api/internal/service" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" "gorm.io/driver/postgres" @@ -48,13 +47,18 @@ import ( // @in header // @name Authorization +func fatal(logger *slog.Logger, msg string, args ...any) { + logger.Error(msg, args...) + os.Exit(1) +} + func main() { - logger := setupLogger() + logger, _ := logging.New(logging.Options{Service: "ez-api"}) // 1. Load Configuration cfg, err := config.Load() if err != nil { - logger.Fatal().Err(err).Msg("Failed to load config") + fatal(logger, "failed to load config", "err", err) } // 2. Initialize Redis Client @@ -68,28 +72,28 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := rdb.Ping(ctx).Err(); err != nil { - logger.Fatal().Err(err).Msg("Failed to connect to Redis") + fatal(logger, "failed to connect to redis", "err", err) } - logger.Info().Msg("Connected to Redis successfully") + logger.Info("connected to redis successfully") // 3. Initialize GORM (PostgreSQL) db, err := gorm.Open(postgres.Open(cfg.Postgres.DSN), &gorm.Config{}) if err != nil { - logger.Fatal().Err(err).Msg("Failed to connect to PostgreSQL") + fatal(logger, "failed to connect to postgresql", "err", err) } sqlDB, err := db.DB() if err != nil { - logger.Fatal().Err(err).Msg("Failed to get generic database object") + fatal(logger, "failed to get generic database object", "err", err) } // Verify DB connection if err := sqlDB.Ping(); err != nil { - logger.Fatal().Err(err).Msg("Failed to ping PostgreSQL") + fatal(logger, "failed to ping postgresql", "err", err) } - logger.Info().Msg("Connected to PostgreSQL successfully") + logger.Info("connected to postgresql successfully") // Auto Migrate if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.LogRecord{}); err != nil { - logger.Fatal().Err(err).Msg("Failed to auto migrate") + fatal(logger, "failed to auto migrate", "err", err) } // 4. Setup Services and Handlers @@ -101,7 +105,7 @@ func main() { adminService, err := service.NewAdminService() if err != nil { - logger.Fatal().Err(err).Msg("Failed to create admin service") + fatal(logger, "failed to create admin service", "err", err) } masterService := service.NewMasterService(db) healthService := service.NewHealthCheckService(db, rdb) @@ -112,7 +116,7 @@ func main() { // 4.1 Prime Redis snapshots so DP can start with data if err := syncService.SyncAll(db); err != nil { - logger.Warn().Err(err).Msg("Initial sync warning") + logger.Warn("initial sync warning", "err", err) } // 5. Setup Gin Router @@ -176,9 +180,9 @@ func main() { // 6. Start Server with Graceful Shutdown go func() { - logger.Info().Str("port", cfg.Server.Port).Msg("Starting ez-api") + logger.Info("starting ez-api", "port", cfg.Server.Port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatal().Err(err).Msg("Server failed") + fatal(logger, "server failed", "err", err) } }() @@ -186,36 +190,14 @@ func main() { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - logger.Info().Msg("Shutting down server...") + logger.Info("shutting down server...") // Shutdown with timeout ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { - logger.Fatal().Err(err).Msg("Server forced to shutdown") + fatal(logger, "server forced to shutdown", "err", err) } - logger.Info().Msg("Server exited properly") -} - -func setupLogger() zerolog.Logger { - level := zerolog.InfoLevel - if raw := strings.TrimSpace(os.Getenv("EZ_LOG_LEVEL")); raw != "" { - if parsed, err := zerolog.ParseLevel(strings.ToLower(raw)); err == nil { - level = parsed - } - } - - zerolog.SetGlobalLevel(level) - output := zerolog.ConsoleWriter{ - Out: os.Stdout, - TimeFormat: time.RFC3339, - } - - logger := zerolog.New(output).Level(level).With(). - Timestamp(). - Str("service", "ez-api"). - Logger() - log.Logger = logger - return logger + logger.Info("server exited properly") } diff --git a/internal/jsoncodec/jsoncodec.go b/internal/jsoncodec/jsoncodec.go new file mode 100644 index 0000000..b160289 --- /dev/null +++ b/internal/jsoncodec/jsoncodec.go @@ -0,0 +1,17 @@ +package jsoncodec + +import ( + "io" + + "github.com/bytedance/sonic" +) + +func Marshal(v any) ([]byte, error) { return sonic.Marshal(v) } + +func Unmarshal(data []byte, v any) error { return sonic.Unmarshal(data, v) } + +func UnmarshalString(data string, v any) error { return sonic.UnmarshalString(data, v) } + +func NewEncoder(w io.Writer) sonic.Encoder { return sonic.ConfigDefault.NewEncoder(w) } + +func NewDecoder(r io.Reader) sonic.Decoder { return sonic.ConfigDefault.NewDecoder(r) } diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..e43ef5a --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,63 @@ +package logging + +import ( + "log/slog" + "os" + "strings" + "time" + + "github.com/rs/zerolog" +) + +type Options struct { + Service string +} + +func New(opts Options) (*slog.Logger, zerolog.Logger) { + level := parseLevel(strings.TrimSpace(os.Getenv("EZ_LOG_LEVEL"))) + zerolog.SetGlobalLevel(toZerologLevel(level)) + + output := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + } + + zl := zerolog.New(output). + Level(toZerologLevel(level)). + With(). + Timestamp(). + Str("service", strings.TrimSpace(opts.Service)). + Logger() + + sl := slog.New(NewZerologHandler(zl, level)) + slog.SetDefault(sl) + return sl, zl +} + +func parseLevel(raw string) slog.Level { + switch strings.ToLower(raw) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + case "info", "": + return slog.LevelInfo + default: + return slog.LevelInfo + } +} + +func toZerologLevel(level slog.Level) zerolog.Level { + switch { + case level <= slog.LevelDebug: + return zerolog.DebugLevel + case level <= slog.LevelInfo: + return zerolog.InfoLevel + case level <= slog.LevelWarn: + return zerolog.WarnLevel + default: + return zerolog.ErrorLevel + } +} diff --git a/internal/logging/zerolog_handler.go b/internal/logging/zerolog_handler.go new file mode 100644 index 0000000..d88d304 --- /dev/null +++ b/internal/logging/zerolog_handler.go @@ -0,0 +1,133 @@ +package logging + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/rs/zerolog" +) + +type ZerologHandler struct { + logger zerolog.Logger + level slog.Level + attrs []slog.Attr + groups []string +} + +func NewZerologHandler(logger zerolog.Logger, level slog.Level) *ZerologHandler { + return &ZerologHandler{ + logger: logger, + level: level, + } +} + +func (h *ZerologHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +func (h *ZerologHandler) Handle(_ context.Context, record slog.Record) error { + event := h.eventFor(record.Level) + if event == nil { + return nil + } + + for _, attr := range h.attrs { + h.addAttr(event, h.key(attr.Key), attr.Value) + } + record.Attrs(func(attr slog.Attr) bool { + h.addAttr(event, h.key(attr.Key), attr.Value) + return true + }) + + event.Msg(record.Message) + return nil +} + +func (h *ZerologHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + cp := h.clone() + cp.attrs = append(cp.attrs, attrs...) + return cp +} + +func (h *ZerologHandler) WithGroup(name string) slog.Handler { + if strings.TrimSpace(name) == "" { + return h + } + cp := h.clone() + cp.groups = append(cp.groups, name) + return cp +} + +func (h *ZerologHandler) clone() *ZerologHandler { + cp := *h + cp.attrs = append([]slog.Attr(nil), h.attrs...) + cp.groups = append([]string(nil), h.groups...) + return &cp +} + +func (h *ZerologHandler) key(k string) string { + k = strings.TrimSpace(k) + if k == "" { + return "" + } + if len(h.groups) == 0 { + return k + } + return strings.Join(h.groups, ".") + "." + k +} + +func (h *ZerologHandler) eventFor(level slog.Level) *zerolog.Event { + switch { + case level >= slog.LevelError: + return h.logger.Error() + case level >= slog.LevelWarn: + return h.logger.Warn() + case level >= slog.LevelInfo: + return h.logger.Info() + default: + return h.logger.Debug() + } +} + +func (h *ZerologHandler) addAttr(event *zerolog.Event, key string, value slog.Value) { + if event == nil || strings.TrimSpace(key) == "" { + return + } + + value = value.Resolve() + + switch value.Kind() { + case slog.KindGroup: + for _, groupAttr := range value.Group() { + groupKey := h.key(key + "." + groupAttr.Key) + h.addAttr(event, groupKey, groupAttr.Value.Resolve()) + } + case slog.KindString: + event.Str(key, value.String()) + case slog.KindBool: + event.Bool(key, value.Bool()) + case slog.KindInt64: + event.Int64(key, value.Int64()) + case slog.KindUint64: + event.Uint64(key, value.Uint64()) + case slog.KindFloat64: + event.Float64(key, value.Float64()) + case slog.KindDuration: + event.Dur(key, value.Duration()) + case slog.KindTime: + event.Time(key, value.Time()) + default: + anyValue := value.Any() + if err, ok := anyValue.(error); ok { + event.AnErr(key, err) + return + } + if stringer, ok := anyValue.(fmt.Stringer); ok { + event.Str(key, stringer.String()) + return + } + event.Interface(key, anyValue) + } +} diff --git a/internal/service/logger.go b/internal/service/logger.go index da22707..b742d81 100644 --- a/internal/service/logger.go +++ b/internal/service/logger.go @@ -2,10 +2,10 @@ package service import ( "context" + "log/slog" "time" "github.com/ez-api/ez-api/internal/model" - "github.com/rs/zerolog/log" "gorm.io/gorm" ) @@ -47,7 +47,7 @@ func (w *LogWriter) Start(ctx context.Context) { return } if err := w.db.Create(&buf).Error; err != nil { - log.Error().Err(err).Msg("log batch insert failed") + slog.Default().Error("log batch insert failed", "err", err) } buf = buf[:0] } diff --git a/internal/service/sync.go b/internal/service/sync.go index ca5d7c5..f718730 100644 --- a/internal/service/sync.go +++ b/internal/service/sync.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/bytedance/sonic" + "github.com/ez-api/ez-api/internal/jsoncodec" "github.com/ez-api/ez-api/internal/model" "github.com/ez-api/ez-api/internal/util" "github.com/redis/go-redis/v9" @@ -212,7 +212,7 @@ func (s *SyncService) SyncAll(db *gorm.DB) error { if p.BanUntil != nil { snap.BanUntil = p.BanUntil.UTC().Unix() } - payload, err := sonic.Marshal(snap) + payload, err := jsoncodec.Marshal(snap) if err != nil { return fmt.Errorf("marshal provider %d: %w", p.ID, err) } @@ -271,7 +271,7 @@ func (s *SyncService) SyncAll(db *gorm.DB) error { SupportsFIM: m.SupportsFIM, MaxOutputTokens: m.MaxOutputTokens, } - payload, err := sonic.Marshal(snap) + payload, err := jsoncodec.Marshal(snap) if err != nil { return fmt.Errorf("marshal model %s: %w", m.Name, err) } @@ -286,7 +286,7 @@ func (s *SyncService) SyncAll(db *gorm.DB) error { } func (s *SyncService) hsetJSON(ctx context.Context, key, field string, val interface{}) error { - payload, err := sonic.Marshal(val) + payload, err := jsoncodec.Marshal(val) if err != nil { return fmt.Errorf("marshal %s:%s: %w", key, field, err) }