广告位联系
返回顶部
分享到

基于GORM实现CreateOrUpdate的方法

Golang 来源:互联网 作者:佚名 发布时间:2022-10-23 20:55:41 人浏览
摘要

CreateOrUpdate 是业务开发中很常见的场景,我们支持用户对某个业务实体进行创建/配置。希望实现的 repository 接口要达到以下两个要求: 如果此前不存在该实体,创建一个新的; 如果此

CreateOrUpdate 是业务开发中很常见的场景,我们支持用户对某个业务实体进行创建/配置。希望实现的 repository 接口要达到以下两个要求:

  • 如果此前不存在该实体,创建一个新的;
  • 如果此前该实体已经存在,更新相关属性。

根据笔者的团队合作经验看,很多 Golang 开发同学不是很确定对于这种场景到底怎么实现,写出来的代码五花八门,还可能有并发问题。今天我们就来看看基于 GORM 怎么来实现 CreateOrUpdate。

GORM 写接口原理

我们先来看下 GORM 提供了那些方法来支持我们往数据库插入数据,对 GORM 比较熟悉的同学可以忽略这部分:

Create

插入一条记录到数据库,注意需要通过数据的指针来创建,回填主键;

1

2

3

4

5

6

7

8

9

// Create insert the value into database

func (db *DB) Create(value interface{}) (tx *DB) {

    if db.CreateBatchSize > 0 {

        return db.CreateInBatches(value, db.CreateBatchSize)

    }

    tx = db.getInstance()

    tx.Statement.Dest = value

    return tx.callbacks.Create().Execute(tx)

}

赋值 Dest 后直接进入 Create 的 callback 流程。

Save

保存所有的字段,即使字段是零值。如果我们传入的结构主键为零值,则会插入记录。

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

31

32

33

34

35

36

37

38

39

// Save update value in database, if the value doesn't have primary key, will insert it

func (db *DB) Save(value interface{}) (tx *DB) {

    tx = db.getInstance()

    tx.Statement.Dest = value

    reflectValue := reflect.Indirect(reflect.ValueOf(value))

    for reflectValue.Kind() == reflect.Ptr || reflectValue.Kind() == reflect.Interface {

        reflectValue = reflect.Indirect(reflectValue)

    }

    switch reflectValue.Kind() {

    case reflect.Slice, reflect.Array:

        if _, ok := tx.Statement.Clauses["ON CONFLICT"]; !ok {

            tx = tx.Clauses(clause.OnConflict{UpdateAll: true})

        }

        tx = tx.callbacks.Create().Execute(tx.Set("gorm:update_track_time", true))

    case reflect.Struct:

        if err := tx.Statement.Parse(value); err == nil && tx.Statement.Schema != nil {

            for _, pf := range tx.Statement.Schema.PrimaryFields {

                if _, isZero := pf.ValueOf(tx.Statement.Context, reflectValue); isZero {

                    return tx.callbacks.Create().Execute(tx)

                }

            }

        }

        fallthrough

    default:

        selectedUpdate := len(tx.Statement.Selects) != 0

        // when updating, use all fields including those zero-value fields

        if !selectedUpdate {

            tx.Statement.Selects = append(tx.Statement.Selects, "*")

        }

        tx = tx.callbacks.Update().Execute(tx)

        if tx.Error == nil && tx.RowsAffected == 0 && !tx.DryRun && !selectedUpdate {

            result := reflect.New(tx.Statement.Schema.ModelType).Interface()

            if result := tx.Session(&Session{}).Limit(1).Find(result); result.RowsAffected == 0 {

                return tx.Create(value)

            }

        }

    }

    return

}

关注点:

  • 在 reflect.Struct 的分支,判断 PrimaryFields 也就是主键列是否为零值,如果是,直接开始调用 Create 的 callback,这也和 Save 的说明匹配;
  • switch 里面用到了 fallthrough 关键字,说明 switch 命中后继续往下命中 default;
  • 如果我们没有用 Select() 方法指定需要更新的字段,则默认是全部更新,包含所有零值字段,这里用的通配符 *
  • 如果主键不为零值,说明记录已经存在,这个时候就会去更新。

