
很多后台管理系统刚开始开发的时候,数据库通常是写死的。
比如:
go
dsn := "root:123456@tcp(127.0.0.1:3306)/admin"
db, err := gorm.Open(mysql.Open(dsn))
项目自己用当然没有问题。
但是一旦准备把项目开源,问题马上就来了。
有人习惯 MySQL,有人公司的基础设施全部是 PostgreSQL;有人只是想把项目下载下来跑个 Demo,希望直接使用 SQLite;还有一些企业项目历史上就是 SQL Server。
这时候你会发现:
真正通用的后台管理系统,数据库不能写死。
我最近在整理自己的开源后台管理系统 ShiyuAdmin 时,就专门做了这一层设计。
项目地址:
text
https://github.com/Rodert/ShiyuAdmin
ShiyuAdmin 后端采用 Go + Gin + GORM,目前连接层已经支持:
text
PostgreSQL
MySQL
SQLite
SQL Server
今天不讲 CRUD,我们专门聊聊:
一个 Go 后台管理系统,应该怎么设计自己的多数据库适配层?
一、为什么不能在业务代码里判断数据库?
假设我们最开始只支持 MySQL。
代码可能是:
go
func InitDatabase() (*gorm.DB, error) {
dsn := "root:123456@tcp(localhost:3306)/admin?charset=utf8mb4&parseTime=True&loc=Local"
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
后来有人说:
text
我们公司不用 MySQL,可以支持 PostgreSQL 吗?
于是你改成:
go
if dbType == "mysql" {
return gorm.Open(mysql.Open(mysqlDSN))
}
if dbType == "postgres" {
return gorm.Open(postgres.Open(postgresDSN))
}
再后来:
text
能不能支持 SQLite?
继续加:
go
if dbType == "sqlite" {
return gorm.Open(sqlite.Open(path))
}
再来一个 SQL Server。
慢慢地你就会发现,数据库初始化代码开始散落在整个项目里。
最糟糕的情况是业务层甚至会出现:
go
if dbType == "mysql" {
// MySQL 查询
} else if dbType == "postgres" {
// PostgreSQL 查询
}
这种代码一旦出现,数据库适配基本就失控了。
更合理的结构应该是:
text
业务层
↓
Service
↓
Repository
↓
GORM
↓
Database Dialector
↓
MySQL / PostgreSQL / SQLite / SQL Server
业务层根本不应该关心下面到底是哪一种数据库。
二、ShiyuAdmin 是怎么做的?
ShiyuAdmin 把数据库连接逻辑单独放到了:
text
backend/shiyu-admin-backend/pkg/database/database.go
核心思想非常简单:
go
func Connect(cfg *config.Config) (*gorm.DB, error) {
var dialector gorm.Dialector
switch cfg.Database.Driver {
case "postgres", "postgresql":
dialector = postgres.Open(...)
case "mysql":
dialector = mysql.Open(...)
case "sqlite":
dialector = sqlite.Open(...)
case "sqlserver", "mssql":
dialector = sqlserver.Open(...)
default:
return nil, errors.Errorf(
"unsupported database driver: %s",
cfg.Database.Driver,
)
}
return gorm.Open(dialector, &gorm.Config{})
}
这里有一个非常重要的设计:
go
var dialector gorm.Dialector
无论底层是什么数据库,最后全部被转换成:
go
gorm.Dialector
然后统一:
go
gorm.Open(dialector, gcfg)
也就是说,对系统上层来说:
text
MySQL
PostgreSQL
SQLite
SQL Server
最后全部变成:
go
*gorm.DB
这就是 ORM 带来的一个重要价值。
三、GORM Dialector 到底是什么?
很多刚接触 GORM 的开发者可能只写过:
go
gorm.Open(mysql.Open(dsn))
但真正值得注意的是:
go
mysql.Open(dsn)
返回的是一个:
go
gorm.Dialector
可以简单理解成:
数据库方言适配器。
不同数据库之间有大量差异:
text
连接协议不同
SQL 方言不同
分页语法不同
字段类型不同
自增策略不同
索引实现不同
RETURNING 行为不同
DDL 语法不同
GORM 在中间帮我们屏蔽了一部分差异。
于是上层可以统一使用:
go
db.Create(&user)
db.First(&user)
db.Where("username = ?", username).First(&user)
db.Model(&user).Updates(data)
db.Delete(&user)
而不用写:
go
switch databaseType {
case "mysql":
...
case "postgres":
...
}
整个应用层会干净很多。
四、第一步:把数据库配置抽象出来
ShiyuAdmin 没有直接在数据库代码里写用户名和密码,而是统一定义配置结构。
大致可以抽象为:
go
type Config struct {
Database struct {
Driver string
Host string
Port int
Username string
Password string
Database string
SSLMode string
Timezone string
}
}
于是数据库层根本不用知道:
text
配置来自 YAML?
环境变量?
Docker?
Kubernetes Secret?
Render?
Railway?
它只关心:
go
cfg.Database.Driver
cfg.Database.Host
cfg.Database.Port
cfg.Database.Username
cfg.Database.Password
这种设计其实比"支持几种数据库"更加重要。
因为它完成了:
text
配置系统
↓
数据库连接层
↓
ORM
↓
业务系统
之间的解耦。
五、MySQL 的 DSN 怎么构建?
ShiyuAdmin 中 MySQL 的连接逻辑大致是:
go
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.Database.Username,
cfg.Database.Password,
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Database,
)
dialector = mysql.Open(dsn)
最终可能生成:
text
root:123456@tcp(127.0.0.1:3306)/shiyu_admin?charset=utf8mb4&parseTime=True&loc=Local
这里几个参数比较重要。
charset=utf8mb4
推荐:
text
utf8mb4
而不是:
text
utf8
因为 MySQL 中传统的 utf8 并不是真正完整的 UTF-8。
比如 Emoji:
text
😀
🚀
🔥
可能就会出现问题。
现代项目基本建议直接:
text
utf8mb4
parseTime=True
Go 读取数据库中的:
text
DATETIME
TIMESTAMP
字段时,通常希望直接映射为:
go
time.Time
所以一般都会开启:
text
parseTime=True
loc=Local
控制时间解析时使用的时区。
实际生产环境还需要结合:
text
数据库服务器时区
Docker 时区
Go 服务时区
业务时区
统一规划。
否则非常容易出现:
text
数据库显示 10:00
Go 读取出来 02:00
前端又显示 10:00
这种经典的八小时时区问题。
六、PostgreSQL 的 DSN 又完全不同
PostgreSQL 的连接格式通常是:
go
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=%s",
cfg.Database.Host,
cfg.Database.Username,
cfg.Database.Password,
cfg.Database.Database,
cfg.Database.Port,
cfg.Database.SSLMode,
cfg.Database.Timezone,
)
dialector = postgres.Open(dsn)
例如:
text
host=127.0.0.1
user=postgres
password=123456
dbname=shiyu_admin
port=5432
sslmode=disable
TimeZone=Asia/Shanghai
可以看到:
MySQL 和 PostgreSQL 连 DSN 格式都完全不一样。
所以千万不要试图在 Service 层处理这些事情。
它们应该全部封装在:
text
database.Connect()
内部。
七、SQLite 是另一种完全不同的思路
SQLite 最有意思。
MySQL 是:
text
客户端
↓
TCP
↓
MySQL Server
PostgreSQL:
text
客户端
↓
TCP
↓
PostgreSQL Server
SQLite 则可以直接:
text
Go Application
↓
xxx.db 文件
所以连接 SQLite 非常简单:
go
dialector = sqlite.Open(cfg.Database.Database)
配置甚至可以只有:
yaml
database:
driver: sqlite
database: shiyu-admin.db
这对于开源项目特别有价值。
因为很多人体验一个后台项目的时候,并不想:
text
安装 MySQL
创建数据库
创建用户
设置密码
修改配置
导入 SQL
他只是想:
bash
git clone xxx
go run .
然后看看项目长什么样。
SQLite 非常适合承担:
text
开发环境
Demo 环境
单机环境
自动化测试
快速体验
这几个场景。
八、SQL Server 为什么建议使用 URL 构造 DSN?
SQL Server 的连接字符串处理稍微麻烦一点。
尤其当密码中包含:
text
@
#
:
/
?
&
这些特殊字符的时候,如果直接字符串拼接,很容易出问题。
所以 ShiyuAdmin 这里采用的是:
go
dsnURL := &url.URL{
Scheme: "sqlserver",
User: url.UserPassword(
cfg.Database.Username,
cfg.Database.Password,
),
Host: net.JoinHostPort(
cfg.Database.Host,
fmt.Sprintf("%d", cfg.Database.Port),
),
}
然后设置数据库名:
go
query := dsnURL.Query()
query.Set(
"database",
cfg.Database.Database,
)
dsnURL.RawQuery = query.Encode()
最后:
go
dialector = sqlserver.Open(
dsnURL.String(),
)
这比:
go
fmt.Sprintf(
"sqlserver://%s:%s@%s:%d",
username,
password,
host,
port,
)
更加稳妥。
特别是数据库密码来自用户配置时。
九、真正关键的一步:统一返回 *gorm.DB
前面的所有判断,最终都会走到这里:
go
db, err := gorm.Open(
dialector,
&gorm.Config{},
)
然后返回:
go
*gorm.DB
这意味着下面的 Repository:
go
type UserRepository struct {
db *gorm.DB
}
完全不需要知道使用什么数据库。
例如:
go
func NewUserRepository(
db *gorm.DB,
) *UserRepository {
return &UserRepository{
db: db,
}
}
查询用户:
go
func (r *UserRepository) FindByUsername(
username string,
) (*User, error) {
var user User
err := r.db.
Where("username = ?", username).
First(&user).
Error
if err != nil {
return nil, err
}
return &user, nil
}
这段代码既可以跑:
text
MySQL
也可以跑:
text
PostgreSQL
甚至可以跑:
text
SQLite
SQL Server
这才是真正的数据库解耦。
十、多数据库支持的核心不是 switch
看到这里,有些人可能觉得:
不就是写了一个 switch 吗?
实际上不是。
真正重要的是:
text
所有数据库差异
被限制在一个非常小的边界内。
理想情况下只有:
text
pkg/database
知道当前数据库是什么。
而:
text
API
Service
Repository
Domain
最好完全不知道。
也就是说系统形成:
text
┌─────────────────────┐
│ HTTP API │
├─────────────────────┤
│ Service │
├─────────────────────┤
│ Repository │
├─────────────────────┤
│ GORM │
├─────────────────────┤
│ Database Adapter │
├─────────────────────┤
│ MySQL PostgreSQL... │
└─────────────────────┘
如果以后新增一种数据库,我们希望修改的是:
text
Database Adapter
而不是把整个系统改一遍。
十一、ShiyuAdmin 启动时是怎么使用数据库连接的?
ShiyuAdmin 服务启动以后,会先执行:
go
db, err = database.Connect(cfg)
得到统一的:
go
*gorm.DB
然后继续执行数据库初始化逻辑。
例如:
go
bootstrap.AutoMigrate(db)
再初始化:
go
repoDB.NewUserRepository(db)
repoDB.NewRoleRepository(db)
repoDB.NewMenuRepository(db)
repoDB.NewDeptRepository(db)
可以看到。
后面的代码根本不在乎:
text
database.Connect()
内部到底创建了什么数据库。
这就是一个比较典型的:
text
依赖向上抽象
十二、AutoMigrate 为什么也很重要?
ShiyuAdmin 目前使用:
go
db.AutoMigrate(...)
统一创建核心模型。
类似:
go
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&User{},
&Role{},
&Menu{},
&Dept{},
&UserRole{},
&RoleMenu{},
)
}
对于后台管理系统来说,这种方式开发效率非常高。
定义:
go
type User struct {
ID uint
Username string
Nickname string
}
然后:
go
db.AutoMigrate(&User{})
GORM 会调用不同 Dialector,根据数据库生成对应的建表 SQL。
十三、但是 AutoMigrate 并不是万能的
这里必须提醒一下。
如果项目规模变大,只靠:
go
AutoMigrate
是不够的。
因为生产数据库迁移涉及:
text
字段增加
字段删除
字段重命名
索引调整
唯一约束
历史数据迁移
数据修复
回滚
灰度发布
比如:
text
users.nickname
准备从:
text
VARCHAR(50)
改成:
text
VARCHAR(200)
可能问题不大。
但是如果你要:
text
删除字段
拆表
合表
重新设计索引
就不能简单依赖 AutoMigrate。
更成熟的项目通常会使用:
text
golang-migrate
Atlas
Goose
Flyway
Liquibase
这样的数据库 Migration 工具。
十四、连接成功还不够,还要设置连接池
ShiyuAdmin 获取 GORM 对应的底层:
go
*sql.DB
之后,还会配置数据库连接池。
例如:
go
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
然后:
go
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(
30 * time.Minute,
)
这几个参数非常值得讲。
十五、SetMaxOpenConns 是干什么的?
go
sqlDB.SetMaxOpenConns(50)
表示:
text
数据库最大打开连接数 = 50
很多人会觉得:
连接越多性能越高。
其实完全不是。
如果:
text
10 个 Go 实例
每个:
text
100 个连接
那理论最大就是:
text
1000 个数据库连接
数据库可能直接被拖死。
所以连接池大小应该结合:
text
数据库 max_connections
应用实例数量
请求量
SQL 执行时间
CPU
磁盘 IO
综合考虑。
十六、SetMaxIdleConns 又是什么?
go
sqlDB.SetMaxIdleConns(10)
意思是最多保留:
text
10 个空闲连接
如果完全不保留空闲连接:
每次请求可能都要:
text
TCP 建连
认证
握手
创建 Session
执行 SQL
关闭连接
成本非常高。
连接池的价值就是:
text
把已经创建好的连接重复使用。
十七、连接为什么还要设置生命周期?
go
sqlDB.SetConnMaxLifetime(
30 * time.Minute,
)
很多人第一次看到会疑惑:
连接不是一直复用最好吗?
并不是。
因为长期连接可能遇到:
text
数据库主动断开
NAT 超时
负载均衡连接过期
数据库重启
网络设备清理 Session
所以通常会主动给连接设置一个生命周期。
例如:
text
30 分钟
到期重新创建。
十八、多数据库真正难的地方其实是 SQL
做到:
text
数据库能连接
其实只是第一步。
真正麻烦的是:
text
SQL 是否兼容。
比如获取当前时间。
MySQL:
sql
SELECT NOW();
PostgreSQL:
sql
SELECT NOW();
看起来一样。
但很多 SQL 并没有这么幸运。
比如字符串拼接。
MySQL 常见:
sql
SELECT CONCAT(first_name, last_name);
PostgreSQL 可以:
sql
SELECT first_name || last_name;
SQL Server 又可能有自己的处理方式。
再比如:
text
LIMIT
OFFSET
TOP
IDENTITY
AUTO_INCREMENT
SERIAL
RETURNING
不同数据库都可能存在差异。
十九、所以 Repository 层应该尽量避免大量 Raw SQL
例如这种代码:
go
db.Raw(`
SELECT *
FROM sys_user
WHERE status = 1
LIMIT 10
`).Scan(&users)
看起来没什么问题。
但是:
text
LIMIT
并不是所有数据库都按照完全相同的方式处理。
如果改成:
go
db.
Where("status = ?", 1).
Limit(10).
Find(&users)
GORM 可以根据不同 Dialector:
text
生成对应数据库的 SQL。
所以如果一个项目真的想支持多数据库:
优先级应该是:
text
GORM API
↓
GORM Expression
↓
Dialect Adapter
↓
必要时 Raw SQL
而不是到处:
go
db.Raw(...)
二十、分页也不要自己拼 SQL
比如很多人习惯:
go
sql := fmt.Sprintf(
"SELECT * FROM users LIMIT %d OFFSET %d",
pageSize,
offset,
)
更推荐:
go
db.
Offset(offset).
Limit(pageSize).
Find(&users)
甚至封装:
go
func Paginate(
page int,
pageSize int,
) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 10
}
offset := (page - 1) * pageSize
return db.
Offset(offset).
Limit(pageSize)
}
}
调用:
go
db.
Scopes(Paginate(page, pageSize)).
Find(&users)
这样以后数据库切换时风险会小很多。
二十一、模型设计也会影响数据库兼容性
假设模型这样写:
go
type User struct {
ID uint `gorm:"primaryKey"`
}
通常比较容易跨数据库。
但如果大量指定数据库特有类型:
go
gorm:"type:jsonb"
这就明显偏向:
text
PostgreSQL
如果写:
go
gorm:"type:mediumtext"
又明显偏向:
text
MySQL
所以一个真正强调跨数据库的项目,Model 层应该尽量使用通用类型。
比如:
go
type User struct {
ID uint64
Username string
Nickname string
Status int
CreatedAt time.Time
UpdatedAt time.Time
}
而让:
text
GORM Dialector
负责尽可能完成底层类型映射。
二十二、配置文件应该如何切换?
ShiyuAdmin 已经给不同环境准备了独立配置。
例如 MySQL:
yaml
database:
driver: mysql
host: shiyu-mysql
port: 3306
username: shiyu
password: shiyu123
database: shiyu_admin_scaffold
timezone: Asia/Shanghai
如果切换成 PostgreSQL,可以变成:
yaml
database:
driver: postgres
host: localhost
port: 5432
username: postgres
password: 123456
database: shiyu_admin
ssl_mode: disable
timezone: Asia/Shanghai
SQLite:
yaml
database:
driver: sqlite
database: ./data/shiyu-admin.db
SQL Server:
yaml
database:
driver: sqlserver
host: localhost
port: 1433
username: sa
password: YourStrongPassword
database: shiyu_admin
应用启动代码完全不用变化。
还是:
go
db, err := database.Connect(cfg)
二十三、这就是"配置驱动架构"
如果今天:
yaml
driver: mysql
系统启动:
text
MySQL
明天修改:
yaml
driver: postgres
代码不动:
text
PostgreSQL
再改:
yaml
driver: sqlite
代码依然不动:
text
SQLite
这其实就是非常典型的:
text
Configuration Driven Architecture
配置驱动架构。
把:
text
环境差异
从:
text
代码
中抽离出来。
二十四、如果让我继续优化 ShiyuAdmin 的数据库层
当前这种设计已经比较适合中小型后台项目。
但如果准备继续扩大,我会进一步拆分:
text
database/
├── database.go
├── mysql.go
├── postgres.go
├── sqlite.go
├── sqlserver.go
└── pool.go
比如:
go
func mysqlDialector(
cfg *config.Config,
) gorm.Dialector {
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.Database.Username,
cfg.Database.Password,
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Database,
)
return mysql.Open(dsn)
}
PostgreSQL:
go
func postgresDialector(
cfg *config.Config,
) gorm.Dialector {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=%s",
cfg.Database.Host,
cfg.Database.Username,
cfg.Database.Password,
cfg.Database.Database,
cfg.Database.Port,
cfg.Database.SSLMode,
cfg.Database.Timezone,
)
return postgres.Open(dsn)
}
主函数就会变得非常干净:
go
func Connect(
cfg *config.Config,
) (*gorm.DB, error) {
dialector, err := newDialector(cfg)
if err != nil {
return nil, err
}
db, err := gorm.Open(
dialector,
newGormConfig(),
)
if err != nil {
return nil, err
}
if err := configurePool(db); err != nil {
return nil, err
}
return db, nil
}
二十五、甚至可以进一步使用 Factory 模式
例如:
go
type DialectorFactory func(
cfg *config.Config,
) gorm.Dialector
然后注册:
go
var factories = map[string]DialectorFactory{
"mysql": mysqlDialector,
"postgres": postgresDialector,
"postgresql": postgresDialector,
"sqlite": sqliteDialector,
"sqlserver": sqlserverDialector,
"mssql": sqlserverDialector,
}
创建:
go
func newDialector(
cfg *config.Config,
) (gorm.Dialector, error) {
factory, ok := factories[
cfg.Database.Driver
]
if !ok {
return nil, fmt.Errorf(
"unsupported database: %s",
cfg.Database.Driver,
)
}
return factory(cfg), nil
}
以后增加:
text
Oracle
达梦
TiDB
CockroachDB
理论上都可以继续扩展。
二十六、多数据库项目一定要做测试矩阵
这个问题特别容易被忽略。
很多项目 README 写着:
text
支持 MySQL
支持 PostgreSQL
支持 SQLite
实际情况却是:
text
只在 MySQL 跑过。
真正要维护多数据库,我更建议 CI 做:
text
MySQL
PostgreSQL
SQLite
SQL Server
测试矩阵。
GitHub Actions 可以设计成:
yaml
strategy:
matrix:
database:
- mysql
- postgres
- sqlite
- sqlserver
然后分别执行:
bash
go test ./...
核心测试至少覆盖:
text
创建用户
查询用户
更新用户
删除用户
创建角色
角色绑定用户
创建菜单
菜单树查询
事务
分页
唯一索引
软删除
否则所谓的:
text
支持多数据库
很容易慢慢变成:
text
理论支持多数据库。
二十七、一个值得注意的设计:不要为了兼容牺牲全部能力
跨数据库也不是数据库支持得越多越好。
比如 PostgreSQL 有:
text
JSONB
Array
GIN Index
Full Text Search
RETURNING
MySQL 有自己的:
text
JSON
FULLTEXT
Generated Column
SQL Server 也有大量企业功能。
如果为了做到:
text
所有数据库完全一样
最后可能只能使用数据库的:
text
最小公共子集。
这未必合理。
更加实际的方案是:
text
核心功能跨数据库兼容
+
高级功能允许数据库特化
例如:
go
switch db.Dialector.Name() {
case "postgres":
// PostgreSQL 优化
case "mysql":
// MySQL 优化
}
但是这种判断应该:
text
集中在基础设施层
而不是散落在业务层。
二十八、总结
ShiyuAdmin 这个多数据库设计,看起来核心代码并不复杂。
本质上就是:
go
switch cfg.Database.Driver
然后选择:
go
mysql.Open()
postgres.Open()
sqlite.Open()
sqlserver.Open()
最后统一:
go
gorm.Open()
但真正值得学习的并不是这个 switch。
而是它背后的架构思想:
text
配置
↓
数据库适配器
↓
GORM
↓
Repository
↓
Service
↓
API
让上层业务彻底摆脱:
text
我到底在使用什么数据库?
这个问题。
一个后台系统真正想做到可复用、可部署、可二次开发,就不能只考虑:
text
代码在我的电脑上能不能跑。
还应该考虑:
text
别人拿到以后怎么跑?
企业环境怎么部署?
换数据库改多少代码?
开发环境能不能快速启动?
数据库差异有没有污染业务层?
这也是为什么我觉得:
数据库适配层看起来只是几十行代码,却是一个通用后台系统非常重要的基础设施设计。
ShiyuAdmin 目前使用 Go + Gin + GORM 构建后端,在数据库连接层把 PostgreSQL、MySQL、SQLite 和 SQL Server 统一抽象到了 *gorm.DB 之上。
如果你正在做自己的后台管理系统,这套思路同样值得参考:
text
不要让业务代码依赖某一种数据库。
让数据库变成基础设施,而不是业务的一部分。
项目:
text
ShiyuAdmin
https://github.com/Rodert/ShiyuAdmin