跳至主要内容

[Rails] Active Storage Overview

keywords:active storage, 檔案上傳, 雲端儲存

安裝

# 建立專案後
$ rails new <project_name> --webpack=stimulus --database=postgresql --skip-coffee --skip-test
$ rails active_storage:install
$ rails db:migrate

設定

設定 Active Storage 的服務

# ./config/storage.yml
local:
service: Disk
root: <%= Rails.root.join("storage") %>

test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>

amazon:
service: S3
access_key_id: ''
secret_access_key: ''

根據不同開發環境設定不同的儲存位置:

# ./config/environments/development.rb
# Store files locally.
config.active_storage.service = :local
# ./config/environments/production.rb
# Store files on Amazon S3.
config.active_storage.service = :amazon

上傳檔案

has_one_attached

has_one_attached 用來將資料和檔案做一對一的關聯,每一筆資料可以有一個檔案。

假設我們有一個 Model 是 User

# app/model/user.rb
class User < ApplicationRecord
has_one_attached :avatar
end

在資料庫不需要為 User 建立 :avatar 欄位即可使用 has_one_attached

接著在 Controller 的部分把 :avatar 設定成可以使用的 params:

# app/controller/users_controller.rb

class UsersController < ApplicationController
#...

private
# Never trust parameters from the scary internet, only allow the white list through.
def user_params
params.require(:user).permit(:name, :avatar)
end
end

在 View 的地方使用 file_field 上傳圖檔,如此 Active Storage 會自動儲存上傳的檔案:

<%= form_with(model: user, local: true) do |form| %>
<!-- has_one_attached -->
<div class="field">
<%= form.label :avatar %>
<%= form.file_field :avatar %>
</div>

<div class="actions">
<%= form.submit %>
</div>
<% end %>

其他可用方法:avatar.attach, avatar.attached?

# 為使用者添加檔案
user.avatar.attach(params[:avatar])

# 檢查使用者是否有上傳檔案
user.avatar.attached?

透過 has_one_attached 上傳時,如果同樣欄位有新的檔案上傳,則會把舊的檔案刪掉,儲存新的檔案。

has_many_attached

透過 has_many_attached 可以用來設定資料和檔案間的一對多關係,每一筆資料可以附帶很多的檔案:

# app/model/user.rb
class User < ApplicationRecord
has_one_attached :avatar
has_many_attached :images
end

接著在 Controller 的部分把 :images 設定成可以使用的 params:

# app/controller/users_controller.rb

class UsersController < ApplicationController
#...

private
# Never trust parameters from the scary internet, only allow the white list through.
def user_params
params.require(:user).permit(:name, :avatar, images: [])
end
end

在 View 的地方使用 file_field 搭配 multiple: true 上傳圖檔,如此 Active Storage 會自動儲存上傳的檔案:

<%= form_with(model: user, local: true) do |form| %>

<!-- has_many_attached -->
<div class="field">
<%= form.label :images %>
<%= form.file_field :images, multiple: true %>
</div>

<div class="actions">
<%= form.submit %>
</div>

<% end %>

其他可用方法:images.attach, images.attached?

user.images.attach(params[:images])

# 檢驗 user 有無任何 images
user.images.attached?

❗️ 透過 has_many_attached 上傳時,如果同樣欄位有新的檔案上傳,則會保留舊的檔案,並把新的檔案附加上去。

移除檔案(purge)

如果要將檔案從 active storage 移除,可以使用 purge

# Synchronously destroy the avatar and actual resource files.
user.avatar.purge

# Destroy the associated models and actual resource files async, via Active Job.
user.avatar.purge_later

使用 .purge 後會馬上刪除檔案,不需要 .save 針對 has_many_attached: 的欄位如果直接使用 .purge 會一次把所有檔案刪除。

或者我們也可以透過 id 刪除特定檔案:

# app/controllers/user_controllers.rb

# has_one_attached
def destroy_avatar
if @user.avatar.id == params[:blob][:id].to_i
@user.avatar.purge
render json: { status:: ok }
else
render json: { status:: fail }
end
end

# has_many_attached
def remove_images
@user.images.each do |image|
image.purge if image.id == params[:image_id].to_i
end

render json: { status: :ok }
end

連結檔案(Linking to Files)

如果是圖檔的話可以使用:

<!-- 使用 variant 前須先安裝其它套件 -->

<!-- has_one_attached -->
<%= image_tag @user.avatar if @user.avatar.attached? %>

<!-- has_many_attached -->
<% if @user.images.attached? %>
<% @user.images.each do |image| %>
<%= image_tag image %>
<% end %>
<% end %>

