优化结构

This commit is contained in:
Yvan
2026-08-21 00:10:27 +08:00
parent 746e33e027
commit 361a562f75
334 changed files with 382 additions and 379 deletions
-2322
View File
File diff suppressed because it is too large Load Diff
-217
View File
@@ -1,217 +0,0 @@
syntax = "proto3";
package kratos.api;
import "google/protobuf/duration.proto";
option go_package = "kra/internal/conf;conf";
message Bootstrap {
Server server = 1;
Data data = 2;
AdminBackend admin = 3;
}
message Server {
message HTTP {
string network = 1;
string addr = 2;
google.protobuf.Duration timeout = 3;
}
HTTP http = 1;
}
message Data {
message Database {
string driver = 1;
string source = 2;
string host = 3;
string port = 4;
string user = 5;
string password = 6;
string name = 7;
string config = 8;
string path = 9;
string alias_name = 10;
bool disable = 11;
string prefix = 12;
string engine = 13;
string log_mode = 14;
int32 max_idle_conns = 15;
int32 max_open_conns = 16;
int32 conn_max_lifetime = 17;
bool singular = 18;
}
message Redis {
string network = 1;
string addr = 2;
google.protobuf.Duration read_timeout = 3;
google.protobuf.Duration write_timeout = 4;
string name = 5;
string password = 6;
int32 db = 7;
bool use_cluster = 8;
repeated string cluster_addrs = 9;
}
message MongoHost {
string host = 1;
string port = 2;
}
message Mongo {
string coll = 1;
string options = 2;
string database = 3;
string username = 4;
string password = 5;
string auth_source = 6;
uint64 min_pool_size = 7;
uint64 max_pool_size = 8;
int64 socket_timeout_ms = 9;
int64 connect_timeout_ms = 10;
bool is_zap = 11;
repeated MongoHost hosts = 12;
}
Database database = 1;
Redis redis = 2;
repeated Database database_list = 3;
repeated Redis redis_list = 4;
Mongo mongo = 5;
}
// AdminBackend contains settings for the administration HTTP transport.
message AdminBackend {
string router_prefix = 1;
JWT jwt = 2;
Captcha captcha = 3;
Local local = 4;
Email email = 5;
Storage storage = 6;
// ConfigPath is populated by the entrypoint and is not required in YAML.
string config_path = 7;
Media media = 8;
repeated Disk disk_list = 9;
System system = 10;
Zap zap = 11;
CORS cors = 12;
App app = 13;
message JWT {
string signing_key = 1;
google.protobuf.Duration expires_time = 2;
google.protobuf.Duration buffer_time = 3;
string issuer = 4;
}
message Captcha {
int32 key_long = 1;
int32 img_width = 2;
int32 img_height = 3;
google.protobuf.Duration store_expiration = 4;
}
message Local {
string store_path = 1;
string path_prefix = 2;
}
message Email {
string to = 1;
string from = 2;
string host = 3;
string secret = 4;
string nickname = 5;
int32 port = 6;
bool is_ssl = 7;
bool is_login_auth = 8;
}
message Media {
int32 session_ttl = 1;
int64 max_file_size = 2;
string chunk_dir = 3;
}
message Disk {
string mount_point = 1;
}
message System {
bool use_redis = 1;
bool use_multipoint = 2;
bool use_strict_auth = 3;
bool disable_auto_migrate = 4;
bool use_mongo = 5;
int32 addr = 6;
int32 iplimit_count = 7;
int32 iplimit_time = 8;
}
message Zap {
string level = 1;
string prefix = 2;
string format = 3;
string director = 4;
string encode_level = 5;
string stacktrace_key = 6;
bool show_line = 7;
bool log_in_console = 8;
int32 retention_day = 9;
bool access_req_body = 10;
bool access_resp_data = 11;
bool access_req_headers = 12;
int32 access_log_max_bytes = 13;
repeated string file_only_modules = 14;
}
message CORS {
string mode = 1;
repeated CORSRule whitelist = 2;
}
message CORSRule {
string allow_origin = 1;
string allow_methods = 2;
string allow_headers = 3;
string expose_headers = 4;
bool allow_credentials = 5;
}
message App {
string node = 1;
string app_id = 2;
string env = 3;
}
message Storage {
string type = 1;
Qiniu qiniu = 2;
ObjectStore aliyun_oss = 3;
ObjectStore huawei_obs = 4;
ObjectStore tencent_cos = 5;
ObjectStore aws_s3 = 6;
ObjectStore cloudflare_r2 = 7;
ObjectStore minio = 8;
}
message Qiniu {
string zone = 1;
string bucket = 2;
string base_url = 3;
string access_key = 4;
string secret_key = 5;
bool use_https = 6;
bool use_cdn_domains = 7;
}
// ObjectStore covers S3-compatible configuration used by public cloud
// providers and self-hosted MinIO.
message ObjectStore {
string endpoint = 1;
string region = 2;
string bucket = 3;
string access_key = 4;
string secret_key = 5;
string base_url = 6;
string path_prefix = 7;
bool use_ssl = 8;
bool force_path_style = 9;
string account_id = 10;
}
}
-128
View File
@@ -1,128 +0,0 @@
package conf
import (
"sync"
"sync/atomic"
"google.golang.org/protobuf/proto"
)
// Runtime owns the active immutable configuration snapshot. Callers always
// receive clones so a config file reload cannot race with request handling.
type Runtime struct {
snapshot atomic.Pointer[runtimeSnapshot]
mu sync.RWMutex
nextID uint64
listeners map[uint64]func(*Data, *AdminBackend)
}
type runtimeSnapshot struct {
data *Data
admin *AdminBackend
}
func NewRuntime(data *Data, admin *AdminBackend) *Runtime {
runtime := &Runtime{listeners: make(map[uint64]func(*Data, *AdminBackend))}
runtime.Replace(data, admin)
return runtime
}
func cloneData(value *Data) *Data {
if value == nil {
return nil
}
return proto.Clone(value).(*Data)
}
func cloneAdmin(value *AdminBackend) *AdminBackend {
if value == nil {
return nil
}
return proto.Clone(value).(*AdminBackend)
}
func (r *Runtime) Replace(data *Data, admin *AdminBackend) {
data = cloneData(data)
admin = cloneAdmin(admin)
r.snapshot.Store(&runtimeSnapshot{data: data, admin: admin})
r.mu.RLock()
listeners := make([]func(*Data, *AdminBackend), 0, len(r.listeners))
for _, listener := range r.listeners {
listeners = append(listeners, listener)
}
r.mu.RUnlock()
for _, listener := range listeners {
listener(cloneData(data), cloneAdmin(admin))
}
}
// Subscribe registers a callback invoked after every successful runtime
// configuration replacement. It is used by long-lived clients such as the
// logger and other long-lived clients that must follow runtime config changes.
func (r *Runtime) Subscribe(listener func(*Data, *AdminBackend)) func() {
if listener == nil {
return func() {}
}
r.mu.Lock()
r.nextID++
id := r.nextID
r.listeners[id] = listener
r.mu.Unlock()
return func() {
r.mu.Lock()
delete(r.listeners, id)
r.mu.Unlock()
}
}
func (r *Runtime) Data() *Data {
snapshot := r.snapshot.Load()
if snapshot == nil {
return nil
}
return cloneData(snapshot.data)
}
func (r *Runtime) Admin() *AdminBackend {
snapshot := r.snapshot.Load()
if snapshot == nil {
return nil
}
return cloneAdmin(snapshot.admin)
}
func (r *Runtime) Values() (*Data, *AdminBackend) {
snapshot := r.snapshot.Load()
if snapshot == nil {
return nil, nil
}
return cloneData(snapshot.data), cloneAdmin(snapshot.admin)
}
func (r *Runtime) ConfigPath() string {
snapshot := r.snapshot.Load()
if snapshot == nil || snapshot.admin == nil {
return ""
}
return snapshot.admin.ConfigPath
}
func (r *Runtime) UpdateDatabase(database *Data_Database) {
for {
current := r.snapshot.Load()
var data *Data
var admin *AdminBackend
if current != nil {
data = cloneData(current.data)
admin = cloneAdmin(current.admin)
}
if data == nil {
data = &Data{}
}
data.Database = proto.Clone(database).(*Data_Database)
next := &runtimeSnapshot{data: data, admin: admin}
if r.snapshot.CompareAndSwap(current, next) {
return
}
}
}
-41
View File
@@ -1,41 +0,0 @@
package conf
import (
"sync"
"testing"
)
func TestRuntimeSnapshotsAreImmutable(t *testing.T) {
runtime := NewRuntime(&Data{Database: &Data_Database{Name: "first"}}, &AdminBackend{RouterPrefix: "/api", Jwt: &AdminBackend_JWT{SigningKey: "secret"}})
admin := runtime.Admin()
admin.RouterPrefix = "/changed"
admin.Jwt.SigningKey = "changed"
data := runtime.Data()
data.Database.Name = "changed"
if got := runtime.Admin(); got.RouterPrefix != "/api" || got.Jwt.SigningKey != "secret" {
t.Fatalf("admin snapshot mutated: %+v", got)
}
if got := runtime.Data().Database.Name; got != "first" {
t.Fatalf("data snapshot mutated: %s", got)
}
}
func TestRuntimeConcurrentReplaceAndRead(t *testing.T) {
runtime := NewRuntime(&Data{Database: &Data_Database{Name: "initial"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "initial"}})
var wait sync.WaitGroup
for worker := 0; worker < 8; worker++ {
wait.Add(1)
go func() {
defer wait.Done()
for index := 0; index < 500; index++ {
_ = runtime.Admin().Jwt.SigningKey
_ = runtime.Data().Database.Name
}
}()
}
for index := 0; index < 500; index++ {
runtime.Replace(&Data{Database: &Data_Database{Name: "next"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "next"}})
}
wait.Wait()
}
-67
View File
@@ -1,67 +0,0 @@
package logging
import (
"context"
"log/slog"
)
type contextFieldsKey struct{}
// ContextFields contains request metadata attached to logs emitted with a
// request context.
type ContextFields struct {
RequestID string
TraceID string
SpanID string
ParentSpanID string
DeviceID string
ClientIP string
HTTPMethod string
HTTPPath string
}
func WithContextFields(ctx context.Context, fields *ContextFields) context.Context {
return context.WithValue(ctx, contextFieldsKey{}, fields)
}
func ContextFieldsFrom(ctx context.Context) *ContextFields {
if ctx == nil {
return nil
}
fields, _ := ctx.Value(contextFieldsKey{}).(*ContextFields)
return fields
}
type contextHandler struct{ handler slog.Handler }
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level)
}
func (h *contextHandler) Handle(ctx context.Context, record slog.Record) error {
if fields := ContextFieldsFrom(ctx); fields != nil {
record.AddAttrs(
slog.String("request_id", fields.RequestID),
slog.String("trace_id", fields.TraceID),
slog.String("device_id", fields.DeviceID),
slog.String("client_ip", fields.ClientIP),
slog.String("http_method", fields.HTTPMethod),
slog.String("http_path", fields.HTTPPath),
)
if fields.SpanID != "" {
record.AddAttrs(slog.String("span_id", fields.SpanID))
}
if fields.ParentSpanID != "" {
record.AddAttrs(slog.String("parent_span_id", fields.ParentSpanID))
}
}
return h.handler.Handle(ctx, record)
}
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &contextHandler{handler: h.handler.WithAttrs(attrs)}
}
func (h *contextHandler) WithGroup(name string) slog.Handler {
return &contextHandler{handler: h.handler.WithGroup(name)}
}
-88
View File
@@ -1,88 +0,0 @@
package logging
import (
"os"
"path/filepath"
"sync"
"time"
)
// DailyWriter writes log entries to <root>/<yyyy-mm-dd>/<name>. It keeps the
// file handle open for the active day and rotates on the first write after
// midnight, matching the directory layout consumed by the log viewer.
type DailyWriter struct {
mu sync.Mutex
root string
name string
date string
file *os.File
retentionDay int
}
func NewDailyWriter(root, name string, retentionDay int) *DailyWriter {
w := &DailyWriter{root: root, name: name, retentionDay: retentionDay}
w.removeExpired()
return w
}
func (w *DailyWriter) removeExpired() {
if w.retentionDay <= 0 {
return
}
entries, err := os.ReadDir(w.root)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -w.retentionDay)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
date, err := time.Parse("2006-01-02", entry.Name())
if err == nil && date.Before(cutoff) {
_ = os.RemoveAll(filepath.Join(w.root, entry.Name()))
}
}
}
func (w *DailyWriter) Write(value []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
date := time.Now().Format("2006-01-02")
if w.file == nil || w.date != date {
if w.file != nil {
_ = w.file.Close()
}
filePath := filepath.Join(w.root, date, filepath.Clean(w.name))
directory := filepath.Dir(filePath)
if err := os.MkdirAll(directory, 0o755); err != nil {
return 0, err
}
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return 0, err
}
w.file, w.date = file, date
}
return w.file.Write(value)
}
func (w *DailyWriter) Sync() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
return w.file.Sync()
}
func (w *DailyWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
-98
View File
@@ -1,98 +0,0 @@
package logging
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"regexp"
"strconv"
"strings"
)
type stackFrame struct {
File string
Line int
Func string
}
var stackFileLinePattern = regexp.MustCompile(`\s*(.+\.go):(\d+)\s*$`)
func finalApplicationCaller(stack string) (stackFrame, bool) {
if stack == "" {
return stackFrame{}, false
}
functionName := ""
for _, raw := range strings.Split(stack, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
matches := stackFileLinePattern.FindStringSubmatch(line)
if matches == nil {
functionName = line
continue
}
lineNumber, _ := strconv.Atoi(matches[2])
if skipStackFile(matches[1]) {
functionName = ""
continue
}
return stackFrame{File: matches[1], Line: lineNumber, Func: functionName}, true
}
return stackFrame{}, false
}
func skipStackFile(filename string) bool {
normalized := strings.ReplaceAll(filename, "\\", "/")
for _, marker := range []string{
"/go/pkg/mod/",
"/go.uber.org/",
"/gorm.io/",
"/internal/logging/",
"/internal/server/middleware/",
"/internal/server/router/",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return strings.Contains(normalized, "/src/") &&
(strings.Contains(normalized, "/go/go") || strings.Contains(normalized, "/go/src/"))
}
func functionSourceAt(filename string, line int) (name, source string, startLine, endLine int, err error) {
content, err := os.ReadFile(filename)
if err != nil {
return "", "", 0, 0, fmt.Errorf("read file failed: %w", err)
}
files := token.NewFileSet()
parsed, err := parser.ParseFile(files, filename, content, parser.ParseComments)
if err != nil {
return "", "", 0, 0, fmt.Errorf("parse file failed: %w", err)
}
var target *ast.FuncDecl
ast.Inspect(parsed, func(node ast.Node) bool {
declaration, ok := node.(*ast.FuncDecl)
if !ok {
return true
}
start := files.Position(declaration.Pos()).Line
end := files.Position(declaration.End()).Line
if line >= start && line <= end {
target, startLine, endLine = declaration, start, end
return false
}
return true
})
if target == nil {
return "", "", 0, 0, fmt.Errorf("no function encloses line %d in %s", line, filename)
}
start := files.Position(target.Pos()).Offset
end := files.Position(target.End()).Offset
if start < 0 || end > len(content) || start >= end {
return "", "", 0, 0, fmt.Errorf("invalid offsets for function: start=%d end=%d len=%d", start, end, len(content))
}
return target.Name.Name, string(content[start:end]), startLine, endLine, nil
}
-469
View File
@@ -1,469 +0,0 @@
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-kratos/kratos/contrib/otel/v3/tracing"
kratoslog "github.com/go-kratos/kratos/v3/log"
"go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
"go.uber.org/zap/zapcore"
)
type Options struct {
Level, Format, EncodeLevel, Prefix, StacktraceKey string
LogInConsole, ShowLine bool
RetentionDay int
FileOnlyModules []string
}
// ErrorEntry is the storage-neutral representation of an Error-level log.
// Keeping it in internal/logging lets the log core report failures without taking a
// dependency on the application service or persistence layers.
type ErrorEntry struct {
Form, Info, Level, RequestID, TraceID string
}
// ErrorSink receives Error-level log entries. The sink must not log failures
// through the same logger, otherwise a storage failure could recurse forever.
type ErrorSink interface {
RecordLogError(context.Context, ErrorEntry) error
}
type ErrorSinkFunc func(context.Context, ErrorEntry) error
func (f ErrorSinkFunc) RecordLogError(ctx context.Context, entry ErrorEntry) error {
return f(ctx, entry)
}
type errorSinkState struct {
mu sync.RWMutex
sink ErrorSink
}
func (s *errorSinkState) record(entry ErrorEntry) {
if s == nil {
return
}
s.mu.RLock()
sink := s.sink
s.mu.RUnlock()
if sink != nil {
_ = sink.RecordLogError(context.Background(), entry)
}
}
type handlerOperation struct {
attrs []slog.Attr
group string
}
type reloadableHandlerState struct {
mu sync.RWMutex
handler slog.Handler
cleanup func()
}
type reloadableHandler struct {
state *reloadableHandlerState
ops []handlerOperation
}
func (h *reloadableHandler) resolved() slog.Handler {
current := h.state.handler
for _, operation := range h.ops {
if operation.group != "" {
current = current.WithGroup(operation.group)
} else {
current = current.WithAttrs(operation.attrs)
}
}
return current
}
func (h *reloadableHandler) Enabled(ctx context.Context, level slog.Level) bool {
h.state.mu.RLock()
defer h.state.mu.RUnlock()
return h.resolved().Enabled(ctx, level)
}
func (h *reloadableHandler) Handle(ctx context.Context, record slog.Record) error {
h.state.mu.RLock()
defer h.state.mu.RUnlock()
return h.resolved().Handle(ctx, record)
}
func (h *reloadableHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
next := append([]handlerOperation(nil), h.ops...)
next = append(next, handlerOperation{attrs: append([]slog.Attr(nil), attrs...)})
return &reloadableHandler{state: h.state, ops: next}
}
func (h *reloadableHandler) WithGroup(name string) slog.Handler {
next := append([]handlerOperation(nil), h.ops...)
next = append(next, handlerOperation{group: name})
return &reloadableHandler{state: h.state, ops: next}
}
type ReloadableLogger struct {
state *reloadableHandlerState
filename string
errorSink *errorSinkState
}
func (l *ReloadableLogger) Reload(root string, options Options) {
handler, cleanup := newZapHandler(root, l.filename, options, l.errorSink)
l.state.mu.Lock()
previous := l.state.cleanup
l.state.handler = handler
l.state.cleanup = cleanup
l.state.mu.Unlock()
if previous != nil {
previous()
}
}
// SetErrorSink changes the database/audit target while keeping the current
// logger and its hot-reload state intact.
func (l *ReloadableLogger) SetErrorSink(sink ErrorSink) {
if l == nil || l.errorSink == nil {
return
}
l.errorSink.mu.Lock()
l.errorSink.sink = sink
l.errorSink.mu.Unlock()
}
func (l *ReloadableLogger) Close() {
l.state.mu.Lock()
cleanup := l.state.cleanup
l.state.cleanup = nil
l.state.mu.Unlock()
if cleanup != nil {
cleanup()
}
}
type moduleFilterCore struct {
zapcore.Core
fileOnly map[string]struct{}
mod string
}
func (c *moduleFilterCore) With(fields []zapcore.Field) zapcore.Core {
mod := c.mod
if value := moduleField(fields); value != "" {
mod = value
}
return &moduleFilterCore{Core: c.Core.With(fields), fileOnly: c.fileOnly, mod: mod}
}
func (c *moduleFilterCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return checked.AddCore(entry, c)
}
return checked
}
func (c *moduleFilterCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
mod := c.mod
if value := moduleField(fields); value != "" {
mod = value
}
if _, excluded := c.fileOnly[mod]; excluded {
return nil
}
return c.Core.Write(entry, fields)
}
func moduleField(fields []zapcore.Field) string {
for _, field := range fields {
if field.Key == "mod" {
if field.String != "" {
return field.String
}
if field.Interface != nil {
return fmt.Sprint(field.Interface)
}
}
}
return ""
}
type routedFileState struct {
mu sync.Mutex
writers map[string]*DailyWriter
}
type routedFileCore struct {
base zapcore.Core
encoder zapcore.Encoder
level zapcore.LevelEnabler
root string
retentionDay int
state *routedFileState
fields []zapcore.Field
errorSink *errorSinkState
}
func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core {
inherited := append([]zapcore.Field(nil), c.fields...)
inherited = append(inherited, fields...)
return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited, errorSink: c.errorSink}
}
func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return checked.AddCore(entry, c)
}
return checked
}
func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
baseErr := c.base.Write(entry, fields)
allFields := append([]zapcore.Field(nil), c.fields...)
allFields = append(allFields, fields...)
if entry.Level >= zapcore.ErrorLevel && !isGORMLoggerEntry(entry.Caller.File, allFields) {
c.errorSink.record(errorEntryFromZap(entry, allFields))
}
paths := routedLogPaths(moduleField(allFields), entry.Level)
if len(paths) == 0 {
return baseErr
}
buffer, err := c.encoder.Clone().EncodeEntry(entry, allFields)
if err != nil {
if baseErr != nil {
return baseErr
}
return err
}
defer buffer.Free()
for _, name := range paths {
writer := c.writer(name)
if _, writeErr := writer.Write(buffer.Bytes()); writeErr != nil && baseErr == nil {
baseErr = writeErr
}
}
return baseErr
}
func isGORMLoggerEntry(filename string, fields []zapcore.Field) bool {
for _, field := range fields {
if field.Key == "gorm_logger" {
return true
}
}
normalized := strings.ReplaceAll(filename, "\\", "/")
return strings.HasSuffix(normalized, "/gorm_logger_writer.go") ||
strings.HasSuffix(normalized, "/internal/data/gorm_logger.go")
}
func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry {
requestID, traceID, errorText := "", "", ""
for _, field := range fields {
switch field.Key {
case "request_id":
if requestID == "" {
requestID = zapFieldString(field)
}
case "trace_id":
if traceID == "" {
traceID = zapFieldString(field)
}
case "error", "err":
if errorText == "" {
errorText = zapFieldString(field)
}
}
}
info := entry.Message
if errorText != "" {
info += " | 错误: " + errorText
}
if entry.Caller.File != "" {
info += fmt.Sprintf(" \n 源文件:%s:%d", entry.Caller.File, entry.Caller.Line)
}
if entry.Stack != "" {
info += " \n 调用栈:" + entry.Stack
if frame, ok := finalApplicationCaller(entry.Stack); ok {
functionName, source, startLine, endLine, err := functionSourceAt(frame.File, frame.Line)
if err == nil {
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", frame.File, frame.Line, functionName, startLine, endLine, source)
} else {
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s) | extract_err=%v", frame.File, frame.Line, functionName, err)
}
}
}
return ErrorEntry{Form: "后端", Info: info, Level: entry.Level.String(), RequestID: requestID, TraceID: traceID}
}
func zapFieldString(field zapcore.Field) string {
if field.String != "" {
return field.String
}
if field.Interface != nil {
return fmt.Sprint(field.Interface)
}
return ""
}
func (c *routedFileCore) Sync() error {
result := c.base.Sync()
c.state.mu.Lock()
defer c.state.mu.Unlock()
for _, writer := range c.state.writers {
if err := writer.Sync(); err != nil && result == nil {
result = err
}
}
return result
}
func (c *routedFileCore) writer(name string) *DailyWriter {
c.state.mu.Lock()
defer c.state.mu.Unlock()
writer := c.state.writers[name]
if writer == nil {
writer = NewDailyWriter(c.root, name, c.retentionDay)
c.state.writers[name] = writer
}
return writer
}
func (c *routedFileCore) Close() {
c.state.mu.Lock()
defer c.state.mu.Unlock()
for name, writer := range c.state.writers {
_ = writer.Close()
delete(c.state.writers, name)
}
}
func routedLogPaths(module string, level zapcore.Level) []string {
paths := make([]string, 0, 2)
if module = safeModuleName(module); module != "" {
switch module {
case "http":
paths = append(paths, filepath.Join("http", "access.log"))
case "timedTask":
paths = append(paths, filepath.Join("timedTask", "task.log"))
case "error":
paths = append(paths, filepath.Join("error", "error.log"))
default:
paths = append(paths, filepath.Join(module, "application.log"))
}
}
if level >= zapcore.ErrorLevel {
errorPath := filepath.Join("error", "error.log")
if len(paths) == 0 || paths[len(paths)-1] != errorPath {
paths = append(paths, errorPath)
}
}
return paths
}
func safeModuleName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
return r
}
return -1
}, value)
}
// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3.
// The file layout remains compatible with the administration log viewer.
func newZapHandler(root, filename string, options Options, errorSink *errorSinkState) (slog.Handler, func()) {
file := NewDailyWriter(root, filename, options.RetentionDay)
encoder := zap.NewProductionEncoderConfig()
encoder.EncodeTime = zapcore.RFC3339NanoTimeEncoder
if options.StacktraceKey != "" {
encoder.StacktraceKey = options.StacktraceKey
}
switch options.EncodeLevel {
case "CapitalLevelEncoder":
encoder.EncodeLevel = zapcore.CapitalLevelEncoder
case "CapitalColorLevelEncoder":
encoder.EncodeLevel = zapcore.CapitalColorLevelEncoder
case "LowercaseColorLevelEncoder":
encoder.EncodeLevel = zapcore.LowercaseColorLevelEncoder
default:
encoder.EncodeLevel = zapcore.LowercaseLevelEncoder
}
var outputEncoder zapcore.Encoder = zapcore.NewJSONEncoder(encoder)
if options.Format != "" && options.Format != "json" {
if options.Prefix != "" {
encoder.EncodeTime = func(value time.Time, output zapcore.PrimitiveArrayEncoder) {
output.AppendString(options.Prefix + value.Format("2006-01-02 15:04:05.000"))
}
}
outputEncoder = zapcore.NewConsoleEncoder(encoder)
}
level := zap.DebugLevel
// zapcore.Level.Set reports an error but does not make the intended
// configuration handling obvious here. Parse the configured value directly
// so debug/warn/error are actually applied after startup and hot reload.
if value := strings.TrimSpace(strings.ToLower(options.Level)); value != "" {
if err := level.UnmarshalText([]byte(value)); err != nil {
level = zap.DebugLevel
}
}
levelEnabler := zap.NewAtomicLevelAt(level)
fileCore := zapcore.NewCore(
outputEncoder.Clone(),
zapcore.AddSync(file),
levelEnabler,
)
core := zapcore.Core(fileCore)
if options.LogInConsole {
fileOnly := make(map[string]struct{}, len(options.FileOnlyModules))
for _, module := range options.FileOnlyModules {
fileOnly[module] = struct{}{}
}
consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler)
core = zapcore.NewTee(fileCore, &moduleFilterCore{Core: consoleCore, fileOnly: fileOnly})
}
routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: errorSink}
zapLogger := zap.New(routed)
handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)}
if options.ShowLine {
handlerOptions = append(handlerOptions, zapslog.WithCaller(true))
}
handler := zapslog.NewHandler(zapLogger.Core(), handlerOptions...)
cleanup := func() {
_ = zapLogger.Sync()
routed.Close()
_ = file.Close()
}
return handler, cleanup
}
// NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing
// the underlying Zap core when the runtime configuration changes.
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
errorSink := &errorSinkState{}
baseHandler, cleanup := newZapHandler(root, filename, options, errorSink)
state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup}
control := &ReloadableLogger{state: state, filename: filename, errorSink: errorSink}
handler := &contextHandler{handler: &reloadableHandler{state: state}}
logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...)
return logger, control
}
func NewZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, func()) {
logger, control := NewReloadableZapLogger(root, filename, options, attrs...)
return logger, control.Close
}
-119
View File
@@ -1,119 +0,0 @@
package logging
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func TestZapHandlerHonorsConfiguredLevel(t *testing.T) {
root := t.TempDir()
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "error", Format: "json"}, nil)
defer cleanup()
if handler.Enabled(context.Background(), slog.LevelInfo) {
t.Fatal("info should be disabled when level is error")
}
if !handler.Enabled(context.Background(), slog.LevelError) {
t.Fatal("error should be enabled when level is error")
}
}
func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) {
root := t.TempDir()
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "not-a-level", Format: "json"}, nil)
defer cleanup()
if !handler.Enabled(context.Background(), slog.LevelDebug) {
t.Fatal("invalid levels should use the reference debug fallback")
}
}
func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
root := t.TempDir()
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil)
logger := slog.New(handler)
logger.Info("request", "mod", "http", "request_id", "req-1")
logger.Error("failed", "mod", "users")
cleanup()
dateEntries, err := os.ReadDir(root)
if err != nil || len(dateEntries) != 1 {
t.Fatalf("expected one daily log directory, entries=%v err=%v", len(dateEntries), err)
}
date := dateEntries[0].Name()
paths := []string{
filepath.Join(root, date, "application.log"),
filepath.Join(root, date, "http", "access.log"),
filepath.Join(root, date, "users", "application.log"),
filepath.Join(root, date, "error", "error.log"),
}
for _, path := range paths {
if info, err := os.Stat(path); err != nil || info.Size() == 0 {
t.Fatalf("expected non-empty routed log %s: info=%v err=%v", path, info, err)
}
}
}
func TestZapHandlerRecordsEveryErrorThroughSink(t *testing.T) {
root := t.TempDir()
var entries []ErrorEntry
logger, control := NewReloadableZapLogger(root, "application.log", Options{Level: "info", Format: "json"})
defer control.Close()
control.SetErrorSink(ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
entries = append(entries, entry)
return nil
}))
logger.Error("task execution failed", "mod", "timedTask", "request_id", "request-1", "trace_id", "trace-1", "error", os.ErrPermission)
control.Reload(t.TempDir(), Options{Level: "info", Format: "json"})
logger.Error("reloaded logger failure", "mod", "system")
if len(entries) != 2 {
t.Fatalf("expected two error entries across reload, got %d", len(entries))
}
entry := entries[0]
if entry.Form != "后端" || entry.Level != "error" || entry.RequestID != "request-1" || entry.TraceID != "trace-1" || !strings.Contains(entry.Info, "task execution failed") || !strings.Contains(entry.Info, os.ErrPermission.Error()) {
t.Fatalf("unexpected error entry: %+v", entry)
}
}
func TestErrorEntryIncludesFinalApplicationSource(t *testing.T) {
filename := filepath.Join(t.TempDir(), "worker.go")
content := "package sample\n\nfunc execute() {\n\tprintln(\"failed\")\n}\n"
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
entry := errorEntryFromZap(zapcore.Entry{Message: "task failed", Level: zapcore.ErrorLevel, Stack: "kra/app/system/worker.execute\n" + filename + ":4"}, nil)
if !strings.Contains(entry.Info, "最终调用方法:"+filename+":4 (execute lines 3-5)") || !strings.Contains(entry.Info, "func execute()") {
t.Fatalf("expected final caller source in error entry: %s", entry.Info)
}
}
func TestErrorSinkSkipsGORMBridge(t *testing.T) {
root := t.TempDir()
var entries []ErrorEntry
state := &errorSinkState{sink: ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
entries = append(entries, entry)
return nil
})}
base := zapcore.NewNopCore()
core := &routedFileCore{base: base, encoder: zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), level: zapcore.ErrorLevel, root: root, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: state}
defer core.Close()
for _, filename := range []string{"/tmp/gorm_logger_writer.go", "/workspace/internal/data/gorm_logger.go"} {
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed", Caller: zapcore.EntryCaller{Defined: true, File: filename, Line: 10}}, nil); err != nil {
t.Fatal(err)
}
}
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed"}, []zapcore.Field{zap.Bool("gorm_logger", true)}); err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("gorm bridge error must not recurse into sys_error: %+v", entries)
}
}