事实上有一些业务场景下,我们可以用 Save 来实现 CreateOrUpdate 的语义:

  • 首次调用时主键ID为空,这时 Save 会走到 Create 分支去插入数据。
  • 随后调用时存在主键ID,触发更新逻辑。

但 Save 本身语义其实比较混乱,不太建议使用,把这部分留给业务自己实现,用Updates,Create用起来更明确些。

Update & Updates

Update 前者更新单个列。

Updates 更新多列,且当使用 struct 更新时,默认情况下,GORM 只会更新非零值的字段(可以用 Select 指定来解这个问题)。使用 map 更新时则会全部更新。

1

2

3

4

5

6

7

8

9

10

11

12

// Update update attributes with callbacks, refer: https://gorm.io/docs/update.html#Update-Changed-Fields

func (db *DB) Update(column string, value interface{}) (tx *DB) {

    tx = db.getInstance()

    tx.Statement.Dest = map[string]interface{}{column: value}

    return tx.callbacks.Update().Execute(tx)

}

// Updates update attributes with callbacks, refer: https://gorm.io/docs/update.html#Update-Changed-Fields

func (db *DB) Updates(values interface{}) (tx *DB) {

    tx = db.getInstance()

    tx.Statement.Dest = values

    return tx.callbacks.Update().Execute(tx)

}

这里也能从实现中看出来一些端倪。Update 接口内部是封装了一个 map[string]interface{},而 Updates 则是可以接受 map 也可以走 struct,最终写入 Dest。

FirstOrInit

获取第一条匹配的记录,或者根据给定的条件初始化一个实例(仅支持 struct 和 map)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

// FirstOrInit gets the first matched record or initialize a new instance with given conditions (only works with struct or map conditions)

func (db *DB) FirstOrInit(dest interface{}, conds ...interface{}) (tx *DB) {

    queryTx := db.Limit(1).Order(clause.OrderByColumn{

        Column: clause.Column{Table: clause.CurrentTable, Name: clause.PrimaryKey},

    })

    if tx = queryTx.Find(dest, conds...); tx.RowsAffected == 0 {

        if c, ok := tx.Statement.Clauses["WHERE"]; ok {

            if where, ok := c.Expression.(clause.Where); ok {

                tx.assignInterfacesToValue(where.Exprs)

            }

        }

        // initialize with attrs, conds

        if len(tx.Statement.attrs) > 0 {

            tx.assignInterfacesToValue(tx.Statement.attrs...)

        }

    }

    // initialize with attrs, conds

    if len(tx.Statement.assigns) > 0 {

        tx.assignInterfacesToValue(tx.Statement.assigns...)

    }

    return

}

注意,Init 和 Create 的区别,如果没有找到,这里会把实例给初始化,不会存入 DB,可以看到 RowsAffected == 0 分支的处理,这里并不会走 Create 的 callback 函数。这里的定位是一个纯粹的读接口。

FirstOrCreate

获取第一条匹配的记录,或者根据给定的条件创建一条新纪录(仅支持 struct 和 map 条件)。FirstOrCreate可能会执行两条sql,他们是一个事务中的。

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

31

32

33

34

35

36

37

38

39

40

41

42

43

// FirstOrCreate gets the first matched record or create a new one with given conditions (only works with struct, map conditions)

