diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d9ddca9 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +test: + go test -v ./... \ No newline at end of file diff --git a/example/logger/logger.go b/example/logger/logger.go index e98223d..8f3a739 100644 --- a/example/logger/logger.go +++ b/example/logger/logger.go @@ -26,13 +26,13 @@ func SearchUser(id int) error { } func main() { - l := zapx.NewDefaultZapLogger("./logs", logger.EnvTest) + l := zapx.NewDefaultZapLogger() id := 1 A(id, l) - l.Info("查询出错id:", logger.Int("id", id)) + l.Info("查询出错id:", logger.Field{"id": id}) } func A(id int, l logger.Logger) { err := SearchUser(id) - l.Error("查询数据库出错:", logger.Error(err)) + l.Error("查询数据库出错:" + err.Error()) } diff --git a/go.mod b/go.mod index 4e0842e..2c7c1cc 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/stretchr/testify v1.11.0 github.com/uber/jaeger-client-go v2.30.0+incompatible github.com/ulule/limiter/v3 v3.11.2 - go.uber.org/zap v1.21.0 + go.uber.org/zap v1.27.0 golang.org/x/sync v0.16.0 google.golang.org/grpc v1.74.2 google.golang.org/grpc/examples v0.0.0-20250830142414-29ba00196bb8 @@ -62,6 +62,8 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj/v2 v2.5.5 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set v1.7.1 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect @@ -77,11 +79,13 @@ require ( github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect @@ -122,15 +126,19 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/uber/jaeger-lib v2.4.1+incompatible // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.etcd.io/etcd/api/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/v3 v3.6.5 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/mock v0.6.0 // indirect - go.uber.org/multierr v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 6cbdc44..c6a61e4 100644 --- a/go.sum +++ b/go.sum @@ -108,6 +108,10 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -192,6 +196,7 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= @@ -240,7 +245,10 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -417,6 +425,12 @@ github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/v3 v3.6.5 h1:yRwZNFBx/35VKHTcLDeO7XVLbCBFbPi+XV4OC3QJf2U= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= @@ -442,9 +456,13 @@ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= @@ -626,6 +644,9 @@ google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b h1:eZTgydvqZO44zyTZAvMaSyAxccZZdraiSAGvqOczVvk= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index e195b3e..671846e 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -10,59 +10,4 @@ type Logger interface { Sync() error } -type Field struct { - Key string - Val any -} - -func Any(key string, val any) Field { - return Field{ - Key: key, - Val: val, - } -} - -func Error(err error) Field { - return Field{ - Key: "error", - Val: err, - } -} - -func Int64(key string, val int64) Field { - return Field{ - Key: key, - Val: val, - } -} - -func Int(key string, val int) Field { - return Field{ - Key: key, - Val: val, - } -} - -func String(key string, val string) Field { - return Field{ - Key: key, - Val: val, - } -} - -func Int32(key string, val int32) Field { - return Field{ - Key: key, - Val: val, - } -} - -// ---------- 环境枚举 ---------- -type Env int8 - -const ( - EnvUnknown Env = iota - EnvDev // 开发:彩色多行栈,仅控制台 - EnvTest // 测试:彩色多行栈到控制台 + JSON 到文件 - EnvProd // 生产:全 JSON 单行 -) +type Field map[string]interface{} diff --git a/pkg/logger/logx/logs_test.go b/pkg/logger/logx/logs_test.go new file mode 100644 index 0000000..550f3a1 --- /dev/null +++ b/pkg/logger/logx/logs_test.go @@ -0,0 +1,68 @@ +package logx + +import ( + "bytes" + "log" + "strings" + "testing" + + "github.com/muxi-Infra/muxi-micro/pkg/logger" +) + +func TestNewStdLogger(t *testing.T) { + l := NewStdLogger() + if l == nil { + t.Fatal("expected logger, got nil") + } + if l.logger == nil { + t.Fatal("expected underlying log.Logger initialized") + } +} + +func TestOutputLevels(t *testing.T) { + l := NewStdLogger() + + fields := logger.Field{"key": "value"} + l.Info("info msg", fields) + l.Debug("debug msg") + l.Warn("warn msg") + l.Error("error msg") + +} + +func TestWithFields(t *testing.T) { + l := NewStdLogger() + + // 添加初始字段 + l2 := l.With(logger.Field{"request_id": "1234"}).(*StdLogger) + if v, ok := l2.fields["request_id"]; !ok || v != "1234" { + t.Fatalf("expected field request_id=1234, got %v", l2.fields) + } + + // 再添加新字段(测试合并) + l3 := l2.With(logger.Field{"user": "alice"}).(*StdLogger) + if v, ok := l3.fields["user"]; !ok || v != "alice" { + t.Fatalf("expected user=alice, got %v", l3.fields) + } +} + +func TestOutputFieldMerging(t *testing.T) { + l := NewStdLogger() + buf := &bytes.Buffer{} + l.logger = log.New(buf, "", 0) + + l.fields["base"] = "yes" + l.output("INFO", "test", logger.Field{"child": "ok"}) + + out := buf.String() + if !strings.Contains(out, "\"base\":\"yes\"") || !strings.Contains(out, "\"child\":\"ok\"") { + t.Fatalf("field merge failed: %s", out) + } +} + +func TestSync(t *testing.T) { + l := NewStdLogger() + if err := l.Sync(); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} diff --git a/pkg/logger/logx/logx.go b/pkg/logger/logx/logx.go new file mode 100644 index 0000000..c22b82c --- /dev/null +++ b/pkg/logger/logx/logx.go @@ -0,0 +1,126 @@ +package logx + +import ( + "encoding/json" + "fmt" + "github.com/muxi-Infra/muxi-micro/pkg/logger" + "log" + "os" + "runtime" + "strings" + "sync" + "time" +) + +type StdLogger struct { + logger *log.Logger + fields logger.Field + mu sync.RWMutex +} + +// NewStdLogger 创建一个基于标准库 log 的 Logger 实现,非常轻量,适合简单场景/日志的兜底场景使用 +func NewStdLogger() *StdLogger { + return &StdLogger{ + logger: log.New(os.Stdout, "", 0), + fields: make(logger.Field), + } +} + +func (l *StdLogger) Info(msg string, fields ...logger.Field) { + l.output("INFO", msg, fields...) +} + +func (l *StdLogger) Debug(msg string, fields ...logger.Field) { + l.output("DEBUG", msg, fields...) +} + +func (l *StdLogger) Warn(msg string, fields ...logger.Field) { + l.output("WARN", msg, fields...) +} + +func (l *StdLogger) Error(msg string, fields ...logger.Field) { + l.output("ERROR", msg, fields...) +} + +func (l *StdLogger) Fatal(msg string, fields ...logger.Field) { + l.output("FATAL", msg, fields...) + os.Exit(1) +} + +// With 创建带上下文的 logger +func (l *StdLogger) With(fields ...logger.Field) logger.Logger { + l.mu.RLock() + base := make(logger.Field, len(l.fields)) + for k, v := range l.fields { + base[k] = v + } + l.mu.RUnlock() + + for _, f := range fields { + for k, v := range f { + base[k] = v + } + } + + return &StdLogger{ + logger: l.logger, + fields: base, + } +} + +// Sync 在标准库中没有缓冲区,这里直接返回 nil +func (l *StdLogger) Sync() error { + return nil +} + +func (l *StdLogger) output(level, msg string, fields ...logger.Field) { + l.mu.RLock() + defer l.mu.RUnlock() + + // 合并全局与传入字段 + merged := make(logger.Field) + for k, v := range l.fields { + merged[k] = v + } + for _, f := range fields { + for k, v := range f { + merged[k] = v + } + } + + // 获取时间与调用信息 + now := time.Now().Format("2006-01-02T15:04:05.000-0700") + _, file, line, ok := runtime.Caller(2) + caller := "unknown" + if ok { + short := file + if idx := strings.LastIndex(file, "/"); idx != -1 { + short = file[idx+1:] + } + caller = fmt.Sprintf("%s:%d", short, line) + } + + // 构建 JSON map + logMap := map[string]interface{}{ + "level": level, + "@timestamp": now, + "caller": caller, + "msg": msg, + } + + // 合并自定义字段 + for k, v := range merged { + logMap[k] = v + } + + // 序列化为 JSON + jsonBytes, err := json.Marshal(logMap) + if err != nil { + // 兜底输出 + l.logger.Printf("[LOGGING_ERROR] level=%s msg=%s err=%v", level, msg, err) + return + } + + // 输出单行 JSON 日志 + l.logger.Println(string(jsonBytes)) +} diff --git a/pkg/logger/zapx/zapx.go b/pkg/logger/zapx/zapx.go index a6b92c8..543f9f8 100644 --- a/pkg/logger/zapx/zapx.go +++ b/pkg/logger/zapx/zapx.go @@ -3,13 +3,13 @@ package zapx import ( "fmt" "github.com/muxi-Infra/muxi-micro/pkg/logger" + "github.com/muxi-Infra/muxi-micro/static" "go.uber.org/zap" "go.uber.org/zap/zapcore" "gopkg.in/natefinch/lumberjack.v2" "log" "os" "path/filepath" - "strings" ) type ZapLogger struct{ l *zap.Logger } @@ -19,154 +19,117 @@ type ZapOption func(*ZapCfg) type ZapCfg struct { core zapcore.Core options []zap.Option + env static.Env + logDir string } -func NewDefaultZapLogger(logDir string, env logger.Env) logger.Logger { +func NewDefaultZapLogger() logger.Logger { return NewZapLogger( - WithDefaultZapCore( - WithLogDir(logDir), - WithCoreEnv(env), - ), - WithDefaultZapOptions(), + WithZapCore(NewDefaultZapCore("./logs", static.EnvProd)), + WithZapOptions(NewDefaultZapOptions()...), ) } -func NewZapLogger(opts ...ZapOption) logger.Logger { - cfg := &ZapCfg{} - for _, opt := range opts { - opt(cfg) - } - if cfg.core == nil { - log.Panic("缺少 zap-core 核心配置") - } - return &ZapLogger{l: zap.New(cfg.core, cfg.options...)} -} - -type CoreOption func(*coreCfg) - -type coreCfg struct { - env logger.Env - splitByLevel bool - logDir string -} - -func WithCoreEnv(env logger.Env) CoreOption { - return func(cfg *coreCfg) { +func WithCoreEnv(env static.Env) ZapOption { + return func(cfg *ZapCfg) { cfg.env = env } } -func WithCoreSplit(splitByLevel bool) CoreOption { - return func(cfg *coreCfg) { - cfg.splitByLevel = splitByLevel - } +// WithZapCore 允许自定义 core, 如果传入了会覆盖Env,logDir的配置,请注意 +func WithZapCore(core zapcore.Core) ZapOption { + return func(cfg *ZapCfg) { cfg.core = core } } -func WithLogDir(logDir string) CoreOption { - return func(cfg *coreCfg) { +func WithLogDir(logDir string) ZapOption { + return func(cfg *ZapCfg) { cfg.logDir = logDir } } -func WithDefaultZapCore(opts ...CoreOption) ZapOption { - return func(cfg *ZapCfg) { - var corecfg = coreCfg{ - splitByLevel: false, - logDir: "./logs", - env: logger.EnvProd, - } +// 允许替换 Option +func WithZapOptions(opts ...zap.Option) ZapOption { + return func(cfg *ZapCfg) { cfg.options = opts } +} - for _, opt := range opts { - opt(&corecfg) - } - // dev 只需要 stdout,不强制创建 logDir - if corecfg.env != logger.EnvDev { - corecfg.logDir = filepath.Clean(corecfg.logDir) - if err := os.MkdirAll(corecfg.logDir, 0755); err != nil { - log.Panicf("无法创建日志目录: %v", err) - } - } +// 默认 core +func NewDefaultZapCore(logDir string, env static.Env) (core zapcore.Core) { - jsonEnc := zapcore.NewJSONEncoder(prodEncoderConfig()) - consoleEnc := zapcore.NewConsoleEncoder(devEncoderConfig()) - - switch corecfg.env { - // ======== DEV:彩色到控制台 ======== - case logger.EnvDev: - cfg.core = zapcore.NewCore(consoleEnc, zapcore.AddSync(os.Stdout), zapcore.DebugLevel) - return - - // ======== TEST:控制台彩色 + 文件 JSON ======== - case logger.EnvTest: - consoleCore := zapcore.NewCore(consoleEnc, zapcore.AddSync(os.Stdout), zapcore.DebugLevel) - fileCore := buildFileCores(jsonEnc, corecfg.splitByLevel, corecfg.logDir, false) // 仅文件 - cfg.core = zapcore.NewTee(append([]zapcore.Core{consoleCore}, fileCore...)...) - return - - // ======== PROD(默认):全 JSON 单行 ======== - case logger.EnvProd: - cores := buildFileCores(jsonEnc, corecfg.splitByLevel, corecfg.logDir, true) // stdout+file 共写 - cfg.core = zapcore.NewTee(cores...) - - default: - log.Panic("非法的环境") - return + // dev 只需要 stdout,不强制创建 logDir + if env != static.EnvDev { + logDir = filepath.Clean(logDir) + if err := os.MkdirAll(logDir, 0755); err != nil { + log.Panicf("无法创建日志目录: %v", err) } } -} -// 允许替换 Option -func WithZapOptions(opts ...zap.Option) ZapOption { - return func(cfg *ZapCfg) { cfg.options = opts } + jsonEnc := zapcore.NewJSONEncoder(prodEncoderConfig()) + consoleEnc := zapcore.NewConsoleEncoder(devEncoderConfig()) + + switch env { + // ======== DEV:彩色到控制台 ======== + case static.EnvDev: + core = zapcore.NewCore(consoleEnc, zapcore.AddSync(os.Stdout), zapcore.DebugLevel) + return + + // ======== TEST:控制台彩色 + 文件 JSON ======== + case static.EnvTest: + consoleCore := zapcore.NewCore(consoleEnc, zapcore.AddSync(os.Stdout), zapcore.DebugLevel) + fileCore := buildFileCores(jsonEnc, logDir, false) // 仅文件 + core = zapcore.NewTee(append([]zapcore.Core{consoleCore}, fileCore...)...) + return + + // ======== PROD(默认):全 JSON 单行 ======== + case static.EnvProd: + cores := buildFileCores(jsonEnc, logDir, true) // stdout+file 共写 + core = zapcore.NewTee(cores...) + + default: + log.Panic("非法的环境") + return + } + + return core } // 默认 Option -func WithDefaultZapOptions() ZapOption { - return func(cfg *ZapCfg) { - cfg.options = []zap.Option{ - zap.AddCaller(), - zap.AddStacktrace(zapcore.WarnLevel), - zap.AddCallerSkip(1), - } +func NewDefaultZapOptions() []zap.Option { + return []zap.Option{ + zap.AddCaller(), + zap.AddStacktrace(zapcore.WarnLevel), + zap.AddCallerSkip(1), } } -// 允许自定义 core -func WithZapCore(core zapcore.Core) ZapOption { - return func(cfg *ZapCfg) { cfg.core = core } +func NewZapLogger(opts ...ZapOption) logger.Logger { + cfg := &ZapCfg{ + logDir: "./logs", + env: static.EnvProd, + } + for _, opt := range opts { + opt(cfg) + } + + // 如果用户没有传入 core,则使用默认的 + if cfg.core == nil { + cfg.core = NewDefaultZapCore(cfg.logDir, cfg.env) + } + + return &ZapLogger{l: zap.New(cfg.core, cfg.options...)} } // 构造文件相关 core;如果 withStdout=true,则 stdout 也走同 encoder(生产) -func buildFileCores(enc zapcore.Encoder, split bool, dir string, withStdout bool) []zapcore.Core { +func buildFileCores(enc zapcore.Encoder, dir string, withStdout bool) []zapcore.Core { var cores []zapcore.Core stdout := zapcore.AddSync(os.Stdout) - if !split { - var ws zapcore.WriteSyncer - if withStdout { - ws = zapcore.NewMultiWriteSyncer(stdout, zapcore.AddSync(newRotateLogger(fmt.Sprintf("%s/app.log", dir)))) - } else { - ws = zapcore.AddSync(newRotateLogger(fmt.Sprintf("%s/app.log", dir))) - } - cores = append(cores, zapcore.NewCore(enc, ws, zapcore.DebugLevel)) - return cores - } - - levels := []zapcore.Level{ - zapcore.DebugLevel, zapcore.InfoLevel, zapcore.WarnLevel, - zapcore.ErrorLevel, zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel, - } - for _, lv := range levels { - fileWS := zapcore.AddSync(newRotateLogger(fmt.Sprintf("%s/%s.log", dir, strings.ToLower(lv.String())))) - var ws zapcore.WriteSyncer - if withStdout { - ws = zapcore.NewMultiWriteSyncer(stdout, fileWS) - } else { - ws = fileWS - } - core := zapcore.NewCore(enc, ws, zap.LevelEnablerFunc(func(l zapcore.Level) bool { return l == lv })) - cores = append(cores, core) + var ws zapcore.WriteSyncer + if withStdout { + ws = zapcore.NewMultiWriteSyncer(stdout, zapcore.AddSync(newRotateLogger(fmt.Sprintf("%s/app.log", dir)))) + } else { + ws = zapcore.AddSync(newRotateLogger(fmt.Sprintf("%s/app.log", dir))) } + cores = append(cores, zapcore.NewCore(enc, ws, zapcore.DebugLevel)) return cores } @@ -220,8 +183,10 @@ func (z *ZapLogger) Sync() error { return z.l.Sync() } func convert(fields []logger.Field) []zap.Field { var res []zap.Field - for _, arg := range fields { - res = append(res, zap.Any(arg.Key, arg.Val)) + for _, f := range fields { + for k, v := range f { + res = append(res, zap.Any(k, v)) + } } return res } diff --git a/pkg/logger/zapx/zapx_test.go b/pkg/logger/zapx/zapx_test.go index c29fb10..2c15e0f 100644 --- a/pkg/logger/zapx/zapx_test.go +++ b/pkg/logger/zapx/zapx_test.go @@ -1,22 +1,21 @@ package zapx import ( - "errors" "fmt" - "os" - "path/filepath" - "testing" - "github.com/muxi-Infra/muxi-micro/pkg/logger" + "github.com/muxi-Infra/muxi-micro/static" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "os" + "path/filepath" + "testing" ) func TestNewDefaultZapLogger_AllEnv(t *testing.T) { - envs := []logger.Env{logger.EnvDev, logger.EnvTest, logger.EnvProd} + envs := []static.Env{static.EnvDev, static.EnvTest, static.EnvProd} for _, env := range envs { t.Run(fmt.Sprintf("%v", env), func(t *testing.T) { - l := NewDefaultZapLogger("./logs/test_default", env) + l := NewDefaultZapLogger() if l == nil { t.Errorf("NewDefaultZapLogger 返回空") } @@ -33,7 +32,6 @@ func TestNewZapLogger_CustomCore(t *testing.T) { ) l := NewZapLogger( WithZapCore(core), - WithDefaultZapOptions(), ) if l == nil { t.Fatal("自定义 core 创建失败") @@ -41,46 +39,30 @@ func TestNewZapLogger_CustomCore(t *testing.T) { logAll(l) } -func TestNewZapLogger_WithOutCore(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("core为nil未触发 panic") - } - }() - - NewZapLogger() -} - func TestNewZapLogger_CustomOptions(t *testing.T) { - core := zapcore.NewCore( - zapcore.NewConsoleEncoder(devEncoderConfig()), - zapcore.AddSync(os.Stdout), - zapcore.InfoLevel, - ) + opts := []zap.Option{zap.AddCaller(), zap.AddCallerSkip(2)} l := NewZapLogger( - WithZapCore(core), + WithCoreEnv(static.EnvTest), + WithLogDir("./logs/custom_options"), WithZapOptions(opts...), ) if l == nil { t.Fatal("自定义 options 创建失败") } logAll(l) - } func TestWithDefaultZapCore_CreatesLogDir(t *testing.T) { dir := "./logs/test_create_dir" _ = os.RemoveAll(dir) - opt := WithDefaultZapCore( - WithCoreEnv(logger.EnvTest), - WithCoreSplit(false), - WithLogDir(dir), + core := NewDefaultZapCore( + dir, + static.EnvTest, ) - cfg := &ZapCfg{} - opt(cfg) + NewZapLogger(WithZapCore(core)) if _, err := os.Stat(dir); os.IsNotExist(err) { t.Errorf("日志目录未创建: %v", dir) } @@ -93,9 +75,8 @@ func TestWithDefaultZapCore_IllegalEnv(t *testing.T) { } }() - opt := WithDefaultZapCore(WithCoreSplit(true), WithLogDir("./logs/illegal"), WithCoreEnv(logger.Env(99))) - cfg := &ZapCfg{} - opt(cfg) + core := NewDefaultZapCore("./logs/illegal", static.Env(99)) + NewZapLogger(WithZapCore(core)) } func TestWithZapCore_OverridesPrevious(t *testing.T) { @@ -126,10 +107,8 @@ func TestWithZapOptions_AppendsOptions(t *testing.T) { func TestLogDirClean(t *testing.T) { // 测试 logDir clean 是否去除末尾斜杠 logDir := "./logs/clean-test////" - opt := WithDefaultZapCore(WithCoreSplit(true), WithLogDir(logDir), WithCoreEnv(logger.EnvTest)) - cfg := &ZapCfg{} - opt(cfg) - + core := NewDefaultZapCore(logDir, static.EnvTest) + NewZapLogger(WithZapCore(core)) cleaned := filepath.Clean(logDir) if _, err := os.Stat(cleaned); os.IsNotExist(err) { t.Errorf("clean 后目录未创建: %s", cleaned) @@ -138,17 +117,20 @@ func TestLogDirClean(t *testing.T) { func logAll(l logger.Logger) { - l.With(logger.String("all", "everyLog")) + l.With(logger.Field{ + "string": "string", + "int": 1, + }) + l.Info("test", - logger.Int("1", 1), - logger.Int32("1", 1), - logger.Int64("1", 1), - logger.Any("1", 1), - logger.Error(errors.New("1234")), + logger.Field{ + "string": "string", + "int": 1, + }, ) - l.Debug("test", logger.Int("1", 1)) - l.Warn("test", logger.Int("1", 1)) - l.Error("test", logger.Int("1", 1)) + l.Debug("test") + l.Warn("test") + l.Error("test") l.Sync() } diff --git a/pkg/transport/grpc/grpc.go b/pkg/transport/grpc/grpc.go new file mode 100644 index 0000000..21e034e --- /dev/null +++ b/pkg/transport/grpc/grpc.go @@ -0,0 +1 @@ +package grpc diff --git a/pkg/transport/grpc/registry/etcd/etcd.go b/pkg/transport/grpc/registry/etcd/etcd.go new file mode 100644 index 0000000..7e863a8 --- /dev/null +++ b/pkg/transport/grpc/registry/etcd/etcd.go @@ -0,0 +1,123 @@ +package etcd + +import ( + "context" + "fmt" + "github.com/muxi-Infra/muxi-micro/pkg/logger" + "github.com/muxi-Infra/muxi-micro/pkg/logger/logx" + "github.com/muxi-Infra/muxi-micro/pkg/transport/grpc/registry" + clientv3 "go.etcd.io/etcd/client/v3" + "time" +) + +type EtcdRegistry struct { + client *clientv3.Client + logger logger.Logger + endpoints []string + dialTimeout time.Duration + leaseTTL int64 + namespace string +} + +type Option func(*EtcdRegistry) + +func WithEndpoints(endpoints []string) Option { + return func(r *EtcdRegistry) { + r.endpoints = endpoints + } +} + +func WithLogger(l logger.Logger) Option { + return func(r *EtcdRegistry) { + r.logger = l + } +} + +func WithDialTimeout(timeout time.Duration) Option { + return func(r *EtcdRegistry) { + r.dialTimeout = timeout + } +} + +func WithLeaseTTL(ttl int64) Option { + return func(r *EtcdRegistry) { + r.leaseTTL = ttl + } +} + +func WithNamespace(ns string) Option { + return func(r *EtcdRegistry) { + r.namespace = ns + } +} + +// ===== 构造函数 ===== +func NewEtcdRegistry(opts ...Option) (*EtcdRegistry, error) { + r := &EtcdRegistry{ + endpoints: []string{"127.0.0.1:2379"}, + dialTimeout: 5 * time.Second, + leaseTTL: 10, + namespace: "/services", + logger: logx.NewStdLogger(), + } + + for _, opt := range opts { + opt(r) + } + + cli, err := clientv3.New(clientv3.Config{ + Endpoints: r.endpoints, + DialTimeout: r.dialTimeout, + }) + if err != nil { + return nil, err + } + r.client = cli + return r, nil +} + +// ===== 核心方法 ===== +func (r *EtcdRegistry) Register(ctx context.Context, serviceName, port string) error { + host, err := registry.GetLocalIP() + if err != nil { + return err + } + + key := fmt.Sprintf("%s/%s/%s:%s", r.namespace, serviceName, host, port) + val := fmt.Sprintf("%s:%s", host, port) + + lease, err := r.client.Grant(ctx, r.leaseTTL) + if err != nil { + return err + } + + _, err = r.client.Put(ctx, key, val, clientv3.WithLease(lease.ID)) + if err != nil { + return err + } + + ch, err := r.client.KeepAlive(ctx, lease.ID) + if err != nil { + return err + } + + go func() { + for { + select { + case ka, ok := <-ch: + if !ok { + r.logger.Warn(fmt.Sprintf("keepalive channel closed for %s", key)) + return + } + r.logger.Debug(fmt.Sprintf("lease renewed: %v", ka.ID)) + case <-ctx.Done(): + r.logger.Info(fmt.Sprintf("deregistering service: %s", key)) + return + } + } + }() + + r.logger.Info(fmt.Sprintf("✅ Service registered: %s -> %s", key, val)) + + return nil +} diff --git a/pkg/transport/grpc/registry/registry.go b/pkg/transport/grpc/registry/registry.go new file mode 100644 index 0000000..317fc59 --- /dev/null +++ b/pkg/transport/grpc/registry/registry.go @@ -0,0 +1,28 @@ +package registry + +import ( + "context" + "fmt" + "net" +) + +type RegistrationCenter interface { + Register(ctx context.Context, serviceName, host, port string) error +} + +func GetLocalIP() (string, error) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + // 过滤掉 loopback 地址 + if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ip4 := ipNet.IP.To4(); ip4 != nil { + return ip4.String(), nil + } + } + } + return "", fmt.Errorf("no valid IP address found") +} diff --git a/pkg/transport/grpc/server.go b/pkg/transport/grpc/server.go new file mode 100644 index 0000000..53499bd --- /dev/null +++ b/pkg/transport/grpc/server.go @@ -0,0 +1,58 @@ +package grpc + +import ( + "context" + "github.com/muxi-Infra/muxi-micro/pkg/transport/grpc/registry" + "google.golang.org/grpc" + "log" + "net" +) + +type Option func(*GRPCServer) + +func WithRegistrationCenter(registrationCenter registry.RegistrationCenter) Option { + return func(s *GRPCServer) { + s.registrationCenter = registrationCenter + } +} + +func NewGRPCServer(grpcServer *grpc.Server, opts ...Option) *GRPCServer { + s := GRPCServer{ + grpcServer: grpcServer, + } + + for _, opt := range opts { + opt(&s) + } + + return &s +} + +func (s *GRPCServer) Serve(ctx context.Context, serviceName, host, port string) error { + + // 注册服务到注册中心,如果有的话 + if s.registrationCenter != nil { + err := s.registrationCenter.Register(ctx, serviceName, host, port) + if err != nil { + return err + } + } + + // 启动 gRPC 服务器 + lis, err := net.Listen("tcp", ":"+port) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + + log.Printf("gRPC server started on :%s", port) + if err := s.grpcServer.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + + return nil +} + +type GRPCServer struct { + registrationCenter registry.RegistrationCenter + grpcServer *grpc.Server +} diff --git a/pkg/transport/http/ginx/engine/engine.go b/pkg/transport/http/ginx/engine/engine.go new file mode 100644 index 0000000..55944a2 --- /dev/null +++ b/pkg/transport/http/ginx/engine/engine.go @@ -0,0 +1,81 @@ +package engine + +import ( + "github.com/gin-contrib/pprof" + "github.com/gin-gonic/gin" + "github.com/muxi-Infra/muxi-micro/pkg/logger" + "github.com/muxi-Infra/muxi-micro/pkg/logger/logx" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/log" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/middleware/cors" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/middleware/limiter" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/middleware/timeout" + "github.com/muxi-Infra/muxi-micro/static" +) + +type engineConfig struct { + env static.Env + g *gin.Engine + l logger.Logger + name string +} + +type EngineOption func(*engineConfig) + +// 设置服务名称 +func WithEnv(env static.Env) EngineOption { + return func(cfg *engineConfig) { + cfg.env = env + } +} + +// WithGinEngine 手动控制gin的Engine +func WithGinEngine(g *gin.Engine) EngineOption { + return func(cfg *engineConfig) { + cfg.g = g + } +} + +func WithName(name string) EngineOption { + return func(cfg *engineConfig) { + cfg.name = name + } +} +func WithLogger(l logger.Logger) EngineOption { + return func(cfg *engineConfig) { + cfg.l = l + } +} + +// 创建默认引擎,附带常用中间件和可选配置 +func NewEngine(opts ...EngineOption) *gin.Engine { + cfg := &engineConfig{ + env: static.EnvProd, + g: gin.Default(), + name: log.DefaultName, + l: logx.NewStdLogger(), //如果不配置logger的话就默认使用标准输出 + } + + for _, opt := range opts { + opt(cfg) + } + + // 非生产环境注册 pprof + if cfg.env != static.EnvProd { + pprof.Register(cfg.g) + } + + cfg.g.Use( + log.GlobalLoggerMiddleware(cfg.l), + log.GlobalNameMiddleware(cfg.name), + ) + + return cfg.g +} + +func UseDefaultMiddleware(g *gin.Engine) { + g.Use( + cors.Cors(), + limiter.Limiter(), + timeout.Timeout(), + ) +} diff --git a/pkg/transport/http/ginx/engine/engine_test.go b/pkg/transport/http/ginx/engine/engine_test.go new file mode 100644 index 0000000..2a86f96 --- /dev/null +++ b/pkg/transport/http/ginx/engine/engine_test.go @@ -0,0 +1,21 @@ +package engine + +import ( + "github.com/gin-gonic/gin" + "github.com/muxi-Infra/muxi-micro/pkg/logger/logx" + "github.com/muxi-Infra/muxi-micro/static" + "testing" +) + +func TestDefaultEngine(t *testing.T) { + t.Run("create", func(t *testing.T) { + g := NewEngine( + WithGinEngine(gin.New()), + WithEnv(static.EnvDev), + WithLogger(logx.NewStdLogger()), + WithName("test"), + ) + UseDefaultMiddleware(g) + }) + +} diff --git a/pkg/transport/http/ginx/ginx.go b/pkg/transport/http/ginx/ginx.go deleted file mode 100644 index c74859f..0000000 --- a/pkg/transport/http/ginx/ginx.go +++ /dev/null @@ -1,202 +0,0 @@ -package ginx - -import ( - "github.com/gin-contrib/pprof" - "github.com/gin-gonic/gin" - "github.com/muxi-Infra/muxi-micro/pkg/errs" - t_http "github.com/muxi-Infra/muxi-micro/pkg/transport/http" - "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/middleware/cors" - "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/middleware/limiter" - "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/middleware/timeout" - - "net/http" -) - -var shouldBindErr = errs.NewErr("bind fail", "ctx shouldBind failed") - -var ( - defaultBindErrCode = 42201 - defaultGetClaimsErrCode = 40101 -) - -type engineConfig struct { - env t_http.Env - g *gin.Engine -} - -type EngineOption func(*engineConfig) - -// 设置运行环境 -func WithEnv(env t_http.Env) EngineOption { - return func(cfg *engineConfig) { - cfg.env = env - } -} - -// 手动控制gin的Engine -func WithEngine(g *gin.Engine) EngineOption { - return func(cfg *engineConfig) { - cfg.g = g - } -} - -// 创建默认引擎,附带常用中间件和可选配置 -func NewDefaultEngine(opts ...EngineOption) *gin.Engine { - cfg := &engineConfig{ - env: t_http.EnvProd, - g: gin.Default(), - } - - for _, opt := range opts { - opt(cfg) - } - - // 非生产环境注册 pprof - if cfg.env != t_http.EnvProd { - pprof.Register(cfg.g) - } - - return cfg.g -} - -func UseDefaultMiddleware(g *gin.Engine) { - g.Use( - cors.Cors(), - limiter.Limiter(), - timeout.Timeout(), - ) -} - -func SetBindErrCode(errCode int) { - defaultBindErrCode = errCode -} - -func SetGetClaimsErrCode(errCode int) { - defaultGetClaimsErrCode = errCode -} - -// 解析参数通用函数 -func bind(ctx *gin.Context, req any) error { - var err error - // 根据请求方法选择合适的绑定方式 - if ctx.Request.Method == http.MethodGet { - err = ctx.ShouldBindQuery(req) // 处理GET请求的查询参数 - } else { - err = ctx.ShouldBind(req) // 处理POST、PUT等请求的请求体数据 - } - - if err != nil { - return shouldBindErr.WithCause(err) - } - - return nil -} - -func WrapClaimsAndReq[Req any, UserClaims any](getClaims func(*gin.Context) (UserClaims, error), fn func(*gin.Context, Req, UserClaims) t_http.Response) gin.HandlerFunc { - return func(ctx *gin.Context) { - - // 检查前置中间件是否存在错误,如果存在应当直接返回 - if len(ctx.Errors) > 0 { - return - } - - //解析请求 - var req Req - err := bind(ctx, &req) - if err != nil { - ctx.JSON(http.StatusBadRequest, t_http.CommonResp{ - Code: defaultBindErrCode, - Message: "非法的参数: " + err.Error(), - Data: nil, - }) - return - } - - //获取uc参数 - uc, err := getClaims(ctx) - if err != nil { - ctx.JSON(http.StatusUnauthorized, t_http.CommonResp{ - Code: defaultGetClaimsErrCode, - Message: "登陆状态异常:" + err.Error(), - Data: nil, - }) - return - } - - //执行函数 - res := fn(ctx, req, uc) - ctx.JSON(res.HttpCode, res.CommonResp) - return - } -} - -// WrapReq 。用于处理有请求体的请求 -// ctx表示上下文,req表示请求结构体,Resp表示响应结构体(这里全部填web.Response) -func WrapReq[Req any](fn func(*gin.Context, Req) t_http.Response) gin.HandlerFunc { - return func(ctx *gin.Context) { - //检查前置中间件是否存在错误,如果存在应当直接返回 - if len(ctx.Errors) > 0 { - return - } - - //解析参数 - var req Req - err := bind(ctx, &req) - if err != nil { - ctx.JSON(http.StatusBadRequest, t_http.CommonResp{ - Code: defaultBindErrCode, - Message: "非法的参数: " + err.Error(), - Data: nil, - }) - return - } - - // 调用业务逻辑函数 - res := fn(ctx, req) - ctx.JSON(res.HttpCode, res.CommonResp) - return - } -} - -// Wrap 。用于处理没有请求体的请求 -// ctx表示上下文,Resp表示响应结构体(这里全部填web.Response) -func Wrap(fn func(*gin.Context) t_http.Response) gin.HandlerFunc { - return func(ctx *gin.Context) { - //检查前置中间件是否存在错误,如果存在应当直接返回 - if len(ctx.Errors) > 0 { - return - } - - res := fn(ctx) - ctx.JSON(res.HttpCode, res.CommonResp) - return - } -} - -// WrapClaims 用于处理有用户验证但是没有请求体的请求 -// ctx表示上下文,Resp表示响应结构体(这里全部填web.Response),UserClaims表示用户信息 -func WrapClaims[UserClaims any](getClaims func(ctx *gin.Context) (UserClaims, error), fn func(*gin.Context, UserClaims) t_http.Response) gin.HandlerFunc { - return func(ctx *gin.Context) { - - //检查前置中间件是否存在错误,如果存在应当直接返回 - if len(ctx.Errors) > 0 { - return - } - - //获取uc参数 - uc, err := getClaims(ctx) - if err != nil { - ctx.JSON(http.StatusUnauthorized, t_http.CommonResp{ - Code: defaultGetClaimsErrCode, - Message: "登陆状态异常:" + err.Error(), - Data: nil, - }) - return - } - - //执行函数 - res := fn(ctx, uc) - ctx.JSON(res.HttpCode, res.CommonResp) - return - } -} diff --git a/pkg/transport/http/ginx/ginx_test.go b/pkg/transport/http/ginx/ginx_test.go deleted file mode 100644 index 4a2471f..0000000 --- a/pkg/transport/http/ginx/ginx_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package ginx - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - t_http "github.com/muxi-Infra/muxi-micro/pkg/transport/http" - "github.com/stretchr/testify/assert" -) - -type demoReq struct { - Name string `json:"name" form:"name" binding:"required"` -} - -type demoClaims struct { - UID int64 `json:"uid"` -} - -func handleDemoReq(c *gin.Context, r demoReq) t_http.Response { - return t_http.Response{ - HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{ - Code: 0, - Message: "ok", - Data: r, - }, - } -} - -func handleWithClaims(c *gin.Context, uc demoClaims) t_http.Response { - return t_http.Response{ - HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{ - Code: 0, - Message: "ok", - Data: uc, - }, - } -} - -func handleWithClaimsAndReq(c *gin.Context, r demoReq, uc demoClaims) t_http.Response { - return t_http.Response{ - HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{ - Code: 0, - Message: "ok", - Data: gin.H{ - "name": r.Name, - "uid": uc.UID, - }, - }, - } -} - -func handleNoBody(c *gin.Context) t_http.Response { - return t_http.Response{ - HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{Code: 0, Message: "pong"}, - } -} - -func mockClaimsOK(c *gin.Context) (demoClaims, error) { - return demoClaims{UID: 1}, nil -} - -var authError = errors.New("认证失败") - -func mockClaimsFail(c *gin.Context) (demoClaims, error) { - return demoClaims{}, authError -} - -func decodeResp[T any](body []byte) (T, error) { - var resp t_http.CommonResp - var t T - err := json.Unmarshal(body, &resp) - if err != nil { - return t, err - } - db, _ := json.Marshal(resp.Data) - err = json.Unmarshal(db, &t) - return t, err -} - -func setErrorHandlerFunc() gin.HandlerFunc { - return func(c *gin.Context) { - c.Error(errors.New("test")) - } -} - -func TestWrapReq(t *testing.T) { - gin.SetMode(gin.TestMode) - g := gin.New() - g.POST("/demo", WrapReq(handleDemoReq)) - - tests := []struct { - name string - body string - status int - expect string - }{ - {"非法参数", `{"foo":"bar"}`, http.StatusBadRequest, ""}, - {"正常参数", `{"name":"bar"}`, http.StatusOK, "bar"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/demo", bytes.NewBufferString(tt.body)) - req.Header.Set("Content-Type", "application/json") - g.ServeHTTP(w, req) - - assert.Equal(t, tt.status, w.Code) - if tt.status == http.StatusOK { - res, err := decodeResp[demoReq](w.Body.Bytes()) - assert.NoError(t, err) - assert.Equal(t, tt.expect, res.Name) - } - }) - } - - t.Run("error in front Middleware", func(t *testing.T) { - g.GET("/testFrontError", setErrorHandlerFunc(), WrapReq(handleDemoReq)) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/testFrontError", bytes.NewBufferString("{\"name\":\"bar\"}")) - req.Header.Set("Content-Type", "application/json") - g.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - }) -} - -func TestWrapClaims(t *testing.T) { - gin.SetMode(gin.TestMode) - g := gin.New() - g.GET("/ok", WrapClaims(mockClaimsOK, handleWithClaims)) - g.GET("/fail", WrapClaims(mockClaimsFail, handleWithClaims)) - - t.Run("验证失败", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/fail", nil) - g.ServeHTTP(w, req) - - var resp t_http.CommonResp - _ = json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, http.StatusUnauthorized, w.Code) - assert.Equal(t, "登陆状态异常:"+authError.Error(), resp.Message) - }) - - t.Run("验证成功", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/ok", nil) - g.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("error in front Middleware", func(t *testing.T) { - g.GET("/testFrontError", setErrorHandlerFunc(), WrapClaims(mockClaimsOK, handleWithClaims)) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/testFrontError", bytes.NewBufferString("{\"name\":\"bar\"}")) - req.Header.Set("Content-Type", "application/json") - g.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - }) -} - -func TestWrapClaimsAndReq(t *testing.T) { - gin.SetMode(gin.TestMode) - g := gin.New() - g.POST("/full", WrapClaimsAndReq(mockClaimsOK, handleWithClaimsAndReq)) - g.GET("/full", WrapClaimsAndReq(mockClaimsOK, handleWithClaimsAndReq)) - g.POST("/fail", WrapClaimsAndReq(mockClaimsFail, handleWithClaimsAndReq)) - - t.Run("Success get", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/full?name=bob", nil) - g.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp t_http.CommonResp - _ = json.Unmarshal(w.Body.Bytes(), &resp) - db, _ := json.Marshal(resp.Data) - var m map[string]any - _ = json.Unmarshal(db, &m) - - assert.Equal(t, "ok", resp.Message) - assert.Equal(t, "bob", m["name"]) - assert.Equal(t, float64(1), m["uid"]) // json.Unmarshal => float64 - }) - - t.Run("Success post", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/full", bytes.NewBufferString(`{"name":"bob"}`)) - req.Header.Set("Content-Type", "application/json") - - g.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var resp t_http.CommonResp - _ = json.Unmarshal(w.Body.Bytes(), &resp) - db, _ := json.Marshal(resp.Data) - var m map[string]any - _ = json.Unmarshal(db, &m) - - assert.Equal(t, "ok", resp.Message) - assert.Equal(t, "bob", m["name"]) - assert.Equal(t, float64(1), m["uid"]) - }) - - t.Run("BindErr", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/full", bytes.NewBufferString(`{}`)) - req.Header.Set("Content-Type", "application/json") - g.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - - t.Run("AuthErr", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/fail", bytes.NewBufferString(`{"name":"alice"}`)) - req.Header.Set("Content-Type", "application/json") - g.ServeHTTP(w, req) - - var resp t_http.CommonResp - _ = json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, http.StatusUnauthorized, w.Code) - assert.Equal(t, "登陆状态异常:"+authError.Error(), resp.Message) - }) - - t.Run("error in front Middleware", func(t *testing.T) { - g.GET("/testFrontError", setErrorHandlerFunc(), WrapClaimsAndReq(mockClaimsOK, handleWithClaimsAndReq)) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/testFrontError", bytes.NewBufferString("{\"name\":\"bar\"}")) - req.Header.Set("Content-Type", "application/json") - g.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - }) -} - -func TestWrap(t *testing.T) { - gin.SetMode(gin.TestMode) - g := gin.New() - - t.Run("请求正常", func(t *testing.T) { - g.GET("/ping", Wrap(handleNoBody)) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/ping", nil) - g.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp t_http.CommonResp - _ = json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, "pong", resp.Message) - }) - - t.Run("error in front Middleware", func(t *testing.T) { - g.GET("/testFrontError", setErrorHandlerFunc(), Wrap(handleNoBody)) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/testFrontError", bytes.NewBufferString("{\"name\":\"bar\"}")) - req.Header.Set("Content-Type", "application/json") - g.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - }) - -} - -func TestDefaultEngine(t *testing.T) { - t.Run("pprof enabled", func(t *testing.T) { - g := NewDefaultEngine( - WithEngine(gin.Default()), - WithEnv(t_http.EnvTest), - ) - g.GET("/ping", Wrap(handleNoBody)) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/debug/pprof/", nil) - g.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("pprof disabled", func(t *testing.T) { - g := NewDefaultEngine( - WithEngine(gin.Default()), - WithEnv(t_http.EnvProd), - ) - SetBindErrCode(42062) - SetGetClaimsErrCode(12345) - UseDefaultMiddleware(g) - g.GET("/ping", Wrap(handleNoBody)) - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/debug/pprof/", nil) - g.ServeHTTP(w, req) - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} diff --git a/pkg/transport/http/ginx/handler/handler.go b/pkg/transport/http/ginx/handler/handler.go new file mode 100644 index 0000000..45b2f06 --- /dev/null +++ b/pkg/transport/http/ginx/handler/handler.go @@ -0,0 +1,93 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/muxi-Infra/muxi-micro/pkg/errs" + t_http "github.com/muxi-Infra/muxi-micro/pkg/transport/http" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/log" + "net/http" +) + +const DefaultBindErrCode = 42201 + +var ( + ErrBindFailed = errs.NewErr("bind fail", "request bind failed") +) + +// 解析参数通用函数 +func Bind(ctx *gin.Context, req any) error { + var err error + // 根据请求方法选择合适的绑定方式 + if ctx.Request.Method == http.MethodGet { + err = ctx.ShouldBindQuery(req) // 处理GET请求的查询参数 + } else { + err = ctx.ShouldBind(req) // 处理POST、PUT请求的请求体数据 + } + + if err != nil { + return ErrBindFailed.WithCause(err) + } + + return nil +} + +// WrapReq 。用于处理有请求体的请求 +// ctx表示上下文,req表示请求结构体 +func WrapReq[Req any](fn func(*gin.Context, Req)) gin.HandlerFunc { + return func(ctx *gin.Context) { + //检查前置中间件是否存在错误,如果存在应当直接返回 + if len(ctx.Errors) > 0 { + return + } + //解析参数 + var req Req + err := Bind(ctx, &req) + if err != nil { + HandleResponse(ctx, t_http.Response{ + HttpCode: http.StatusBadRequest, + Code: DefaultBindErrCode, + Message: "非法的参数: " + err.Error(), + Data: nil, + }) + return + } + // 调用业务逻辑函数 + fn(ctx, req) + return + } +} + +// Wrap 。用于处理没有请求体的请求 +// ctx表示上下文 +func Wrap(fn func(*gin.Context)) gin.HandlerFunc { + return func(ctx *gin.Context) { + //检查前置中间件是否存在错误,如果存在应当直接返回 + if len(ctx.Errors) > 0 { + return + } + // 调用业务逻辑函数 + fn(ctx) + return + } +} + +// HandleResponse 处理需要自定义业务码的请求 +func HandleResponse(ctx *gin.Context, resp t_http.Response) { + finalResp := t_http.FinalResp{ + Code: resp.Code, + Message: resp.Message, + Data: resp.Data, + LogID: log.GetLogID(ctx), + } + + ctx.JSON(resp.HttpCode, finalResp) +} + +// HandleSuccessResponseWithData 快速处理成功响应 +func HandleSuccessResponseWithData(ctx *gin.Context, data any) { + HandleResponse(ctx, t_http.Response{ + Code: http.StatusOK, + Message: "success", + Data: data, + }) +} diff --git a/pkg/transport/http/ginx/handler/handler_test.go b/pkg/transport/http/ginx/handler/handler_test.go new file mode 100644 index 0000000..6c40696 --- /dev/null +++ b/pkg/transport/http/ginx/handler/handler_test.go @@ -0,0 +1,126 @@ +package handler + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + t_http "github.com/muxi-Infra/muxi-micro/pkg/transport/http" + "github.com/stretchr/testify/assert" +) + +type demoReq struct { + Name string `json:"name" form:"name" binding:"required"` +} + +func handleDemoReq(c *gin.Context, r demoReq) { + HandleResponse(c, t_http.Response{ + HttpCode: http.StatusOK, + Code: 0, + Message: "ok", + Data: r, + }) +} + +func handleNoBody(c *gin.Context) { + HandleResponse(c, t_http.Response{ + HttpCode: http.StatusOK, + Code: 0, + Message: "pong", + }) +} + +func decodeResp[T any](body []byte) (T, error) { + var resp t_http.FinalResp + var t T + err := json.Unmarshal(body, &resp) + if err != nil { + return t, err + } + db, _ := json.Marshal(resp.Data) + err = json.Unmarshal(db, &t) + return t, err +} + +func setErrorHandlerFunc() gin.HandlerFunc { + return func(c *gin.Context) { + c.Error(errors.New("test")) + } +} + +func TestWrapReq(t *testing.T) { + gin.SetMode(gin.TestMode) + g := gin.New() + g.POST("/demo", WrapReq(handleDemoReq)) + + tests := []struct { + name string + body string + status int + expect string + }{ + {"非法参数", `{"foo":"bar"}`, http.StatusBadRequest, ""}, + {"正常参数", `{"name":"bar"}`, http.StatusOK, "bar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/demo", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + g.ServeHTTP(w, req) + + assert.Equal(t, tt.status, w.Code) + if tt.status == http.StatusOK { + res, err := decodeResp[demoReq](w.Body.Bytes()) + assert.NoError(t, err) + assert.Equal(t, tt.expect, res.Name) + } + }) + } + + t.Run("error in front Middleware", func(t *testing.T) { + g.GET("/testFrontError", setErrorHandlerFunc(), WrapReq(handleDemoReq)) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/testFrontError", bytes.NewBufferString("{\"name\":\"bar\"}")) + req.Header.Set("Content-Type", "application/json") + g.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + }) +} + +func TestWrap(t *testing.T) { + gin.SetMode(gin.TestMode) + g := gin.New() + + t.Run("请求正常", func(t *testing.T) { + g.GET("/ping", Wrap(handleNoBody)) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/ping", nil) + g.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp t_http.FinalResp + _ = json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "pong", resp.Message) + }) + + t.Run("error in front Middleware", func(t *testing.T) { + g.GET("/testFrontError", setErrorHandlerFunc(), Wrap(handleNoBody)) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/testFrontError", bytes.NewBufferString("{\"name\":\"bar\"}")) + req.Header.Set("Content-Type", "application/json") + g.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + }) + +} diff --git a/pkg/transport/http/ginx/log/log.go b/pkg/transport/http/ginx/log/log.go new file mode 100644 index 0000000..9d1e42f --- /dev/null +++ b/pkg/transport/http/ginx/log/log.go @@ -0,0 +1,96 @@ +package log + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/hex" + "fmt" + "github.com/gin-gonic/gin" + "github.com/muxi-Infra/muxi-micro/pkg/logger" + "time" +) + +const ( + LogIDKey = "Gin-LogID" + LoggerKey = "Gin-Logger" + NameKey = "Gin-Name" + DefaultName = "unknown" +) + +// 生成日志 ID +func genLogID(prefix string) string { + // 当前时间纳秒 + 随机字节混合 + timeBytes := []byte(fmt.Sprintf("%d", time.Now().UnixNano())) + + //生成随机短id + b := make([]byte, 8) + _, _ = rand.Read(b) + randomBytes := hex.EncodeToString(b) + combined := append(timeBytes, randomBytes...) + + // SHA1 哈希处理 + hash := sha1.Sum(combined) + shortHash := hex.EncodeToString(hash[:8]) // 取前8字节(16个字符) + + logID := fmt.Sprintf("%s-%s", prefix, shortHash) + return logID +} + +// 设置到响应中需要 +func SetLogID(ctx *gin.Context, logID string) { + ctx.Set(LogIDKey, logID) +} + +// 设置到响应中需要 +func GetLogID(ctx *gin.Context) string { + var logID string + value, ok := ctx.Get(LogIDKey) + if !ok { + // 先尝试从X-Request-ID请求头里面取,如果需要用http跨服务调用的话可以考虑用这个保证整个调用链一致 + logID = ctx.Request.Header.Get("X-Request-ID") + // 如果不存在则尝试去生成一个 + if logID == "" { + logID = genLogID(GetGlobalName(ctx)) + } + SetLogID(ctx, logID) + return logID + } + return value.(string) +} + +// 用于设置在上下文中获取携带了特殊信息的日志,主动打印需要 +func SetLogger(ctx *gin.Context, logger logger.Logger) { + ctx.Set(LoggerKey, logger) +} + +// 用于获取在上下文中获取携带了特殊信息的日志 +func GetLogger(ctx *gin.Context) logger.Logger { + ginLogger, ok := ctx.Get(LoggerKey) + if !ok { + return nil // 如果不存在需要返回一个空指针 + } + return ginLogger.(logger.Logger) +} + +func GlobalLoggerMiddleware(l logger.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + l = l.With(logger.Field{ + "logID": GetLogID(ctx), // 保证ctx中的所有日志都是自带logID的 + }) + SetLogger(ctx, l) + } +} + +func GlobalNameMiddleware(name string) gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Set(NameKey, name) + } +} + +func GetGlobalName(ctx *gin.Context) string { + value, ok := ctx.Get(NameKey) + if !ok { + return DefaultName + } + return value.(string) +} diff --git a/pkg/transport/http/ginx/log/log_test.go b/pkg/transport/http/ginx/log/log_test.go new file mode 100644 index 0000000..9c2fcda --- /dev/null +++ b/pkg/transport/http/ginx/log/log_test.go @@ -0,0 +1,122 @@ +package log + +import ( + "github.com/gin-gonic/gin" + "github.com/muxi-Infra/muxi-micro/pkg/logger" + "github.com/muxi-Infra/muxi-micro/pkg/logger/logx" + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" +) + +// TestGenLogID 测试生成的 LogID 格式和唯一性 +func TestGenLogID(t *testing.T) { + id1 := genLogID("test") + id2 := genLogID("test") + + if id1 == id2 { + t.Errorf("expected unique logID, got same: %s", id1) + } + + // 匹配前缀 test-xxxxxx + re := regexp.MustCompile(`^test-[0-9a-f]{16}$`) + if !re.MatchString(id1) { + t.Errorf("invalid logID format: %s", id1) + } +} + +// TestSetAndGetLogID 测试在 Gin Context 中正确设置与获取 +func TestSetAndGetLogID(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("GET", "/", nil) + logID := "abc-123" + SetLogID(ctx, logID) + + got := GetLogID(ctx) + if got != logID { + t.Errorf("expected %s, got %s", logID, got) + } +} + +// TestAutoGenerateLogID 当没有 LogID 时自动生成 +func TestAutoGenerateLogID(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("GET", "/", nil) + id := GetLogID(ctx) + if id == "" { + t.Errorf("expected auto-generated logID, got empty") + } + if !regexp.MustCompile(`^unknown-[0-9a-f]{16}$`).MatchString(id) { + t.Errorf("unexpected auto logID format: %s", id) + } +} + +// TestGlobalMiddleware 测试中间件能正确注入 logID 与 logger +func TestGlobalMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + stdLogger := logx.NewStdLogger() + + router.Use(GlobalNameMiddleware("mockService")) + router.Use(GlobalLoggerMiddleware(stdLogger)) + + router.GET("/ping", func(ctx *gin.Context) { + l := GetLogger(ctx) + if l == nil { + t.Errorf("logger not injected into context") + } + + logID := GetLogID(ctx) + if logID == "" { + t.Errorf("logID should not be empty") + } + + // 记录一条日志,确保不会 panic + l.Info("middleware test", logger.Field{"req": "ping"}) + + ctx.JSON(http.StatusOK, gin.H{ + "ok": true, + "logID": logID, + }) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/ping", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + t.Logf("response: %s", w.Body.String()) +} + +// TestGetGlobalName 测试全局服务名逻辑 +func TestGetGlobalName(t *testing.T) { + ctx := &gin.Context{} + + // 未设置时默认返回 unknown + if name := GetGlobalName(ctx); name != DefaultName { + t.Errorf("expected %s, got %s", DefaultName, name) + } + + // 设置后应返回对应值 + GlobalNameMiddleware("testService")(ctx) + name := GetGlobalName(ctx) + if name != "testService" { + t.Errorf("expected testService, got %s", name) + } +} + +// TestLogIDPerformance 简单测试生成 ID 的性能 +func TestLogIDPerformance(t *testing.T) { + start := time.Now() + for i := 0; i < 10000; i++ { + _ = genLogID("perf") + } + elapsed := time.Since(start) + t.Logf("generated 10k logIDs in %v", elapsed) +} diff --git a/pkg/transport/http/ginx/middleware/cors/cors_test.go b/pkg/transport/http/ginx/middleware/cors/cors_test.go index 929e23e..288fa0b 100644 --- a/pkg/transport/http/ginx/middleware/cors/cors_test.go +++ b/pkg/transport/http/ginx/middleware/cors/cors_test.go @@ -1,6 +1,6 @@ package cors -// TODO 测试可用性,没弄明白怎么用代码测试跨域问题,现在的 +// TODO 测试可用性,没弄明白怎么用代码测试跨域问题,现在的只测试了配置项 import ( "github.com/stretchr/testify/assert" "testing" diff --git a/pkg/transport/http/ginx/middleware/limiter/limiter.go b/pkg/transport/http/ginx/middleware/limiter/limiter.go index 2efed86..66c1e26 100644 --- a/pkg/transport/http/ginx/middleware/limiter/limiter.go +++ b/pkg/transport/http/ginx/middleware/limiter/limiter.go @@ -3,9 +3,11 @@ package limiter import ( "github.com/gin-gonic/gin" t_http "github.com/muxi-Infra/muxi-micro/pkg/transport/http" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/handler" "github.com/ulule/limiter/v3" l_gin "github.com/ulule/limiter/v3/drivers/middleware/gin" "github.com/ulule/limiter/v3/drivers/store/memory" + "net/http" ) const ( @@ -81,19 +83,20 @@ func Limiter(opts ...Option) gin.HandlerFunc { // 自定义限流返回结构 return l_gin.NewMiddleware(lim, l_gin.WithLimitReachedHandler(func(c *gin.Context) { - c.AbortWithStatusJSON(429, t_http.CommonResp{ - Message: cfg.msgRateLimited, - Code: cfg.codeRateLimited, - Data: nil, - }, - ) + handler.HandleResponse(c, t_http.Response{ + HttpCode: http.StatusTooManyRequests, + Code: cfg.codeRateLimited, + Message: cfg.msgRateLimited, + Data: nil, + }) }), l_gin.WithErrorHandler(func(c *gin.Context, err error) { - c.AbortWithStatusJSON(500, t_http.CommonResp{ - Message: cfg.msgRateError + err.Error(), - Code: cfg.codeRateError, - Data: nil, + handler.HandleResponse(c, t_http.Response{ + HttpCode: http.StatusInternalServerError, + Code: cfg.codeRateError, + Message: cfg.msgRateError + err.Error(), + Data: nil, }) }), ) diff --git a/pkg/transport/http/ginx/middleware/limiter/limiter_test.go b/pkg/transport/http/ginx/middleware/limiter/limiter_test.go index 6339379..0e83c3e 100644 --- a/pkg/transport/http/ginx/middleware/limiter/limiter_test.go +++ b/pkg/transport/http/ginx/middleware/limiter/limiter_test.go @@ -55,7 +55,7 @@ func TestLimiter_RateLimited(t *testing.T) { // 第 3 次:检查限流响应 assert.Equal(t, http.StatusTooManyRequests, w.Code) // 429 - var resp t_http.CommonResp + var resp t_http.FinalResp err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(t, err) @@ -87,7 +87,7 @@ func TestLimiter_CustomOption(t *testing.T) { r.ServeHTTP(w2, req2) assert.Equal(t, http.StatusTooManyRequests, w2.Code) - var resp t_http.CommonResp + var resp t_http.FinalResp _ = json.Unmarshal(w2.Body.Bytes(), &resp) assert.Equal(t, 90001, resp.Code) assert.Equal(t, "慢点儿~", resp.Message) @@ -129,7 +129,7 @@ func TestLimiter_CustomOption(t *testing.T) { req.Header.Set("X-Forwarded-For", "203.0.113.1") r.ServeHTTP(w, req) - var resp t_http.CommonResp + var resp t_http.FinalResp _ = json.Unmarshal(w.Body.Bytes(), &resp) assert.Equal(t, 500, w.Code) assert.Equal(t, DefaultCodeRateError, resp.Code) diff --git a/pkg/transport/http/ginx/middleware/timeout/timeout.go b/pkg/transport/http/ginx/middleware/timeout/timeout.go index d0c10e2..87f6020 100644 --- a/pkg/transport/http/ginx/middleware/timeout/timeout.go +++ b/pkg/transport/http/ginx/middleware/timeout/timeout.go @@ -4,6 +4,8 @@ import ( "github.com/gin-contrib/timeout" "github.com/gin-gonic/gin" t_http "github.com/muxi-Infra/muxi-micro/pkg/transport/http" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/handler" + "net/http" "time" ) @@ -59,10 +61,11 @@ func Timeout(opts ...Option) gin.HandlerFunc { }), timeout.WithResponse(func(c *gin.Context) { - c.JSON(504, t_http.CommonResp{ - Code: cfg.code, - Message: cfg.message, - Data: nil, + handler.HandleResponse(c, t_http.Response{ + HttpCode: http.StatusGatewayTimeout, + Code: cfg.code, + Message: cfg.message, + Data: nil, }) }), ) diff --git a/pkg/transport/http/ginx/middleware/timeout/timeout_test.go b/pkg/transport/http/ginx/middleware/timeout/timeout_test.go index 22331a4..5359cba 100644 --- a/pkg/transport/http/ginx/middleware/timeout/timeout_test.go +++ b/pkg/transport/http/ginx/middleware/timeout/timeout_test.go @@ -19,7 +19,7 @@ func setupRouterWithTimeout(handlerFunc gin.HandlerFunc) *gin.Engine { r.Use(handlerFunc) r.GET("/normal", func(c *gin.Context) { - c.JSON(http.StatusOK, t_http.CommonResp{ + c.JSON(http.StatusOK, t_http.FinalResp{ Code: 0, Message: "OK", Data: "success", @@ -28,7 +28,7 @@ func setupRouterWithTimeout(handlerFunc gin.HandlerFunc) *gin.Engine { r.GET("/sleep", func(c *gin.Context) { time.Sleep(2 * time.Second) - c.JSON(http.StatusOK, t_http.CommonResp{ + c.JSON(http.StatusOK, t_http.FinalResp{ Code: 0, Message: "OK", Data: "should timeout", @@ -58,7 +58,7 @@ func TestTimeoutMiddleware(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/sleep", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) - var resp t_http.CommonResp + var resp t_http.FinalResp err := json.Unmarshal(w.Body.Bytes(), &resp) if err != nil { t.Fatal(err) diff --git a/pkg/transport/http/http.go b/pkg/transport/http/http.go index 7c1402a..2322eac 100644 --- a/pkg/transport/http/http.go +++ b/pkg/transport/http/http.go @@ -1,21 +1,19 @@ package http +// Response 在使用的时候会接触到的字段 type Response struct { - HttpCode int `json:"httpCode"` - CommonResp + HttpCode int `json:"httpCode"` + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` } -type CommonResp struct { +// FinalResp 最终响应的时候的实际字段 +type FinalResp struct { Code int `json:"code"` Message string `json:"message"` Data any `json:"data"` + LogID string `json:"logID"` } -type Env int8 - -const ( - EnvUnknown Env = iota - EnvDev // 开发:彩色多行栈,仅控制台 - EnvTest // 测试:彩色多行栈到控制台 + JSON 到文件 - EnvProd // 生产:全 JSON 单行 -) +// TODO 完善测试,现在的测试覆盖率还是比较有限 diff --git a/static/static.go b/static/static.go new file mode 100644 index 0000000..ff0ca4f --- /dev/null +++ b/static/static.go @@ -0,0 +1,11 @@ +package static + +// ---------- 环境枚举 ---------- +type Env int8 + +const ( + EnvUnknown Env = iota + EnvDev + EnvTest + EnvProd +) diff --git a/tool/gin/example/main.go b/tool/gin/example/main.go index 51fe4e3..5c32efe 100644 --- a/tool/gin/example/main.go +++ b/tool/gin/example/main.go @@ -1,12 +1,13 @@ package main import ( + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/engine" + "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx/handler" + "github.com/muxi-Infra/muxi-micro/static" "net/http" "github.com/gin-gonic/gin" - "github.com/muxi-Infra/muxi-micro/pkg/errs" t_http "github.com/muxi-Infra/muxi-micro/pkg/transport/http" - "github.com/muxi-Infra/muxi-micro/pkg/transport/http/ginx" ) // 定义请求和响应结构体 @@ -15,137 +16,43 @@ type LoginRequest struct { Password string `json:"password" binding:"required"` } -type UserInfo struct { - ID int `json:"id"` - Username string `json:"username"` - Email string `json:"email"` -} - -// 模拟用户认证信息 -type UserClaims struct { - UserID int `json:"user_id"` - Username string `json:"username"` - Role string `json:"role"` -} - func main() { g := gin.Default() - ginx.UseDefaultMiddleware(g) - router := ginx.NewDefaultEngine( - ginx.WithEnv(t_http.EnvDev), - ginx.WithEngine(g), + engine.UseDefaultMiddleware(g) + router := engine.NewEngine( + engine.WithEnv(static.EnvDev), ) - // 1. 注册不需要请求体和用户认证的路由 - router.GET("/ping", ginx.Wrap(func(ctx *gin.Context) t_http.Response { - return t_http.Response{ + // 1. 注册不需要请求体 + router.GET("/ping", handler.Wrap(func(ctx *gin.Context) { + + handler.HandleResponse(ctx, t_http.Response{ HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{ - Code: 0, - Message: "pong", - Data: nil, - }, - } + Code: 0, + Message: "pong", + Data: nil, + }) })) - // 2. 注册需要请求体但不需要用户认证的路由 - router.POST("/login", ginx.WrapReq(func(ctx *gin.Context, req LoginRequest) t_http.Response { + // 2. 注册需要请求体 + router.POST("/login", handler.WrapReq(func(ctx *gin.Context, req LoginRequest) { // 模拟登录逻辑 if req.Username == "admin" && req.Password == "123456" { - return t_http.Response{ + handler.HandleResponse(ctx, t_http.Response{ HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{ - Code: 0, - Message: "登录成功", - Data: "token-string", - }, - } + Message: "登录成功", + Data: "token-string", + }) + return } - return t_http.Response{ + handler.HandleResponse(ctx, t_http.Response{ HttpCode: http.StatusUnauthorized, - CommonResp: t_http.CommonResp{ - Code: 40100, - Message: "用户名或密码错误", - Data: nil, - }, - } + Code: 40100, + Message: "用户名或密码错误", + Data: nil, + }) + return })) - // 3. 注册需要用户认证但不需要请求体的路由 - router.GET("/profile", ginx.WrapClaims( - // 获取用户认证信息的函数 - func(ctx *gin.Context) (UserClaims, error) { - // 这里应该从JWT或其他认证方式中获取用户信息 - // 模拟从Header中获取token并解析 - token := ctx.GetHeader("Authorization") - if token == "" { - return UserClaims{}, errs.NewErr("认证失败", "缺少Authorization头") - } - - // 这里应该验证token的有效性 - // 模拟返回用户信息 - return UserClaims{ - UserID: 1, - Username: "admin", - Role: "admin", - }, nil - }, - // 业务处理函数 - func(ctx *gin.Context, claims UserClaims) t_http.Response { - // 模拟获取用户信息 - userInfo := UserInfo{ - ID: claims.UserID, - Username: claims.Username, - Email: claims.Username + "@example.com", - } - - return t_http.Response{ - HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{ - Code: 0, - Message: "获取用户信息成功", - Data: userInfo, - }, - } - }, - )) - - // 4. 注册既需要请求体又需要用户认证的路由 - router.POST("/update-profile", ginx.WrapClaimsAndReq( - // 获取用户认证信息的函数 - func(ctx *gin.Context) (UserClaims, error) { - // 同上,从Header中获取token并解析 - token := ctx.GetHeader("Authorization") - if token == "" { - return UserClaims{}, errs.NewErr("认证失败", "缺少Authorization头") - } - - // 模拟返回用户信息 - return UserClaims{ - UserID: 1, - Username: "admin", - Role: "admin", - }, nil - }, - // 业务处理函数 - func(ctx *gin.Context, req UserInfo, claims UserClaims) t_http.Response { - // 模拟更新用户信息 - updatedInfo := UserInfo{ - ID: claims.UserID, - Username: claims.Username, - Email: req.Email, // 使用请求中的新邮箱 - } - - return t_http.Response{ - HttpCode: http.StatusOK, - CommonResp: t_http.CommonResp{ - Code: 0, - Message: "更新用户信息成功", - Data: updatedInfo, - }, - } - }, - )) - router.Run("0.0.0.0:8080") }