本文探讨了如何通过 GORM(Go语言中的ORM框架)来设计和操作数据库,以实现用户关系和数据管理。通过E-R图的展示,我们定义了user和follow表,分别用于存储用户信息和关注关系。文章介绍了表结构的设计、数据库连接的建立,以及在GORM中进行增删改查操作的方法。同时,还探讨了如何防止SQL注入,确保应用程序的安全性。
表设计与数据库连接
在极简版抖音中,用户可以关注其它用户,如果两个用户互相关注,那么他们就是好友关系。刻画这种关系的 E-R 图如下:
根据 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_id 和 followee_id 组成,确保每个关注关系的唯一性;外键约束确保关注者和被关注者的用户 ID 在 user 表中存在,并且在删除用户时进行级联删除。
特别的是,在表中有些创建时间created_at、修改时间updated_at、删除时间deleted_at的字段,这其实是为 GORM 框架的约定做适配。GORM 约定使用 CreatedAt、UpdatedAt 追踪创建/更新时间。如果你定义了这种字段,GORM 在创建、更新时会自动填充 当前时间。
要使用不同名称的字段,也可以配置 autoCreateTime、autoUpdateTime 标签。
如果你想要保存 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方法查找得到,如果需要全部符合条件的数据则用Find。Find也可以接受传入单个结构,但是这样做性能较差,而且不能保证每次的返回结果都相同。
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)