func (db *DB) FirstOrCreate(dest interface{}, conds ...interface{}) (tx *DB) {

    tx = db.getInstance()

    queryTx := db.Session(&Session{}).Limit(1).Order(clause.OrderByColumn{

        Column: clause.Column{Table: clause.CurrentTable, Name: clause.PrimaryKey},

    })

    if result := queryTx.Find(dest, conds...); result.Error == nil {

        if result.RowsAffected == 0 {

            if c, ok := result.Statement.Clauses["WHERE"]; ok {

                if where, ok := c.Expression.(clause.Where); ok {

                    result.assignInterfacesToValue(where.Exprs)

                }

            }

            // initialize with attrs, conds

            if len(db.Statement.attrs) > 0 {

                result.assignInterfacesToValue(db.Statement.attrs...)

            }

            // initialize with attrs, conds

            if len(db.Statement.assigns) > 0 {

                result.assignInterfacesToValue(db.Statement.assigns...)

            }

            return tx.Create(dest)

        } else if len(db.Statement.assigns) > 0 {

            exprs := tx.Statement.BuildCondition(db.Statement.assigns[0], db.Statement.assigns[1:]...)

            assigns := map[string]interface{}{}

            for _, expr := range exprs {

                if eq, ok := expr.(clause.Eq); ok {

                    switch column := eq.Column.(type) {

                    case string:

                        assigns[column] = eq.Value

                    case clause.Column:

                        assigns[column.Name] = eq.Value

                    default:

                    }

                }

            }

            return tx.Model(dest).Updates(assigns)

        }

    } else {

        tx.Error = result.Error

    }

    return tx

}

注意区别,同样是构造 queryTx 去调用 Find 方法查询,后续的处理很关键:

  • 若没有查到结果,将 where 条件,Attrs() 以及 Assign() 方法赋值的属性写入对象,从源码可以看到是通过三次 assignInterfacesToValue 实现的。属性更新后,调用 Create 方法往数据库中插入;
  • 若查到了结果,但 Assign() 此前已经写入了一些属性,就将其写入对象,进行 Updates 调用。

第一个分支好理解,需要插入新数据。重点在于 else if len(db.Statement.assigns) > 0 分支。

我们调用 FirstOrCreate 时,需要传入一个对象,再传入一批条件,这批条件会作为 Where 语句的部分在一开始进行查询。而这个函数同时可以配合 Assign() 使用,这一点就赋予了生命力。

不管是否找到记录,Assign 都会将属性赋值给 struct,并将结果写回数据库。

方案一:FirstOrCreate + Assign

1

2

3

4

5

6

7

8

9

10

func (db *DB) Attrs(attrs ...interface{}) (tx *DB) {

    tx = db.getInstance()

    tx.Statement.attrs = attrs

    return

}

func (db *DB) Assign(attrs ...interface{}) (tx *DB) {

    tx = db.getInstance()

    tx.Statement.assigns = attrs

    return

}

这种方式充分利用了 Assign 的能力。我们在上面 FirstOrCreate 的分析中可以看出,这里是会将 Assign 进来的属性应用到 struct 上,写入数据库的。区别只在于是插入(Insert)还是更新(Update)。

1

2

3

4

5

6

7

8

9

10

// 未找到 user,根据条件和 Assign 属性创建记录

db.Where(User{Name: "non_existing"}).Assign(User{Age: 20}).FirstOrCreate(&user)

// SELECT * FROM users WHERE name = 'non_existing' ORDER BY id LIMIT 1;

// INSERT INTO "users" (name, age) VALUES ("non_existing", 20);

// user -> User{ID: 112, Name: "non_existing", Age: 20}

// 找到了 `name` = `jinzhu` 的 user,依然会根据 Assign 更新记录

db.Where(User{Name: "jinzhu"}).Assign(User{Age: 20}).FirstOrCreate(&user)

// SELECT * FROM users WHERE name = 'jinzhu' ORDER BY id LIMIT 1;

// UPDATE users SET age=20 WHERE id = 111;

// user -> User{ID: 111, Name: "jinzhu", Age: 20}

所以,要实现 CreateOrUpdate,我们可以将需要 Update 的属性通过 Assign 函数放进来,随后如果通过 Where 找到了记录,也会将 Assign 属性应用上,随后 Update。

这样的思路一定是可以跑通的,但使用之前要看场景。

为什么?

因为参看上面源码我们就知道,FirstOrCreate 本质是 Select + Insert 或者 Select + Update。

无论怎样,都是两条 SQL,可能有并发安全问题。如果你的业务场景不存在并发,可以放心用 FirstOrCreate + Assign,功能更多,适配更多场景。

而如果可能有并发安全的坑,我们就要考虑方案二:Upsert。

方案二:Upsert

