feat: bootstrap reusable GoOmni framework
This commit is contained in:
37
database/README.md
Normal file
37
database/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# database
|
||||
|
||||
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
|
||||
|
||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||
- `Health`:用于就绪检查。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
||||
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
||||
|
||||
框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动:
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
|
||||
db, err := database.Open(context.Background(), cfg)
|
||||
defer db.Close()
|
||||
```
|
||||
|
||||
连接池相关环境变量:
|
||||
|
||||
```text
|
||||
GO_OMNI_DB_DRIVER=mysql
|
||||
GO_OMNI_DB_DSN=user:password@tcp(127.0.0.1:3306)/app?parseTime=true
|
||||
GO_OMNI_DB_MAX_OPEN_CONNS=10
|
||||
GO_OMNI_DB_MAX_IDLE_CONNS=5
|
||||
GO_OMNI_DB_CONN_MAX_LIFETIME=30m
|
||||
GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
|
||||
GO_OMNI_DB_PING_TIMEOUT=5s
|
||||
```
|
||||
|
||||
`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
|
||||
175
database/database.go
Normal file
175
database/database.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Package database provides the database/sql lifecycle used by GoOmni projects.
|
||||
// It deliberately does not import a database driver; each application chooses one.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Driver string
|
||||
DSN string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
ConnMaxIdleTime time.Duration
|
||||
PingTimeout time.Duration
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
MaxOpenConns: 10,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: 30 * time.Minute,
|
||||
ConnMaxIdleTime: 5 * time.Minute,
|
||||
PingTimeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads GO_OMNI_DB_* style settings. DSN is required.
|
||||
func ConfigFromEnv(prefix string) (Config, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
prefix = "GO_OMNI_DB_"
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
cfg.Driver = strings.TrimSpace(os.Getenv(prefix + "DRIVER"))
|
||||
if cfg.Driver == "" {
|
||||
cfg.Driver = "mysql"
|
||||
}
|
||||
cfg.DSN = strings.TrimSpace(os.Getenv(prefix + "DSN"))
|
||||
if cfg.DSN == "" {
|
||||
return Config{}, fmt.Errorf("%sDSN is required", prefix)
|
||||
}
|
||||
var err error
|
||||
if cfg.MaxOpenConns, err = envInt(prefix+"MAX_OPEN_CONNS", cfg.MaxOpenConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.MaxIdleConns, err = envInt(prefix+"MAX_IDLE_CONNS", cfg.MaxIdleConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.ConnMaxLifetime, err = envDuration(prefix+"CONN_MAX_LIFETIME", cfg.ConnMaxLifetime); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.ConnMaxIdleTime, err = envDuration(prefix+"CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.PingTimeout, err = envDuration(prefix+"PING_TIMEOUT", cfg.PingTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if strings.TrimSpace(c.Driver) == "" {
|
||||
return errors.New("database driver is required")
|
||||
}
|
||||
if strings.TrimSpace(c.DSN) == "" {
|
||||
return errors.New("database DSN is required")
|
||||
}
|
||||
if c.MaxOpenConns < 0 || c.MaxIdleConns < 0 {
|
||||
return errors.New("database pool limits cannot be negative")
|
||||
}
|
||||
if c.MaxIdleConns > c.MaxOpenConns && c.MaxOpenConns > 0 {
|
||||
return errors.New("max idle connections cannot exceed max open connections")
|
||||
}
|
||||
if c.ConnMaxLifetime < 0 || c.ConnMaxIdleTime < 0 || c.PingTimeout <= 0 {
|
||||
return errors.New("database timeouts are invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DB owns a connection pool and exposes the embedded database/sql methods.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
config Config
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, cfg Config) (*DB, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open(cfg.Driver, cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
if cfg.MaxOpenConns > 0 {
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
if cfg.MaxIdleConns > 0 {
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
}
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
||||
defer cancel()
|
||||
if err := db.PingContext(checkCtx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
return &DB{DB: db, config: cfg}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Config() Config { return db.config }
|
||||
|
||||
func (db *DB) Health(ctx context.Context) error {
|
||||
if db == nil || db.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
return db.PingContext(ctx)
|
||||
}
|
||||
|
||||
// InTx commits only when work returns nil. Any work error or panic rolls back.
|
||||
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
|
||||
if db == nil || db.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
|
||||
}
|
||||
}()
|
||||
if err = work(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) (int, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a duration: %w", key, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
58
database/database_test.go
Normal file
58
database/database_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigFromEnv(t *testing.T) {
|
||||
keys := []string{
|
||||
"GO_OMNI_DB_DRIVER", "GO_OMNI_DB_DSN", "GO_OMNI_DB_MAX_OPEN_CONNS",
|
||||
"GO_OMNI_DB_MAX_IDLE_CONNS", "GO_OMNI_DB_CONN_MAX_LIFETIME",
|
||||
"GO_OMNI_DB_CONN_MAX_IDLE_TIME", "GO_OMNI_DB_PING_TIMEOUT",
|
||||
}
|
||||
for _, key := range keys {
|
||||
t.Setenv(key, "")
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
t.Setenv("GO_OMNI_DB_DSN", "root:secret@tcp(127.0.0.1:3306)/demo")
|
||||
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "20")
|
||||
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "8")
|
||||
t.Setenv("GO_OMNI_DB_CONN_MAX_LIFETIME", "2m")
|
||||
t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "750ms")
|
||||
cfg, err := ConfigFromEnv("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Driver != "mysql" || cfg.DSN == "" || cfg.MaxOpenConns != 20 || cfg.MaxIdleConns != 8 || cfg.ConnMaxLifetime != 2*time.Minute || cfg.PingTimeout != 750*time.Millisecond {
|
||||
t.Fatalf("unexpected config: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
t.Setenv("GO_OMNI_DB_DSN", "demo")
|
||||
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "bad")
|
||||
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "MAX_OPEN_CONNS") {
|
||||
t.Fatalf("expected invalid pool limit error, got %v", err)
|
||||
}
|
||||
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "2")
|
||||
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "3")
|
||||
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "max idle") {
|
||||
t.Fatalf("expected pool relationship error, got %v", err)
|
||||
}
|
||||
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "1")
|
||||
t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "0s")
|
||||
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "timeouts") {
|
||||
t.Fatalf("expected timeout error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenValidatesBeforeLoadingDriver(t *testing.T) {
|
||||
_, err := Open(context.Background(), Config{Driver: "", DSN: "demo"})
|
||||
if err == nil || !strings.Contains(err.Error(), "driver") {
|
||||
t.Fatalf("expected driver validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user