From 52cfe1b9110407c7f94092324144f3e73cfc5acb Mon Sep 17 00:00:00 2001 From: hujie Date: Mon, 2 Mar 2026 23:30:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 321 +++++++++++++++++++++++++++++++++++++++++ app.go | 55 ++++++- app_test.go | 356 ++++++++++++++++++++++++++++++++++++++++++++++ config/config.go | 52 +++++++ database/mysql.go | 137 ++++++++++++++++++ database/redis.go | 69 +++++++++ go.mod | 12 +- logger/logger.go | 66 +++++---- svr/server.go | 56 ++++++++ 9 files changed, 1091 insertions(+), 33 deletions(-) create mode 100644 README.md create mode 100644 app_test.go create mode 100644 database/mysql.go create mode 100644 database/redis.go create mode 100644 svr/server.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..50a191f --- /dev/null +++ b/README.md @@ -0,0 +1,321 @@ +# go-web-gin + +一个基于 [Gin](https://github.com/gin-gonic/gin) 的 Go Web 应用脚手架,提供开箱即用的基础设施组件。 + +## 特性 + +- 🚀 **快速启动** - 简洁的 API 设计,快速搭建 Web 服务 +- 🔧 **配置管理** - 支持 YAML 配置文件和环境变量覆盖 +- 📝 **结构化日志** - 基于 [Zap](https://github.com/uber-go/zap) 的高性能日志 +- 🗄️ **数据库支持** - MySQL (GORM) 和 Redis 单例连接 +- 🌍 **多环境** - 支持 Local、Development、Production 环境 +- 🧩 **单例模式** - 组件单例管理,避免重复初始化 + +## 安装 + +```bash +go get git.hujye.com/infrastructure/go-web-gin +``` + +## 快速开始 + +### 1. 创建配置文件 + +在项目根目录创建 `config/config.yml`: + +```yaml +server: + host: 0.0.0.0 + port: 8080 + mode: debug # debug, release, test + +app: + name: my-app + environment: local + log_level: info + +log: + output_to_file: true + filename: logs/app.log + max_size: 100 + max_backups: 3 + max_age: 28 + compress: true + +mysql: + host: 127.0.0.1 + port: 3306 + user: root + password: "" + db_name: test + charset: utf8mb4 + parse_time: true + max_idle_conns: 10 + max_open_conns: 100 + conn_max_lifetime: 3600 + +redis: + addr: 127.0.0.1:6379 + password: "" + db: 0 + pool_size: 10 + min_idle_conns: 5 + max_retries: 3 + dial_timeout: 5 + read_timeout: 3 + write_timeout: 3 +``` + +### 2. 编写主程序 + +```go +package main + +import ( + "context" + + go_web_gin "git.hujye.com/infrastructure/go-web-gin" + "github.com/gin-gonic/gin" +) + +func main() { + // 创建应用实例 + app := go_web_gin.New() + + // 初始化数据库(可选) + app.InitMySQL() + app.InitRedis() + + // 注册全局中间件 + app.UseMiddleware(gin.Recovery()) + + // 注册路由 + app.RegisterRoutes(func(e *gin.Engine) { + e.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) + + e.GET("/users/:id", func(c *gin.Context) { + id := c.Param("id") + // 使用日志 + app.Logger().Info(c.Request.Context(), "get user", "id", id) + c.JSON(200, gin.H{"user_id": id}) + }) + + // 使用数据库 + e.GET("/db-test", func(c *gin.Context) { + db := app.DB() + if db != nil { + // 执行数据库操作 + c.JSON(200, gin.H{"db": "connected"}) + } else { + c.JSON(500, gin.H{"error": "db not initialized"}) + } + }) + + // 使用 Redis + e.GET("/redis-test", func(c *gin.Context) { + rdb := app.Redis() + if rdb != nil { + rdb.Set(c.Request.Context(), "test", "value", 0) + c.JSON(200, gin.H{"redis": "connected"}) + } else { + c.JSON(500, gin.H{"error": "redis not initialized"}) + } + }) + }) + + // 启动服务 + // 非生产环境可传入自定义地址,生产环境使用配置文件地址 + app.Run() +} +``` + +## API 文档 + +### App 结构 + +```go +type App struct { + engine *gin.Engine + db *gorm.DB + rdb *redis.Client + logger *logger.Logger +} +``` + +### 方法列表 + +| 方法 | 说明 | 返回值 | +|------|------|--------| +| `New()` | 创建新的 App 实例 | `*App` | +| `UseMiddleware(...)` | 注册全局中间件 | - | +| `RegisterRoutes(func)` | 注册路由 | - | +| `InitMySQL()` | 初始化 MySQL 连接 | `*gorm.DB` | +| `InitRedis()` | 初始化 Redis 连接 | `*redis.Client` | +| `DB()` | 获取数据库实例 | `*gorm.DB` | +| `Redis()` | 获取 Redis 实例 | `*redis.Client` | +| `Logger()` | 获取日志实例 | `*logger.Logger` | +| `Run(addr ...string)` | 启动服务 | `error` | + +### Run 方法行为 + +- **非生产环境**:优先使用传入的地址,否则使用配置文件地址 +- **生产环境**:始终使用配置文件地址 + +```go +// 使用配置文件地址 +app.Run() + +// 非生产环境使用 :3000,生产环境仍用配置文件地址 +app.Run(":3000") +``` + +## 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `RUN_ENV` | 运行环境 (`LOCAL`, `DEVELOPMENT`, `PRODUCTION`) | `LOCAL` | +| `CFG_PATH` | 配置文件路径 | `config/config.yml` | + +## 日志使用 + +```go +// 基本用法 +app.Logger().Info(ctx, "message") +app.Logger().Error(ctx, "error message") +app.Logger().Warn(ctx, "warning message") +app.Logger().Debug(ctx, "debug message") + +// 带键值对 +app.Logger().Info(ctx, "user logged in", "user_id", "123", "ip", "192.168.1.1") +``` + +日志格式会根据环境自动切换: +- **Local**: Console 格式,便于阅读 +- **Development/Production**: JSON 格式,便于日志收集 + +## 上下文用户信息 + +使用 `web` 包在上下文中传递用户信息: + +```go +import "git.hujye.com/infrastructure/go-web-gin/web" + +// 设置用户信息 +ctx = web.SetUserID(ctx, "user-123") +ctx = web.SetUserName(ctx, "john") +ctx = web.SetTrace(ctx, "trace-456") +ctx = web.SetFromIP(ctx, "192.168.1.1") + +// 获取用户信息 +userID := web.GetUserID(ctx) +userName := web.GetUserName(ctx) + +// 或一次性构建 +userInfo := web.NewUserInfo(). + WithUserID("user-123"). + WithUserName("john"). + WithTrace("trace-456"). + WithFromIP("192.168.1.1") +ctx = web.ToContext(ctx, userInfo) +``` + +日志会自动从上下文中提取用户信息并记录。 + +## 使用 svr 包直接操作 Gin + +如果需要直接使用 Gin 引擎单例: + +```go +import "git.hujye.com/infrastructure/go-web-gin/svr" + +// 获取引擎 +engine := svr.GetEngine() + +// 快捷方法 +svr.Use(middleware...) +svr.GET("/ping", handler) +svr.POST("/users", handler) +svr.PUT("/users/:id", handler) +svr.DELETE("/users/:id", handler) + +// 路由组 +api := svr.Group("/api/v1") +api.GET("/users", handler) + +// 启动服务 +svr.Run(":8080") +``` + +## 配置读取 + +```go +import "git.hujye.com/infrastructure/go-web-gin/config" + +// 获取完整配置 +cfg := config.Get() + +// 访问配置项 +addr := cfg.GetAddr() // host:port +isDebug := cfg.IsDebug() // 是否 debug 模式 +isRelease := cfg.IsRelease() // 是否 release 模式 + +// 动态读取配置(支持嵌套键) +dbHost := config.GetString("mysql.host") +dbPort := config.GetInt("mysql.port") +``` + +## 项目结构 + +``` +go-web-gin/ +├── app.go # 应用核心 +├── app_test.go # 测试套件 +├── config/ +│ └── config.go # 配置管理 +├── database/ +│ ├── mysql.go # MySQL 单例 +│ └── redis.go # Redis 单例 +├── env/ +│ └── env.go # 环境变量 +├── logger/ +│ ├── logger.go # 日志核心 +│ ├── encoder.go # 编码器 +│ └── struct.go # 结构定义 +├── server/ +│ └── server.go # 服务器封装 +├── svr/ +│ └── server.go # Gin 单例 +└── web/ + └── user.go # 上下文用户信息 +``` + +## 运行测试 + +```bash +# 运行所有测试 +go test -v ./... + +# 运行特定测试套件 +go test -v ./... -run TestAppRunSuite +``` + +## 依赖 + +- [gin-gonic/gin](https://github.com/gin-gonic/gin) - Web 框架 +- [uber-go/zap](https://github.com/uber-go/zap) - 高性能日志 +- [go-gorm/gorm](https://github.com/go-gorm/gorm) - ORM +- [redis/go-redis](https://github.com/redis/go-redis) - Redis 客户端 +- [spf13/viper](https://github.com/spf13/viper) - 配置管理 +- [stretchr/testify](https://github.com/stretchr/testify) - 测试框架 + +## License + +MIT License + +## Author + +**hujye** + +- Email: hujie@hujye.com diff --git a/app.go b/app.go index cb47e9e..619f24e 100644 --- a/app.go +++ b/app.go @@ -4,13 +4,20 @@ import ( "log" "git.hujye.com/infrastructure/go-web-gin/config" - "git.hujye.com/infrastructure/go-web-gin/server" + "git.hujye.com/infrastructure/go-web-gin/database" + "git.hujye.com/infrastructure/go-web-gin/logger" + "git.hujye.com/infrastructure/go-web-gin/svr" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" ) // App represents the application type App struct { - server *server.Server + engine *gin.Engine + db *gorm.DB + rdb *redis.Client + logger *logger.Logger } // New creates a new App instance and loads config file @@ -21,21 +28,57 @@ func New() *App { } return &App{ - server: server.New(), + engine: svr.GetEngine(), + logger: logger.GetLogger(), } } // UseMiddleware registers global middleware func (a *App) UseMiddleware(middleware ...gin.HandlerFunc) { - a.server.Engine().Use(middleware...) + a.engine.Use(middleware...) } // RegisterRoutes registers routes with the given handler function func (a *App) RegisterRoutes(registerFunc func(*gin.Engine)) { - registerFunc(a.server.Engine()) + registerFunc(a.engine) +} + +// InitMySQL initializes the MySQL database connection +func (a *App) InitMySQL() *gorm.DB { + a.db = database.GetDB() + return a.db +} + +// DB returns the gorm.DB instance +func (a *App) DB() *gorm.DB { + return a.db +} + +// InitRedis initializes the Redis connection +func (a *App) InitRedis() *redis.Client { + a.rdb = database.GetRedis() + return a.rdb +} + +// Redis returns the redis.Client instance +func (a *App) Redis() *redis.Client { + return a.rdb +} + +// Logger returns the logger.Logger instance +func (a *App) Logger() *logger.Logger { + return a.logger } // Run starts the application server func (a *App) Run(addr ...string) error { - return a.server.Run(addr...) + listenAddr := config.Get().GetAddr() + + // Non-production: use provided address if given, otherwise use config + if !config.Get().IsRelease() && len(addr) > 0 && addr[0] != "" { + listenAddr = addr[0] + } + + a.logger.Info(nil, "Server starting", "addr", listenAddr, "mode", config.Get().Server.Mode) + return a.engine.Run(listenAddr) } diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..33db962 --- /dev/null +++ b/app_test.go @@ -0,0 +1,356 @@ +package go_web_gin + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" +) + +// AppTestSuite is the test suite for App +type AppTestSuite struct { + suite.Suite + app *App +} + +// SetupSuite runs once before all tests +func (s *AppTestSuite) SetupSuite() { + gin.SetMode(gin.TestMode) + s.app = New() +} + +// TearDownSuite runs once after all tests +func (s *AppTestSuite) TearDownSuite() { + // Cleanup if needed +} + +// SetupTest runs before each test +func (s *AppTestSuite) SetupTest() { + // Reset state if needed +} + +// TearDownTest runs after each test +func (s *AppTestSuite) TearDownTest() { + // Cleanup after each test if needed +} + +// TestNew tests the New function +func (s *AppTestSuite) TestNew() { + app := New() + s.NotNil(app, "New should return a non-nil App") + s.NotNil(app.engine, "engine should be initialized") + s.NotNil(app.logger, "logger should be initialized") +} + +// TestNewSingleton tests that svr.GetEngine returns the same instance +func (s *AppTestSuite) TestNewSingleton() { + app1 := New() + app2 := New() + s.Equal(app1.engine, app2.engine, "engine should be singleton") +} + +// TestUseMiddleware tests the UseMiddleware method +func (s *AppTestSuite) TestUseMiddleware() { + middlewareCalled := false + middleware := func(c *gin.Context) { + middlewareCalled = true + c.Next() + } + + s.app.UseMiddleware(middleware) + + // Create a test route to verify middleware + s.app.engine.GET("/test-middleware", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest("GET", "/test-middleware", nil) + w := httptest.NewRecorder() + s.app.engine.ServeHTTP(w, req) + + s.True(middlewareCalled, "middleware should be called") +} + +// TestRegisterRoutes tests the RegisterRoutes method +func (s *AppTestSuite) TestRegisterRoutes() { + app := New() + + app.RegisterRoutes(func(e *gin.Engine) { + e.GET("/test-route", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "ok"}) + }) + }) + + req := httptest.NewRequest("GET", "/test-route", nil) + w := httptest.NewRecorder() + app.engine.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + s.Contains(w.Body.String(), "ok") +} + +// TestRegisterRoutesMultiple tests registering multiple routes +func (s *AppTestSuite) TestRegisterRoutesMultiple() { + app := New() + + app.RegisterRoutes(func(e *gin.Engine) { + e.GET("/route1", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"route": 1}) + }) + e.POST("/route2", func(c *gin.Context) { + c.JSON(http.StatusCreated, gin.H{"route": 2}) + }) + e.PUT("/route3", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"route": 3}) + }) + }) + + // Test GET + req1 := httptest.NewRequest("GET", "/route1", nil) + w1 := httptest.NewRecorder() + app.engine.ServeHTTP(w1, req1) + s.Equal(http.StatusOK, w1.Code) + + // Test POST + req2 := httptest.NewRequest("POST", "/route2", nil) + w2 := httptest.NewRecorder() + app.engine.ServeHTTP(w2, req2) + s.Equal(http.StatusCreated, w2.Code) + + // Test PUT + req3 := httptest.NewRequest("PUT", "/route3", nil) + w3 := httptest.NewRecorder() + app.engine.ServeHTTP(w3, req3) + s.Equal(http.StatusOK, w3.Code) +} + +// TestDB tests the DB method returns nil when not initialized +func (s *AppTestSuite) TestDB() { + app := New() + s.Nil(app.DB(), "DB should be nil when not initialized") +} + +// TestRedis tests the Redis method returns nil when not initialized +func (s *AppTestSuite) TestRedis() { + app := New() + s.Nil(app.Redis(), "Redis should be nil when not initialized") +} + +// TestLogger tests the Logger method +func (s *AppTestSuite) TestLogger() { + app := New() + logger := app.Logger() + s.NotNil(logger, "Logger should not be nil") +} + +// TestLoggerNotNil tests that logger is always initialized +func (s *AppTestSuite) TestLoggerNotNil() { + s.NotNil(s.app.Logger(), "Logger should always be initialized in New()") +} + +// TestLoggerMethods tests that logger methods work +func (s *AppTestSuite) TestLoggerMethods() { + s.NotPanics(func() { + s.app.Logger().Info(nil, "Test info message") + s.app.Logger().Debug(nil, "Test debug message") + s.app.Logger().Warn(nil, "Test warn message") + }, "Logger methods should not panic") +} + +// TestLoggerWithContext tests logger with context +func (s *AppTestSuite) TestLoggerWithContext() { + ctx := context.Background() + s.NotPanics(func() { + s.app.Logger().Info(ctx, "Test with context") + }, "Logger should work with context") +} + +// TestRunInvalidAddress tests Run with invalid address +func (s *AppTestSuite) TestRunInvalidAddress() { + app := New() + app.RegisterRoutes(func(e *gin.Engine) { + e.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"pong": true}) + }) + }) + + // Use a channel to handle the async nature of Run + errCh := make(chan error, 1) + go func() { + errCh <- app.Run("invalid:address:format") + }() + + select { + case err := <-errCh: + s.Error(err, "Run should return error for invalid address") + case <-time.After(2 * time.Second): + // Server might start, which is fine for this test + } +} + +// TestHTTPMethod tests various HTTP methods +func (s *AppTestSuite) TestHTTPMethod() { + app := New() + + app.RegisterRoutes(func(e *gin.Engine) { + e.DELETE("/delete", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"deleted": true}) + }) + e.PATCH("/patch", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"patched": true}) + }) + e.OPTIONS("/options", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + e.HEAD("/head", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + }) + + // Test DELETE + req := httptest.NewRequest("DELETE", "/delete", nil) + w := httptest.NewRecorder() + app.engine.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + + // Test PATCH + req2 := httptest.NewRequest("PATCH", "/patch", nil) + w2 := httptest.NewRecorder() + app.engine.ServeHTTP(w2, req2) + s.Equal(http.StatusOK, w2.Code) + + // Test OPTIONS + req3 := httptest.NewRequest("OPTIONS", "/options", nil) + w3 := httptest.NewRecorder() + app.engine.ServeHTTP(w3, req3) + s.Equal(http.StatusNoContent, w3.Code) + + // Test HEAD + req4 := httptest.NewRequest("HEAD", "/head", nil) + w4 := httptest.NewRecorder() + app.engine.ServeHTTP(w4, req4) + s.Equal(http.StatusOK, w4.Code) +} + +// TestRouteGroup tests route groups +func (s *AppTestSuite) TestRouteGroup() { + app := New() + + app.RegisterRoutes(func(e *gin.Engine) { + api := e.Group("/api") + { + api.GET("/users", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"users": []string{}}) + }) + api.GET("/posts", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"posts": []string{}}) + }) + } + }) + + req1 := httptest.NewRequest("GET", "/api/users", nil) + w1 := httptest.NewRecorder() + app.engine.ServeHTTP(w1, req1) + s.Equal(http.StatusOK, w1.Code) + + req2 := httptest.NewRequest("GET", "/api/posts", nil) + w2 := httptest.NewRecorder() + app.engine.ServeHTTP(w2, req2) + s.Equal(http.StatusOK, w2.Code) +} + +// TestMiddlewareChain tests middleware chain +func (s *AppTestSuite) TestMiddlewareChain() { + app := New() + order := []string{} + + app.UseMiddleware(func(c *gin.Context) { + order = append(order, "middleware1-before") + c.Next() + order = append(order, "middleware1-after") + }) + + app.UseMiddleware(func(c *gin.Context) { + order = append(order, "middleware2-before") + c.Next() + order = append(order, "middleware2-after") + }) + + app.RegisterRoutes(func(e *gin.Engine) { + e.GET("/chain", func(c *gin.Context) { + order = append(order, "handler") + c.Status(http.StatusOK) + }) + }) + + req := httptest.NewRequest("GET", "/chain", nil) + w := httptest.NewRecorder() + app.engine.ServeHTTP(w, req) + + expected := []string{ + "middleware1-before", + "middleware2-before", + "handler", + "middleware2-after", + "middleware1-after", + } + s.Equal(expected, order, "middleware should execute in correct order") +} + +// TestQueryParam tests query parameters +func (s *AppTestSuite) TestQueryParam() { + app := New() + + app.RegisterRoutes(func(e *gin.Engine) { + e.GET("/search", func(c *gin.Context) { + q := c.Query("q") + c.JSON(http.StatusOK, gin.H{"query": q}) + }) + }) + + req := httptest.NewRequest("GET", "/search?q=test", nil) + w := httptest.NewRecorder() + app.engine.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + s.Contains(w.Body.String(), "test") +} + +// TestPathParam tests path parameters +func (s *AppTestSuite) TestPathParam() { + app := New() + + app.RegisterRoutes(func(e *gin.Engine) { + e.GET("/users/:id", func(c *gin.Context) { + id := c.Param("id") + c.JSON(http.StatusOK, gin.H{"user_id": id}) + }) + }) + + req := httptest.NewRequest("GET", "/users/123", nil) + w := httptest.NewRecorder() + app.engine.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + s.Contains(w.Body.String(), "123") +} + +// TestNotFound tests 404 handling +func (s *AppTestSuite) TestNotFound() { + app := New() + + req := httptest.NewRequest("GET", "/nonexistent", nil) + w := httptest.NewRecorder() + app.engine.ServeHTTP(w, req) + + s.Equal(http.StatusNotFound, w.Code) +} + +// TestAppRunSuite runs the test suite +func TestAppRunSuite(t *testing.T) { + suite.Run(t, new(AppTestSuite)) +} diff --git a/config/config.go b/config/config.go index d663338..eca380d 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,8 @@ type Config struct { Server ServerConfig `mapstructure:"server"` App AppConfig `mapstructure:"app"` Log LogConfig `mapstructure:"log"` + MySQL MySQLConfig `mapstructure:"mysql"` + Redis RedisConfig `mapstructure:"redis"` } // ServerConfig represents server configuration @@ -44,6 +46,33 @@ type LogConfig struct { Compress bool `mapstructure:"compress"` } +// MySQLConfig represents mysql configuration +type MySQLConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"db_name"` + Charset string `mapstructure:"charset"` + ParseTime bool `mapstructure:"parse_time"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + MaxOpenConns int `mapstructure:"max_open_conns"` + ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // seconds +} + +// RedisConfig represents redis configuration +type RedisConfig struct { + Addr string `mapstructure:"addr"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` + PoolSize int `mapstructure:"pool_size"` + MinIdleConns int `mapstructure:"min_idle_conns"` + MaxRetries int `mapstructure:"max_retries"` + DialTimeout int `mapstructure:"dial_timeout"` // seconds + ReadTimeout int `mapstructure:"read_timeout"` // seconds + WriteTimeout int `mapstructure:"write_timeout"` // seconds +} + // Load loads configuration from environment variable CFG_PATH // Default path is config/config.yml func Load() (*Config, error) { @@ -94,6 +123,29 @@ func Get() *Config { MaxAge: 28, Compress: true, }, + MySQL: MySQLConfig{ + Host: "127.0.0.1", + Port: 3306, + User: "root", + Password: "", + DBName: "test", + Charset: "utf8mb4", + ParseTime: true, + MaxIdleConns: 10, + MaxOpenConns: 100, + ConnMaxLifetime: 3600, + }, + Redis: RedisConfig{ + Addr: "127.0.0.1:6379", + Password: "", + DB: 0, + PoolSize: 10, + MinIdleConns: 5, + MaxRetries: 3, + DialTimeout: 5, + ReadTimeout: 3, + WriteTimeout: 3, + }, } } return globalConfig diff --git a/database/mysql.go b/database/mysql.go new file mode 100644 index 0000000..373e4fe --- /dev/null +++ b/database/mysql.go @@ -0,0 +1,137 @@ +package database + +import ( + "context" + "fmt" + "sync" + "time" + + "git.hujye.com/infrastructure/go-web-gin/config" + "git.hujye.com/infrastructure/go-web-gin/logger" + "gorm.io/driver/mysql" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +var ( + db *gorm.DB + once sync.Once +) + +// gormLogger implements gorm/logger.Interface using project's zap logger +type gormLogger struct { + logLevel gormlogger.LogLevel +} + +// newGormLogger creates a new gorm logger +func newGormLogger(level gormlogger.LogLevel) *gormLogger { + return &gormLogger{logLevel: level} +} + +// LogMode sets log level +func (l *gormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + newLogger := *l + newLogger.logLevel = level + return &newLogger +} + +// Info logs info messages +func (l *gormLogger) Info(ctx context.Context, msg string, data ...interface{}) { + if l.logLevel >= gormlogger.Info { + logger.GetLogger().Info(ctx, fmt.Sprintf("gorm: %s", fmt.Sprintf(msg, data...))) + } +} + +// Warn logs warn messages +func (l *gormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + if l.logLevel >= gormlogger.Warn { + logger.GetLogger().Warn(ctx, fmt.Sprintf("gorm: %s", fmt.Sprintf(msg, data...))) + } +} + +// Error logs error messages +func (l *gormLogger) Error(ctx context.Context, msg string, data ...interface{}) { + if l.logLevel >= gormlogger.Error { + logger.GetLogger().Error(ctx, fmt.Sprintf("gorm: %s", fmt.Sprintf(msg, data...))) + } +} + +// Trace logs sql query with execution time +func (l *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + if l.logLevel <= gormlogger.Silent { + return + } + + elapsed := time.Since(begin) + sql, rows := fc() + + switch { + case err != nil && l.logLevel >= gormlogger.Error: + logger.GetLogger().Error(ctx, "gorm query error", "duration", elapsed.Milliseconds(), "sql", sql, "rows", rows, "error", err.Error()) + case elapsed > 200*time.Millisecond && l.logLevel >= gormlogger.Warn: + logger.GetLogger().Warn(ctx, "gorm slow query", "duration", elapsed.Milliseconds(), "sql", sql, "rows", rows) + case l.logLevel >= gormlogger.Info: + logger.GetLogger().Debug(ctx, "gorm query", "duration", elapsed.Milliseconds(), "sql", sql, "rows", rows) + } +} + +// GetDB returns the gorm.DB singleton instance +// It will initialize the connection on first call +func GetDB() *gorm.DB { + once.Do(func() { + db = initDB() + }) + return db +} + +// initDB initializes and returns a new gorm.DB connection +func initDB() *gorm.DB { + cfg := config.Get().MySQL + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%t&loc=Local", + cfg.User, + cfg.Password, + cfg.Host, + cfg.Port, + cfg.DBName, + cfg.Charset, + cfg.ParseTime, + ) + + var logLevel gormlogger.LogLevel + if config.Get().IsDebug() { + logLevel = gormlogger.Info + } else { + logLevel = gormlogger.Silent + } + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: newGormLogger(logLevel), + }) + if err != nil { + panic(fmt.Sprintf("failed to connect to database: %v", err)) + } + + sqlDB, err := db.DB() + if err != nil { + panic(fmt.Sprintf("failed to get database instance: %v", err)) + } + + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + + return db +} + +// Close closes the database connection +func Close() error { + if db != nil { + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() + } + return nil +} diff --git a/database/redis.go b/database/redis.go new file mode 100644 index 0000000..95cd06d --- /dev/null +++ b/database/redis.go @@ -0,0 +1,69 @@ +package database + +import ( + "context" + "fmt" + "sync" + "time" + + "git.hujye.com/infrastructure/go-web-gin/config" + "git.hujye.com/infrastructure/go-web-gin/logger" + "github.com/redis/go-redis/v9" +) + +var ( + rdb *redis.Client + redisOnce sync.Once +) + +// GetRedis returns the redis.Client singleton instance. +// It will initialize the connection on first call +func GetRedis() *redis.Client { + redisOnce.Do(func() { + rdb = initRedis() + }) + return rdb +} + +// initRedis initializes and returns a new redis.Client connection. +// It will panic if connection fails +func initRedis() *redis.Client { + cfg := config.Get().Redis + + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConns, + MaxRetries: cfg.MaxRetries, + DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, + ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := rdb.Ping(ctx).Err(); err != nil { + logger.GetLogger().Error(nil, "redis connect failed", "addr", cfg.Addr, "error", err.Error()) + panic(fmt.Sprintf("failed to connect to redis: %v", err)) + } + + logger.GetLogger().Info(nil, "redis connected successfully", "addr", cfg.Addr, "db", cfg.DB) + + return rdb +} + +// CloseRedis closes the redis connection. +// Returns error if close fails, nil otherwise +func CloseRedis() error { + if rdb != nil { + if err := rdb.Close(); err != nil { + logger.GetLogger().Error(nil, "redis close failed", "error", err.Error()) + return err + } + logger.GetLogger().Info(nil, "redis connection closed") + } + return nil +} diff --git a/go.mod b/go.mod index ee6bac6..76b2b25 100644 --- a/go.mod +++ b/go.mod @@ -4,26 +4,35 @@ go 1.24.0 require ( github.com/gin-gonic/gin v1.10.0 + github.com/redis/go-redis/v9 v9.18.0 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -43,13 +52,14 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/logger/logger.go b/logger/logger.go index 6552176..7569742 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -2,7 +2,6 @@ package logger import ( "context" - "fmt" "os" "strings" "sync" @@ -125,53 +124,68 @@ func getUserFields(ctx context.Context) []zap.Field { } // Info logs an info message -func (l *Logger) Info(ctx context.Context, msg string, values ...interface{}) { +func (l *Logger) Info(ctx context.Context, msg string, keyValues ...interface{}) { allFields := getUserFields(ctx) - formattedMsg := fmt.Sprintf(msg, values...) - if len(values) > 0 { - allFields = append(allFields, zap.String("msg", formattedMsg)) + for i := 0; i < len(keyValues); i += 2 { + if i+1 < len(keyValues) { + if key, ok := keyValues[i].(string); ok { + allFields = append(allFields, zap.Any(key, keyValues[i+1])) + } + } } - l.logger.Info(formattedMsg, allFields...) + l.logger.Info(msg, allFields...) } // Error logs an error message -func (l *Logger) Error(ctx context.Context, msg string, values ...interface{}) { +func (l *Logger) Error(ctx context.Context, msg string, keyValues ...interface{}) { allFields := getUserFields(ctx) - formattedMsg := fmt.Sprintf(msg, values...) - if len(values) > 0 { - allFields = append(allFields, zap.String("msg", formattedMsg)) + for i := 0; i < len(keyValues); i += 2 { + if i+1 < len(keyValues) { + if key, ok := keyValues[i].(string); ok { + allFields = append(allFields, zap.Any(key, keyValues[i+1])) + } + } } - l.logger.Error(formattedMsg, allFields...) + l.logger.Error(msg, allFields...) } // Debug logs a debug message -func (l *Logger) Debug(ctx context.Context, msg string, values ...interface{}) { +func (l *Logger) Debug(ctx context.Context, msg string, keyValues ...interface{}) { allFields := getUserFields(ctx) - formattedMsg := fmt.Sprintf(msg, values...) - if len(values) > 0 { - allFields = append(allFields, zap.String("msg", formattedMsg)) + for i := 0; i < len(keyValues); i += 2 { + if i+1 < len(keyValues) { + if key, ok := keyValues[i].(string); ok { + allFields = append(allFields, zap.Any(key, keyValues[i+1])) + } + } } - l.logger.Debug(formattedMsg, allFields...) + l.logger.Debug(msg, allFields...) } // Warn logs a warning message -func (l *Logger) Warn(ctx context.Context, msg string, values ...interface{}) { +func (l *Logger) Warn(ctx context.Context, msg string, keyValues ...interface{}) { allFields := getUserFields(ctx) - formattedMsg := fmt.Sprintf(msg, values...) - if len(values) > 0 { - allFields = append(allFields, zap.String("msg", formattedMsg)) + for i := 0; i < len(keyValues); i += 2 { + if i+1 < len(keyValues) { + if key, ok := keyValues[i].(string); ok { + allFields = append(allFields, zap.Any(key, keyValues[i+1])) + } + } } - l.logger.Warn(formattedMsg, allFields...) + l.logger.Warn(msg, allFields...) } // Fatal logs a fatal message and exits -func (l *Logger) Fatal(ctx context.Context, msg string, values ...interface{}) { +func (l *Logger) Fatal(ctx context.Context, msg string, keyValues ...interface{}) { allFields := getUserFields(ctx) - formattedMsg := fmt.Sprintf(msg, values...) - if len(values) > 0 { - allFields = append(allFields, zap.String("msg", formattedMsg)) + for i := 0; i < len(keyValues); i += 2 { + if i+1 < len(keyValues) { + if key, ok := keyValues[i].(string); ok { + allFields = append(allFields, zap.Any(key, keyValues[i+1])) + } + } } - l.logger.Fatal(formattedMsg, allFields...) + l.logger.Fatal(msg, allFields...) } // Sync flushes any buffered log entries diff --git a/svr/server.go b/svr/server.go new file mode 100644 index 0000000..0876458 --- /dev/null +++ b/svr/server.go @@ -0,0 +1,56 @@ +package svr + +import ( + "sync" + + "github.com/gin-gonic/gin" +) + +var ( + engine *gin.Engine + once sync.Once +) + +// GetEngine returns the gin.Engine singleton instance. +// It will initialize the engine on first call +func GetEngine() *gin.Engine { + once.Do(func() { + engine = gin.New() + }) + return engine +} + +// Use registers global middleware +func Use(middleware ...gin.HandlerFunc) { + GetEngine().Use(middleware...) +} + +// Group creates a new router group +func Group(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup { + return GetEngine().Group(relativePath, handlers...) +} + +// GET is a shortcut for router.Handle("GET", path, handlers) +func GET(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes { + return GetEngine().GET(relativePath, handlers...) +} + +// POST is a shortcut for router.Handle("POST", path, handlers) +func POST(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes { + return GetEngine().POST(relativePath, handlers...) +} + +// PUT is a shortcut for router.Handle("PUT", path, handlers) +func PUT(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes { + return GetEngine().PUT(relativePath, handlers...) +} + +// DELETE is a shortcut for router.Handle("DELETE", path, handlers) +func DELETE(relativePath string, handlers ...gin.HandlerFunc) gin.IRoutes { + return GetEngine().DELETE(relativePath, handlers...) +} + +// Run starts the server +func Run(addr ...string) error { + return GetEngine().Run(addr...) +}