鉴于 MySQL 提供了 ON DUPLICATE KEY UPDATE 的能力,我们可以充分利用唯一键的约束,来搞定并发场景下的 CreateOrUpdate。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

import "gorm.io/gorm/clause"

// 不处理冲突

DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&user)

// `id` 冲突时,将字段值更新为默认值

DB.Clauses(clause.OnConflict{

  Columns:   []clause.Column{{Name: "id"}},

  DoUpdates: clause.Assignments(map[string]interface{}{"role": "user"}),

}).Create(&users)

// MERGE INTO "users" USING *** WHEN NOT MATCHED THEN INSERT *** WHEN MATCHED THEN UPDATE SET ***; SQL Server

// INSERT INTO `users` *** ON DUPLICATE KEY UPDATE ***; MySQL

// Update columns to new value on `id` conflict

DB.Clauses(clause.OnConflict{

  Columns:   []clause.Column{{Name: "id"}},

  DoUpdates: clause.AssignmentColumns([]string{"name", "age"}),

}).Create(&users)

// MERGE INTO "users" USING *** WHEN NOT MATCHED THEN INSERT *** WHEN MATCHED THEN UPDATE SET "name"="excluded"."name"; SQL Server

// INSERT INTO "users" *** ON CONFLICT ("id") DO UPDATE SET "name"="excluded"."name", "age"="excluded"."age"; PostgreSQL

// INSERT INTO `users` *** ON DUPLICATE KEY UPDATE `name`=VALUES(name),`age=VALUES(age); MySQL

这里依赖了 GORM 的 Clauses 方法,我们来看一下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

type Interface interface { 

    Name() string 

    Build(Builder) 

    MergeClause(*Clause) 

}

// AddClause add clause

func (stmt *Statement) AddClause(v clause.Interface) {

    if optimizer, ok := v.(StatementModifier); ok {

        optimizer.ModifyStatement(stmt)

    } else {

        name := v.Name()

        c := stmt.Clauses[name]

        c.Name = name

        v.MergeClause(&c)

        stmt.Clauses[name] = c

    }

}

这里添加进来一个 Clause 之后,会调用 MergeClause 将语句进行合并,而 OnConflict 的适配是这样:

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

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

package clause

type OnConflict struct {

    Columns      []Column

    Where        Where

    TargetWhere  Where

    OnConstraint string

    DoNothing    bool

    DoUpdates    Set

    UpdateAll    bool

}

func (OnConflict) Name() string {

    return "ON CONFLICT"

}

// Build build onConflict clause

func (onConflict OnConflict) Build(builder Builder) {

    if len(onConflict.Columns) > 0 {

        builder.WriteByte('(')

        for idx, column := range onConflict.Columns {

            if idx > 0 {

                builder.WriteByte(',')

            }

            builder.WriteQuoted(column)

        }

        builder.WriteString(`) `)

    }

    if len(onConflict.TargetWhere.Exprs) > 0 {

        builder.WriteString(" WHERE ")

        onConflict.TargetWhere.Build(builder)

        builder.WriteByte(' ')

    }

    if onConflict.OnConstraint != "" {

        builder.WriteString("ON CONSTRAINT ")

        builder.WriteString(onConflict.OnConstraint)

        builder.WriteByte(' ')

    }

    if onConflict.DoNothing {

        builder.WriteString("DO NOTHING")

    } else {

        builder.WriteString("DO UPDATE SET ")

        onConflict.DoUpdates.Build(builder)

    }

    if len(onConflict.Where.Exprs) > 0 {

        builder.WriteString(" WHERE ")

        onConflict.Where.Build(builder)

        builder.WriteByte(' ')

    }

}

// MergeClause merge onConflict clauses

func (onConflict OnConflict) MergeClause(clause *Clause) {

    clause.Expression = onConflict

}

初阶的用法中,我们只需要关注三个属性:

  • DoNothing:冲突后不处理,参照上面的 Build 实现可以看到,这里只会加入 DO NOTHING;
  • DoUpdates: 配置一批需要赋值的 KV,如果没有指定 DoNothing,会根据这一批 Assignment 来写入要更新的列和值;

