这篇文章主要针对 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
。
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
进行文件上传。从代码上看,它调用了service
的upload
方法
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
资源的删除要比资源的创建(上传)要简单得多,源码如下
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
会做两件事情
ActiveRecord::Base#destroy
。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
会比较简单一些,因为它仅仅是用来推断资源的类型。而Analyzable
跟Representable
要复杂许多,因为他们还会依赖不同的插件,根据不同的文件类型去调用不同的插件来完成相应的工作,不同的插件策略有所不同,这里无法一一详解。举个例子,当我们要获取图片的缩略图的时候可能直接对原图片进行裁剪就可以了,然而如果是视频的话可能就是先从视频中截取,然后再对图片进行裁剪,这是两种不同的策略,在Representable
会依赖不同的插件进行处理。同理,一般文档生成缩略图的方式也跟视频会有所不同,则需要专门的文档处理插件。
这篇文章主要对ActiveStorage
文件管理方面的功能做了源码分析,其实主要是针对ActiveStorage::Blob
。简单讲解了一下如何通过它来管理资源文件,如何进行一些更细粒度的操作,接下来我们就知道如何利用它已经封装好的东西来编写出优雅的文件上传接口。最后还简单聊了一下它的几个常见的功能扩展,通过这些扩展我们能够方便地识别出文件的类型,分析并存储文件的重要元数据,以及生成对应文件的缩略图,大大简化了日常的编码工作。