Rails ActiveStorage 原理剖析 - 附件绑定篇

lanzhiheng · 2021年04月24日 · 493 次阅读

这个篇章会着重分析一下,在 ActiveStorage 的支持下如何做到把资源与附件关联到一块的,以及它提供了哪些方法,方便了我们对附件的管理。原文链接:https://www.lanzhiheng.com/posts/attachments-in-activestorage

前面两篇文章介绍了 ActiveStorage 的数据表结构已经对应的资源管理模式,这篇文章主要来看看附件跟资源是如何绑定的。

Attachable

这里有个比较重要的概念就是attachable,这个变量名出现在了很多地方,但是由于没有类型定义,所以大部分人都不知道它到底代表了什么。所幸源码里面有下面的注释

# Attaches an +attachable+ to the record.
#
# If the record is persisted and unchanged, the attachment is saved to
# the database immediately. Otherwise, it'll be saved to the DB when the
# record is next saved.
#
#   person.avatar.attach(params[:avatar]) # ActionDispatch::Http::UploadedFile object
#   person.avatar.attach(params[:signed_blob_id]) # Signed reference to blob from direct upload
#   person.avatar.attach(io: File.open("/path/to/face.jpg"), filename: "face.jpg", content_type: "image/jpg")
#   person.avatar.attach(avatar_blob) # ActiveStorage::Blob object

可以看到attachable能够代表很多的数据类型,让我们一个个来分析看看

1. Form 表单获取的文件数据

首先一个比较典型的就是ActionDispatch::Http::UploadedFile,通常代表那种直接从表单提交过来的文件数据。在前后端分离还没有流行的年代,这种做法比较流行,现在我也只在 ActiveAdmin 里面会用这种做法。其实也就是填写表单的时候把文件数据附上,表单提交的时候携带文件数据一起提交到服务器。

upload-file.png

对服务器来说压力比较大,特别在一次提交多个文件的情况下,一个请求中要传输的数据量较大,进而要处理的数据比较多,经常会引发请求超时的情况(笔者公司的 ActiveAdmin 后台经常就出现这种状况)。

2. ActiveStorage::Blob 的signed_id

前后端分离开始流行之后,都不会直接把文件本身直接提交到业务服务器,而是客户端先把自己需要的图片通过别的接口上传,然后把上传成功后生成的链接传给服务器。这样不仅减少了请求中的数据传输量,还降低了服务器的压力。数据库只需要保存相关的链接即可。不过如果你是用 ActiveStorage,我更建议你采用它提供的机制,为资源与附件的关系创建 attachment 记录

附件可以通过ActiveStorage::Blob提供的方法来上传,上传完成之后数据库也会有一条相关的记录,通过ActiveStorage::Blob#service_url可以获取附件上传后的链接,也可以通过ActiveStorage::Blob#signed_id来获取对应的signed_id。有些库会倾向于在资源字段中存储前者,但是用 ActiveStorage 的话我个人更倾向于把附件“绑定”过去,这样也比较好管理

upload-and-create.png

当然了,这种做法需要我们操作好几个表。

3. 文件 io

这种比较简单也比较直观,也就是文件的 io 对象,大概就是这样子

file-io.png

ActiveStorage 会自动帮你完成附件上传,并且把它跟资源绑定在一起。

4. ActiveStorage::Blob 对象

最后一个是ActiveStorage::Blob对象,这个也比较好理解,上传完成之后我们就能得到这个对象了,直接绑定即可

bind-with-blob.png

绑定原理浅析

一般来说掌握上面这些基本操作就能写出比较有 ActiveStorage 范儿的 Ruby 代码了。不过有些时候我们还是想深入了解一下它是怎么做到这些的?attach方法背后又经历了什么?下面就来简单分析一下,先来看看attach方法来自哪里,它干了什么。其实attach方法可以算是一个统一接口,无论资源 - 附件,是一对一的关系还是一对多的关系,他们都有这个方法,只是各自的实现不同

one-and-many.png

分别看看他们的源码

module ActiveStorage
  # Representation of a single attachment to a model.
  class Attached::One < Attached
    def attach(attachable)
      if record.persisted? && !record.changed?
        record.update(name => attachable)
      else
        record.public_send("#{name}=", attachable)
      end
    end
  end
end

module ActiveStorage
  # Decorated proxy object representing of multiple attachments to a model.
  class Attached::Many < Attached
    def attach(*attachables)
      if record.persisted? && !record.changed?
        record.update(name => blobs + attachables.flatten)
      else
        record.public_send("#{name}=", (change&.attachables || blobs) + attachables.flatten)
      end
    end
  end
end

他们接口一样,只不过是实现有所不同。同样都是分两种情况

情况 1

第一种情况,如果资源本身已经持久化了,并且资源的其他字段没有并没有变动的情况下

irb(main):020:0> m = User.first # 其他字段变动的场景
irb(main):021:0> m.changed?
=> false
irb(main):022:0> m.nickname = 'hello'
irb(main):023:0> m.changed?
=> true

它会直接调用ActiveRecord::Persistence#update方法来更新附件所对应字段的数据,过程有点像是这样

> m = User.first
> m.update(avatar: ActiveStorage::Blob.first.signed_id)
=> true

情况 2

而第二种情况,就是如果资源本身并未持久化,又或者是资源的其他字段有所变更,这种时候直接调用ActiveRecord::Persistence#update来直接更新数据就相当不合适了,因为会导致资源提前持久化,所以它会先赋值。有点像这个过程

