Rails中实现深拷贝
近期业务上遇到一个需求,就是需要对已有的数据做深拷贝。除了要拷贝这条数据的所有字段之外,还需要拷贝它所关联的图片,以及它所关联的其他资源数据。
常规做法
针对简单的ActiveRecord
数据,你完全就可以采用原厂自带的ApplicationRecord#dup
方法
> address = Address.last
=> #<Address id: 304, name: "6406 Ivy", mobile: "2221231232", area: "北京市市辖区", street: "lanzhiheng\r\n6406 Ivy", default: false, user_id: nil, created_at: "2021-05-31 03:19:04", updated_at: "2021-05-31 03:19:04">
> new_address = address.dup
> new_address.save
=> true
> new_address
=> #<Address id: 305, name: "6406 Ivy", mobile: "2221231232", area: "北京市市辖区", street: "lanzhiheng\r\n6406 Ivy", default: false, user_id: nil, created_at: "2021-07-05 12:31:16", updated_at: "2021-07-05 12:31:16">
这能够满足常见的对象拷贝需求。然而很多时候业务没有那么简单,特别是你使用了ActiveStorage
的情况下。假设我的User
包含了avatar
字段
> user = User.first
> user.avatar.service_url
=> "https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/tmnlb87i7vidnnpzfpxtg2rx834u"
然而avatar
这个字段却不能直接拷贝
> new_user = user.dup
> new_user.save
=> true
# 退出REPL重新进入
> User.last.avatar.service_url
=> Module::DelegationError (service_url delegated to attachment, but attachment is nil)
可见使用了ActiveStorage技术的相关字段无法用这种方式拷贝。如果当时采用的是直接存储url的模式,比如Carrierwave哪里还需要这番折腾。如此看来要伺候好ActiveStorage这位大爷,我们需要某种名为深拷贝的东西了。
深拷贝
1. ActiveStorage的问题
笔者之前写过一系列关于ActiveStorage的文章,从原理层面剖析过ActiveStorage。它很好地对资源,附件,资源-附件的联系做了ORM,方便了开发者对数据的管理。然而也会带来一些问题。
- 获取资源以及对应附件的时候需要同时访问多个表,多少会慢一些。
- 资源-附件之间的关系存放在数据表
active_storage_attachments
中,直观是直观不少,但是维护起来会稍微麻烦一些。当你要修改字段名,从cover
=>image
,除了要调整数据表字段之外,还需要把对应数据也迁移一下UPDATE active_storage_attachments SET name = 'image' WHERE name = 'cover';
2. 深拷贝工具deep_cloneable
最近在网上发现了一个比较方便的深拷贝工具deep_cloneable,它除了可以用来拷贝对象自身的属性(作用跟ApplicationRecord#dup
差不多)
> old_address = Address.last
> old_address.deep_clone
还可以拷贝与资源相关联的其他资源,比如我要拷贝文章Post
以及它所属的分类Category
> post = Post.first
> m = post.deep_clone include: [:category]
=> #<Post id: nil, title: "3.times { p '黑客与画家' }",.....
> m.category
=> #<Category id: nil, key: "blogs", ....
可见拷贝出来的数据都是没有持久化的,需要自己去调用save
进行入库。你甚至还可以把自己不想要的字段给去掉
> post = Post.first
> post.deep_clone except: :title
=> #<Post id: nil, title: nil, body: "用Ruby的酷炫语法......
3. 针对ActiveStorage相关字段做深拷贝
针对ActiveStorage相关的字段做深拷贝,deep_cloneable官方也有相关的教程,总结起来就两种模式
- 拷贝资源对象(Post,User之流)的同时,也创建附件对象(ActiveStorage::Blob)的副本,还要拷贝它们之前的关系(ActiveStorage::Attachment)。
- 拷贝资源对象(Post,User之流)的同时,不创建附件对象(ActiveStorage::Blob)的副本,只拷贝它们之间的关系(ActiveStorage::Attachment)。
两种做法各有各的好处。可以根据自己的业务场景作出选择。第一种做法的好处在于,它是绝对的深拷贝,副本对象与原对象之间的依赖关系彻底断除。但是会增加附件的存储成本。第二种做法副本对象与原对象之间会共享附件资源(ActiveStorage::Blob),但是需要创建专属的附件-资源关系(ActiveStorage::Attachment)算是浅一点的深拷贝,它的好处在于能够节省附件的存储成本,重复的图片不用存两份。
这些都需要根据自身的业务需求来选择,笔者有点洁癖所以会选择第二种。官方没有根据has_many_attached
这种场景提供共享ActiveStorage::Blob
的做法,我这里贴一下我的解决方案,以下是我的示例代码。
def transfer_to(user)
new_obj = deep_clone(include: :goods) do |original, kopy|
original.attachment_reflections.each_key do |key|
original_field = original.send(key)
kopy_field = kopy.send(key)
next unless original_field.attached?
if original_field.is_a?(ActiveStorage::Attached::Many)
attachments = original_field.blobs.map do |blob|
ActiveStorage::Attachment.new(record: kopy, name: key, blob: blob)
end
kopy.send("#{key}_attachments=", attachments)
else
attachment = original_field
kopy_field.attach(attachment.blob)
end
end
end
new_obj
end
说白了它就是完全拷贝平台商品记录的所有数据,但是图片(附件)的话它跟原有记录用的是同一份,并创建自己专属的记录(ActiveStorage::Attachment)。跟原有的商品记录相比只有所属用户不同。
而deep_cloneable官方所提供的完全深拷贝策略说简单点就是先把原来的附件下载下来,并创建数据流,然后用官方推崇的方法重新创建新的ActiveStorage::Blob
以及ActiveStorage::Attachment
记录。好处在于代码比较好理解,弊端在于为附件创建数据流的过程需要先把附件下载,然后构造数据,如果附件比较多的情况下可能会有点慢,而且用于构造数据的代码也相对较多。
总结
这篇文章主要盘点了在Rails中,ActiveRecord的常见拷贝方式。可以用原厂提供的ApplicationRecord#dup
来做,然而当涉及到ActiveStorage
相关的数据以及联表拷贝的时候它就有点费劲了,这个时候可以借助第三方的deep_cloneable来做,原生就带有对ActiveStorage
相关数据拷贝的支持,拷贝起来更加便利。