GORM 实践文章 | 青训营

122 阅读6分钟

本文探讨了如何通过 GORM(Go语言中的ORM框架)来设计和操作数据库,以实现用户关系和数据管理。通过E-R图的展示,我们定义了userfollow表,分别用于存储用户信息和关注关系。文章介绍了表结构的设计、数据库连接的建立,以及在GORM中进行增删改查操作的方法。同时,还探讨了如何防止SQL注入,确保应用程序的安全性。

表设计与数据库连接

在极简版抖音中,用户可以关注其它用户,如果两个用户互相关注,那么他们就是好友关系。刻画这种关系的 E-R 图如下: gorm 笔记.png

根据 E-R 图,可以设计出表结构如下:

DROP TABLE IF EXISTS follow;
DROP TABLE IF EXISTS user;

-- 用户表
CREATE TABLE user (
    id INT AUTO_INCREMENT PRIMARY KEY,
    age int COMMENT '年龄',
    name VARCHAR(255) COMMENT '用户名称',
    signature TEXT COMMENT '个人简介',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    deleted_at TIMESTAMP NULL COMMENT '删除时间'
);

-- 关注关联表(多对多关系)
CREATE TABLE follow (
    follower_id INT,
    followee_id INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (follower_id, followee_id),
    FOREIGN KEY (follower_id) REFERENCES user(id) ON DELETE CASCADE,
    FOREIGN KEY (followee_id) REFERENCES user(id) ON DELETE CASCADE
);

user表中,id为用户的唯一标识,自增主键、age为用户的年龄,name为用户的名称,signature为用户的个人简介,可以包含较长的文本信息。

follow表中,follower_id为关注者的用户 ID,作为外键引用到 user 表的 id 字段,followee_id为被关注者的用户 ID,同样作为外键引用到 user 表的 id 字段。主键由 follower_idfollowee_id 组成,确保每个关注关系的唯一性;外键约束确保关注者和被关注者的用户 ID 在 user 表中存在,并且在删除用户时进行级联删除。

特别的是,在表中有些创建时间created_at、修改时间updated_at、删除时间deleted_at的字段,这其实是为 GORM 框架的约定做适配。GORM 约定使用 CreatedAtUpdatedAt 追踪创建/更新时间。如果你定义了这种字段,GORM 在创建、更新时会自动填充 当前时间。 要使用不同名称的字段,也可以配置 autoCreateTimeautoUpdateTime 标签。

如果你想要保存 UNIX(毫/纳)秒时间戳,而不是 time,只需将 time.Time 修改为 int 即可。

type User struct {
    CreatedAt time.Time // 在创建时,如果该字段值为零值,则使用当前时间填充
    UpdatedAt int       // 在创建时该字段值为零值或者在更新时,使用当前时间戳秒数填充
    Updated   int64 `gorm:"autoUpdateTime:nano"` // 使用时间戳纳秒数填充更新时间
    Updated   int64 `gorm:"autoUpdateTime:milli"` // 使用时间戳毫秒数填充更新时间
    Created   int64 `gorm:"autoCreateTime"`      // 使用时间戳秒数填充创建时间
}

表创建就绪之后,就可以连接数据库开始开发了,根据课上所讲,可以使用如下方式连接 MySQL 数据库。

mysql_user := os.Getenv("MYSQL_USER")
mysql_password := os.Getenv("MYSQL_PASSWORD")
mysql_host := os.Getenv("MYSQL_HOST")
mysql_port := os.Getenv("MYSQL_PORT")
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/tiktok?charset=utf8&parseTime=True&loc=Local", mysql_user, mysql_password, mysql_host, mysql_port)

DB, err := gorm.Open(mysql.Open(dsn),
        &gorm.Config{
                PrepareStmt:            true,
                SkipDefaultTransaction: true,
        },
)

在这个示例中,使用fmt.Sprintf函数将环境变量中的 MySQL 连接信息填充到连接字符串模板中。连接字符串中包括用户名、密码、主机、端口、数据库名称以及一些连接选项,如字符集、解析时间、时区等。

&gorm.Config{...}GORM 配置结构体中,设置这两处都可以显著提高性能:

  • PrepareStmt: true:开启预编译语句,提高查询性能。GORM 会在执行 SQL 查询前将查询语句预编译为准备好的语句,然后多次执行。
  • SkipDefaultTransaction: true:跳过默认事务。这意味着 GORM 不会自动开启一个默认的事务,需要在代码中显式地开启事务(如果需要的话)。