1

2

3

4

5

type Set []Assignment

type Assignment struct {

    Column Column

    Value  interface{}

}

  • UpdateAll: 冲突后更新所有的值(非 default tag字段)。

需要注意的是,所谓 OnConflict,并不一定是主键冲突,唯一键也包含在内。所以,使用 OnConflict 这套 Upsert 的先决条件是【唯一索引】或【主键】都可以。生成一条SQL语句,并发安全。

如果没有唯一索引的限制,我们就无法复用这个能力,需要考虑别的解法。如果

总结

  • 若你的 CreateOrUpdate 能用到【唯一索引】或【主键】,建议使用方案二,这也是作者金柱大佬最推荐的方案,并发安全;
  • 若无法用【唯一索引】来限制,需要用其他列来判断,且不关注并发安全,可以采用方案一;
  • 若只需要按照【主键】是否为零值来实现 CreateOrUpdate,可以使用 Save(接口语义不是特别明确,用的时候小心,如果可以,尽量用 Create/Update)。

版权声明 : 本文内容来源于互联网或用户自行发布贡献,该文观点仅代表原作者本人。本站仅提供信息存储空间服务和不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权, 违法违规的内容, 请发送邮件至2530232025#qq.cn(#换@)举报,一经查实,本站将立刻删除。

您可能感兴趣的文章 :

原文链接 : https://juejin.cn/post/7155840164504272933
    Tag :
相关文章
  • 深入理解Golang channel的应用
    channel是用于 goroutine 之间的同步、通信的数据结构 channel 的底层是通过 mutex 来控制并发的,但它为程序员提供了更高一层次的抽象,封装了
  • 基于GORM实现CreateOrUpdate的方法
    CreateOrUpdate 是业务开发中很常见的场景,我们支持用户对某个业务实体进行创建/配置。希望实现的 repository 接口要达到以下两个要求: 如果
  • Golang中的内存逃逸的介绍
    什么是内存逃逸分析 内存逃逸分析是go的编译器在编译期间,根据变量的类型和作用域,确定变量是堆上还是栈上 简单说就是编译器在编译
  • Golang自旋锁的介绍
    自旋锁 获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。 它是为实现保护共享资源而提出的
  • Go语言读写锁RWMutex的源码

    Go语言读写锁RWMutex的源码
    在前面两篇文章中初见 Go Mutex、Go Mutex 源码详解,我们学习了Go语言中的Mutex,它是一把互斥锁,每次只允许一个goroutine进入临界区,可以保
  • Go项目实现优雅关机与平滑重启功能
    什么是优雅关机? 优雅关机就是服务端关机命令发出后不是立即关机,而是等待当前还在处理的请求全部处理完毕后再退出程序,是一种对
  • Go语言操作Excel利器之excelize类库的介绍
    在开发中一些需求需要通过程序操作excel文档,例如导出excel、导入excel、向excel文档中插入图片、表格和图表等信息,使用Excelize就可以方便
  • 利用Go语言快速实现一个极简任务调度系统

    利用Go语言快速实现一个极简任务调度系统
    任务调度(Task Scheduling)是很多软件系统中的重要组成部分,字面上的意思是按照一定要求分配运行一些通常时间较长的脚本或程序。在爬
  • GoLang中的iface 和 eface 的区别介绍

    GoLang中的iface 和 eface 的区别介绍
    GoLang之iface 和 eface 的区别是什么? iface和eface都是 Go 中描述接口的底层结构体,区别在于iface描述的接口包含方法,而eface则是不包含任何方
  • Golang接口使用的教程
    go语言并没有面向对象的相关概念,go语言提到的接口和java、c++等语言提到的接口不同,它不会显示的说明实现了接口,没有继承、子类、
  • 本站所有内容来源于互联网或用户自行发布,本站仅提供信息存储空间服务,不拥有版权,不承担法律责任。如有侵犯您的权益,请您联系站长处理!
  • Copyright © 2017-2022 F11.CN All Rights Reserved. F11站长开发者网 版权所有 | 苏ICP备2022031554号-1 | 51LA统计