跳到主要内容

数据库

本章节将介绍如何在 Go-Sail 中与数据库进行交互。

简介

几乎所有的现代 Web 应用都需要与数据库交互。Go-Sail 使用 GORM 作为数据库操作层引擎,使其能够轻松连接多种类型的数据库。

目前,Go-Sail 默认内置下列数据库配置,开发者可以通过简单的 API 调用方式访问它们,无需关心底层细节。

  • MySQL / MariaDB
  • PostgreSQL
  • SQLite
  • SQL Server
  • Clickhouse

在前面的章节中,我们实现了登录接口,但是用户名和密码是固定硬编码的。接下来我们将开始和数据库进行真实的交互。

提示

以下代码以 MySQL 数据库为例。

定义模型

main.go
package main

type User struct {
Username string `gorm:"column:username;type:varchar(100);NOT NULL;comment:username"`
Password string `gorm:"column:password;type:varchar(1024);NOT NULL;comment:password"`
}

func (User) TableName() string {
return "users"
}

配置

Go-Sail 是配置驱动的。想要连接数据库,首先需要配置数据库连接信息。回顾我们在快速上手部分写的代码,当时配置是空的,现在我们来按需补充配置。

main.go
package main

import (
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
)

var (
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)

if loginRequest.Username != "go-sail" || loginRequest.Password != "password" {
sail.Response(c).Failure("登录失败,用户名或密码不匹配!")
return
}

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
)

func main() {
sail.WakeupHttp("go-sail", conf).Hook(registerRoutes, nil, nil).Launch()
}

当服务开启时,配置中指定了启用数据库组件且数据库驱动为 MySQL,Go-Sail 会根据配置初始化数据库组件,然后可供全局使用。

执行 SQL 查询

当数据库连接成功、组件准备就绪后,可以通过 API 语法调用数据库进行查询等操作。

以上面为例,我们不再硬编码,而是从数据库的用户表中查询用户是否存在,进一步比对用户输入的密码和数据库存储的密码。

main.go
package main

import (
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
)

type User struct {
Username string `gorm:"column:username;type:varchar(100);NOT NULL;comment:username"`
Password string `gorm:"column:password;type:varchar(1024);NOT NULL;comment:password"`
}

func (User) TableName() string {
return "users"
}

var (
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)

var user User
sail.GetDBR().Where(&User{Username: loginRequest.Username}).First(&user)
// 用户不存在
if len(loginRequest.Username) == 0 {
sail.Response(c).Failure("登录失败,用户名或密码不匹配!")
return
}
// 密码不匹配
if loginRequest.Password != user.Password {
sail.Response(c).Failure("登录失败,用户名或密码不匹配!")
return
}
token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
)

func main() {
sail.WakeupHttp("go-sail", conf).Hook(registerRoutes, nil, nil).Launch()
}

读写分离

如上面配置部分所见,我们配置了 Read 和 Write 两种连接,用于读写分离场景。有些服务只需要频繁查数据库,几乎不会写入,比如定时任务每分钟查询用户数然后统计发送到 Slack;有些既要读也要写,比如用户注册,先查询用户不存在再插入新用户。

这就是典型的 OLAP 和 OLTP 场景,在 Web 应用中非常常见。如果你的业务没有这种需求,或架构没有读写分离,只有一个数据库实例,直接把读写配置写成一样即可。

以下是调用数据库读函数的例子,可以使用 GetDBR() 方法。

main.go
package main

import (
"github.com/keepchen/go-sail/v3/sail"
)

...
sail.GetDBR().Where(...).First(...)
...

这是调用数据库写函数的例子,可以使用 GetDBW() 方法。

main.go
package main

import (
"github.com/keepchen/go-sail/v3/sail"
)

...
sail.GetDBW().Where(...).Updates(...)
...
提示

数据库操作的语法糖和 GORM 完全一致,本质上就是直接使用的 GORM。更详细用法请参考 GORM 官方文档

数据库事务

数据库事务对于确保数据一致性非常重要。在 Go-Sail 中,你可以方便地用 GORM 提供的事务处理机制,示例如下:

main.go
package main

import (
"github.com/keepchen/go-sail/v3/sail"
)

...
sail.GetDBW().Transaction(func(tx *gorm.DB) error {
// 查询用户是否存在
err := tx.Where(&User{Username: "..."}).First(&user).Error
if err != nil {
return err
}
if len(user.Username) != 0 {
return nil
}
// 用户不存在则创建
return tx.Create(&User{Username: "...", Password: "..."}).Error
})
...
危险

需要注意的是,事务方法内部一定要用 tx 操作,不能混用其它数据库对象,否则会出现不可预知的问题。

此外,即使你的读写配置完全相同,也强烈建议事务场景要用 Go-Sail 提供的 GetDBW() 关键字。

