基于GORM实现CreateOrUpdate方法详解
正文
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方法的资料请关注猪先飞其它相关文章!
原文出处:https://juejin.cn/post/7155840164504272933
相关文章
php 中file_get_contents超时问题的解决方法
file_get_contents超时我知道最多的原因就是你机器访问远程机器过慢,导致php脚本超时了,但也有其它很多原因,下面我来总结file_get_contents超时问题的解决方法总结。...2016-11-25- 相信很多站长都遇到过这样一个问题,访问页面时出现408错误,下面一聚教程网将为大家介绍408错误出现的原因以及408错误的解决办法。 HTTP 408错误出现原因: HTT...2017-01-22
- php如何实现抓取网页图片,相较于手动的粘贴复制,使用小程序要方便快捷多了,喜欢编程的人总会喜欢制作一些简单有用的小软件,最近就参考了网上一个php抓取图片代码,封装了一个php远程抓取图片的类,测试了一下,效果还不错分享...2015-10-30
- 下面我们来看一篇关于Android子控件超出父控件的范围显示出来方法,希望这篇文章能够帮助到各位朋友,有碰到此问题的朋友可以进来看看哦。 <RelativeLayout xmlns:an...2016-10-02
- ps软件是现在非常受大家喜欢的一款软件,有着非常不错的使用功能。这次文章就给大家介绍下ps把文字背景变透明的操作方法,喜欢的一起来看看。 1、使用Photoshop软件...2017-07-06
intellij idea快速查看当前类中的所有方法(推荐)
这篇文章主要介绍了intellij idea快速查看当前类中的所有方法,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下...2020-09-02- 1.在没有设置默认值的情况下: 复制代码 代码如下:SELECT userinfo.id, user_name, role, adm_regionid, region_name , create_timeFROM userinfoLEFT JOIN region ON userinfo.adm_regionid = region.id 结果:...2014-05-31
js导出table数据到excel即导出为EXCEL文档的方法
复制代码 代码如下: <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta ht...2013-10-13- 批量更新mysql更新语句很简单,更新一条数据的某个字段,一般这样写:复制代码 代码如下:UPDATE mytable SET myfield = 'value' WHERE other_field = 'other_value';如果更新同一字段为同一个值,mysql也很简单,修改下where即...2013-10-04
- 本文涉及的主题虽然很基础,在许多人看来属于小伎俩,但在JavaScript基础知识中属于一个综合性的话题。这里会涉及到对象属性的封装、原型、构造函数、闭包以及立即执行表达式等知识。公有方法 公有方法就是能被外部访问...2015-11-08
- ps软件是一款非常不错的图片处理软件,有着非常不错的使用效果。这次文章要给大家介绍的是ps怎么制作倒影,一起来看看设计倒影的方法。 用ps怎么做倒影最终效果̳...2017-07-06
- 最近想自学PHP ,做了个验证码,但不知道怎么搞的,总出现一个如下图的小红叉,但验证码就是显示不出来,原因如下 未修改之前,出现如下错误; (1)修改步骤如下,原因如下,原因是apache权限没开, (2)点击打开php.int., 搜索extension=ph...2013-10-04
- 单个字符分割 string s="abcdeabcdeabcde"; string[] sArray=s.Split('c'); foreach(string i in sArray) Console.WriteLine(i.ToString()); 输出下面的结果: ab de...2020-06-25
安卓手机wifi打不开修复教程,安卓手机wifi打不开解决方法
手机wifi打不开?让小编来告诉你如何解决。还不知道的朋友快来看看。 手机wifi是现在生活中最常用的手机功能,但是遇到手机wifi打不开的情况该怎么办呢?如果手机wifi...2016-12-21- javascript控制页面控件隐藏显示的两种方法,方法的不同之处在于控件隐藏后是否还在页面上占位 方法一: 复制代码 代码如下: document.all["panelsms"].style.visibility="hidden"; document.all["panelsms"].style.visi...2013-10-13
连接MySql速度慢的解决方法(skip-name-resolve)
最近在Linux服务器上安装MySql5后,本地使用客户端连MySql速度超慢,本地程序连接也超慢。 解决方法:在配置文件my.cnf的[mysqld]下加入skip-name-resolve。原因是默认安装的MySql开启了DNS的反向解析。如果禁用的话就不能...2015-10-21- 本篇文章是对C#方法进行了详细的总结与介绍,需要的朋友参考下...2020-06-25
- 步骤:Window -> PHP -> Editor -> Templates,这里可以设置(增、删、改、导入等)管理你的模板。新建文件注释、函数注释、代码块等模板的实例新建模板,分别输入Name、Description、Patterna)文件注释Name: 3cfileDescriptio...2013-10-04
- EXCEL数据上传到SQL SERVER中的方法需要注意到三点!注意点一:要把EXCEL数据上传到SQL SERVER中必须提前把EXCEL传到服务器上.做法: 在ASP.NET环境中,添加一个FileUpload上传控件后台代码的E.X: 复制代码 代码如下: if...2013-09-23
- mysql锁定单个表的方法 复制代码 代码如下:mysql>lock table userstat read; mysql>unlock tables; 页级的典型代表引擎为BDB。 表级的典型代表引擎为MyISAM,MEMORY以及很久以前的ISAM。 行级的典型代表引擎为INN...2014-05-31