四.连接表与事务
一.多对多查询
多对多关系通过在两个表之间建立媒介的连接表来实现两个表之间的关联
1.添加和查询
以Articles
和Tag
为例:
1
2
3
4
5
6
7
8
9
10
type Tag struct {
ID int `gorm:"size:10"`
Name string `gorm:"type:varchar(20);not null"`
Articles []Article `gorm:"many2many:articles_tags;"`
}
type Article struct {
ID int `gorm:"size:10"`
Name string `gorm:"type:varchar(20);not null"`
Tags []Tag `gorm:"many2many:articles_tags"`
}
运行AutoMigrate
后我们发现,他创建了三个表,其中两个为Articles
和Tag
,第三个为连接表
其中GORM
标签的内容为many2many:连接表表名
字段 | 数据类型(与ID一致) | 连接的外键 | 是否主键 |
---|---|---|---|
tag_id | smallint | Tag表的ID | 是 |
article_id | smallint | Article表的ID | 是 |
此时我们在添加数据时便可一并添加对应的另一张表的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DB.Create(&Article{
Name: "First",
Tags: []Tag{
{
Name: "GO",
},
{
Name: "GORM",
},
},
})
//对应的SQL语句
INSERT INTO `tags` (`name`) VALUES ('GO'),('GORM') ON DUPLICATE KEY UPDATE `id`=`id`;
INSERT INTO `article_tag` (`article_id`,`tag_id`) VALUES (1,1),(1,2)
ON DUPLICATE KEY UPDATE `article_id`=`article_id`;
INSERT INTO `articles` (`name`) VALUES ('First');
总而言之,多对多的添加与一对多的添加有相似之处,只需要在添加一个表的字段时同步传入另一个表的数据即可。
1
2
3
4
5
6
var article Article
DB.Model(&Article{}).Preload("Tags").Take(&article)
DB.Create(&Article{
Name: "Second",
Tags: article.Tags,
})
上述代码实现了将First
的所有Tags
复制给Second
的操作
如何查询Article
的所有Tags
呢?
1
2
3
var article Article
DB.Preload("Tags").Take(&article)
fmt.Println(article.Tags)
反之则原理相同。
2.删除和更新
由于外键约束,我们不能直接删除Article
或Tag
,因此,我们删除时要删除与其对应的外键关系
1
2
3
var article Article
DB.Preload("Tags").Take(&article)
_ = DB.Model(&article).Association("Tags").Delete(article.Tags)
通过上述的代码,我们将ID = 1
的Article
的所有Tag
标签给清除了
此后我们便可以进行更新操作
1
2
3
4
5
_ = DB.Model(&article).Association("Tags").Append([]Tag{
{Name: "JavaScript"},
{Name: "HTML"},
{Name: "CSS"},
})
综上,通过先删除后添加的操作,我们可以实现多对多关系的更新
此外,我们还可以使用Replace
语句直接进行更新,从而省去了先删除后添加的操作。
1
2
3
4
5
var article Article
DB.Preload("Tags").Take(&article, 1)
_ = DB.Model(&article).Association("Tags").Replace(&Tag{
Name: "Gin",
})
但要注意,此时的Model
已经确认了更改的对象为ID = 1
的article
数据。
一旦涉及到更改外键内容时,要将外键连接的字段填入Association
之中。
二.自定义连接表
1.建立自定义连接表
为了在多对多关系连接时存入更多的信息,我们可以自定义多对多连接表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Tag struct {
ID int `gorm:"size:10"`
Name string `gorm:"type:varchar(20);not null"`
Articles []Article `gorm:"many2many:Article_tag"`
}
type Article struct {
ID int `gorm:"size:10"`
Name string `gorm:"type:varchar(20);not null"`
Tags []Tag `gorm:"many2many:Article_tag"`
}
type Article_tag struct {
ArticleID int `gorm:"size:10;primaryKey"`
TagID int `gorm:"size:10;primaryKey"`
CreatedAt time.Time `gorm:"type:datetime;not null"`
}
上述内容中,我们建立了一个自定义的连接表Article_Tag
,并在该表内存入Article
与Tag
连接时的时间。
为了将Article
和Tags
与Article_tag
表进行连接,我们需要在gorm
标签内写入many2many:Article_tag
,并将两表的主键在连接表内的对应字段设置为主键(即ArticleID
和TagID
)
不仅如此,我们在创建这三个表之前还要手动添加连接关系
1
2
_ = DB.SetupJoinTable(&Article{}, "Tags", &Article_tag{})
_ = DB.SetupJoinTable(&Tag{}, "Articles", &Article_tag{})
上述代码即通过Tags
字段建立Articles
表与Article_tag
表的连接,通过Articles
字段建立Tags
表与Article_tag
表的连接
之后我们只需要建立这三张表即可:
1
_ = DB.AutoMigrate(&Article{}, &Tag{}, &Article_tag{})
2.关联已有数据
假定在Tag
表中存有GO、GORM、GIN
三行数据,我们要将Tag
表中的三个数据全部与新创建的Article
表中的GOWeb
进行关联
1
2
3
4
5
6
var tags []Tag
DB.Find(&tags, []int{1, 2, 3})
DB.Create(&Article{
Name: "GOWeb",
Tags: tags,
})
通过上述的Create
语句,我们可以创建对应字段,但在Article_tag
中只有两个主键有对应数据。
要想在关联两个表的同时,在关联表中加入某一特定数据,我们便需要写一个HOOK
函数
1
2
3
4
5
6
func (p *Article_tag) BeforeCreate(db *gorm.DB) error {
p.CreatedAt = time.Now()
return nil
}
_ = DB.SetupJoinTable(&Article{}, "Tags", &Article_tag{})
_ = DB.SetupJoinTable(&Tag{}, "Articles", &Article_tag{})
这时在创建Article_tag
的对应数据前,会先将该数据的CreatedAt
的值改为time.Now()
且SetupJoinTable
语句应当在每次操作时都运行,以保证更改字段后三个表的关联关系继续维持。
对于自定义关联表的替换与多对多关系的替换一致,如要将ID = 1
的Tags
修改为ID = 4
的数据,则:
1
2
3
4
5
6
var article Article
var tags []Tag
DB.Preload("Tags").Take(&article, 4)
tags = append(tags, article.Tags...)
article.ID = 1
_ = DB.Model(&article).Association("Tags").Replace(&tags)
3.自定义连接表主键
在实际开发过程中,表名会根据项目具体需要而定,如果表名特别长时,我们创建关联表的字段就比较麻烦,此时我们可以自定义关联表字段名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type TagModel struct {
ID int `gorm:"size:10"`
Name string `gorm:"type:varchar(20);not null"`
Articles []ArticleModel `gorm:"many2many:ArticleTagModel;joinForeignKey:TagID;joinReferences:ArticleID"`
}
type ArticleModel struct {
ID int `gorm:"size:10"`
Title string `gorm:"type:varchar(20);not null"`
Tags []TagModel `gorm:"many2many:ArticleTagModel;joinForeignKey:ArticleID;joinReferences:TagID"`
}
type ArticleTagModel struct {
ArticleID int `gorm:"size:10;primaryKey"`
TagID int `gorm:"size:10;primaryKey"`
CreatedAt time.Time `gorm:"type:datetime;not null"`
}
上述代码其实是一种自定义外键建立连接表的途径,我们在Article
和Tag
内设置外键,分别连接关联表的两个主键。
其中,joinForeignKey
表示当前表的外键连接的是关联表的哪个主键,joinReferences
则表示当前字段(如ArticleModel
的Tags
字段)参照的是关联表中的哪个字段。
之后我们进行创建表格
1
_ = DB.AutoMigrate(&ArticleModel{}, &TagModel{}, &ArticleTagModel{})
其创建、删除、更新均与多对多连接表一致,如下列代码将创建一个ArticleModel
数据并将该数据与两个新添加的TagModel
数据进行关联。
1
2
3
4
5
6
7
8
9
10
DB.Create(&ArticleModel{
Title: "GOWeb",
Tags: []TagModel{
{
Name: "GO",
}, {
Name: "GORM",
},
},
})
此外,如果要查询某个ArticleModel
关联了哪些TagModel
,则:
1
2
3
4
5
6
var article ArticleModel
article.Title = "GOWeb"
DB.Preload("Tags").Take(&article)
fmt.Println(article.Tags)
//输出结果
[{1 GO []} {2 GORM []}]
除了这种传统方法以外,我们也可以通过关联表作为媒介来查询,只需要在关联表中建立对应的字段来存储关联的两个表的信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type ArticleTagModel struct {
ArticleID int `gorm:"size:10;primaryKey"`
Article ArticleModel `gorm:"foreignKey:ArticleID"`
TagID int `gorm:"size:10;primaryKey"`
Tag TagModel `gorm:"foreignKey:TagID"`
CreatedAt time.Time `gorm:"type:datetime;not null"`
}
var articleTag []ArticleTagModel
DB.Preload("Tag").Find(&articleTag, 1)
for _, tag := range articleTag {
fmt.Printf("%+v\n", tag.Tag)
}
//输出结果
{ID:1 Name:GO Articles:[]}
{ID:2 Name:GORM Articles:[]}
但这种操作只能将对应的TagModel
输出或传值,并不能进行分页等ORM操作。我们如何让article.Tags
能进行ORM
操作呢?
此时我们可以选择使用Association
语
1
2
var tags []TagModel
_ = DB.Model(&article).Association("Tags").Find(&tags)
此时,我们便可以进行各种ORM操作。
通过之前很多的例子我们可以认识到Association
的作用
在上述的例子中,Association
实际是在ORM中打开了Article
中的Tags []TagModel
字段,让我们方便的进行ORM操作
再如此前的Delete
、Append
或Replace
操作,也是同样的道理
1
_ = DB.Model(&article).Association("Tags").Append([]Tag)
上述的例句即在Article
的Tags
中加入新的Tag
,同样也是在ORM中打开结构体中的Tag
切片,并对其进行ORM操作
三.自定义数据类型
1.JSON数据
在日常的开发中,我们可能遇见要将JSON
数据存入数据库的情况(虽然大多数情况下不推荐这样做)
在这种需求的驱使下,GORM
为我们提供了一种方法。
自定义数据类型必须实现Scan
和Value
接口。
现定义以下的结构体来存储JSON
数据的信息
1
2
3
4
5
6
7
8
9
10
11
12
type Info struct {
Status int `json:"status"`
Msg string `json:"msg"`
} //存储JSON数据
type User struct {
ID uint `gorm:"size:10"`
Name string `gorm:"type:VARCHAR(20);not null"`
Info Info `gorm:"type:string"`
} //表对象
_ = DB.AutoMigrate(&User{})//创建表
在GORM
里存储JSON
的思路是,将JSON
数据按照结构体的内容生成对应的字符串,实际在数据库中的数据类型为longtext
。为此我们需要创建对应的Scan
和Value
方法
1
2
3
4
5
6
7
8
9
10
11
12
func (i *Info) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return errors.New(fmt.Sprintf("Error with value:", value))
}
err := json.Unmarshal(bytes, i)
return err
}
func (i Info) Value() (driver.Value, error) {
return json.Marshal(i)
}
Scan
方法首先创建了一个[]byte
类型的切片bytes
,之后将Info
里的JSON
数据给Unmarshal
并存储在之前创建的bytes
切片中,而Value
方法则是通过Marshal
将结构体转化为对应的JSON
类型。
注意:这两个方法中,Scan
是*Info
的方法,Value
是Info
的方法,不能更改或替换!
1
2
3
4
5
6
7
8
9
10
DB.Create(&User{
Name: "Outer",
Info: Info{
Status: 0,
Msg: "JSON responded",
},
})
//输出的SQL语句
INSERT INTO `users` (`name`,`info`) VALUES
('Outer','{"status":0,"msg":"JSON responded"}');
在添加字段时,直接在对应的数据中写入结构体的内容即可,通过上述操作创建的表格时支持创建或查询JSON
数据的。
1
2
3
4
5
var user User
DB.Take(&user, "id=?", 1)
fmt.Println(user)
//输出结果
{1 Outer {0 JSON responded}}
2.数组数据
在开发中我们可能需要向数据库中存储数组信息,此时可以将数组信息当做一个JSON
数据进行操作
1
2
3
4
5
6
type Array []string //也可以是int等类型
type Host struct {
ID uint `gorm:"size:10"`
IP string `gorm:"type:VARCHAR(20);not null"`
Ports Array `gorm:"type:string"`
}
之后仍然需要Scan
和Value
方法来进行JSON
序列化和反序列化
1
2
3
4
5
6
7
8
9
10
11
12
func (i *Array) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return errors.New(fmt.Sprintf("Error with value:", value))
}
err := json.Unmarshal(bytes, i)
return err
}
func (i Array) Value() (driver.Value, error) {
return json.Marshal(i)
}
此时便可以创建含有数组字段的表了。
1
2
3
4
5
6
7
8
9
10
DB.Create(&Host{
ID: 2,
IP: "127.0.0.1",
Ports: Array{
"80",
"43",
},
})
//输出的SQL语句
INSERT INTO `hosts` (`ip`,`ports`,`id`) VALUES ('127.0.0.1','["80","43"]',1);
我们也可以选择取消Marshal
和Unmarshal
,而直接使用Strings
库里的方法来实现自定义的字符串存储方法。
1
2
3
4
5
6
7
8
9
10
11
12
func (arr *Array) Scan(value interface{}) error {
data, ok := value.([]byte)
if !ok {
return errors.New(fmt.Sprintf("Error with value:", value))
}
*arr = strings.Split(string(data), "|")
return nil
}
func (arr Array) Value() (driver.Value, error) {
return strings.Join(arr, "|"), nil
}
上述的Scan
方法可以实现将字符串切片的各个元素连接成一整个字符串,中间用|
隔开。Value
方法则进行逆操作,去除|
符并将字符串分割。
四.枚举
这里补充一个项目中常用的知识点,与GORM
无关。
在常规的开发中,面对某个经常使用的全局变量,我们通常将其定为宏常量,便于我们进行全局更改以及比较
1
2
3
4
5
6
7
8
9
10
const (
Running = "Running"
Offline = "Offline"
Except = "Except"
)
if Host.Status == Running {
c.JSON(gin.H{
"msg":"Status:Running",
}),
}
但大量的宏常量字符串数据可能会消耗空间,因此我们常常使用特定编码来替代。此时我们需要写一个解码的函数来将编码所代表的信息展示出来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const (
Running = 1
Offline = 2
Except = 3
)
func (h Host) MarshalJSON() ([]byte, error) {
var status string
switch h.Status {
case Running:
status = "Running"
case Offline:
status = "Offline"
case Except:
status = "Except"
}
return json.Marshal(map[string]interface{}{
"IP": h.IP,
"status": status,
})
}
host := Host{
IP: "127.0.0.1",
Status: Running,
}
data, _ := json.Marshal(host)
fmt.Println(string(data))
//输出结果
{"IP":"127.0.0.1","status":"Running"}
这种情况仍然具有一定弊端,因此我们后来便使用了更方便的类型别名来实现这类操作,即给int
起了一个别名Status
来区分编码和正常的int
类型,再在之后通过Marshal
来解码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Status int
type Host struct {
IP string `json:"ip"`
Status Status `json:"status"`
}
const (
Running Status = 1
Offline Status = 2
Except Status = 3
)
func (s Status) MarshalJSON() ([]byte, error) {
var str string
switch s {
case Running:
str = "Running"
case Offline:
str = "Offline"
case Except:
str = "Except"
}
return json.Marshal(str)
}
通过上述的MarshalJSON
在每次Marshal
函数被使用时都会触发(类似一个HOOK
函数),因此在Marshal
过程中一旦遇到Status
类型的值,MarshalJSON
都会将其先转化为对应的字符串类型再输出。
那这种情况下,如何在GORM
中实现解码?
假定我们有一个表,其结构体为
1
2
3
4
5
6
7
8
9
10
type Host struct {
ID int `json:"id"`
IP string `json:"ip"`
Status Status `json:"status"`
}
DB.AutoMigrate(&Host{})
DB.Create(&Host{
IP: "127.0.0.1",
Status: Running,
})
此时我们在数据库中,Status
对应的数据为1
,即Running
的编码值,如果我们直接进行查询和输出,输出的值仍将为1
而不是Running
如果我们将其Marshal
化,则可以将其编码的对应常量名显示
1
2
3
4
5
6
var host Host
DB.Take(&host)
data, _ := json.Marshal(host)
fmt.Println(string(data))
//输出结果
{"id":1,"ip":"127.0.0.1","status":"Running"}
上述的过程实际上是创建了一个Marshal
函数的魔法方法,我们也可以创建string
函数的魔法方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
func (s Status) String() string {
var str string
switch s {
case Running:
str = "Running"
case Offline:
str = "Offline"
case Except:
str = "Except"
}
return str
}
//使用Println输出的结果
{1 127.0.0.1 Running}
这样,每次我们要Println
或使用String
强制转换时,Status
的String()
均会触发。
在GO
中,类似上方MarshalJSON
或String
的方法被称为魔法方法,Go
语言运行时会在特定的上下文中自动查找并调用它们.
以下是一些 Go 语言中常见的魔法方法:
String()
方法:当你使用fmt
包(如fmt.Println
或fmt.Printf
)打印一个类型的值时,如果该类型定义了String()
方法,则该方法会被自动调用以获取该值的字符串表示。MarshalJSON()
和UnmarshalJSON(data []byte) error
方法:这两个方法允许类型自定义其 JSON 序列化和反序列化的行为。当使用encoding/json
包进行 JSON 编码和解码时,如果类型实现了这些方法,则它们会被自动调用。Error()
方法:如果你定义了一个类型并希望它满足error
接口,你应该为该类型实现一个Error()
方法,该方法返回一个描述错误的字符串。当使用error
类型的值时,Error()
方法会被自动调用以获取错误的描述。io.Reader
和io.Writer
接口的方法:这些方法(如Read(p []byte) (n int, err error)
和Write(p []byte) (n int, err error)
)允许类型与 Go 的 I/O 系统交互,但它们同样不是“自动触发”的;它们必须在需要读取或写入数据的上下文中被显式调用。
五.事务
在数据库操作中常常需要使用事务。
事务是由一个或多个SQL语句组成,这些语句作为一个整体一起向系统提交,要么全部执行成功,要么全部不执行,即事务是不可分割的工作单位。
事务类似与函数但又不完全一致,事务若出现错误便全部不执行,因此具有原子性(不可分割)。
GORM
中的事务是默认开启的,以此来确保数据的一致性,可以通过下述语句进行关闭;
1
2
3
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
SkipDefaultTransaction: true,
})
在此过程中我们以下列的表进行实例
1
2
3
4
5
type User struct {
ID int `gorm:"size:10"`
Name string `gorm:"size:25"`
Money int `gorm:"type:int;default:0"`
}
我们定义一个事务,来让一个用户转给另一位用户100元钱
1
2
3
4
5
6
7
8
9
10
11
12
13
DB.Transaction(func(tx *gorm.DB) error {
Outer.Money -= 100
err := tx.Model(&Outer).Update("Money", Outer.Money).Error
if err != nil {
return err
}
Cyrex.Money += 100
err = tx.Model(&Cyrex).Update("Money", Cyrex.Money).Error
if err != nil {
return err
}
return nil
})
注意:Transaction
传入的参数类型为func (db *DB) Transaction(fc func(tx *DB) error, opts ...*sql.TxOptions) (err error)
的函数,但一般只返回err
值即可,即func (db *DB) Transaction(fc func(tx *DB) error) (err error)
此外,我们可以不将事务进行封装,而是使用GORM
中自带的一些控制语句来实现类似事务的操作
-
db.Begin
:开始事务 -
db.Rollback
:遇到错误回滚事务 -
db.Commit
:提交事务
因此,我们上述的操作也可以这样来实现:
1
2
3
4
5
6
7
8
9
10
11
12
DB.Begin()
Outer.Money -= 100
err := DB.Model(&Outer).Update("Money", Outer.Money).Error
if err != nil {
DB.Rollback()
}
Cyrex.Money += 100
err = DB.Model(&Cyrex).Update("Money", Cyrex.Money).Error
if err != nil {
DB.Rollback()
}
DB.Commit()