feat: complete contract-driven business development and MySQL integration

This commit is contained in:
2026-09-24 21:56:04 +08:00
parent f9afb8f8dd
commit f5478cdc76
108 changed files with 6557 additions and 550 deletions

View File

@@ -1,5 +1,7 @@
# database
结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。

69
database/mysql/README.md Normal file
View File

@@ -0,0 +1,69 @@
# 官方 MySQL 适配器
本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。
生成项目选择 `--database mysql` 即接入:
```sh
go-omni new --database mysql --module example.com/billing ./billing
```
`etc/config.yaml` 使用结构化配置,环境覆盖优先:
```yaml
database:
host: db.example.com
port: 3306
user: app
database: billing
parseTime: true
location: UTC
charset: utf8mb4
collation: utf8mb4_bin
connectTimeout: 5s
readTimeout: 5s
writeTimeout: 5s
tls:
mode: required
caFile: /etc/certs/database-ca.pem
serverName: db.example.com
pool:
maxOpenConns: 20
maxIdleConns: 5
connMaxLifetime: 30m
connMaxIdleTime: 5m
pingTimeout: 5s
```
通过 `GO_OMNI_DB_PASSWORD` 传密码;还有 HOST、PORT、USER、DATABASE、TLS_CA_FILE、TLS_CERT_FILE、TLS_KEY_FILE、POOL_MAX_OPEN_CONNS 等。业务也可自定义 env 标签。新生成服务不再读取 DSN;独立数据库迁移 CLI 和手写 `database.Open/OpenConnector` 的原接口保留。
默认启用 TLS 并校验系统信任链与主机名;私有 CA 用 caFile,双向 TLS 额外设置 certFile/keyFile。只有明确设置 `tls.mode: disabled` 才使用明文。Unix socket 用 socket,若在 socket 上使用 TLS,必须提供 serverName。不会注册全局 TLS 配置或全局驱动 Logger。
手写入口:
```go
cfg := mysqladapter.DefaultConfig()
// 从项目配置覆盖 cfg;不要打印含密码的完整配置。
db, err := mysqladapter.Open(ctx, cfg, mysqladapter.WithLogger(logger))
if err != nil { return err }
defer db.Close()
// db.DB 是标准 *sql.DB;也可用框架 Health、InTx 等方法。
```
生成项目的 `internal/svc/databaseOptions.go` 是保留的项目扩展位置:
```go
func (c *ServiceContext) DatabaseOptions() []mysqladapter.Option {
return []mysqladapter.Option{
mysqladapter.WithStartupCheck(func(ctx context.Context, db *database.DB) error {
return checkProjectSchema(ctx, db)
}),
}
}
```
检查在 Ping 后运行,共享启动时限;失败或 panic 会关闭新连接池。版本/表结构检查放在这些回调中,不放到框架。回调必须响应 context,并且不能自行关闭借用的 db。依赖尚未完成整体初始化时不要把连接池发布到外部。
`WithDriverConfig(func(*mysql.Config) error)` 支持高级拨号、驱动参数等,配置在回调后克隆;这是项目明确选择的底层扩展。`NewConnector` 返回标准 driver.Connector,供已有基础设施使用;它不接受启动检查选项。任意驱动仍可用核心 `database.OpenConnector`。
错误有安全摘要,errors.Is/As 保留 TLS/驱动/项目检查的原因。配置验证不读证书、不连接数据库;Open 才读取证书、创建池并握手。不要自行打印原始配置或展开可能包含内部信息的 driver 字段给用户。

View File

