字节青训营笔记---day5(1)

226 阅读9分钟

课程目录

  • 理解 database/sql

    • database/sql 的基本用法
    • 设计原理
    • 基础概念介绍
  • GORM 使用简介

    • GORM 的基本用法
    • Model 定义
    • 惯例约定
    • 关联介绍
  • GORM 设计原理

    • SQL 生成
    • 扩展机制
    • ConnPool
    • Dialector
  • GORM 最佳实践

    • GORM 最佳实践
    • 企业级开发
    • FAQ

database/sql

quick start

示例代码

package main


import (
    "database/sql"
    
    _ "github.com/go-sql-driver/mysql"
)

func main() {
    db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/hello")
    
    rows, err := db.Query("select id, name from users where id = ?", 1)
    if err != nil {
        //xxx
    }
    
    defer func() {
        err = rows.Close()
    }
    
    var users []User
    for rows.Next() {
        var user User
        err := rows.Scan(&uesr.ID, &user.Name)
        
        if err != nil {
            //...
        }
        
        users = append(users, user)
    }
    
    if rows.Err() != nil {
        //...
    }
}

image.png

设计原理

database/sql 向上层应用程序提供一个标准的 api 操作接口,向底层数据库暴露一些简单的驱动接口,在 database/sql 包内部实现连接的管理。

这样的设计就意味着,如果想要支持一些不一样的数据库,只需要去实现一个相同的驱动连接接口、操作接口同时将相同的操作接口暴露给应用程序即可。

连接池设计时使用了池化技术(应用于一些昂贵的、费时资源的优化管理)

image.png

database/sql 操作过程的伪实现

for i := 0; i < maxBadConnRetries; i++ {
    // 从连接池获取连接或通过 driver 新建连接
    dc, err := db.conn(ctx, strategy)
    // 有空闲连接 -> reuse -> max life time
    // 新建连接 -> max open ....
    
    // 将连接放回连接池
    defer dc.db.putConn(dc, err, true)
    // validateConnection 有无错误
    // max life time , max idle conns 检查
    
    // 连接实现 driver.Queryer, driver.Execer 等 interface
    if err == nil {
        err = dc.ci.Query(sql, args...)
    }
    
    isBadConn = errors.Is(err, driver.ErrBadConn)
    if !isBadConn {
        break
    }
}

Driver 连接接口 1

// Driver 接口
type Driver interface {
    Open(name string) (Conn, error)
}

