基于GORM实现CreateOrUpdate方法详解

目录
  • 正文
  • GORM 写接口原理
    • Create
    • Save
    • Update & Updates
    • FirstOrInit
    • FirstOrCreate
  • 方案一:FirstOrCreate + Assign
  • 方案二:Upsert
  • 总结

正文

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

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

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

GORM 写接口原理

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

Create

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

// 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

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

// 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 更新时则会全部更新。

// 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)

// 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,他们是一个事务中的。

// 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

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)。

// 未找到 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。

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 方法,我们来看一下:

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 的适配是这样:

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 来写入要更新的列和值;
type Set []Assignment
type Assignment struct {
	Column Column
	Value  interface{}
}
  • UpdateAll: 冲突后更新所有的值(非 default tag字段)。

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

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

总结

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

以上就是基于GORM实现CreateOrUpdate方法详解的详细内容,更多关于GORM CreateOrUpdate方法的资料请关注我们其它相关文章!

(0)

相关推荐

  • golang gorm的Callbacks事务回滚对象操作示例

    目录 1. Callbacks 1.1. 创建对象 1.2. 更新对象 1.3. 删除对象 1.4. 查询对象 1.5. 回调示例 1. Callbacks 您可以将回调方法定义为模型结构的指针,在创建,更新,查询,删除时将被调用,如果任何回调返回错误,gorm将停止未来操作并回滚所有更改. 1.1. 创建对象 创建过程中可用的回调 // begin transaction 开始事物 BeforeSave BeforeCreate // save before associations 保存前关

  • golang gorm开发架构及写插件示例

    目录 1. 开发 1.1. 架构 1.2. 写插件 1.2.1. 注册新的callback 1.2.2. 删除现有的callback 1.2.3. 替换现有的callback 1.2.4. 注册callback顺序 1.2.5. 预定义回调 1. 开发 1.1. 架构 Gorm使用可链接的API,*gorm.DB是链的桥梁,对于每个链API,它将创建一个新的关系. db, err := gorm.Open("postgres", "user=gorm dbname=gorm

  • golang gorm模型结构体的定义示例

    目录 1. 模型 1.1. 模型定义 2. 约定 2.1. gorm.Model 结构体 2.2. 表名是结构体名称的复数形式 2.3. 更改默认表名 2.4. 列名是字段名的蛇形小写 2.5. 字段ID为主键 2.6. 字段CreatedAt用于存储记录的创建时间 2.7. 字段UpdatedAt用于存储记录的修改时间 2.8. 字段DeletedAt用于存储记录的删除时间,如果字段存在 1. 模型 1.1. 模型定义 type User struct { gorm.Model Birthda

  • golang gorm错误处理事务以及日志用法示例

    目录 1. 高级用法 1.1. 错误处理 1.2. 事物 1.2.1. 一个具体的例子 1.3. SQL构建 1.3.1. 执行原生SQL 1.3.2. sql.Row & sql.Rows 1.3.3. 迭代中使用sql.Rows的Scan 1.4. 通用数据库接口sql.DB 1.4.1. 连接池 1.5. 复合主键 1.6. 日志 1.6.1. 自定义日志 1. 高级用法 1.1. 错误处理 执行任何操作后,如果发生任何错误,GORM将其设置为*DB的Error字段 if err := d

  • Go GORM版本2.0新特性介绍

    目录 前言 新版本的特性 Context 支持 批量插入 预编译模式 Joins 预加载 Find to Map Create From Map 事务嵌套 前言 公元2021年3月30日,坊间流传PHP的git服务器被黑客攻入,因恶意代码服务器将关停,PHP还是世界上最好的语言吗?不知道,我是转Go了. 本来是想写gorm相关的知识点的,遇到了批量插入的问题,发现很不科学,才发现gorm已经出了新版本2.0版本,最新的Tag是v1.21.6,我目前使用的是v1.9.10. 新版本的特性 GORM

  • golang gorm更新日志执行SQL示例详解

    目录 1. 更新日志 1.1. v1.0 1.1.1. 破坏性变更 gorm执行sql 1. 更新日志 1.1. v1.0 1.1.1. 破坏性变更 gorm.Open返回类型为*gorm.DB而不是gorm.DB 更新只会更新更改的字段 大多数应用程序不会受到影响,只有当您更改回调中的更新值(如BeforeSave,BeforeUpdate)时,应该使用scope.SetColumn,例如: func (user *User) BeforeUpdate(scope *gorm.Scope) {

  • 基于GORM实现CreateOrUpdate方法详解

    目录 正文 GORM 写接口原理 Create Save Update & Updates FirstOrInit FirstOrCreate 方案一:FirstOrCreate + Assign 方案二:Upsert 总结 正文 CreateOrUpdate 是业务开发中很常见的场景,我们支持用户对某个业务实体进行创建/配置.希望实现的 repository 接口要达到以下两个要求: 如果此前不存在该实体,创建一个新的: 如果此前该实体已经存在,更新相关属性. 根据笔者的团队合作经验看,很多

  • 基于RestTemplate的使用方法(详解)

    1.postForObject :传入一个业务对象,返回是一个String 调用方: BaseUser baseUser=new BaseUser(); baseUser.setUserid(userid); baseUser.setPass(pass); String postForObject = restTemplate.postForObject(this.getURL()+"/user/login", baseUser, String.class); return postF

  • 基于python时间处理方法(详解)

    在处理数据和进行机器学习的时候,遇到了大量需要处理的时间序列.比如说:数据库读取的str和time的转化,还有time的差值计算.总结一下python的时间处理方面的内容. 一.字符串和时间序列的转化 time.strptime():字符串=>时间序列 time.strftime():时间序列=>字符串 import time start = "2017-01-01" end = "2017-8-12" startTime = time.strptime

  • 基于Android RxCache使用方法详解

    前言 我为什么使用这个库? 事实上Android开发中缓存功能的实现选择有很多种,File缓存,SP缓存,或者数据库缓存,当然还有一些简单的库/工具类,比如github上的这个: [ASimpleCache]:a simple cache for android and java 但是都不是很好用(虽然可能学习成本比较低,因为它使用起来相对简单),我可能需要很多的静态常量来作为key存储缓存数据value,并设置缓存的有效期,这可能需要很多Java代码去实现,并且过程繁琐. 如果您使用的网络请求

  • 基于原生ajax与封装的ajax使用方法(详解)

    当我们不会写后端接口来测试ajax时,我们可以使用node环境来模拟一个后端接口. 1.模拟后端接口可参考网站整站开发小例子,在打开命令窗口并转到所在项目文件夹下在命令行中输入npm install express --save,安装express中间件. 2.把当中的app.js的内容换成 var express=require('express'); //var path=require('path'); var app=express(); //app.set('view',path.jo

  • 基于ScheduledExecutorService的两种方法(详解)

    开发中,往往遇到另起线程执行其他代码的情况,用java定时任务接口ScheduledExecutorService来实现. ScheduledExecutorService是基于线程池设计的定时任务类,每个调度任务都会分配到线程池中的一个线程去执行,也就是说,任务是并发执行,互不影响. 注意,只有当调度任务来的时候,ScheduledExecutorService才会真正启动一个线程,其余时间ScheduledExecutorService都是处于轮询任务的状态. 1.scheduleAtFix

  • 基于HashMap遍历和使用方法(详解)

    map的几种遍历方式: Map< String, String> map = new HashMap<>(); map.put("aa", "@sohu.com"); map.put("bb","@163.com"); map.put("cc", "@sina.com"); System.out.println("普通的遍历方法,通过Map.keySet

  • 基于js对象,操作属性、方法详解

    一,概述 在Java语言中,我们可以定义自己的类,并根据这些类创建对象来使用,在Javascript中,我们也可以定义自己的类,例如定义User类.Hashtable类等等. 目前在Javascript中,已经存在一些标准的类,例如Date.Array.RegExp.String.Math.Number等等,这为我们编程提供了许多方便.但对于复杂的客户端程序而言,这些还远远不够. 与Java不同,Java2提供给我们的标准类很多,基本上满足了我们的编程需求,但是Javascript提供的标准类很

  • 基于JavaScript中字符串的match与replace方法(详解)

    1.match方法 match() 方法可在字符串内检索指定的值,或找到一个或多个正则表达式的匹配. match()方法的返回值为:存放匹配结果的数组. 2.replace方法 replace() 方法用于在字符串中用一些字符替换另一些字符,或替换一个与正则表达式匹配的子串. replace方法的返回值为:一个新的字符串. 3.说明 以上2个方法的参数在使用正则表达式时主要添加全局g,这样才能对字符串进行全部匹配或者替换. 示例代码: <!DOCTYPE html> <html lang

  • 基于PHP7错误处理与异常处理方法(详解)

    PHP7错误处理 PHP 7 改变了大多数错误的报告方式.不同于传统(PHP 5)的错误报告机制,现在大多数错误被作为 Error 异常抛出. 这种 Error 异常可以像 Exception 异常一样被第一个匹配的 try / catch 块所捕获.如果没有匹配的 catch 块,则调用异常处理函数(事先通过 set_exception_handler() 注册)进行处理. 如果尚未注册异常处理函数,则按照传统方式处理:被报告为一个致命错误(Fatal Error). Error 类并非继承自

随机推荐