@@ -0,0 +1,115 @@
package mysql_test
import (
"context"
"crypto/tls"
"errors"
"git.yuntuo.hk/k2safe/go-omni/database"
adapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"
"net"
"os"
"strconv"
"testing"
"time"
)
func TestStructuredMySQLAdapterTLS(t *testing.T) {
if os.Getenv("GO_OMNI_CONNECTOR_MYSQL_TEST") != "1" {
t.Skip("use scripts/verifyConnector.py with an isolated local MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cfg := adapter.DefaultConfig()
host, port, err := net.SplitHostPort(os.Getenv("GO_OMNI_CONNECTOR_ADDR"))
if err != nil {
t.Fatal(err)
}
cfg.Host = host
cfg.Port, _ = strconv.Atoi(port)
cfg.User = os.Getenv("GO_OMNI_CONNECTOR_USER")
cfg.Password = os.Getenv("GO_OMNI_CONNECTOR_PASSWORD")
cfg.Database = os.Getenv("GO_OMNI_CONNECTOR_DATABASE")
cfg.TLS.CAFile = os.Getenv("GO_OMNI_CONNECTOR_CA_FILE")
cfg.TLS.ServerName = "localhost"
cfg.Pool.MaxOpenConns = 2
cfg.Pool.MaxIdleConns = 2
cfg.Pool.PingTimeout = 2 * time.Second
cfg.Session = map[string]string{"time_zone": "'+00:00'"}
checked := false
first, err := adapter.Open(ctx, cfg, adapter.WithStartupCheck(func(ctx context.Context, db *database.DB) error {
var one int
err := db.QueryRowContext(ctx, "SELECT 1").Scan(&one)
checked = one == 1
return err
}))
if err != nil {
t.Fatal(err)
}
defer first.Close()
if !checked {
t.Fatal("startup check not called")
}
cfg.Session = map[string]string{"time_zone": "'+08:00'"}
second, err := adapter.Open(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer second.Close()
for i, db := range []*database.DB{first, second} {
var key, cipher, zone string
if err = db.QueryRowContext(ctx, "SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&key, &cipher); err != nil || cipher == "" {
t.Fatal("not encrypted", err)
}
if err = db.QueryRowContext(ctx, "SELECT @@session.time_zone").Scan(&zone); err != nil || zone != []string{"+00:00", "+08:00"}[i] {
t.Fatal("session isolation failed", zone, err)
}
if db.Stats().MaxOpenConnections != 2 {
t.Fatal("pool limit ignored")
}
}
for _, bad := range []string{"untrusted", "hostname"} {
invalid := cfg
if bad == "untrusted" {
invalid.TLS.CAFile = ""
} else {
invalid.TLS.ServerName = "wrong.invalid"
}
db, e := adapter.Open(ctx, invalid)
if db != nil {
_ = db.Close()
t.Fatal("bad certificate accepted")
}
var verify *tls.CertificateVerificationError
if !errors.As(e, &verify) {
t.Fatal("lost TLS verification cause", e)
}
}
cause := errors.New("project schema check failed")
if db, e := adapter.Open(ctx, cfg, adapter.WithStartupCheck(func(context.Context, *database.DB) error { return cause })); db != nil || !errors.Is(e, cause) {
t.Fatal("failed startup escaped", e)
}
if err = first.Health(ctx); err != nil {
t.Fatal("failed pool affected healthy pool", err)
}
// The server requires a client certificate for this separate fixture user.
cfg.User = "omni_mtls"
cfg.Password = ""
cfg.Database = ""
cfg.Session = nil
if db, e := adapter.Open(ctx, cfg); e == nil {
db.Close()
t.Fatal("missing required client certificate accepted")
}
cfg.TLS.CertFile = os.Getenv("GO_OMNI_CONNECTOR_CLIENT_CERT")
cfg.TLS.KeyFile = os.Getenv("GO_OMNI_CONNECTOR_CLIENT_KEY")
mtls, err := adapter.Open(ctx, cfg)
if err != nil {
t.Fatal("mTLS failed", err)
}
if err = mtls.Close(); err != nil {
t.Fatal(err)
}
if err = mtls.Close(); err != nil {
t.Fatal("pool close is not idempotent", err)
}
}

322
database/mysql/mysql.go Normal file
View File

@@ -0,0 +1,322 @@
// Package mysql provides an optional MySQL adapter over database.OpenConnector.
// Each pool owns independent driver settings; no global driver registry changes
// are made. It never migrates tables or retries application transactions.
package mysql
import (
"context"
"crypto/tls"
"crypto/x509"
"database/sql/driver"
"errors"
"fmt"
"io"
"net"
"os"
"regexp"
"strconv"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/logx"
mysqldriver "github.com/go-sql-driver/mysql"
)
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Socket string `json:"socket"`
User string `json:"user"`
Password string `json:"password" env:",secret"`
Database string `json:"database"`
ParseTime bool `json:"parseTime"`
Location string `json:"location"`
Charset string `json:"charset"`
Collation string `json:"collation"`
// Session values are SQL expressions, as required by the driver, for
// example time_zone: "'+00:00'". They are trusted deployment configuration.
Session map[string]string `json:"session"`
ConnectTimeout time.Duration `json:"connectTimeout"`
ReadTimeout time.Duration `json:"readTimeout"`
WriteTimeout time.Duration `json:"writeTimeout"`
MultiStatements bool `json:"multiStatements"`
TLS TLSConfig `json:"tls"`
Pool database.PoolConfig `json:"pool"`
}
type TLSConfig struct {
Mode string `json:"mode"` // required (default) or disabled
CAFile string `json:"caFile"`
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
ServerName string `json:"serverName"`
}
func DefaultConfig() Config {
return Config{Host: "127.0.0.1", Port: 3306, ParseTime: true, Location: "UTC", Charset: "utf8mb4", ConnectTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, TLS: TLSConfig{Mode: "required"}, Pool: database.DefaultPoolConfig()}
}
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate does not read certificate files or open connections. All defaults
// come from DefaultConfig; explicit zero I/O timeouts mean no driver deadline.
func (c Config) Validate() error {
if c.Socket == "" && (c.Host == "" || strings.ContainsAny(c.Host, "/\r\n\t ") || c.Port < 1 || c.Port > 65535) {
return errors.New("mysql: invalid host or port")
}
if strings.ContainsRune(c.Socket, 0) || strings.ContainsRune(c.Database, 0) || strings.ContainsRune(c.User, 0) {
return errors.New("mysql: invalid connection field")
}
if c.ConnectTimeout < 0 || c.ReadTimeout < 0 || c.WriteTimeout < 0 {
return errors.New("mysql: timeouts cannot be negative")
}
if err := c.Pool.Validate(); err != nil {
return err
}
if c.Location != "" {
if _, err := time.LoadLocation(c.Location); err != nil {
return errors.New("mysql: invalid time location")
}
}
if c.Charset != "" && !identifier.MatchString(c.Charset) {
return errors.New("mysql: invalid charset")
}
if c.Collation != "" && !identifier.MatchString(c.Collation) {
return errors.New("mysql: invalid collation")
}
for key := range c.Session {
if !identifier.MatchString(key) {
return errors.New("mysql: invalid session variable name")
}
}
if c.TLS.Mode != "" && c.TLS.Mode != "required" && c.TLS.Mode != "disabled" {
return errors.New("mysql: TLS mode must be required or disabled")
}
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return errors.New("mysql: TLS client certificate and key must be configured together")
}
if c.TLS.Mode == "disabled" && (c.TLS.CAFile != "" || c.TLS.CertFile != "" || c.TLS.ServerName != "") {
return errors.New("mysql: TLS fields cannot be used when disabled")
}
if c.Socket != "" && c.TLS.Mode != "disabled" && c.TLS.ServerName == "" {
return errors.New("mysql: TLS over a unix socket requires serverName")
}
return nil
}
// Error has a safe summary; errors.Is/As retain driver/verification causes.
// Do not log raw configuration or a driver's manually extracted error fields.
type Error struct {
Operation string
cause error
}
func (e *Error) Error() string { return "mysql: " + e.Operation + " failed" }
func (e *Error) Unwrap() error { return e.cause }
type Check func(context.Context, *database.DB) error
type options struct {
logger *logx.Logger
checks []Check
configure func(*mysqldriver.Config) error
}
type Option func(*options)
func WithLogger(l *logx.Logger) Option { return func(o *options) { o.logger = l } }
// WithStartupCheck runs after Ping, before ownership reaches the caller. A
// failure closes the pool. Checks must honor ctx and must not close the pool.
func WithStartupCheck(check Check) Option {
return func(o *options) {
if check != nil {
o.checks = append(o.checks, check)
}
}
}
// WithDriverConfig provides an advanced, per-pool escape hatch. Configuration
// is cloned after this callback; keep credentials out of callback errors/logs.
func WithDriverConfig(fn func(*mysqldriver.Config) error) Option {
return func(o *options) { o.configure = fn }
}
func settings(opts []Option) options {
var o options
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
return o
}
// Open returns the existing Omni pool; .DB is the same standard sql.DB for
// repositories or ORMs. Ping and startup checks share one bounded deadline.
func Open(ctx context.Context, cfg Config, opts ...Option) (db *database.DB, err error) {
if ctx == nil {
ctx = context.Background()
}
if err = ctx.Err(); err != nil {
return nil, err
}
o := settings(opts)
if o.logger == nil {
o.logger = logx.FromContext(ctx)
}
connector, err := connector(cfg, o)
if err != nil {
return nil, err
}
return openPool(ctx, connector, cfg.Pool, o.checks)
}
func openPool(ctx context.Context, connector driver.Connector, pool database.PoolConfig, checks []Check) (db *database.DB, err error) {
checkCtx, cancel := context.WithTimeout(ctx, pool.PingTimeout)
defer cancel()
db, err = database.OpenConnector(checkCtx, connector, pool)
if err != nil {
return nil, &Error{Operation: "connect", cause: err}
}
defer func() {
if p := recover(); p != nil {
_ = db.Close()
panic(p)
}
if err != nil {
err = errors.Join(err, db.Close())
db = nil
}
}()
for _, check := range checks {
if err = check(checkCtx, db); err != nil {
return db, &Error{Operation: "startup check", cause: err}
}
if err = checkCtx.Err(); err != nil {
return db, &Error{Operation: "startup check", cause: err}
}
}
return db, nil
}
// NewConnector builds a standard connector for callers that own pool creation.
// Startup checks are only executed by Open.
func NewConnector(cfg Config, opts ...Option) (driver.Connector, error) {
o := settings(opts)
if len(o.checks) > 0 {
return nil, errors.New("mysql: startup checks require Open, not NewConnector")
}
return connector(cfg, o)
}
func connector(cfg Config, o options) (driver.Connector, error) {
d, err := driverConfig(cfg, o)
if err != nil {
return nil, err
}
c, err := mysqldriver.NewConnector(d)
if err != nil {
return nil, &Error{Operation: "connector configuration", cause: err}
}
return c, nil
}
func driverConfig(cfg Config, o options) (*mysqldriver.Config, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
d := mysqldriver.NewConfig()
d.User, d.Passwd, d.DBName = cfg.User, cfg.Password, cfg.Database
d.Net, d.Addr = "tcp", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
if cfg.Socket != "" {
d.Net, d.Addr = "unix", cfg.Socket
}
d.ParseTime = cfg.ParseTime
if cfg.Location != "" {
d.Loc, _ = time.LoadLocation(cfg.Location)
}
if cfg.Charset != "" {
if err := d.Apply(mysqldriver.Charset(cfg.Charset, cfg.Collation)); err != nil {
return nil, &Error{Operation: "charset", cause: err}
}
} else {
d.Collation = cfg.Collation
}
d.Params = make(map[string]string, len(cfg.Session))
for k, v := range cfg.Session {
d.Params[k] = v
}
d.Timeout, d.ReadTimeout, d.WriteTimeout = cfg.ConnectTimeout, cfg.ReadTimeout, cfg.WriteTimeout
d.MultiStatements = cfg.MultiStatements
logger := o.logger
if logger == nil {
logger = logx.FromContext(nil)
}
d.Logger = driverLog{logger: logger, secret: cfg.Password}
if cfg.TLS.Mode != "disabled" {
name := cfg.TLS.ServerName
if name == "" {
name = cfg.Host
}
t := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: name}
if cfg.TLS.CAFile != "" {
pem, err := readCertificate(cfg.TLS.CAFile)
if err != nil {
return nil, &Error{Operation: "read CA", cause: err}
}
t.RootCAs = x509.NewCertPool()
if !t.RootCAs.AppendCertsFromPEM(pem) {
return nil, errors.New("mysql: invalid CA certificate")
}
}
if cfg.TLS.CertFile != "" {
cert, err := readCertificate(cfg.TLS.CertFile)
if err != nil {
return nil, &Error{Operation: "read client certificate", cause: err}
}
key, err := readCertificate(cfg.TLS.KeyFile)
if err != nil {
return nil, &Error{Operation: "read client key", cause: err}
}
pair, err := tls.X509KeyPair(cert, key)
if err != nil {
return nil, &Error{Operation: "client key pair", cause: err}
}
t.Certificates = []tls.Certificate{pair}
}
d.TLS = t
}
if o.configure != nil {
if err := o.configure(d); err != nil {
return nil, &Error{Operation: "custom configuration", cause: err}
}
}
return d.Clone(), nil
}
func readCertificate(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, (4<<20)+1))
if err != nil {
return nil, err
}
if len(data) > 4<<20 {
return nil, errors.New("certificate file exceeds 4 MiB")
}
return data, nil
}
type driverLog struct {
logger *logx.Logger
secret string
}
func (l driverLog) Print(values ...any) {
message := fmt.Sprint(values...)
if l.secret != "" {
message = strings.ReplaceAll(message, l.secret, "[REDACTED]")
}
l.logger.Error("database.driver", "driver", "mysql", "error", message)
}