透過 url_for 會建立一個暫時性的轉址連結,這個連結會從你的 application 轉到檔案存在於外部服務的位置(例如,Google Cloud, Amazon S3),這個網址預設會於 5 分鐘後失效:

<!-- app/views/users/show.html.erb -->
<!-- 此方法會建立暫時性的轉址(預設 5 分鐘失效)-->
<%= url_for(@user.avatar) %>

若要建立下載用的連結可以使用 rails_blob_{path|url} 這個 helper:

<!-- app/views/users/show.html.erb -->
<%= rails_blob_path(user.avatar, disposition: "attachment") %>

如果需要從 View 以外的地方取用下載連結可以使用 rails_blob_urlrails_blob_path

# in controller
avatar_url = Rails.application.routes.url_helpers.rails_blob_url(user.avatar)
avatar_url = Rails.application.routes.url_helpers.rails_blob_path(user.avatar, only_path: true)

直接上傳(Direct Upload)

在 View 中上傳的 input 多加上 direct_upload: true

<%= form_with(model: user, local: true) do |form| %>
<!-- has_many_attached -->
<div class="field">
<%= form.label :images %>
<%= form.file_field :images, multiple: true, direct_upload: true %>
</div>

<div class="actions">
<%= form.submit %>
</div>
<% end %>

載入 JS 檔

首先在 Assets 中載入 activestorage.js

/* ./app/assets/javascripts/application.js */

//= require activestorage

或者透過 npm 載入:

import * as ActiveStorage from 'activestorage';
ActiveStorage.start();

可用的事件

修改自官網:

// direct_uploads.js

addEventListener('direct-uploads:start', (event) => {
console.log('direct-uploads:start', event.detail);
});

addEventListener('direct-upload:initialize', (event) => {
console.log('direct-upload:initialize', event.detail);
const { target, detail } = event;
const { id, file } = detail;
target.insertAdjacentHTML(
'beforebegin',
`
<div id="direct-upload-${id}" class="direct-upload direct-upload--pending">
<div id="direct-upload-progress-${id}" class="direct-upload__progress" style="width: 0%"></div>
<span class="direct-upload__filename">${file.name}</span>
</div>
`,
);
});

addEventListener('direct-upload:start', (event) => {
console.log('direct-upload:start', event.detail);
const { target, detail } = event;
const { id, file } = detail;
const element = document.getElementById(`direct-upload-${id}`);
element.classList.remove('direct-upload--pending');
});

addEventListener('direct-upload:before-blob-request', (event) => {
console.log('direct-upload:before-blob-request', event.detail);
const { target, detail } = event;
const { id, file, xhr } = detail;
});

addEventListener('direct-upload:progress', (event) => {
console.log('direct-upload:progress', event.detail);
const { target, detail } = event;
const { id, file, progress } = detail;
const progressElement = document.getElementById(`direct-upload-progress-${id}`);
progressElement.style.width = `${progress}%`;
});

addEventListener('direct-upload:error', (event) => {
console.log('direct-upload:error', event.detail);
event.preventDefault();
const { target, detail } = event;
const { id, file, error } = detail;
const element = document.getElementById(`direct-upload-${id}`);
element.classList.add('direct-upload--error');
element.setAttribute('title', error);
});

addEventListener('direct-upload:end', (event) => {
console.log('direct-upload:end', event.detail);
const { id, file } = event.detail;
const element = document.getElementById(`direct-upload-${id}`);
element.classList.add('direct-upload--complete');
});

addEventListener('direct-uploads:end', (event) => {
console.log('direct-uploads:end', event.detail);
});

可套用的 CSS

/* direct_uploads.css */

.direct-upload {
display: inline-block;
position: relative;
padding: 2px 4px;
margin: 0 3px 3px 0;
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 3px;
font-size: 11px;
line-height: 13px;
}

.direct-upload--pending {
opacity: 0.6;
}

.direct-upload__progress {
position: absolute;
top: 0;
left: 0;
bottom: 0;
opacity: 0.2;
background: #0076ff;
transition: width 120ms ease-out, opacity 60ms 60ms ease-in;
transform: translate3d(0, 0, 0);
}

.direct-upload--complete .direct-upload__progress {
opacity: 0.4;
}

.direct-upload--error {
border-color: red;
}

input[type='file'][data-direct-upload-url][disabled] {
display: none;
}

進階使用

# project_controllers

# 從 signed_id 可以利用 find_signed(<signed_id>) 找到該圖片
image = ActiveStorage::Blob.find_signed(params[:signed_id])

# 取得該圖片後可以利用 routes.url_helpers.rails_representation_url 取得該圖片的 URL
image_url = Rails.application.routes.url_helpers.rails_representation_url(image.variant(resize: '100x100'), only_path: true)

資料來源