Ruby ActiveStorage 原理剖析 - 资源管理篇

lanzhiheng · February 11, 2021 · Last by winny replied at May 21, 2021 · 851 hits

这篇文章主要针对 ActiveStorage 的基本文件管理功能以及相关的拓展做简单的源码分析。原文发布于 https://www.lanzhiheng.com/posts/file-management-in-activestorage

前言

上一篇文章简单讲解了ActiveStorage的基本数据表结构,包括各个数据表是如何关联到一起,并相互协作的。还有就是如果代码没有写好会引发的 N+1 问题。这篇文章主要来探讨一下ActiveStorage是如何对文件进行管理的(主要是ActiveStorage::Blob),比方说文件该如何上传以及删除,怎样才能编写出一个较为优雅的文件上传接口?

最后还会简单列举一些相关的代码扩展,其中会包括用于分析出文件类型的ActiveStorage::Blob::Identifiable,用于解析出不同类型文件基础元数据的ActiveStorage::Blob::Analyzable,以及预览相关的ActiveStorage::Blob::Representable

上传与删除

1. 上传

ActiveStorage::Blob里面有一个很直接的方法用于文件上传ActiveStorage::Blob#upload

class ActiveStorage::Blob < ActiveRecord::Base
  ...
  def upload(io, identify: true)
    unfurl io, identify: identify
    upload_without_unfurling io
  end

  def unfurl(io, identify: true) #:nodoc:
    self.checksum     = compute_checksum_in_chunks(io)
    self.content_type = extract_content_type(io) if content_type.nil? || identify
    self.byte_size    = io.size
    self.identified   = true
  end

  def upload_without_unfurling(io) #:nodoc:
    service.upload key, io, checksum: checksum, **service_metadata
  end

  private
    def service_metadata
      if forcibly_serve_as_binary?
        { content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
      elsif !allowed_inline?
        { content_type: content_type, disposition: :attachment, filename: filename }
      else
        { content_type: content_type }
      end
    end
end

在它的内部会依次调用ActiveStorage::Blob#unfurl以及ActiveStorage::Blob#upload_without_unfurling,从源码上看,前一个方法的作用很直观,就是设置ActiveStorage::Blob对象的一些基础信息,比如checksum, content_type, byte_size等等。最后设置的identified并不是字段之一,但是它会存储在元数据中。

huiliu_web_development=# select byte_size, metadata, content_type, checksum from active_storage_blobs LIMIT 1;
 byte_size |                           metadata                           | content_type |         checksum
-----------+--------------------------------------------------------------+--------------+--------------------------
     21959 | {"identified":true,"width":512,"height":512,"analyzed":true} | image/jpeg   | X9OyGLKjA6rbPqOcat2KNg==

数据准备完毕之后会调用ActiveStorage::Blob#upload_without_unfurling进行文件上传。从代码上看,它调用了serviceupload方法

class ActiveStorage::Blob < ActiveRecord::Base
  ...
  class_attribute :service
  ...
end

可以把service理解成文件存储服务的适配器。例如,笔者用的是阿里云的 OSS 服务来做文件管理,那么它对应的service大概就类似这样

> ActiveStorage::Blob.new.service
=> #<ActiveStorage::Service::AliyunService:0x00007f84385e5920 @config={:access_key_id=>"xxx", :access_key_secret=>"xxx", :bucket=>"huiliu-development", :endpoint=>"https://oss-cn-beijing.aliyuncs.com", :path=>"resources", :public=>true}, @public=true>

配置不同的适配器,对应的service也会有所不同,文件也会上传到不同的第三方服务中去。而每个服务对应的适配器都会有一个upload方法,它们以当前ActiveStorage::Blob对象的基本数据作为参数,并上传到对应的服务中去。

> m = ActiveStorage::Blob.new(content_type: 'image/png')
=> #<ActiveStorage::Blob id: nil, key: nil, filename: nil, content_type: "image/png...

> m.upload(File.open('./backflow-entry-banner.png'), identify: false)
  Aliyun Storage (713.5ms) Uploaded file to key: gggpor3ehm6ytok516vgox94aui8 (checksum: Tdt6F9h1FRqDcP8mEzuyGw==)
=> true

> m.persisted?
=> false

> m.service_url
  Aliyun Storage (0.1ms) Generated URL for file at key: gggpor3ehm6ytok516vgox94aui8 (https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/gggpor3ehm6ytok516vgox94aui8)
=> "https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/gggpor3ehm6ytok516vgox94aui8"

这里需要注意的是,这个方法只负责把文件上传到对应的服务中去,但是ActiveStorage::Blob这个对象本身并没有持久化,也就是说数据库不会保存对应的文件记录,故而无法跟踪已经上传的文件。除非你另外手动去存储上传后得到的 url 信息,不过我们一般不会这么做,如果用ActiveStorage来实现文件托管的话,我们一般会在文件上传的时候也生成一条对应的ActiveStorage::Blob记录,所以我建议采用ActiveStorage::Blob::create_and_upload!来做上传(ActiveStorage::Blob::create_after_upload!是一样的)。

class ActiveStorage::Blob < ActiveRecord::Base
  ...
  class << self
    def build_after_unfurling(io:, filename:, content_type: nil, metadata: nil, identify: true) #:nodoc:
      new(filename: filename, content_type: content_type, metadata: metadata).tap do |blob|
        blob.unfurl(io, identify: identify)
      end
    end

    def create_after_unfurling!(io:, filename:, content_type: nil, metadata: nil, identify: true, record: nil) #:nodoc:
      build_after_unfurling(io: io, filename: filename, content_type: content_type, metadata: metadata, identify: identify).tap(&:save!)
    end

    # Creates a new blob instance and then uploads the contents of the given <tt>io</tt> to the
    # service. The blob instance is saved before the upload begins to avoid clobbering another due
    # to key collisions.
    #
    # When providing a content type, pass <tt>identify: false</tt> to bypass automatic content type inference.
    def create_and_upload!(io:, filename:, content_type: nil, metadata: nil, identify: true, record: nil)
      create_after_unfurling!(io: io, filename: filename, content_type: content_type, metadata: metadata, identify: identify).tap do |blob|
        blob.upload_without_unfurling(io)
      end
    end

    alias_method :create_after_upload!, :create_and_upload!
  ...
  end
end

理论上它做的事情比ActiveStorage::Blob#upload要多一点,那就是它会创建ActiveStorage::Blob的实例并持久化(通过ActiveStorage::Blob::create_after_unfurling!),然后再把特定的资源上传到对应的服务中去(通过ActiveStorage::Blob#upload_without_unfurling)。简单点,如果你想要单独开发一个文件上传的接口,那么可以直接利用ActiveStorage已有的东西,类似这样

class UserController < ApplicationController
  def file_uploads
    raise Exceptions::InvalidParams, '非法参数' unless params[:file].is_a?(ActionDispatch::Http::UploadedFile)

    blob = ActiveStorage::Blob.create_after_upload!(io: params[:file], filename: params[:file].original_filename)
    json_response({
                    signed_id: blob.signed_id,
                    url: blob.service_url
                  }, :created)
  end
end

2. 删除

资源的删除要比资源的创建(上传)要简单得多,源码如下

class ActiveStorage::Blob < ActiveRecord::Base
  ...
  def delete
    service.delete(key)
    service.delete_prefixed("variants/#{key}/") if image?
  end

  # Deletes the file on the service and then destroys the blob record. This is the recommended way to dispose of unwanted
  # blobs. Note, though, that deleting the file off the service will initiate a HTTP connection to the service, which may
  # be slow or prevented, so you should not use this method inside a transaction or in callbacks. Use #purge_later instead.
  def purge
    destroy
    delete
  rescue ActiveRecord::InvalidForeignKey
  end

  # Enqueues an ActiveStorage::PurgeJob to call #purge. This is the recommended way to purge blobs from a transaction,
  # an Active Record callback, or in any other real-time scenario.
  def purge_later
    ActiveStorage::PurgeJob.perform_later(self)
  end
end
class ActiveStorage::PurgeJob < ActiveStorage::BaseJob
  queue_as { ActiveStorage.queues[:purge] }

  discard_on ActiveRecord::RecordNotFound
  retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :exponentially_longer

  def perform(blob)
    blob.purge
  end
end

在真实环境中我们常用的删除手法有两种,一种是同步删除ActiveStorage::Blob#purge一种是异步删除ActiveStorage::Blob#purge_later,从代码可以看出他们最终都会调用方法ActiveStorage::Blob#purge,只是时机不同罢了。

ActiveStorage::Blob#purge会做两件事情

  1. 删除数据库中的记录,通过方法ActiveRecord::Base#destroy
  2. 删除远程托管服务的记录,通过ActiveStorage::Blob#delete

ActiveStorage::Blob#delete的代码可以看出,它是通过对应服务的delete方法,并搭配上资源的key来删除静态文件资源的。这个key在上一篇文章也说过是远端文件的唯一标识。可以简单理解成,如果 id 是资源在数据库中的唯一标识,那么 key 就是资源在远端服务上的唯一标识。这个delete方法需要不同托管服务的适配器来各自实现了,不在此文的范畴之内,李华顺开发的适配器activestorage-aliyun其实就是在阿里云的 SDK 基础上做了封装,有兴趣的可以看看源码。

另外就是图片资源可能会用到不同的尺寸。因此,除了原图之外还可能会有多个variants,所以如果要删除图片则还需要删除对应的variants资源,所以会有这句代码

service.delete_prefixed("variants/#{key}/") if image?

这个delete_prefixed当然也是不同托管服务的适配器各自去实现了。

辅助扩展

ActiveStorage::Blob的基本用法已经分析得差不多了,不过还有一些用来辅助编码的相关扩展

class ActiveStorage::Blob < ActiveRecord::Base
  unless Rails.autoloaders.zeitwerk_enabled?
    require_dependency "active_storage/blob/analyzable"
    require_dependency "active_storage/blob/identifiable"
    require_dependency "active_storage/blob/representable"
  end

  include Analyzable
  include Identifiable
  include Representable

  ...
  store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
  ...
end

这里会引入Analyzable用来分析资源的元数据,Identifiable用来识别资源的类型,Representable为资源提供预览功能,这些功能在正式编码的过程中十分常用。

我拿图片来举个例子,通过Identifiable哪怕我们上传的时候没有提供content_type的值,也可以通过这个功能获取到当前上传文件的content_type大概是image/xxxx。(它的实现依赖了 Basecamp 的marcel可以了解一下)。另外对图片来说一般都会有尺寸这些元数据,这也是Analyzable的用处,它能够根据当前的类型 (content_type) 来选择合适的分析器件,并分析该文件的元数据,把元数据存储到数据库中。对图片来说,对应的分析器就是ActiveStorage::Analyzer::ImageAnalyzer,它能够分析出图片资源的宽高。这两个状态都会入库,就是上面提到的代码

...
  store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON

分析完之后或者是资源的身份已经得到识别的情况下它便会把当前的状态也存储到数据库中去,放的是metadata那一列。所以审查数据库的时候会看到类似这样的结果

huiliu_web_development=# select content_type, metadata from active_storage_blobs LIMIT 1;
 content_type |                           metadata
--------------+--------------------------------------------------------------
 image/jpeg   | {"identified":true,"width":512,"height":512,"analyzed":true}

这是两个信息会入库的扩展。最后一个扩展Representable就不需要存储数据库了,它主要用于生成资源的预览。无论是图片,视频,还是普通的文档资源,在一些场景之下需要以缩略图的方式来呈现,这种时候就需要维护一个资源对应的缩略图,这也是Representable的作用,它能够根据不同的资源来生成对应的缩略图并存储在远端。当我们的网站需要用到这些缩略图的时候则会通过对应的 url 来获取,它的存储路径有点像是这样

> b
=> #<ActiveStorage::Blob id: 613, key: "bq4m25sa92orosw2y6yaqaolt0mf", filename: "1607085026613966.mp4", content_type: "video/mp4", metadata: {"identified"=>true, "analyzed"=>true, "width"=>448.0, "height"=>960.0, "duration"=>47.838989}, byte_size: 4525062, checksum: "1scTv1MO8ls0b8C8KZYZ7Q==", created_at: "2020-12-05 08:07:00">

> b.preview(resize_to_limit: [100, 100]).processed.service_url
=> "https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/variants/sdixlhzk43orn66ohbj3qhf8w3jl/3656ab0a1b5f139de53e662b6c25b3ef90f1e2a14af8f8c8f9f5937b8f96e129
> b.preview(resize_to_limit: [200, 100]).processed.service_url
=> "https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/variants/sdixlhzk43orn66ohbj3qhf8w3jl/d87c05efd16f273b78a90296d640e4e2ad3327c7779960c3a79055389d698233"

我这个是一个视频资源,它的缩略图会生成并存放在托管服务中的variants/xxxxx/xxxxx这样的路径下,根据不同的转换策略,生成的路径也会不同。另外需要注意的是,首次获取缩略图的时候可能会比较慢,因为它会有一个先生成再上传到服务端的过程。而第二次获取的时候,由于图片已经存在于远端服务了,所以会快上很多,建议对于需要缩略图的资源提前通过脚本去生成,能够加快网站的访问速度。

这几个扩展中,Identifiable会比较简单一些,因为它仅仅是用来推断资源的类型。而AnalyzableRepresentable要复杂许多,因为他们还会依赖不同的插件,根据不同的文件类型去调用不同的插件来完成相应的工作,不同的插件策略有所不同,这里无法一一详解。举个例子,当我们要获取图片的缩略图的时候可能直接对原图片进行裁剪就可以了,然而如果是视频的话可能就是先从视频中截取,然后再对图片进行裁剪,这是两种不同的策略,在Representable会依赖不同的插件进行处理。同理,一般文档生成缩略图的方式也跟视频会有所不同,则需要专门的文档处理插件。

尾声

这篇文章主要对ActiveStorage文件管理方面的功能做了源码分析,其实主要是针对ActiveStorage::Blob。简单讲解了一下如何通过它来管理资源文件,如何进行一些更细粒度的操作,接下来我们就知道如何利用它已经封装好的东西来编写出优雅的文件上传接口。最后还简单聊了一下它的几个常见的功能扩展,通过这些扩展我们能够方便地识别出文件的类型,分析并存储文件的重要元数据,以及生成对应文件的缩略图,大大简化了日常的编码工作。

你好,博主,第一次登陆这个论坛,可以微信你交流 ruby 吗??(我微信 505461259)

You need to Sign in before reply, if you don't have an account, please Sign up first.