//注册全局 driver
func Register(name string, driver driver.Driver) {
    driversMu.Lock()
    defer driversMu.Unlock()
    
    if driver == nil {
        panic("sql": Reigster driver is nil")
    }
    
    if _, dup := drivers[name]; dup {
        panic("sql: Register called twice for driver "+ name)
    }
    
    drivers[name] = driver
}
//业务代码

import _ "github.com/go-sql-dirver/mysql"

func main() {
    db, err := sql.Open("mysql", "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&&parseTime=True&loc-local")
    ...
}

// 注册 driver
func init() {
    sql.Register("mysql", &MySQLDriver{})
}

Driver 连接接口 2

import "github.com/go-sql-driver/mysql"

func main() {
    connector, err := mysql.NewConnector(&mysql.Config{
        User: "gorm",
        Passwd: "gorm",
        Net: "tcp",
        Addr: "127.0.0.1:3306",
        DBName: "gorm",
        ParseTime: true,
    })
    
    db := sql.OpenDB(connector)
}

操作接口实现

DB 连接的几种类型

  • 直接连接 / Conn
  • 预编译 / Stmt
  • 事务 / Tx

处理返回数据的几种方式

  • Exec / ExecContext -> Result
  • Query / QueryContext -> Rows(Columns)
  • QueryRow / QueryRowContext -> Row(Rows 简化)

相关接口和结构体

type driver.Rows interface {
    // 返回 columns 名字
    Columns() []string
    
    // 实现数据库协议
    // 解析数据到 database/sql.Rows.lastcols 中
    Next(dest []Value) error
    
    // 多批数据解析
    HasNextResultSet() bool
    NextResultSet() error
}

type Rows struct {
    dc *driverConn
    lastcols []driver.Value
    // ...
}

func (rs *Rows) Scan(dest ...any) error {
    for i, sv := range rs.lastcols {
        err := convertAssignRows(dest[i], sv, rs)
        if err != nil {
            return fmt.Errorf(
            `sql: Scan error on column index %d, name %q: %w`,
            i, rs.rowsi.Columns()[i], err,
            )
        }
    }
    return nil
}

func convertAssignRows(dest, src any, rows *Rows) error {
    // ... 常见几种数据类型的赋值
}

小结

  • 基本用法
  • 设计原理
  • 基础概念

GORM 基础使用

  1. 基本用法
  2. Model 定义
  3. 惯例约定
  4. 关联操作

基本用法

用法对比

代码示例 1

import(
    "database/sql"
    
    _ "github.com/go-sql-driver/mysql"
)

func main() {
    db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/hello")
    
    rows, err := db.Query("select id, name form users where id = ?", 1)
    if err != nil {
        //,,,
    }
    
    defer func() {
        err = rows.Close()
    }
    
    var users []User
    for rows.Next() {
        var user User
        err := rows.Scan(&user.ID, &user.Name)
        
        if err != nil {
            //...
        }
        
        users = append(users, user)
    }
    
    if rows.Err() != nil {
        //...
    }
}

使用 ORM

import (
    "gorm.io/gorm"
    "gorm.io/driver/mysql"
)

func main() {
    db, err := gorm.Open(
        mysql.Open("user:password@tcp(127.0.0.1:3306)/hello")
    )
    
    var users []User
    err = db.Select("id", "name").Find(&users, 1).Error
}

CRUD

// 操作数据库
db.AutoMigrate(&Product{})
db.Migrator().CreateTable(&Product{})

// 创建
 user := User{Name: "Jinzhu", Age: 18, Birthday: time.Now()}
 result := db.Create(&user) // pass pointer of data to Create
 
 user.ID // 返回主键 last insert id
 result.Error // 返回 error
 result.RowsAffected // 返回影响的行数
 
 // 批量创建
var users = []User{{Name: "jinzhu1"}, {Name: "jinzhu2"}, {Name: "jinzhu3"}}
db.Create(&users)
db.CreateInBatches(users, 100)

for _, user := range users {
    user.ID // 1, 2, 3
}
// 读取
var product Product
db.First(&product, 1) // 查询 id 为 1的 product
db.First(&product, "code = ?" , "L1212") // 查询code 为 L112 的 product

result := db.Find(&user, []int{1,2,3})
result.RowsAffected // 返回找到的记录数
errors.Is(result.Error, gorm.ErrRecordNotFound) // First,last,Take 查不到数据

// 更新某个字段
db.Model(&product).Update("Price", 2000)
db.Model(&product).UpdateColumn("Price", 200)

// 更新多个字段
db.Model(&product).Updates(Product{Price: 2000, Code: "L1212"})
db.Model(&product).Updates(msp[string]interface{}{"Price": 2000, "Code":"L1221"}

// 批量更新
db.Model(&Product{}).Where("price < ?", 2000).Updates(map[string]interface{}{"Price": 2000})

// 删除
db.Delete(&product)

模型定义对比

type User struct {
    ID uint
    Name string 
    Email *string
    Age uint8
    Birthday *time.Time
    MemberNumber sql.NullString
    ActivatedAt sql.NullTime
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm: "index"`
}
type User struct {
    gorm.Model
    ID uint 
    Name string
    Email *string
    Age uint8
    Birthday *time.Time
    MemberNumber sql.NullString
    ActivatedAt sql.Nulltime
}

//gorm.io/gorm
type Model struct {
    ID uint `gorm:"primaryKey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

惯例约定

约定优于配置

  • 表名为 struct name 的 snake_cases 复数格式
  • 字段名为 field name 的 snake_case 单数格式
  • ID/id 字段为主键,如果为数字,则为自增主键
  • CreatedAt 字段,创建时,保存当前时间
  • UpdatedAt 字段,创建、更新时,保存当前时间
  • gorm.DeletedAt 字段,默认开启 soft delete 模式

一切都可配置

关联介绍

场景: User 拥有一个 Account(has one),拥有多个 Pets(has many),多个 Toys(多态 has many); 他属于某 Company(belongs to),属于某 Manager(单表 belongs to),惯例 Team(单表 has many),会多种 Languages(many to many),拥有很多 Friends(单表 many to many),并且他的 Pet 也有一个玩具 toy(多态 has one)。

type User struct {
    gorm.Model
    
    Name string 
    Account  Account
    
    Pets []*Pet
    Toys []*Toy `gorm:"polymorphic:Owner"`
    
    CompanyID *int
    Company Company
    ManagerID *uint
    Manager *User
    Team []User `gorm:"foreignkey:ManagerID"`
    Languages []Language `gorm:"many2many:UserSpeak;"`
    Friends []*User `gorm:"many2many:user_friends;"`
}

type Pet struct {
    gorm.Model
    UserID *uint
    Toy Toy `gorm:"polymorphic:Owner;"` // 多态
}

type Toy struct {
    ID uint
    Name string
    OwnerID string
    OwnerType string
    CreatedAt time.Time
}

关联操作

// 保存用户及其关联(Upsert)
db.Save(&User{
    Name: "jinzhu",
    Languages: []Language{{Name: "zh-CN"},{Name: "en-US"}},
})

// 关联模式
langAssociation := db.Model(&user).Association("languages")

// 查询关联
langAssociation.Find(&languages)

// 将汉语、英语添加到用户掌握的语言中
langAssociation.Append([]language{languageZH, languageEN})

// 把用户掌握的语言替换成 汉语、德语
langAssociation.Replace([]language{languageZH, languageDE})

// 删除用户所有掌握的语言
langAssociation.Delete(languageZH, languageEN)

// 删除用户所有掌握的语言
langAssociation.Clear()

// 返回用户掌握的语言数量
langAssociation.Count()

// 批量模式 Append, Replace
var users = []User{user1, user2, user3}
langAssociation := db.Model(&users).Association("languages")

// 批量模式 Append,Replace,参数需要与源数据长度相同
// 例如,我们有 3 个 user,将 userA 添加到 user1 的 Team,userB 添加到 user2 的 Team,将 userA、userB、userC 添加 user3 的 Team
db.Model(&users).Association("Team").Append(&userA, &userB, &[]&User{userA, userB, userC})

Preload / Joins 预加载

type User struct {
    Orders []Order
    Profile Profile
}

// 查询用户的时候并找出其订单,个人信息(1 + 1 条 Sql)
db.Preload("Order").Preload("Profile").Find(&users)
// SELECT * FROM users;
// SELECT * FROM orders WHERE user_id IN (1,2,3,4) // 一对多
// SELECT * FROM profiles WHERE user_id IN (1,2,3,4); // 一对一

// 使用 Join SQL 加载(单条 JOIN SQL)
db.Joins("Company").Joins("Manager").First(&user, 1)
db.Joins("Company", DB.Where(&Company{Alive: true})).Find(&users)

// 预加载全部关联(只加载一级关联)
db.Preload(clause.Associations).Find(&users)

// 多级预加载
db.Preload("Orders.OrderItems.Product").Find(&users)

// 多级预加载 + 预加载全部一级关联
db.Preload("Orders.OrderItems.Product").Preload(clause.Associations).Find(&users)

// 查询用户的时候找出其未取消的订单
db.Preload("Orders", "state NOT IN (?)", "cancelled").Find(&users)
db.Preload("Orders", "state = ?", "paid").Preload("Orders.OrderItems").Find(&users)

db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
    return db.Order("orders.amount DESC")
}).Find(&users)

级联删除

// 方法 1:使用数据库约束自动删除
type User struct {
    ID uint 
    Name string 
    Account Account  `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
    
    CreditCards []CreditCard `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
    
    Orders []Order `gorm:"contraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
}

// 需要使用 GORM Migrate 数据库迁移数据库外键才行
db.AutoMigrate(&User{})
// 如果未启动软删除,在删除 User 时会自动删除其依赖
db.Delete(&User{})


// 方法 2:使用 Select 实现级联删除,不依赖数据库约束及软删除
// 删除 users 时,也删除 user 的 account
db.Select("Account").Delete(&user)

// 删除 user 时,也删除 user 的 Orders、CreditCards 记录
db.Select("Orders", "CreditCards").Delete(&user)

// 删除 user 时,也删除 user 的 Orders、CreditCards 记录,也删除订单的 BillingAddress
db.Select("Orders", "Orders.BillingAddress", "CreditCards").Delete(&user)

// 删除 user 时,也删除用户及其依赖的所有 has one/many、many2many记录
db.Select("clause.Associations).Delete(&user)

GORM 设计原理

  1. SQL 生成
  2. 插件扩展
  3. ConnPool
  4. Dialector

SQL 是怎么生成的?

image.png

优点

  • 扩展子句
  • 自定义 clause builder
  • 选择子句 (搭配性质 2:自定义子句,可实现任意接口,在不更改应用代码的情况下,支持其他任意数据库)

插件是怎么工作的?

image.png

示例

预定义 CREATE CALLBACKS 函数

db.Callback().Create().Register("gorm:begin_transaction", BeginTransaction)
db.Callback().Create().Register("gorm:before_create", BeforeCreate)
db.Callback().Create().Register("gorm:save_before_associations", SaveBeforeAssociations)
db.Callback().Create().Register("gorm:create", Create)
db.Callback().Create().Register("gorm"save_after_associations", SaveAfterAssociations)
db.Callback().Create().Register("gorm:after_create" AfterCreate)
db.Callback().Create().Register("gorm:commit_or_rollback_transaction", CommitOrRollBack)

执行 Create 的过程,以此调用注册的 Create Callbacks

db.Create(&Product{code: "K123", Price: 2000})

// gorm 找出 Create 所注册的所有方法并一一调用
func Create(data interface{}) error {
    //...
    for _, f := range db.callbacks.creates {
        f()
    }
}

插件操作

image.png

支持多租户

image.png

支持多数据库、读写分离

image.png

用 Go 简单实现多租户数据库隔离

租户模型

  • 单租户:一个应用一个数据库,其实就是应用分离,数据分离
  • 多租户:一个应用多个数据库,就是同一个应用不同数据库

多租户应用程序的维护更加容易,只需要根据每个租户专属标志,然后提供不同的功能,且公共的功能可以几种更新升级。

数据隔离方式

在 SaaS 系统中一般分离租户的数据有两种模型:

  • 数据的逻辑分离:所有租户只使用一个数据库,它们的数据通过为每个租户使用一个唯一标识符来分隔
  • 数据的物理分离:一个租户分配一个数据库。就是一个应用对应多个数据库,每个数据库就是一个租户。这种方式在客户的增长时扩展了应用的功能,也方便扩展数据库。

物理分离详细实现 目标:每个请求过来,应用程序能够识别租户,并从该租户数据库中提取数据提供给用户。

  • 公共数据库:存储所有租户相关全局配置和所有租户数据库信息
  • 租户数据库:每个租户独立的数据库,根据租户需要来保存数据

启动服务

使用 Gin 框架,初始化一个简单的应用程序并创建基本的路由。

func main() {
    r := gin.Default()
    
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "pong",
        })
    })
    
    r.Run()
}

数据库配置

在公共数据库创建一个保存租户数据库信息表并添加上租户的数据库

Create Table db_tenant (
    id int primary key,
    db_name varchar(100) unique not null,
    db_domain varchar(100) unique not null,
    conn_str varchar(200) not null,
    remark varchar(1000),
)

在配置文件中需要加入一个公共数据库的链接配置,这样就可以在服务启动时连接的数据时公共数据库,接着需要实现根据请求连接到正确的租户数据库的逻辑;主要使用 Gorm 的 DBResolver,来实现多个数据库的连接支持。

func allDbConnect(db *db.gorm) (allDbs map[string]string) {
    db.Model(&db_tenant).Find(&allDbs)
    
    var gconn []gorm.Dialector 
    for _, connStr := range allDbs {
        db := mysql.Open(connStr)
        gconn = append(gconn, db)
    }
    
    db.Use(dbresolver.Register(dbresolver.Config{
        Sources: gconn,
        // sources/replicas 负载均衡策略
        Policys: dbresolver.RandomPolicy{},
    })
    
    return allDbs
}

通过使用中间件来解析每个请求连接从而确定整个请求是由哪个租户数据库来提供数据读写。中间件中主要的处理方式是得到租户对应的数据库连接名,然后从连接池中拿到该连接并设置在全局变量中。在业务逻辑处理中从全局拿到数据库连接,就可以进行数据库读写了。

r.Use(middleware.dbResolve)

func dbResolver(ctx *gin.context){
    dbName := ctx.Query("dbName")
    
    setGlobalDb(dbName)
}

func dbOperater() {
    db := getDb()
    
    var users []User
    db.Clauses(dbresolver.Use(dbName).First(&users)
}

这样就可以启动项目,正常进行数据读写。

什么是 ConnPool ?

在 database/sql 层增加一层抽象

image.png

ConnPool 预编译抽象执行逻辑

image.png

  • 查找缓存的预编译 SQL
  • 未找到,将收到的 SQL 和 Vars 预编译
  • 使用缓存的预编译 SQL 执行
// 全局模式,所有 DB 操作都会预编译并缓存(缓存不含参数部分)
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{PrepareStmt: true})

db.First(&uesr, 1)

// 会话模式,后序会话的操作都会预编译并缓存
tx := db.Session(&Session{PrepareStmt: true})

tx.Find(&users)
tx.Model(&user).Update("Age", 18)

// 全局缓存的语句可被会话使用
tx.First(&user, 2)

stmtManager, ok := tx.ConnPool.(*PreparedStmtDB)
//关闭当前会话的预编译语句
stmtManager.Close()

读写分离

image.png

Dialector

用法示例

import "gorm.io/driver/mysql"

dsn := "user:pass@tcp("127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

import "gorm.io/driver/postgres"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

import "gorm.io/driver/clickhouse"
db, err := gorm.Open(clickhouse.Open(dsn), &gorm.Config{})

import "xxx.io/caches"
db, err := gorm.Open(caches.New(caches.Config{
    Fallback: mysql.Open(dsn),
    Store: lru.New(lru.Config{}),
}), &gorm.Config{})

Dialector 可以用来做什么?

  • 定制 SQL生成
  • 定制 GORM 插件
  • 定制 ConnPool
  • 定制企业特性逻辑

总结

  • SQL 生成
  • 插件扩展
  • ConnPool
  • Dialector