View File

@@ -0,0 +1,146 @@
package mysql
import (
"bytes"
"context"
"crypto/tls"
"database/sql/driver"
"errors"
"net"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/logx"
mysqldriver "github.com/go-sql-driver/mysql"
)
func TestConfigurationTLSLoggingAndIsolation(t *testing.T) {
cfg := DefaultConfig()
cfg.User = "test"
cfg.Password = "do-not-print"
cfg.Database = "mixed-name"
cfg.Session = map[string]string{"time_zone": "'+00:00'"}
cfg.Collation = "utf8mb4_bin"
var out bytes.Buffer
logger, _ := logx.New(logx.Config{Writer: &out})
d, err := driverConfig(cfg, settings([]Option{WithLogger(logger)}))
if err != nil {
t.Fatal(err)
}
if d.TLS == nil || d.TLS.MinVersion != tls.VersionTLS12 || d.TLS.ServerName != cfg.Host || d.MultiStatements || d.Params["time_zone"] != "'+00:00'" {
t.Fatal("driver policy differs")
}
cfg.Session["time_zone"] = "'+08:00'"
if d.Params["time_zone"] != "'+00:00'" {
t.Fatal("session map alias")
}
d.Logger.Print("connection ", cfg.Password)
if strings.Contains(out.String(), cfg.Password) || !strings.Contains(out.String(), "[REDACTED]") {
t.Fatal("secret leaked")
}
cfg.TLS.Mode = "disabled"
d2, err := driverConfig(cfg, options{})
if err != nil || d2.TLS != nil || d.TLS == nil {
t.Fatal("TLS configuration crossed instances", err)
}
cfg.TLS.CAFile = "unused"
if err = cfg.Validate(); err == nil {
t.Fatal("disabled TLS accepted CA")
}
cfg = DefaultConfig()
cfg.TLS.CAFile = filepath.Join(t.TempDir(), "missing")
if err = cfg.Validate(); err != nil {
t.Fatal("Validate reads certificates", err)
}
if _, err = NewConnector(cfg); err == nil {
t.Fatal("missing CA accepted")
}
if err = os.WriteFile(cfg.TLS.CAFile, []byte("do-not-print"), 0600); err != nil {
t.Fatal(err)
}
if _, err = NewConnector(cfg); err == nil || strings.Contains(err.Error(), "do-not-print") {
t.Fatal(err)
}
if _, err = NewConnector(DefaultConfig(), WithStartupCheck(func(context.Context, *database.DB) error { return nil })); err == nil {
t.Fatal("unused startup check accepted")
}
}
func TestDriverHandshakeErrorUsesIndependentLogger(t *testing.T) {
var a, b bytes.Buffer
la, _ := logx.New(logx.Config{Writer: &a})
lb, _ := logx.New(logx.Config{Writer: &b})
cfg := DefaultConfig()
cfg.TLS.Mode = "disabled"
cfg.Pool.PingTimeout = time.Second
for i, l := range []*logx.Logger{la, lb} {
before := []int{a.Len(), b.Len()}
db, err := Open(context.Background(), cfg, WithLogger(l), WithDriverConfig(func(d *mysqldriver.Config) error {
d.DialFunc = func(context.Context, string, string) (net.Conn, error) {
reader, writer := net.Pipe()
_ = writer.Close()
return reader, nil
}
return nil
}))
if db != nil || err == nil {
t.Fatal("broken handshake succeeded")
}
sizes := []int{a.Len(), b.Len()}
if sizes[i] <= before[i] || sizes[1-i] != before[1-i] {
t.Fatal("driver log isolation failed")
}
}
}
type testConnector struct{ closed atomic.Int32 }
func (c *testConnector) Connect(context.Context) (driver.Conn, error) { return &testConn{c}, nil }
func (c *testConnector) Driver() driver.Driver { return testDriver{} }
type testDriver struct{}
func (testDriver) Open(string) (driver.Conn, error) { return nil, errors.New("unexpected DSN") }
type testConn struct{ owner *testConnector }
func (c *testConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("unused") }
func (c *testConn) Begin() (driver.Tx, error) { return nil, errors.New("unused") }
func (c *testConn) Ping(context.Context) error { return nil }
func (c *testConn) Close() error { c.owner.closed.Add(1); return nil }
func TestStartupCheckRollbackAndPanic(t *testing.T) {
for _, panics := range []bool{false, true} {
c := &testConnector{}
cause := errors.New("schema failed")
func() {
if panics {
defer func() {
if recover() == nil {
t.Error("panic lost")
}
}()
}
db, err := openPool(context.Background(), c, database.DefaultPoolConfig(), []Check{func(ctx context.Context, db *database.DB) error {
if _, ok := ctx.Deadline(); !ok {
t.Error("unbounded check")
}
if panics {
panic("failed check")
}
return cause
}})
if db != nil || !errors.Is(err, cause) {
t.Error("failed pool escaped or cause lost", err)
}
}()
if c.closed.Load() != 1 {
t.Fatal("failed initialization leaked pool", c.closed.Load())
}
}
}