Go后端技术栈之GORM库 part four

Posted by OuterCyrex on July 9, 2024

四.连接表与事务

一.多对多查询

多对多关系通过在两个表之间建立媒介的连接表来实现两个表之间的关联

1.添加和查询

ArticlesTag为例:

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后我们发现,他创建了三个表,其中两个为ArticlesTag,第三个为连接表

其中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.删除和更新

由于外键约束,我们不能直接删除ArticleTag,因此,我们删除时要删除与其对应的外键关系

1
2
3
var article Article
DB.Preload("Tags").Take(&article)
_ = DB.Model(&article).Association("Tags").Delete(article.Tags)

通过上述的代码,我们将ID = 1Article的所有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 = 1article数据。

一旦涉及到更改外键内容时,要将外键连接的字段填入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,并在该表内存入ArticleTag连接时的时间。

为了将ArticleTagsArticle_tag表进行连接,我们需要在gorm标签内写入many2many:Article_tag,并将两表的主键在连接表内的对应字段设置为主键(即ArticleIDTagID)

不仅如此,我们在创建这三个表之前还要手动添加连接关系

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 = 1Tags修改为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"`
}

上述代码其实是一种自定义外键建立连接表的途径,我们在ArticleTag内设置外键,分别连接关联表的两个主键

其中,joinForeignKey表示当前表的外键连接的是关联表的哪个主键,joinReferences则表示当前字段(如ArticleModelTags字段)参照的是关联表中的哪个字段。

之后我们进行创建表格

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操作

再如此前的DeleteAppendReplace操作,也是同样的道理

1
_ = DB.Model(&article).Association("Tags").Append([]Tag)

上述的例句即在ArticleTags中加入新的Tag,同样也是在ORM中打开结构体中的Tag切片,并对其进行ORM操作


三.自定义数据类型

1.JSON数据

在日常的开发中,我们可能遇见要将JSON数据存入数据库的情况(虽然大多数情况下不推荐这样做)

在这种需求的驱使下,GORM为我们提供了一种方法。

自定义数据类型必须实现ScanValue接口。

现定义以下的结构体来存储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。为此我们需要创建对应的ScanValue方法

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的方法,ValueInfo的方法,不能更改或替换!

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"`
}

之后仍然需要ScanValue方法来进行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);

我们也可以选择取消MarshalUnmarshal,而直接使用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强制转换时,StatusString()均会触发。


GO中,类似上方MarshalJSONString的方法被称为魔法方法Go 语言运行时会在特定的上下文中自动查找并调用它们.

以下是一些 Go 语言中常见的魔法方法

  1. String() 方法:当你使用 fmt 包(如 fmt.Printlnfmt.Printf)打印一个类型的值时,如果该类型定义了 String() 方法,则该方法会被自动调用以获取该值的字符串表示。
  2. MarshalJSON()UnmarshalJSON(data []byte) error 方法:这两个方法允许类型自定义其 JSON 序列化和反序列化的行为。当使用 encoding/json 包进行 JSON 编码和解码时,如果类型实现了这些方法,则它们会被自动调用。
  3. Error() 方法:如果你定义了一个类型并希望它满足 error 接口,你应该为该类型实现一个 Error() 方法,该方法返回一个描述错误的字符串。当使用 error 类型的值时,Error() 方法会被自动调用以获取错误的描述。
  4. io.Readerio.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()