原文:Ruby on Rails Guides — Active Storage Overview
Active Storage 是 Rails 5.2 所新增的功能,它可以讓你輕鬆的將檔案傳送到 Amazon S3、Google Cloud Storage 或 Microsoft Azure Storage 等雲端儲存服務,並將這些檔案附加到 Active Record。
支援一個主要雲端儲存服務,並在其它服務中建立鏡像以實現備援機制,它也提供了用於測試或本機部署的磁碟服務,但重點還是放在雲端儲存。
檔案可以從伺服器上傳到雲端或直接從客戶端上傳到雲端。
閱讀本指南後,你將知道:
- 如何附加一或多個檔案到記錄。
- 如何刪除檔案。
- 如何連結到檔案。
- 如何使用變體(variant)來轉換圖片。
- 如何產生非圖片檔案的預覽圖,如 PDF 或影片。
- 如何繞過應用程式伺服器,直接從瀏覽器上傳檔案到儲存服務。
- 如何清理測試過程中儲存的檔案。
- 如何實作對其它雲端儲存服務的支援。
1. 什麼是 Active Storage?
Active Storage 方便將檔案上傳到 Amazon S3、Google Cloud Storage 或 Microsoft Azure Storage 等雲端儲存服務,並將這些檔案附加到 Active Record 物件。它配備了本機磁碟服務以進行開發和測試,並支援將檔案鏡像到次要服務以進行備份和遷移。
使用 Active Storage,應用程式可以透過 ImageMagick 轉換上傳圖片,產生非圖片檔案(如 PDF 或影片)的預覽圖,並從任意檔案中提取中繼資料。
2. 安裝
Active Storage 在應用程式資料庫中使用兩個名為 active_storage_blobs
和 active_storage_attachments
的資料表。建立新的應用程式(或將應用程式升級到 Rails 5.2),執行 rails active_storage:install
來產生用來建立這些資料表的遷移。使用 rails db:migrate
來執行遷移。
在 config/storage.yml
定義 Active Storage 服務。對應用程式使用的每個服務,提供一個名稱和必要的設定。下面的範例定義了三個名為 local
、test
和 amazon
的服務:
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: ""
透過設定 Rails.application.config.active_storage.service
告訴 Active Storage 要使用哪個服務。由於每個環境都可能使用不同的服務,因此建議在每個環境的基礎設定上進行。要在開發環境中使用先前範例中的磁碟服務,你可以將以下內容加到 config/environments/development.rb
:
# 在本機儲存檔案。
config.active_storage.service = :local
要在生產環境使用 Amazon S3,你可以將以下內容加到 config/environments/production.rb
:
# 在 Amazon S3 儲存檔案。
config.active_storage.service = :amazon
繼續閱讀來取得關於內建服務轉接器(如 Disk
和 S3
)及其所需設定的更多資訊。
2.1. 磁碟服務
在 config/storage.yml
定義磁碟服務:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
2.2. Amazon S3 服務
在 config/storage.yml
定義 S3 服務:
amazon:
service: S3
access_key_id: ""
secret_access_key: ""
region: ""
bucket: ""
將 aws-sdk-s3 gem 加到 Gemfile
:
gem "aws-sdk-s3", require: false
Active Storage 的核心功能需要以下權限:
s3:ListBucket
、s3:PutObject
、s3:GetObject
和s3:DeleteObject
。如果你設定了其它上傳選項,如 ACL 設定,則可能需要額外的權限。
如果你想使用環境變數、標準 SDK 設定檔、設定檔、IAM 實例設定檔或工作角色,則可以省略上面範例中的
access_key_id
、secret_access_key
和region
值。Amazon S3 服務支援 AWS SDK 文件中描述的所有認證選項。
2.3. Microsoft Azure Storage 服務
在 config/storage.yml
定義 Azure Storage 服務:
azure:
service: AzureStorage
storage_account_name: ""
storage_access_key: ""
container: ""
將 azure-storage gem 加到 Gemfile
:
gem "azure-storage", require: false
2.4. Google Cloud Storage 服務
在 config/storage.yml
定義 Google Cloud Storage 服務:
google:
service: GCS
credentials: <%= Rails.root.join("path/to/keyfile.json") %>
project: ""
bucket: ""
可以選擇提供一個 Hash 憑證來取代密鑰路徑:
google:
service: GCS
credentials:
type: "service_account"
project_id: ""
private_key_id: <%= Rails.application.credentials.dig(:gcs, :private_key_id) %>
private_key: <%= Rails.application.credentials.dig(:gcs, :private_key) %>
client_email: ""
client_id: ""
auth_uri: "https://accounts.google.com/o/oauth2/auth"
token_uri: "https://accounts.google.com/o/oauth2/token"
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs"
client_x509_cert_url: ""
project: ""
bucket: ""
將 google-cloud-storage gem 加到 Gemfile
:
gem "google-cloud-storage", "~> 1.8", require: false
2.5. 鏡像服務
你可以透過定義鏡像服務來讓多個服務保持同步。當檔案被上傳或刪除時,它會在所有鏡像服務中完成。鏡像服務可用來幫助生產環境中服務之間的遷移。你可以開始鏡像到新服務,將現有檔案從舊服務複製到新服務,然後全力投入新服務。根據上述定義你想要使用的每項服務,從鏡像服務中引用它們。
s3_west_coast:
service: S3
access_key_id: ""
secret_access_key: ""
region: ""
bucket: ""
s3_east_coast:
service: S3
access_key_id: ""
secret_access_key: ""
region: ""
bucket: ""
production:
service: Mirror
primary: s3_east_coast
mirrors:
- s3_west_coast
檔案由主服務提供。
與直接上傳功能不相容。
3. 將檔案附加到記錄
3.1. has_one_attached
has_one_attached
指令設定了記錄和檔案間的一對一關係。每個記錄可以附加一個檔案。
例如,假設你的應用程式有一個 User
模型。如果想讓每個使用者都有一個頭像,請這樣定義 User
模型:
class User < ApplicationRecord
has_one_attached :avatar
end
你可以建立一個帶有頭像的使用者:
class SignupController < ApplicationController
def create
user = User.create!(user_params)
session[:user_id] = user.id
redirect_to root_path
end
private
def user_params
params.require(:user).permit(:email_address, :password, :avatar)
end
end
呼叫 avatar.attach
將頭像附加到現有使用者:
Current.user.avatar.attach(params[:avatar])
呼叫 avatar.attached?
來確定特定使用者是否有頭像:
Current.user.avatar.attached?
3.2. has_many_attached
has_many_attached
指令設定了記錄和檔案間的一對多關係。每個記錄可以附加多個檔案。
例如,假設你的應用程式有一個 Message
模型。如果想讓每個訊息都有多張圖片,請這樣定義 Message
模型:
class Message < ApplicationRecord
has_many_attached :images
end
你可以建立一則帶有多張圖片的訊息:
class MessagesController < ApplicationController
def create
message = Message.create!(message_params)
redirect_to message
end
private
def message_params
params.require(:message).permit(:title, :content, images: [])
end
end
呼叫 images.attach
將圖片附加到現有訊息:
@message.images.attach(params[:images])
呼叫 images.attached?
來確定特定訊息是否有圖片:
@message.images.attached?
3.3 附加 File/IO 物件
有時候你需要附加一個不是透過 HTTP 請求傳送的檔案。例如,你可能需要附加從磁碟上產生的檔案,或從使用者提交的網址下載的檔案。你可能也想在模型測試中附加檔案。要做到這一點,至少提供包含一個開啟 IO 物件和檔案名稱的 Hash:
@message.image.attach(io: File.open('/path/to/file'), filename: 'file.pdf')
如果可能,最好提供內容類型。Active Storage 會嘗試從資料來確定檔案的內容類型。若辦不到,它將採用你提供的內容類型。
@message.image.attach(io: File.open('/path/to/file'), filename: 'file.pdf', content_type: 'application/pdf')
如果你未提供內容類型,且 Active Storage 無法自動確定檔案的內容類型,則預設為 application/octet-stream。
譯者註:若使用 AJAX 來送出表單,可能會出現
No 'Access-Control-Allow-Origin' header is present on the requested resource.
的錯誤訊息,以 Amazon S3 為例,需要登入 AWS 後台修改 CORS 設定,設定範例如下:
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<MaxAgeSeconds>3000</MaxAgeSeconds>
<AllowedHeader>Authorization</AllowedHeader>
</CORSRule>
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<MaxAgeSeconds>3000</MaxAgeSeconds>
<AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>
4. 刪除檔案
要從模型中刪除附件,請在附件上呼叫 purge
。如果你的應用程式有設定使用 Active Job,刪除作業可以在背景完成。刪除作業會從儲存服務中刪除 blob 和檔案。
# 同步刪除頭像和實際資源檔案。
user.avatar.purge
# 透過 Active Job 非同步刪除相關模型和實際資源檔案。
user.avatar.purge_later
5. 檔案連結
為 blob 產生一個指向應用程式的永久連結。存取時,會返回一個重新導向到實際服務端點的連結。這種間接的方式將公開網址從實際網址分離開來,並允許例如鏡像不同服務中的附件以實現高可用性。重新導向連結的 HTTP 過期時間為 5 分鐘。
url_for(user.avatar)
要建立下載連結,請使用 rails_blob_{path|url}
輔助方法。使用這個輔助方法可以讓你設定 disposition。
rails_blob_path(user.avatar, disposition: "attachment")
如果你需要從控制器或視圖之外建立連結(背景作業、定時作業、等⋯),可以像這樣存取 rails_blob_path:
Rails.application.routes.url_helpers.rails_blob_path(user.avatar, only_path: true)
6. 下載檔案
如果需要在伺服器端處理 blob,例如,執行分析或進一步轉換時,可以用以下方式下載 blob 並取得二進位物件:
binary = user.avatar.download
某些時候,您可能希望將其轉換為磁碟上的實體檔案,以便將檔案路徑傳遞給外部程式(如掃毒軟體、轉換程式、優化程式、壓縮程式等⋯)。在這種情況下,你可以在類別中引用 ActiveStorage::Downloading
模組,該模組提供了輔助方法直接下載檔案,避免將檔案儲存到記憶體中。ActiveStorage::Downloading
需要定義一個 blob
方法。
class VirusScanner
include ActiveStorage::Downloading
attr_reader :blob
def initialize(blob)
@blob = blob
end
def scan
download_blob_to_tempfile do |file|
system 'scan_virus', file.path
end
end
end
預設情況下,download_blob_to_tempfile
會在 Dir.tmpdir
中建立檔案。如果需要使用其它目錄,請在類別中覆寫 ActiveStorage::Downloading#tempdir:
class VirusScanner
include ActiveStorage::Downloading
# ...
private
def tempdir
'/path/to/tmp'
end
end
如果外部程式是獨立執行的程式,你可能也需要 chmod
該檔案及其目錄,因為 Tempfile
會將權限設定為 0600
,其它用戶無法存取該檔案。
7. 轉換圖片
要建立不同尺寸的圖片,請在 Blob 上呼叫 variant
。你可以傳送任何 MiniMagick 所支援的轉換方式到此方法。
要啟用轉換功能,請將 mini_magick
gem 加到 Gemfile
:
gem 'mini_magick'
當瀏覽器存取不同尺寸的圖片網址時,Active Storage 會將原始的 blob 延遲轉換為指定的格式,並導向到它新的服務位置。
<%= image_tag user.avatar.variant(resize: "100x100") %>
8. 預覽檔案
一些非圖片檔案可以被預覽:也就是說,他們可以用圖片來呈現。例如,可以透過擷取第一個影格來預覽影片檔。Active Storage 內建支援預覽影片和 PDF 文件。
<ul>
<% @message.files.each do |file| %>
<li>
<%= image_tag file.preview(resize: "100x100>") %>
</li>
<% end %>
</ul>
擷取預覽圖片需要第三方應用程式,用於影片的
ffmpeg
和用於 PDF 的mutool
。這些函式庫不是由 Rails 提供的。你必須自行安裝他們才能使用內建的預覽器。在安裝和使用第三方軟體前,請確定你了解這樣做所牽涉的許可。
9. 直接上傳
Active Storage 及其包含的 JavaScript 函式庫支援從客戶端直接上傳到雲端。
9.1. 安裝直接上傳功能
- 在應用程式的 JavaScript 封裝載入
activestorage.js
。
使用 Asset Pipeline:
//= require activestorage
使用 npm 套件:
import * as ActiveStorage from "activestorage"
ActiveStorage.start()
- 在檔案輸入欄位中註記直接上傳。
<%= form.file_field :attachments, multiple: true, direct_upload: true %>
- 就是這樣!在表單提交後會開始上傳檔案。
9.2. 直接上傳功能的 JavaScript 事件
事件名稱 | 事件目標 | 事件資料(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} |
發生錯誤。除非此事件被取消,否則將顯示提醒 。 |
direct-upload:end |
<input> |
{id, file} |
直接上傳已結束。 |
direct-uploads:end |
<form> |
無 | 所有直接上傳都已結束。 |
9.3. 範例
你可以使用這些事件來顯示上傳的進度。
在表單內顯示上傳的檔案:
// direct_uploads.js
addEventListener("direct-upload:initialize", event => {
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 => {
const { id } = event.detail
const element = document.getElementById(`direct-upload-${id}`)
element.classList.remove("direct-upload--pending")
})
addEventListener("direct-upload:progress", event => {
const { id, progress } = event.detail
const progressElement = document.getElementById(`direct-upload-progress-${id}`)
progressElement.style.width = `${progress}%`
})
addEventListener("direct-upload:error", event => {
event.preventDefault()
const { id, error } = event.detail
const element = document.getElementById(`direct-upload-${id}`)
element.classList.add("direct-upload--error")
element.setAttribute("title", error)
})
addEventListener("direct-upload:end", event => {
const { id } = event.detail
const element = document.getElementById(`direct-upload-${id}`)
element.classList.add("direct-upload--complete")
})
加上樣式:
/* 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;
}
9.4. 與函式庫或框架整合
如果你想從 JavaScript 框架中使用直接上傳功能,或者想要整合自訂的拖放解決方案,可以使用 DirectUpload
類別來達成此目的。當從你的函式庫收到選擇的檔案後,實例化一個 DirectUpload 並呼叫它的 create 方法。當上傳完成時,create 會調用 callback。
import { DirectUpload } from "activestorage"
const input = document.querySelector('input[type=file]')
// 綁定到檔案放置 - 在父元素上使用 ondrop 或使用 Dropzone 之類的函式庫
const onDrop = (event) => {
event.preventDefault()
const files = event.dataTransfer.files;
Array.from(files).forEach(file => uploadFile(file))
}
// 綁定到正常的檔案選取
input.addEventListener('change', (event) => {
Array.from(input.files).forEach(file => uploadFile(file))
// 你可以從輸入欄位清除選定的檔案
input.value = null
})
const uploadFile = (file) {
// 表單的檔案輸入欄位需要 direct_upload: true,它提供了 data-direct-upload-url
const url = input.dataset.directUploadUrl
const upload = new DirectUpload(file, url)
upload.create((error, blob) => {
if (error) {
// 錯誤處理
} else {
// 將適當名稱且值為 blob.signed_id 的隱藏輸入欄位加到表單中,以便 blob id 可以在
// 正常上傳流程中被傳送
const hiddenField = document.createElement('input')
hiddenField.setAttribute("type", "hidden");
hiddenField.setAttribute("value", blob.signed_id);
hiddenField.name = input.name
document.querySelector('form').appendChild(hiddenField)
}
})
}
如果你需要追蹤檔案上傳進度,可以傳送第三個參數給 DirectUpload
建構函數。在上傳過程中,DirectUpload 會呼叫物件的 directUploadWillStoreFileWithXHR
方法。然後,你可以在 XHR 上綁定自己的進度處理程式。
import { DirectUpload } from "activestorage"
class Uploader {
constructor(file, url) {
this.upload = new DirectUpload(this.file, this.url, this)
}
upload(file) {
this.upload.create((error, blob) => {
if (error) {
// 錯誤處理
} else {
// 將適當名稱且值為 blob.signed_id 的隱藏輸入欄位加到表單中,以便 blob id 可以在
// 正常上傳流程中被傳送
}
})
}
directUploadWillStoreFileWithXHR(request) {
request.upload.addEventListener("progress",
event => this.directUploadDidProgress(event))
}
directUploadDidProgress(event) {
// 使用 event.loaded 和 event.total 來更新進度列
}
}
10. 移除系統測試過程中儲存的檔案
系統測試透過復原(Rollback)交易來清理測試資料。因為 destroy 永遠不會在對像上呼叫,所以附加的檔案永遠不會被清理。如果你想清除檔案,可以在 after_teardown
callback 中完成。在此處操作可以確保測試過程中建立的連線都已完成,並且不會從 Active Storage 收到錯誤,表示無法找到檔案。
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
def remove_uploaded_files
FileUtils.rm_rf("#{Rails.root}/storage_test")
end
def after_teardown
super
remove_uploaded_files
end
end
如果你的系統測試驗證是否刪除帶有附件的模型,並且使用 Active Job,請將測試環境設定為使用行內佇列轉接器,以便立即執行清除工作,而不是在未來的某個時間執行。
你可能也想為測試環境使用單獨的服務定義,以便你的測試不會刪除在開發過程中建立的檔案。
# 使用行內作業處理,以便立即執行
config.active_job.queue_adapter = :inline
# 在測試環境中分開儲存檔案
config.active_storage.service = :local_test
11. 實作支援其它雲端儲存服務
如果你需要支援除了這些以外的雲端服務,則需要實作 Service。每個服務都透過實作上傳和下載檔案到雲端所需的方法,來擴充 ActiveStorage::Service。
最後更新時間:2018-08-15