超时控制

实际业务处理中,经常会遇到因各种原因操作耗时过长的情况。这时应对数据库操作增加超时控制,比如限定查询不超过 5 秒,否则应直接取消。

main.go
package main

import (
"context"

"github.com/keepchen/go-sail/v3/sail"
)

...
ctx, cancel = context.WithTimeout(context.Background(), time.Second*5)
sail.GetDBW().
WithContext(ctx).
Transaction(func(tx *gorm.DB) error {
// 查询用户是否存在
err := tx.Where(&User{Username: "..."}).First(&user).Error
if err != nil {
return err
}
if len(user.Username) != 0 {
return nil
}
// 用户不存在则创建
return tx.Create(&User{Username: "...", Password: "..."}).Error
})
...

时区配置

基础时间是数据库体系中非常关键的指标,尤其在分布式或全球多数据中心环境下,保证所有系统时间一致性至关重要。当你的服务部署在不同时区且需要一致“当前时间”时,需要在数据库配置中指定 NowFunc

以全球应用举例,为保持一致性,业务层可能要求对全球用户统一时间展示,因此需要统一时间函数与时区,确保后续数据一致正确。

比如我们将数据库的时区设置为 UTC

main.go
package main

import (
"time"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
)

var (
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
NowFunc: func() time.Time {
return time.Now().In(time.UTC)
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)

if loginRequest.Username != "go-sail" || loginRequest.Password != "password" {
sail.Response(c).Failure("登录失败,用户名或密码不匹配!")
return
}

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
)

func main() {
sail.WakeupHttp("go-sail", conf).Hook(registerRoutes, nil, nil).Launch()
}

日志配置

日志是排查故障重要手段,也可以帮助记录事件/数据,比如慢查询、SQL 执行异常等。

Go-Sail 数据库日志组件基于 uber/zap 日志库实现,并与全局日志共享。为更好用它,需要相关配置:

main.go
package main

import (
"time"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
)

var (
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
Logger: db.Logger{
Level: "warn",
SlowThreshold: 100,
SkipCallerLookup: true,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
NowFunc: func() time.Time {
return time.Now().In(time.UTC)
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)

if loginRequest.Username != "go-sail" || loginRequest.Password != "password" {
sail.Response(c).Failure("登录失败,用户名或密码不匹配!")
return
}

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
)

func main() {
sail.WakeupHttp("go-sail", conf).Hook(registerRoutes, nil, nil).Launch()
}

字段说明

  • Level
    日志级别,支持以下(从低到高):

    • silent
    • info
    • warn
    • error
  • SlowThreshold
    慢 SQL 日志阈值,单位毫秒。SQL 执行超过此时长会被记录。

  • SkipCallerLookup
    是否跳过调用链打印。

  • IgnoreRecordNotFoundError
    GORM 查询无结果默认会抛出 RecordNotFoundError。设为 true 可忽略该错误。

  • Colorful
    是否彩色打印日志。一般建议设为 false

自动迁移

通常我们需要自动迁移和同步数据库表结构。以上例子中,最初用户表是不存在的,因此服务启动时需要同步、更新表结构,以便后续使用。

提示

有些公司/团队可能有专业 DBA,数据库表由他们维护。也可能因审核等原因,表结构运维需区分管理,这种场景下可不要求程序自动迁移。

此时可用 GORM 提供的 AutoMigrate 方法:

main.go
package main

import (
"time"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"
"github.com/keepchen/go-sail/v3/lib/db"
)

var (
conf = &config.Config{
DBConf: db.Conf{
Enable: true,
DriverName: "mysql",
Mysql: db.MysqlConf{
Read: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
Write: db.MysqlConfItem{
Host: "127.0.0.1",
Port: 3306,
Username: "root",
Password: "root",
Database: "go_sail",
},
},
Logger: db.Logger{
Level: "warn",
SlowThreshold: 100,
SkipCallerLookup: true,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
NowFunc: func() time.Time {
return time.Now().In(time.UTC)
},
},
}
registerRoutes = func(ginEngine *gin.Engine) {
ginEngine.POST("/login", func(c *gin.Context){
var loginRequest LoginRequest
c.ShouldBind(&loginRequest)

if loginRequest.Username != "go-sail" || loginRequest.Password != "password" {
sail.Response(c).Failure("登录失败,用户名或密码不匹配!")
return
}

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
})
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
}
afterFunc = func() {
sail.GetDBW().AutoMigrate(&User)
var user User
sail.GetDBW().Where(&User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
sail.GetDBW().Create(&User{Username:"go-sail", Password:"password"})
}
}
)

func main() {
sail.WakeupHttp("go-sail", conf).Hook(registerRoutes, nil, afterFunc).Launch()
}