> m = User.first
> m.nickname = 'save after'
> m.avatar = ActiveStorage::Blob.first.signed_id
> m.save

源码里面字段的名字name是一个变量,所以应用了元编程的手段,翻译过来其实就是

m.public_send(:avatar, ActiveStorage::Blob.first.signed_id)

一对多关系的逻辑跟这里类似,这里就不赘述了。主要区别在于,这里要处理的只是单体,而一对多要处理的是数组。另外就是一对多还要处理可能已经存在的绑定关系,会稍微复杂一些。

类 attribute 操作

从前面的例子可以看出,我们可以像修改普通字段那样去修改附件相关的字段(类似于User#name=),只是他们的参数需要是attachable的。好像还没有这方面的专业术语,我姑且称之为“类 attribute”操作。ActiveStorage 是如何给它赋能的呢?这点就值得稍微深入探讨一下了。关键在于这里

class User
  has_many_attached :avatar
end

由于前面“冷落”了一对多的关系,这里重点来分析它,一对一的关系是差不多的。简单来看看has_many_attached干了啥

module ActiveStorage
  # Provides the class-level DSL for declaring an Active Record model's attachments.
  module Attached::Model
    extetnd ActiveSupport::Concern
    # ...
    class_methods do
      def has_many_attached(name, dependent: :purge_later)
          generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
            def #{name}
              @active_storage_attached_#{name} ||= ActiveStorage::Attached::Many.new("#{name}", self)
            end

            def #{name}=(attachables)
              if ActiveStorage.replace_on_assign_to_many
                attachment_changes["#{name}"] =
                  if Array(attachables).none?
                    ActiveStorage::Attached::Changes::DeleteMany.new("#{name}", self)
                  else
                    ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, attachables)
                  end
              else
                if Array(attachables).any?
                  attachment_changes["#{name}"] =
                    ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, #{name}.blobs + attachables)
                end
              end
            end
          CODE
          # ....
        end
      end
    end
  end
end

可以看到,这里利用了元编程的方式,通过参数name来设置相应的方法名,故而经过

has_many_attached :covers

之后的资源就会拥有#covers以及#covers=这两个方法

irb(main):050:0> m = Backflow.first
irb(main):051:0> m.covers
irb(main):053:0> m.covers = nil

在一对多赋值的情况下会有两种模式,通过ActiveStorage.replace_on_assign_to_many来配置。它为真值的时候,是替换模式。也就是,不管原来的资源里面是否有附件挂载的情况,都用新的 attachables 来重新建立所有的绑定。另一种模式为追加模式,也就是在原来绑定的基础上追加新的 attachables。两种模式各有好处,主要看场景了,笔者一般是用后面一种模式。所以如果要追加新的东西

> m = Backflow.first
> m.covers.size
=> 2
> m.covers = [ActiveStorage::Blob.last]
> m.save
> m.covers.size
=> 3

不过删除的时候就会麻烦些了,得自己去调用detach方法来删除

> m.covers.detach
=> 3
irb(main):087:0> m.covers.size
=> 0

另外还要讲一下的是,在这两种不同的模式下,ActiveStorage 都没有直接修改数据,而只是先把“变化”缓存下来,等到最后保存save的时候再入库。这些改变就包括ActiveStorage::Attached::Changes::CreateMany, ActiveStorage::Attached::Changes::DeleteMany等等。有点像这样

> m = Backflow.first
> m.covers = [ActiveStorage::Blob.first]
> m.attachment_changes
=> {"covers"=>#<ActiveStorage::Attached::Changes::CreateMany:0x00007fbea2616788 @name="covers",.....
> m.reload
> m.attachment_changes
=> {}

可见,这些变化会以哈希表的形式缓存在attachment_changes中,并不会调整数据库,资源重新加载之后它们就会清空,源码如下

module ActiveStorage
  # Provides the class-level DSL for declaring an Active Record model's attachments.
  module Attached::Model
    extetnd ActiveSupport::Concern

    class_methods do
      def has_many_attached(name, dependent: :purge_later)
        # ....
        after_save { attachment_changes[name.to_s]&.save }
      end
    end

    # ....
    def attachment_changes #:nodoc:
      @attachment_changes ||= {}
    end

    def changed_for_autosave? #:nodoc:
      super || attachment_changes.any?
    end

    def reload(*) #:nodoc:
      super.tap { @attachment_changes = nil }
    end
  end
end

针对所绑定附件的改动,ActiveStorage 以“变化”-change 的形式先缓存下来,以哈希的方式存储在实例变量@attachment_changes中,持久化的时候再一并处理。至于这些被缓存的“变化”的具体细节,就不在这篇文章说了。

结语

关于 ActiveStorage 资源绑定的部分源码剖析就讲到这里。可以看到 ActiveStorage 提供了不少相关的工具方法让我们能够很方便地管理资源与附件之间的关联关系。我们甚至可以用类似User#avatar=的方法来调整资源与附件的关系。这里稍微要注意的

  1. 赋予的值必须是attachable的,第一小节有详细介绍attachable的数据类型有哪些。
  2. 跟普通的字段一样,这个赋值方法并不会马上把数据持久化到数据库中,而是先缓存下来(这里是通过缓存“变化”),等到资源保存之后再去做附件相关的持久化操作(借助 after_save)。

关于ActiveStorage资源绑定方面的源码今天就先分析到这,感谢您的阅读。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号