增删改查

DB.Create方法可以接受单条数据,也可以传入一个切片,分别对应单个创建和批量创建。有时候切片的长度过大,为了减轻数据库的负载,可以选择使用DB.CreateInBatches方法分批创建,如这里一批只传递 100 条数据。创建完成后,可以从传入的结构体内拿到AUTO_INCREMENT的参数。

// 单个创建
DB.Create(&User{
    Name:      "David",
    Age:       18,
    Signature: "A Gopher.",
})

// 批量创建
var users = []User{{Name: "Alice"}, {Name: "Bob"}, {Name: "Carol"}}
DB.Create(&users)
DB.CreateInBatches(users, 100)
for _, user := range users {
    user.ID  // 1,2,3
}

下面是 GORM 查询的最基本用法,只需要一条数据用First方法查找得到,如果需要全部符合条件的数据则用FindFind也可以接受传入单个结构,但是这样做性能较差,而且不能保证每次的返回结果都相同。

var user User
var users []User
// 返回第一条数据
db.Where("age = ?", 18).First(&user)
// 返回所有符合条件的数据
db.Where("age = ?", 18).Find(&users)

有时候不光是对单个表的查找,需要根据关系把多个表连接起来,可以使用Joins方法。例如,查询某个用户的所有关注者。

uid := 1
fans := make([]*User, 0)
DB.Unscoped().Table("follow").Select("user.*").Joins("JOIN user ON user.id = follow.follower_id").
Where("follow.followee_id = ?", uid).Find(&fans)

有时候批量查询的内容过多,可能会导致内存 OOM 问题,则可以使用Rows方法,它返回一个迭代器,可以遍历这些行。如果担心一个个遍历效率太低,也可以采用FindInBatches方法,分批读取数据。

// 批量查询
rows, err := DB.Model(&User{}).Where("age = ?", 18).Rows()
for rows.Next() {
    // 方法1:sql.Rows Scan
    rows.Scan(&name, &age, &email) // NULL值的情况?
    // 方法2:gormScanRows
    db.ScanRows(rows, &user)
}

DB.Where("age = ?", 18).FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error {
})

GORM 支持更新创建功能,使用Save方法,如果保存的值不包含主键,则会执行Create操作;否则,它将执行Update操作(包括所有字段)。

// INSERT INTO `users` (`name`,`age`,`birthday`,`update_at`) VALUES ("Bob",19,"0000-00-00 00:00:00","0000-00-00 00:00:00")
db.Save(&User{Name: "Bob", Age: 19})

// UPDATE `users` SET `name`="Alice",`age`=18,`birthday`="0000-00-00 00:00:00",`update_at`="0000-00-00 00:00:00" WHERE `id` = 1
db.Save(&User{ID: 1, Name: "Alice", Age: 18})

Delete方法的多数使用场景还是根据主键删除,如下所示。

// DELETE FROM users WHERE id = 10;
db.Delete(&User{}, 10)
// DELETE FROM users WHERE id = 10;
db.Delete(&User{}, "10")
// DELETE FROM users WHERE id IN (1,2,3); 
db.Delete(&users, []int{1,2,3})

或者根据条件进行批量删除。

// DELETE from user where name LIKE "Bo%";
db.Where("name LIKE ?", "Bo%").Delete(&User{})

防止 SQL 注入

成熟的 ORM 框架都有防止 SQL 注入的功能,GORM 也不例外。如果你是通过传参的方式将用户输入传给 GORM 的方法,那么它会检查该参数是否不合法,并进行转义之类的操作来避免注入问题。

name := "Bob; DROP TABLE user;"
// 安全,会被转义
db.Where("name = ?", name).First(&user)

有些时候根据业务的需要,我们不得不使用字符串拼接的方式来创建 SQL 语句。如果用户输入的值在一个确定的范围里面,可以建立一个白名单进行检查;如果用户输入的值是不确定的,则需要遍历该字符串并对特殊字符进行转义(例如:'";-\等)。

以下是 GORM 中需要警惕的方法。

db.Select("name; drop table users;").First(&user)
db.Distinct("name; drop table users;").First(&user)
db.Model(&user).Pluck("name; drop table users;", &names)
db.Group("name; drop table users;").First(&user)
db.Group("name").Having("1 = 1;drop table users;").First(&user)
db.Raw("select name from users; drop table users;").First(&user)
db.Exec("select name from users; drop table users;")
db.Order("name; drop table users;").First(&user)