Rails Active Storage 深入解析:Blob/Attachment 模型、文件分发策略与 Direct Uploads 实战
Active Storage 是 Ruby on Rails 官方提供的文件附件框架:它让你把文件上传到 S3、Google Cloud Storage 等云端服务(也支持本地磁盘服务用于测试和本地部署),并以 Active Record 的方式把文件挂到业务模型上。本篇以仓库中 activestorage/README.md 为骨架,结合 Blob 模型、Attachment 模型、路由定义 与 引擎配置 的源码,系统讲解其架构设计、安装接入、常用 API、重定向/代理两种文件分发策略,以及客户端直传(Direct Uploads)的完整流程与 JavaScript 事件。
一、与 Rails 内其他附件方案的关键差异
README 指出,Active Storage 与 Rails 生态中其他附件方案的核心区别在于:它通过内置的 ActiveStorage::Blob 和 ActiveStorage::Attachment 两个模型(由 Active Record 支撑)实现附件管理,现有应用模型无需添加任何列即可与文件建立关联。附件通过 Attachment 联结模型以多态关联的方式连接到真正的 Blob。
源码印证了这一设计:
- attachment.rb 中定义了两个关键关联:
belongs_to :record, polymorphic: true—— 指向任意业务模型(record_type+record_id多态外键);belongs_to :blob, class_name: "ActiveStorage::Blob", autosave: true—— 指向文件本体记录。- 并通过
delegate_missing_to :blob把所有未定义方法委托给 blob,因此在业务代码中user.avatar.attach(...)这类写法实际调用的是 Blob 的能力。
- blob.rb 的注释明确说明:Blob 存储的是附件元数据(文件名、内容类型等)及其在存储服务中的标识 key,不存储二进制数据本身。Blob 在语义上是不可变的:一个文件对应一个 blob;同一个 blob 可以关联到多个业务模型;如果需要变换(transform)某个 blob,做法是创建新 blob 而不是修改旧 blob(当然之后可以删掉旧版本)。
表结构上,安装迁移 20170806125915_create_active_storage_tables.rb 创建三张表,恰好对应这套模型:
| 表 | 关键字段 | 说明 |
|---|---|---|
active_storage_blobs |
key(唯一索引)、filename、content_type、byte_size、checksum、service_name |
文件元数据;key 是服务端的存储标识 |
active_storage_attachments |
name、多态 record、blob,唯一索引 [record_type, record_id, name, blob_id] |
业务模型与 blob 的联结;外键指向 blobs 表,保证仍被引用时 blob 无法被删除 |
active_storage_variant_records |
blob_id + variation_digest(联合唯一索引) |
记录已生成的变体,用于按需惰性生成时避免重复处理 |
从源码结构看,checksum 是上传完整性校验的关键:上传前 Active Storage 会计算校验和并发送给存储服务验证(见 Blob 的 upload 方法注释),下载时 open 也会校验,不匹配即抛 ActiveStorage::IntegrityError。
二、安装
运行以下命令,把 Active Storage 的迁移文件复制到应用中:
bin/rails active_storage:install
README 特别提醒:如果找不到该任务,请确认 config/application.rb 中存在 require "active_storage/engine"。
安装后,服务端的配置入口是存储配置文件。从 engine.rb 的 active_storage.services 初始化器可以看到,Rails 会优先读取 config/storage/#{Rails.env}.yml,否则回退到 config/storage.yml,若两者都不存在则直接抛出 "Couldn't find Active Storage configuration" 异常。配置文件解析出的多套服务(如 local、amazon、amazon_mirror)会被注册进 ActiveStorage::Service::Registry,再由 config.active_storage.service 指定默认服务——这就是"一个主服务 + 其他服务做镜像"冗余能力的来源(Blob 在上传后可通过 mirror_later 异步同步到镜像服务)。
引擎默认配置同样值得了解(均来自 engine.rb):
routes_prefix默认为/rails/active_storage;resolve_model_to_route默认为:rails_storage_redirect(即默认重定向分发,见第四节);service_urls_expire_in默认 5 分钟——README 中"重定向 HTTP 过期时间为 5 分钟"即来源于此;variant_processor默认为:mini_magick,可切换为:vips或:disabled,也接受自定义 Transformer 类。
三、核心用法示例
3.1 单个附件(has_one_attached)
以下示例完整继承自 README,并补充了源码层面的解释:
class User < ApplicationRecord
# 建立附件与 blob 的关联。当 user 被销毁时,默认会被 purge
#(模型记录被删除,资源文件也被删除)。
has_one_attached :avatar
end
# 给 user 挂一个头像
user.avatar.attach(io: File.open("/path/to/face.jpg"), filename: "face.jpg", content_type: "image/jpeg")
# user 有头像吗?
user.avatar.attached? # => true
# 同步销毁头像及实际资源文件
user.avatar.purge
# 通过 Active Job 异步销毁关联模型与实际资源文件
user.avatar.purge_later
user.avatar.attached? # => false
# 生成一个指向应用的持久 URL。访问时会被重定向到真正的服务端点。
# 这层间接让公开 URL 与实际 URL 解耦,例如允许把附件镜像到不同服务以获得高可用。
# 重定向的 HTTP 过期时间为 5 分钟。
url_for(user.avatar)
class AvatarsController < ApplicationController
def update
# params[:avatar] 是一个 ActionDispatch::Http::UploadedFile 对象
Current.user.avatar.attach(params.require(:avatar))
redirect_to Current.user
end
end
几点源码级说明:
purge/purge_later的区别见 attachment.rb:purge在事务中删除 attachment 并 touch 记录,然后调用blob.purge(销毁记录并调用服务端的delete);purge_later则入队ActiveStorage::PurgeJob。由于删除文件会发起一次到云服务的 HTTP 调用,Blob 的注释明确建议不要在事务或回调中直接 purge,而应使用purge_later。dependent行为同样来自 attachment:after_destroy_commit :purge_dependent_blob会读取has_one_attached/has_many_attached上声明的dependent:选项(:purge或:purge_later),因此"模型销毁时默认 purge 附件"是框架级默认行为。- 上传流程中,attachment 的
:upload回调链会依次触发mirror_blob_later(同步到镜像服务)、analyze_blob_later(入队分析,提取尺寸等元数据)、create_variants(处理命名变体)。analyze的时机可全局配置config.active_storage.analyze(默认:later,即上传后异步分析;:lazily表示惰性分析)。
3.2 多个附件(has_many_attached)
class Message < ApplicationRecord
has_many_attached :images
end
<%= form_with model: @message, local: true do |form| %>
<%= form.text_field :title, placeholder: "Title" %><br>
<%= form.textarea :content %><br><br>
<%= form.file_field :images, multiple: true %><br>
<%= form.submit %>
<% end %>
class MessagesController < ApplicationController
def index
# 使用内置的 with_attached_images scope 避免 N+1
@messages = Message.all.with_attached_images
end
def create
message = Message.create! params.expect(message: [ :title, :content, images: [] ])
redirect_to message
end
def show
@message = Message.find(params[:id])
end
end
关于 N+1:with_attached_images 这类 scope 由 Attached::Model 按反射动态生成,一次性预载 attachments 与 blobs;attachment.rb 的头部注释还给出更彻底的预载方式 with_all_variant_records(当开启 ActiveStorage.track_variants 时连变体记录一起预载)。
3.3 图片变体(Variant)
<%# 访问变体 URL 时会惰性变换原始 blob,然后重定向到新的服务位置 %>
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>
变体机制的要点(结合 variant.rb 与 Blob 注释):
- 变换参数是 image_processing 支持的任意参数(
resize_to_limit、resize_to_fill、质量、宽高比等),底层处理器由config.active_storage.variant_processor决定,默认:mini_magick(MiniMagick/ImageMagick),可切换为:vips(libvips + ruby-vips);engine.rb 中还会在加载失败时给出针对 libvips、ruby-vips、image_processing、mini_magick 的详细安装提示日志。 - 变体是惰性的:
image_tag渲染出的是 representations 路由的签名 URL,首次访问时才实际执行变换并上传新对象,随后重定向;active_storage_variant_records表用variation_digest记录已生成的变体,避免重复计算。 - attachment 还支持
preview(面向视频/PDF 等不可变内容类型的预览图,引擎默认注册了 PopplerPDFPreviewer、MuPDFPreviewer、VideoPreviewer)与representation(统一入口),并支持在模型上声明命名的预定义变体,如avatar.variant(:thumb)。
四、文件分发策略:重定向与代理
Active Storage 支持两种分发方式:重定向(redirecting)与代理(proxying)。
4.1 重定向(默认)
Active Storage 为文件生成稳定的应用 URL,访问时重定向到签名的、短生命周期的服务端点 URL。这样应用服务器无需承担文件数据分发的压力。这是默认策略。
路由层面,config/routes.rb 在 ActiveStorage.routes_prefix(默认 /rails/active_storage)下定义了:
get "/blobs/redirect/:signed_id/*filename" => "active_storage/blobs/redirect#show", as: :rails_service_blob
get "/blobs/proxy/:signed_id/*filename" => "active_storage/blobs/proxy#show", as: :rails_service_blob_proxy
变体(representations)也有对应的 redirect/proxy 两组路由。签名 ID(signed_id)由 ActiveStorage.verifier(app.message_verifier("ActiveStorage"))生成,携带过期时间(默认 urls_expire_in),因此即使 URL 泄漏,服务端点链接也会在到期后失效。
当应用默认配置为代理时,可用 rails_storage_redirect_path / rails_storage_redirect_url 路由帮助方法改为重定向:
<%= image_tag rails_storage_redirect_path(@user.avatar) %>
这两个帮助方法在 routes.rb 中以 direct 路由定义实现:direct :rails_storage_redirect 对 blob 生成 :rails_service_blob 路由,对 variant/preview 等无 signed_id 的对象则生成 :rails_blob_representation 路由,二者都支持 expires_in / expires_at 选项(缺省取 ActiveStorage.urls_expire_in)。
4.2 代理(Proxying)
可选地,文件可以由应用服务器代理:即应用服务器在收到请求后从存储服务下载文件数据并转交给客户端。这适用于把文件经 CDN 分发的场景。
全局默认改为代理:
# config/initializers/active_storage.rb
Rails.application.config.active_storage.resolve_model_to_route = :rails_storage_proxy
对应 engine.rb 中的 ActiveStorage.resolve_model_to_route = app.config.active_storage.resolve_model_to_route || :rails_storage_redirect——这正是为什么默认行为是重定向。
对特定附件显式使用代理,则用 rails_storage_proxy_path / rails_storage_proxy_url:
<%= image_tag rails_storage_proxy_path(@user.avatar) %>
实现上,resolve("ActiveStorage::Blob")、resolve("ActiveStorage::Attachment") 等 resolve 路由块都会调用 route_for(ActiveStorage.resolve_model_to_route, ...),因此模型 URL 的最终走向完全由 resolve_model_to_route 这一个配置决定,切换成本极低。
五、Direct Uploads:客户端直传
5.1 直传安装步骤
README 给出的完整步骤如下(共四步):
1. 在应用 JS 包中包含 Active Storage JavaScript(或直接引用)。 有四种引入方式:
<%= javascript_include_tag "activestorage" %>
(在应用 HTML 中直接引入,不经 asset pipeline 打包,自动启动 autostart)
# config/importmap.rb
pin "@rails/activestorage", to: "activestorage.esm.js"
<script type="module-shim">
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()
</script>
(importmap-rails 方式,ESM 模块,需手动调用 start() 启动)
//= require activestorage
(asset pipeline 方式)
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()
(npm 包方式,需手动启动)
JavaScript 源码位于 activestorage/app/javascript 目录(以 package.json 声明 @rails/activestorage 包名,经 rollup 构建),activestorage / activestorage.esm 两个资产会被引擎自动加入 asset pipeline 的 precompile 列表(engine.rb 中 active_storage.asset 初始化器,受 config.active_storage.precompile_assets 控制,默认开启)。
2. 用直传 URL 标注文件输入框:
<%= form.file_field :attachments, multiple: true, direct_upload: true %>
3. 为第三方存储服务配置 CORS,允许直传请求。
4. 完成! 上传在表单提交时开始。
5.2 直传的工作原理(源码视角)
从 Blob 模型的方法命名可以看出直传的两阶段协议:
- 客户端 JS 拦截表单提交后,先向应用发起
POST /rails/active_storage/direct_uploads(路由名:rails_direct_uploads,处理者为 direct_uploads_controller.rb),为每个文件携带filename、byte_size、checksum、content_type等元数据; - 服务端调用
ActiveStorage::Blob.create_before_direct_upload!(见 blob.rb)创建一条尚无文件对应的 blob 记录——其key已由generate_unique_secure_token生成(28 位 base36 小写字母,避免大小写敏感问题),并返回 blob 的signed_id以及service_url_for_direct_upload/service_headers_for_direct_upload(含签名上传 URL 与请求头); - 客户端拿到签名 URL 后直接 PUT 文件到云存储(这就是必须配置 CORS 的原因),并通过事件上报进度;
- 表单提交完成时,隐藏字段携带
signed_id到达服务端,Active Record 侧将其与记录关联成 attachment。
这一流程的好处是应用服务器不作为上传的中转站,README 同时指出另一种服务端上传方式 create_and_upload!(需要先有一个可 rewinds 的 io)适合任意后端系统处理文件的场景。
5.3 Direct Upload JavaScript 事件表
README 完整列出的事件(CustomEvent,event.detail 为事件数据):
| 事件名 | 事件目标 | 事件数据(event.detail) |
说明 |
|---|---|---|---|
direct-uploads:start |
<form> |
无 | 表单中包含直传字段,且表单被提交。 |
direct-upload:initialize |
<input> |
{id, file} |
表单提交后,为每个文件派发一次。 |
direct-upload:start |
<input> |
{id, file} |
一次直传即将开始。 |
direct-upload:before-blob-request |
<input> |
{id, file, xhr} |
向应用发起直传元数据请求之前。 |
direct-upload:before-storage-request |
<input> |
{id, file, xhr} |
向存储服务发起存储请求之前。 |
direct-upload:progress |
<input> |
{id, file, progress} |
文件存储请求进展中。 |
direct-upload:error |
<input> |
{id, file, error} |
发生错误;除非取消该事件,否则会弹出 alert。 |
direct-upload:end |
<input> |
{id, file} |
一次直传结束。 |
direct-uploads:end |
<form> |
无 | 所有直传均已结束。 |
典型用法是监听 direct-upload:progress 更新进度条,监听 direct-upload:error 抑制默认 alert 并展示自定义错误提示。
六、许可与支持
Active Storage 以 MIT 许可证发布(见 MIT-LICENSE)。相关 API 文档可查阅 Rails 官方 API 文档;关于 Active Storage 的完整概念讲解可参考仓库内官方指南 active_storage_overview.md,其测试覆盖(模型、控制器、服务、数据库等)位于 activestorage/test 目录,可作为行为验证的第一手材料。
小结
- 架构:
Attachment(多态联结)+Blob(元数据与服务端 key)的两层模型让业务模型零侵入地获得文件能力;三张表(blobs / attachments / variant_records)支撑元数据、关联与变体去重。 - 用法:
has_one_attached/has_many_attached+attach/attached?/purge/purge_later/with_attached_*/variant构成日常 API;安装只需bin/rails active_storage:install并确保 engine 已 require。 - 分发:默认重定向(签名短 URL + 5 分钟过期),可全局或按附件切换为代理,切换点就是
resolve_model_to_route一个配置。 - 直传:JS 库 +
direct_upload: true+ CORS 三要素;两阶段协议(先建 blob 拿签名 URL,再直传云存储),全程有 9 个 CustomEvent 可供集成。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00