Обзор Active Storage

В этом руководстве описывается, как прикреплять файлы к моделям Active Record.

После прочтения этого руководства вы узнаете:

  • Как прикрепить один или несколько файлов к записи.
  • Как удалить прикрепленный файл.
  • Как создать ссылку на прикрепленный файл.
  • Как использовать варианты (variants) для преобразования изображений.
  • Как генерировать изображение файла, который не является изображением, например, PDF или видео.
  • Как загружать файлы из браузеров прямо в сервисы хранения (storage service), минуя application серверы.
  • Как очистить файлы, сохраненные во время тестирования.
  • Как реализовать поддержку дополнительных сервисов хранения.

1. Что такое Active Storage?

Active Storage облегчает загрузку файлов в облачные хранилища данных, такие как Amazon S3, Google Cloud Storage или Microsoft Azure Storage, и прикрепляет эти файлы к объектам Active Record. Он поставляется с локальным на основе диска сервисом для разработки и тестирования, и поддерживает отзеркаливание (mirroring) файлов в подчиненных сервисах для резервного копирования и миграций.

Используя Active Storage приложение может преобразовывать изображения или генерировать изображение файла, который не является изображением, такого, например, как PDF или видео, и извлекать метаданные из произвольных файлов.

1.1. Требования

Разные особенности Active Storage зависят от стороннего программного обеспечения, которое Rails не устанавливает, и которое должно быть установлено отдельно:

  • libvips v8.6+ или ImageMagick для анализа и трансформаций изображений
  • ffmpeg v3.4+ для предпросмотра видео и ffprobe для анализа видео/аудио
  • poppler или muPDF для предпросмотра PDF

Анализ и преобразования изображений также требуют гем image_processing. Раскомментируйте его в своем Gemfile, или добавьте:

gem "image_processing", ">= 1.2"

По сравнению с libvips, ImageMagick более известный и распространенный. Однако, libvips может быть до 10 раз быстрее и потреблять 1/10 памяти. Для файлов JPEG это может быть еще более улучшено, с помощью замены libjpeg-dev на libjpeg-turbo-dev, который в 2-7 раз быстрее.

Перед установкой и использованием сторонних программ, убедитесь, что вы понимаете лицензионные последствия этого. В частности, MuPDF лицензирован по AGPL и требует коммерческую лицензию в определенных случаях.

2. Установка

$ bin/rails active_storage:install
$ bin/rails db:migrate

Это настроит конфигурацию и создаст три таблицы, используемые Active Storage: active_storage_blobs, active_storage_attachments и active_storage_variant_records.

Таблица Предназначение
active_storage_blobs Хранит данные о загруженных файлах, такие как имя и тип содержимого.
active_storage_attachments Полиморфная соединительная таблица, соединяющая ваши модели с blobs. Если имя класса вашей модели изменится, вам потребуется выполнить миграцию для этой таблицы, чтобы обновить record_type на новое имя класса вашей модели.
active_storage_variant_records Если включено отслеживание вариантов, хранит записи для каждого сгенерированного варианта.

Если используются UUID вместо чисел в качестве первичного ключа моделей, необходимо установить Rails.application.config.generators { |g| g.orm :active_record, primary_key_type: :uuid } в файле конфигурации.

Сервисы Active Storage объявляются в config/storage.yml. Для каждого сервиса, используемого в приложении, стоит указать имя и необходимую конфигурацию. В нижеприведенном примере объявляются три сервиса с именами 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: ""
  bucket: ""
  region: "" # e.g. 'us-east-1'

Скажите Active Storage, какой сервис использовать, установив Rails.application.config.active_storage.service. Поскольку каждая среда, скорее всего, использует различные сервисы, рекомендуется делать это отдельно для каждого окружения. Чтобы использовать сервис диска из предыдущего примера в среде разработки, нужно добавить следующее в config/environments/development.rb:

# Хранение файлов локально.
config.active_storage.service = :local

Чтобы использовать сервис S3 в production, необходимо добавить следующее в config/environments/production.rb:

# Хранить файлы в Amazon S3.
config.active_storage.service = :amazon

Чтобы использовать тестовый сервис при тестировании, добавьте следующее в config/environments/test.rb:

# Хранить загруженные файлы в локальной файловой системе во временной директории.
config.active_storage.service = :test

Конфигурационные файлы, специфичные для среды, имеют приоритет: в production, к примеру, файл config/storage/production.yml (если существует) будет иметь приоритет перед файлом config/storage.yml.

Рекомендовано использовать Rails.env в имени bucket, чтобы в будущем снизить риск случайного уничтожения данных production.

amazon:
  service: S3
  # ...
  bucket: your_own_bucket-<%= Rails.env %>

google:
  service: GCS
  # ...
  bucket: your_own_bucket-<%= Rails.env %>

azure:
  service: AzureStorage
  # ...
  container: your_container_name-<%= Rails.env %>

Подробнее о встроенных адаптерах сервиса (например, Disk и S3) и требуемой конфигурации написано ниже.

2.1. Сервис Disk

Объявление сервиса Disk в config/storage.yml:

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

2.2. Сервис S3 (Amazon S3 и совместимые с S3 API)

Чтобы подключиться к Amazon S3, объявите сервис S3 в config/storage.yml:

amazon:
  service: S3
  access_key_id: ""
  secret_access_key: ""
  region: ""
  bucket: ""

Опционально предоставьте опции клиента и загрузки:

amazon:
  service: S3
  access_key_id: ""
  secret_access_key: ""
  region: ""
  bucket: ""
  http_open_timeout: 0
  http_read_timeout: 0
  retry_limit: 0
  upload:
    server_side_encryption: "" # 'aws:kms' или 'AES256'
    cache_control: "private, max-age=<%= 1.day.to_i %>"

Установите разумные лимиты HTTP timeout и retry в своем приложении. В некоторых сценариях неудачи конфигурация клиента AWS по умолчанию может держать соединение несколько минут, что приведет к очереди из запросов.

Добавьте гем aws-sdk-s3 в Gemfile:

gem "aws-sdk-s3", require: false

Основные особенности Active Storage требуют следующих прав доступа: s3:ListBucket, s3:PutObject, s3:GetObject и s3:DeleteObject. Публичный доступ дополнительно требует s3:PutObjectAcl. Если есть дополнительные опции загрузки, сконфигурированные также как и настройка ACL, тогда могут потребоваться дополнительные права доступа.

Если необходимо использовать переменные среды, стандартные файлы конфигурации SDK, профили, профили экземпляров IAM или роли задач, можно опустить ключи access_key_id, secret_access_key и region в приведенном выше примере. Сервис S3 поддерживает все опции аутентификации, описанные в документации AWS SDK.

Чтобы подключиться к совместимому с S3 API хранения объектов, такого как DigitalOcean Spaces, предоставьте endpoint:

digitalocean:
  service: S3
  endpoint: https://nyc3.digitaloceanspaces.com
  access_key_id: ...
  secret_access_key: ...
  # ...и другие опции

Есть множество других доступных опций. Их можно посмотреть в документации клиента AWS S3.

2.3. Сервис Microsoft Azure Storage

Объявление сервиса Azure Storage в config/storage.yml:

azure:
  service: AzureStorage
  storage_account_name: ""
  storage_access_key: ""
  container: ""

Кроме того, необходимо добавить гем azure-storage-blob в Gemfile:

gem "azure-storage-blob", "~> 2.0", require: false

2.4. Сервис Google Cloud Storage

Объявление сервиса Google Cloud Storage в config/storage.yml:

google:
  service: GCS
  credentials: <%= Rails.root.join("path/to/keyfile.json") %>
  project: ""
  bucket: ""

Опционально можно предоставить хэш credentials вместо пути к keyfile:

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).dump %>
    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: ""

Опционально предоставьте метаданные Cache-Control для установки на загруженных файлах:

google:
  service: GCS
  ...
  cache_control: "public, max-age=3600"

Опционально используйте IAM вместо credentials при подписании URL. Это полезно, если вы аутентифицируете ваше приложение GKE с помощью Workload Identity, подробности смотрите в этом блоге Google Cloud.

google:
  service: GCS
  ...
  iam: true

Опционально используйте определенный GSA при подписании URL. При использовании IAM, сервер метаданных свяжется для получения GSA email, но этот сервер метаданных не всегда присутствует (например, локальные тесты), и вы не хотите использовать GSA по умолчанию.

google:
  service: GCS
  ...
  iam: true
  gsa_email: "foobar@baz.iam.gserviceaccount.com"

Добавьте гем google-cloud-storage в Gemfile:

gem "google-cloud-storage", "~> 1.11", require: false

2.5. Сервис Mirror

Существует возможность синхронизировать несколько сервисов, определив сервис отзеркаливания. Сервис отзеркаливания копирует загрузки и удаляет из двух или более подчиненных сервисов.

Сервисы отзеркаливания предназначены для временного использования в течение миграции между сервисами в production. Можно начать отзеркаливание в новый сервис, скопировав существующие файлы со старого сервиса на новый, а затем полностью перейти на новый сервис.

Отзеркаливание не атомарно. Возможно, что загрузка будет успешной на основном сервисе и неуспешной на любом из подчиненных сервисов. Перед окончательным переходом на новый сервис, убедитесь, что все файлы были скопированы.

Определим каждый из требуемых сервисов, как описано выше. Будем ссылаться на них с помощью сервиса отзеркаливания.

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

Хотя все вторичные сервисы получают загрузки, скачивания всегда обрабатываются основным сервисом.

Сервисы отзеркаливания совместимы с прямой загрузкой. Новые файлы загружаются непосредственно в основной сервис. Когда напрямую загруженный файл прикрепляется к записи, в очередь помещается фоновое задание для копирования его во вторичные сервисы.

2.6. Публичный доступ

По умолчанию Active Storage предполагает приватный доступ к сервисам. Это означает генерацию подписанных одноразовых URL для бинарных объектов. Если вы предпочитаете сделать бинарные объекты публично доступными, укажите public: true в config/storage.yml вашего приложения:

gcs: &gcs
  service: GCS
  project: ""

private_gcs:
  <<: *gcs
  credentials: <%= Rails.root.join("path/to/private_key.json") %>
  bucket: ""

public_gcs:
  <<: *gcs
  credentials: <%= Rails.root.join("path/to/public_key.json") %>
  bucket: ""
  public: true

Убедитесь, что ваши bucket правильно настроены для публичного доступа. Обратитесь к документации, как разрешить публичное чтение для сервисов хранения Amazon S3, Google Cloud Storage и Microsoft Azure. Amazon S3 дополнительно требует имеющегося разрешения s3:PutObjectAcl.

При конвертации существующего приложения на использование public: true, убедитесь в обновлении каждого отдельного файла в bucket, чтобы он был публично читаемый до переключения.

3. Прикрепление файлов к записям

3.1. has_one_attached

Макрос has_one_attached устанавливает сопоставление (mapping) один-к-одному между записями и файлами. Каждая запись может содержать один прикрепленный файл.

Например, предположим, что в приложении имеется модель User. Если необходимо, чтобы у каждого пользователя был аватар, определите модель User так:

class User < ApplicationRecord
  has_one_attached :avatar
end

или, если используете Rails 6.0+, можно запустить команду генератора модели наподобие:

$ bin/rails generate model User avatar:attachment

Далее можно создать пользователя с аватаром:

<%= form.file_field :avatar %>
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 прикрепляет аватар к существующему пользователю:

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

Вызов avatar.attached? определяет, есть ли у конкретного пользователя аватар:

user.avatar.attached?

Иногда необходимо переопределить сервис по умолчанию для определенного вложения. Указать сервис для вложения можно с помощью опции service с именем вашего сервиса:

class User < ApplicationRecord
  has_one_attached :avatar, service: :google
end

Можно настроить определенные варианты для вложения, вызвав метод variant на вложенном прикрепляемом объекте:

class User < ApplicationRecord
  has_one_attached :avatar do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100]
  end
end

Вызовите avatar.variant(:thumb) для получения варианта thumb аватарки:

<%= image_tag user.avatar.variant(:thumb) %>

Также можно указать определенные варианты для предварительного просмотра:

class User < ApplicationRecord
  has_one_attached :video do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100]
  end
end
<%= image_tag user.video.preview(:thumb) %>

Если вы заранее знаете, что к вашим вариантам будет обращение, можете указать Rails на их предварительную генерацию:

class User < ApplicationRecord
  has_one_attached :video do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true
  end
end

Rails добавит в очередь задание на генерацию варианта после присоединения вложения к записи.

Поскольку Active Storage полагается на полиморфные связи, а полиморфные связи полагаются на хранение имен классов в базе данных, эти данные должны оставаться синхронизированы с именем класса, используемым в Ruby-коде. При переименовании классов, использующих has_one_attached, обязательно также обновите имена классов в столбце полиморфного типа active_storage_attachments.record_type соответствующих строк.

3.2. has_many_attached

Макрос has_many_attached устанавливает отношение один-ко-многим между записями и файлами. У каждой записи может быть много прикрепленных файлов.

Например, предположим, что в приложении имеется модель Message. Если необходимо, чтобы у каждого сообщения было много изображений, определите модель Message так:

class Message < ApplicationRecord
  has_many_attached :images
end

или, если используете Rails 6.0+, можно запустить команду генератора модели наподобие:

$ bin/rails generate model Message images:attachments

Далее можно создать сообщение с изображениями:

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?

Переопределить сервис по умолчанию можно так же, как и для has_one_attached, с помощью опции service:

class Message < ApplicationRecord
  has_many_attached :images, service: :s3
end

Настроить определенные варианты можно так же, как и для has_one_attached, вызвав метод variant на вложенном прикрепляемом объекте:

class Message < ApplicationRecord
  has_many_attached :images do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100]
  end
end

Поскольку Active Storage полагается на полиморфные связи, а полиморфные связи полагаются на хранение имен классов в базе данных, эти данные должны оставаться синхронизированы с именем класса, используемым в Ruby-коде. При переименовании классов, использующих has_one_attached, обязательно также обновите имена классов в столбце полиморфного типа active_storage_attachments.record_type соответствующих строк.

3.3. Прикрепление объектов File/IO

Иногда необходимо прикрепить файл, который не поступает через HTTP-запрос. Например, может понадобиться прикрепить файл, сгенерированный на диске, или загрузить файл из введенного пользователем URL. Также можно захотеть прикрепить файл фикстур в тесте модели. Чтобы сделать это, предоставьте хэш, содержащий как минимум открытый объект IO и имя файла:

@message.images.attach(io: File.open('/path/to/file'), filename: 'file.pdf')

Когда это возможно, предоставьте тип содержимого. Active Storage пытается определить тип содержимого файла по его данным. Если он не может этого сделать, он возвращает тип содержимого, которое предоставляется.

@message.images.attach(io: File.open('/path/to/file'), filename: 'file.pdf', content_type: 'application/pdf')

Можно пропустить определение типа содержимого из данных, передав identify: false вместе с content_type.

@message.images.attach(
  io: File.open('/path/to/file'),
  filename: 'file.pdf',
  content_type: 'application/pdf',
  identify: false
)

Если не предоставляется тип содержимого и Active Storage не может автоматически определить тип содержимого файла, по умолчанию используется application/octet-stream.

Существует дополнительный параметр key, который можно использовать для указания папок/подпапок в вашем S3 Bucket. В противном случае AWS S3 использует случайный ключ для именования ваших файлов. Этот подход полезен, если вы хотите лучше организовать файлы в вашем S3 Bucket.

@message.images.attach(
  io: File.open('/path/to/file'),
  filename: 'file.pdf',
  content_type: 'application/pdf',
  key: "#{Rails.env}/blog_content/intuitive_filename.pdf",
  identify: false
)

Таким образом, файл будет сохранен в папке [S3_BUCKET]/development/blog_content/, когда вы тестируете его в своей среде разработки. Обратите внимание, что при использовании параметра ключа необходимо обеспечить уникальность ключа для успешной загрузки. Рекомендуется добавлять к имени файла уникальный случайный ключ, например:

def s3_file_key
  "#{Rails.env}/blog_content/intuitive_filename-#{SecureRandom.uuid}.pdf"
end
@message.images.attach(
  io: File.open('/path/to/file'),
  filename: 'file.pdf',
  content_type: 'application/pdf',
  key: s3_file_key,
  identify: false
)

3.4. Замена и добавление вложений

По умолчанию в Rails при прикреплении файлов к связи has_many_attached любые существующие вложения будут заменены.

Чтобы сохранить существующие вложения, можно использовать скрытые поля формы с signed_id каждого вложенного файла:

<% @message.images.each do |image| %>
  <%= form.hidden_field :images, multiple: true, value: image.signed_id %>
<% end %>

<%= form.file_field :images, multiple: true %>

Это дает дополнительное преимущество: возможность выборочного удаления существующих вложений, например, с помощью JavaScript для удаления отдельных скрытых полей.

3.5. Валидация формы

Вложения не отправляются в сервис хранения до успешного save связанной записи. Это означает, что если отправка формы не пройдет валидацию, все новые вложения будут потеряны и их потребуется загрузить снова. Однако, поскольку прямые загрузки сохраняются до отправки формы, их можно использовать для сохранения загрузок при ошибке валидации:

<%= form.hidden_field :avatar, value: @user.avatar.signed_id if @user.avatar.attached? %>
<%= form.file_field :avatar, direct_upload: true %>

4. Удаление файлов

Чтобы удалить прикрепленный файл из модели, необходимо вызвать purge на нем. Если приложение использует Active Job, удаление может быть выполнено в фоновом режиме, с помощью вызова purge_later. purge удаляет blob и файл из сервиса хранения.

# Синхронно уничтожить аватар и фактические файлы ресурса.
user.avatar.purge

# Асинхронно уничтожить связанные модели и фактические файлы ресурса с помощью Active Job.
user.avatar.purge_later

5. Раздача файлов

Active Storage поддерживает два способа раздачи файлов: перенаправление и прокси.

Все контроллеры Active Storage по умолчанию доступны публично. Сгенерированные URL трудно угадать, но они постоянные по определению. Если ваши файлы требуют более высокий уровень защиты, рассмотрите реализацию аутентифицированных контроллеров.

5.1. Режим перенаправления

Чтобы сгенерировать постоянный URL для бинарного объекта, можно передать этот объект в хелпер вью url_for. Это создаст URL с signed_id бинарного объекта, который направляет в RedirectController для бинарного объекта.

url_for(user.avatar)
# => https://www.example.com/rails/active_storage/blobs/redirect/:signed_id/my-avatar.png

RedirectController перенаправляет на фактическую конечную точку сервиса. Эта косвенная адресация (indirection) отделяет URL сервиса от фактического, и позволяет, например, отзеркаливание прикрепленных файлов в разных сервисах для высокой доступности. Перенаправление имеет HTTP-прекращение 5 минут.

Чтобы создать ссылку для скачивания, необходимо использовать хелпер rails_blob_{path|url}. С помощью этого хелпера можно установить disposition.

rails_blob_path(user.avatar, disposition: "attachment")

Для предотвращения атак XSS, Active Storage принудительно устанавливает заголовок Content-Disposition как "attachment" для некоторых типов файлов. Чтобы изменить это поведение, смотрите доступные конфигурационные опции в Конфигурирование приложений на Rails.

Если необходимо создать ссылку из-за пределов содержимого контроллера/вью (фоновые задания, задания Cron и т.д.), можно получить доступ к rails_blob_path следующим образом:

Rails.application.routes.url_helpers.rails_blob_path(user.avatar, only_path: true)

5.2. Режим прокси

Опционально файлы могут проксированы вместо этого. Это означает, что серверы вашего приложения будут скачивать данные файла из сервиса хранения в отклик на запросы. Это может быть полезным для раздачи файлов из CDN.

Можно настроить Active Storage для использования проксирования по умолчанию:

# config/initializers/active_storage.rb
Rails.application.config.active_storage.resolve_model_to_route = :rails_storage_proxy

Или, если хотите явно проксировать определенные вложения, есть хелперы URL, которые можно использовать в форме rails_storage_proxy_path и rails_storage_proxy_url.

<%= image_tag rails_storage_proxy_path(@user.avatar) %>
5.2.1. Помещение CDN перед Active Storage

Кроме этого чтобы использовать CDN для вложений Active Storage, необходимо сгенерировать URL с режимом прокси, чтобы они раздавались вашим приложением, и CDN закэширует вложение без каких-либо дополнительных настроек. Это работает из коробки, так как контроллер прокси Active Storage по умолчанию устанавливает заголовок HTTP, указывающий CDN закэшировать отклик.

Также следует убедиться, что сгенерированные URL используют хост CDN, а не хост приложения. Есть несколько способов достичь этого, но в основном это затрагивает изменение вашего файла config/routes.rb, чтобы вы могли сгенерировать правильные URL для вложений и их вариаций. Для примера, можно добавить это:

# config/routes.rb
direct :cdn_image do |model, options|
  expires_in = options.delete(:expires_in) { ActiveStorage.urls_expire_in }

  if model.respond_to?(:signed_id)
    route_for(
      :rails_service_blob_proxy,
      model.signed_id(expires_in: expires_in),
      model.filename,
      options.merge(host: ENV['CDN_HOST'])
    )
  else
    signed_blob_id = model.blob.signed_id(expires_in: expires_in)
    variation_key  = model.variation.key
    filename       = model.blob.filename

    route_for(
      :rails_blob_representation_proxy,
      signed_blob_id,
      variation_key,
      filename,
      options.merge(host: ENV['CDN_HOST'])
    )
  end
end

и затем генерировать маршруты следующим образом:

<%= cdn_image_url(user.avatar.variant(resize_to_limit: [128, 128])) %>

5.3. Аутентифицированные контроллеры

Все контроллеры Active Storage по умолчанию публично доступны. Сгенерированные URL используют signed_id, который трудно угадываемый, но всегда одинаковый. Любой, кто узнает URL бинарного объекта, сможет получить к нему доступ, даже если before_action в вашем ApplicationController в ином случае требовал бы входа. Если ваши файлы требуют более высокий уровень защиты, можно реализовать собственные аутентифицированные контроллеры, основанные на ActiveStorage::Blobs::RedirectController, ActiveStorage::Blobs::ProxyController, ActiveStorage::Representations::RedirectController и ActiveStorage::Representations::ProxyController

Чтобы разрешить аккаунту доступ только к своему логотипу, можно сделать следующее:

# config/routes.rb
resource :account do
  resource :logo
end
# app/controllers/logos_controller.rb
class LogosController < ApplicationController
  # Through ApplicationController:
  # include Authenticate, SetCurrentAccount

  def show
    redirect_to Current.account.logo.url
  end
end
<%= image_tag account_logo_path %>

И затем следует отключить маршруты Active Storage по умолчанию с помощью:

config.active_storage.draw_routes = false

чтобы предотвратить доступ к файлам с помощью публично доступных URL.

6. Скачивание файлов

Иногда необходимо обработать blob после его загрузки - например, чтобы преобразовать его в другой формат. Используйте метод download на вложении для чтения двоичных данных blob в памяти:

binary = user.avatar.download

Возможно, может понадобиться загрузить blob в файл на диске, чтобы внешняя программа могла работать с ним (например, антивирусный сканер или транскодер медиа). Используйте метод open на вложении, чтобы загрузить blob в tempfile на диске:

message.video.open do |file|
  system '/path/to/virus/scanner', file.path
  # ...
end

Важно знать, что этот файл не доступен в колбэке after_create, а только в after_create_commit.

7. Анализ файлов

Active Storage анализирует файлы как только они были загружены, запустив задание в Active Job. Проанализированные файлы будут хранить дополнительную информацию в хэше метаданных, включая analyzed: true. Можно проверить, был ли бинарный объект проанализирован, вызвав analyzed? на нем.

Анализ изображений предоставляет атрибуты width и height. Анализ видео предоставляет их же, а также duration, angle, display_aspect_ratio и булевы значения video и audio для обозначения наличия этих каналов. Анализ аудио предоставляет атрибуты duration и bit_rate.

8. Отображение изображений, видео и PDF

Active Storage поддерживает представление разных файлов. Можно вызвать representation на вложении, чтобы отобразить вариант изображения или предварительный просмотр видео или PDF. До вызова representation, проверьте, что вложение может быть представлено, вызвав representable?. Некоторые форматы файла не могут быть предварительно показаны Active Storage из коробки (например, документы Word); если representable? возвращает false, можно оставить ссылку на файл.

<ul>
  <% @message.files.each do |file| %>
    <li>
      <% if file.representable? %>
        <%= image_tag file.representation(resize_to_limit: [100, 100]) %>
      <% else %>
        <%= link_to rails_blob_path(file, disposition: "attachment") do %>
          <%= image_tag "placeholder.png", alt: "Download file" %>
        <% end %>
      <% end %>
    </li>
  <% end %>
</ul>

Внутри representation вызывает variant для изображений и preview для файлов, которые можно просмотреть предварительно. Можно использовать непосредственно эти методы.

8.1. Ленивая vs немедленная загрузка

По умолчанию Active Storage will обрабатывает представления лениво. Этот код:

image_tag file.representation(resize_to_limit: [100, 100])

Создаст тег <img> с src, указывающим на ActiveStorage::Representations::RedirectController. Браузер сделает запрос к этому контроллеру, что выполнит следующее:

  • Обработает файл и загрузит обработанный файл, если необходимо.
  • Вернет редирект с кодом 302 на файл, направляя либо:
    • на удаленный сервис (например, S3).
    • или на ActiveStorage::Blobs::ProxyController, который вернет содержимое файла, если включен режим прокси.

Ленивая загрузка файла позволяет использовать такие функции, как одноразовые URL, не замедляя начальную загрузку страницы.

Это прекрасно работает в большинстве случаев.

Если хотите немедленно сгенерировать URL для изображений, можно вызвать .processed.url:

image_tag file.representation(resize_to_limit: [100, 100]).processed.url

Отслеживание вариантов Active Storage улучшает производительность этого, сохраняя запись в базе данных, если запрашиваемое представление уже было обработано ранее. Таким образом, вышеприведенных код сделает вызов API к удаленному сервису (например, S3) только единожды, и как только вариант сохранится, будет использовать его. Отслеживание вариантов запускается автоматически, но может быть отключено с помощью config.active_storage.track_variants.

Если вы рендерите множество изображений на странице, вышеприведенный пример может привести к N+1 запросам, загружающим все записи вариантов. Чтобы избежать этих N+1 запросов, используйте именованные скоупы на ActiveStorage::Attachment.

message.images.with_all_variant_records.each do |file|
  image_tag file.representation(resize_to_limit: [100, 100]).processed.url
end

8.2. Преобразование изображений

Преобразование изображений позволяет отобразить изображение с выбранным вами разрешением.

Чтобы создать вариацию изображения, вызовите variant на вложении. В метод можно передать любое преобразование, поддерживаемое процессором варианта. Когда браузер обращается к URL варианта, Active Storage будет лениво преобразовывать исходный blob в указанный формат и перенаправлять его к новому месту расположения сервиса.

<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>

Если запрошен вариант, Active Storage автоматически применит трансформации, в зависимости от формата изображения:

  • Переменные типы изображения (указанны в config.active_storage.variable_content_types) и не рассматриваемые веб-изображениями (указаны в config.active_storage.web_image_content_types), будут преобразованы в PNG.

  • Если не указано quality, для форматирования будет использовано качество по умолчанию обработчика варианта.

Active Storage может использовать либо Vips, либо MiniMagick в качестве обработчика варианта. Умолчания зависит от целевой версии вашей config.load_defaults, и обработчик может быть изменен, устанавливая config.active_storage.variant_processor.

Эти два процессора не полностью совместимы, поэтому при миграции существующего приложения между MiniMagick и Vips, нужно сделать несколько изменений при использовании специфичных опций форматирования:

<!-- MiniMagick -->
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg, sampling_factor: "4:2:0", strip: true, interlace: "JPEG", colorspace: "sRGB", quality: 0) %>

<!-- Vips -->
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg, saver: { subsample_mode: "on", strip: true, interlace: true, quality: 80 }) %>

Доступные параметры определяются гемом image_processing и зависят от используемого процессора варианта, но оба поддерживают следующие параметры:

Параметр Пример Описание
resize_to_limit resize_to_limit: [100, 100] Уменьшает изображение в соответствие указанным габаритам, сохраняя оригинальные пропорции. Изменит размер, только если изображение больше, чем указанные габариты.
resize_to_fit resize_to_fit: [100, 100] Уменьшает изображение в соответствие указанным габаритам, сохраняя оригинальные пропорции. Уменьшит, если изображение больше, чем указанные габариты, или увеличит, если меньше.
resize_to_fill resize_to_fill: [100, 100] Изменит изображение, чтобы заполнить указанные габариты, сохраняя оригинальные пропорции. При необходимости обрежет изображение по большему габариту.
resize_and_pad resize_and_pad: [100, 100] Уменьшает изображение в соответствие указанным габаритам, сохраняя оригинальные пропорции. При необходимости заполнит оставшуюся площадь прозрачным цветом, если исходное изображение имеет альфа-канал, в противном случае черным.
crop crop: [20, 50, 300, 300] Извлекает область из изображения. Первые два аргумента это левый и верхний края извлекаемой области, а последние два аргумента это ширина и высота извлекаемой области.
rotate rotate: 90 Поворачивает изображение на указанный угол.

У image_processing больше доступных опций (таких как saver, позволяющей настроить компрессию изображения) в собственных документациях к процессорам Vips и MiniMagick.

8.3. Предварительный просмотр файлов

Некоторые файлы, который не являются изображениями, могут быть предварительно просмотрены: то есть они могут быть представлены как изображения. Например, видеофайл можно предварительно просмотреть, извлекая его первый кадр. Из коробки Active Storage поддерживает предварительный просмотр видео и документов PDF. Чтобы создать ссылку на лениво генерируемый предварительный просмотр, используйте метод preview вложения:

<%= image_tag message.video.preview(resize_to_limit: [100, 100]) %>

Чтобы добавить поддержку другого формата, добавьте собственный previewer. Обратитесь к документации ActiveStorage::Preview за подробностями.

9. Прямые загрузки

Active Storage со встроенной библиотекой JavaScript поддерживает загрузку прямо от клиента в облако.

9.1. Использование

  • Включите activestorage.js в комплект JavaScript приложения.

    Используя конвейер ресурсов:

    //= require activestorage
    

    Используя пакет npm:

    import * as ActiveStorage from "@rails/activestorage"
    ActiveStorage.start()
    
  • Добавьте direct_upload: true в ваше поле файла:

    <%= form.file_field :attachments, multiple: true, direct_upload: true %>
    

    Или, если не используете FormBuilder, добавьте непосредственно атрибут данных:

    <input type="file" data-direct-upload-url="<%= rails_direct_uploads_url %>" />
    
  • Настройте CORS на сторонних сервисах хранения, чтобы разрешить запросы прямой загрузки.

  • Вот и все! Загрузки начинаются с момента отправки формы.

9.2. Настройка совместного использование ресурсов (CORS)

Чтобы заработала прямая загрузка на сторонний сервис, необходимо настроить сервис, чтобы разрешить запросы с вашего приложения. Примите во внимание документацию по CORS для вашего сервиса:

Позаботьтесь о разрешении:

  • Всех источников, через которые можно получить доступ к вашему приложению
  • Метода запроса PUT
  • Следующих заголовков:
    • Content-Type
    • Content-MD5
    • Content-Disposition (кроме Azure Storage)
    • x-ms-blob-content-disposition (только для Azure Storage)
    • x-ms-blob-type (только для Azure Storage)
    • Cache-Control (для GCS, только если установлена cache_control)

Настройка CORS не нужна для сервиса Disk, так как он использует тот же источник.

9.2.1. Пример: настройка S3 CORS
[
  {
    "AllowedHeaders": [
      "Content-Type",
      "Content-MD5",
      "Content-Disposition"
    ],
    "AllowedMethods": [
      "PUT"
    ],
    "AllowedOrigins": [
      "https://www.example.com"
    ],
    "MaxAgeSeconds": 3600
  }
]
9.2.2. Пример: настройка Google Cloud Storage CORS
[
  {
    "origin": ["https://www.example.com"],
    "method": ["PUT"],
    "responseHeader": ["Content-Type", "Content-MD5", "Content-Disposition"],
    "maxAgeSeconds": 3600
  }
]
9.2.3. Пример: настройка Azure Storage CORS
<Cors>
  <CorsRule>
    <AllowedOrigins>https://www.example.com</AllowedOrigins>
    <AllowedMethods>PUT</AllowedMethods>
    <AllowedHeaders>Content-Type, Content-MD5, x-ms-blob-content-disposition, x-ms-blob-type</AllowedHeaders>
    <MaxAgeInSeconds>3600</MaxAgeInSeconds>
  </CorsRule>
</Cors>

9.3. События JavaScript прямой загрузки

Имя события Цель события Данные события (event.detail) Описание
direct-uploads:start <form> None Форма, содержащая файлы для прямой загрузки полей была отправлена.
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> None Все прямые загрузки закончились.

9.4. Пример

Также можно использовать эти события, чтобы показывать ход загрузки.

direct-uploads

Чтобы показать загруженные файлы в форме:

// 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"></span>
    </div>
  `)
  target.previousElementSibling.querySelector(`.direct-upload__filename`).textContent = file.name
})

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.5. Пользовательские решения для перетаскивания элементов

Для этой цели можно использовать класс DirectUpload. Получив файл из выбранной библиотеки, создайте экземпляр DirectUpload и вызовите его метод create. Этот метод принимает колбэк для вызова, когда загрузка завершена.

import { DirectUpload } from "@rails/activestorage"

const input = document.querySelector('input[type=file]')

// Привязка к сбрасыванию (drop) файла - используйте 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) => {
  // форма требует file_field 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 были переданы в обычном потоке загрузки
      const hiddenField = document.createElement('input')
      hiddenField.setAttribute("type", "hidden");
      hiddenField.setAttribute("value", blob.signed_id);
      hiddenField.name = input.name
      document.querySelector('form').appendChild(hiddenField)
    }
  })
}

9.6. Отслеживание прогресса загрузки файла

При использовании конструктора DirectUpload можно указать третий параметр. Это позволит объекту DirectUpload вызвать метод directUploadWillStoreFileWithXHR во время процесса загрузки. Затем вы можете прикрепить свой собственный обработчик прогресса к XHR для удовлетворения ваших потребностей.

import { DirectUpload } from "@rails/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 {
        // Добавьте соответствующим образом названное скрытое поле в форму со значением of blob.signed_id
      }
    })
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress",
      event => this.directUploadDidProgress(event))
  }

  directUploadDidProgress(event) {
    // Используйте event.loaded и event.total, чтобы обновить индикатор процесса
  }
}

9.7. Интеграция с библиотеками или фреймворками

Получив файл из выбранной библиотеки, необходимо создать экземпляр класса DirectUpload и использовать его метод "create" для запуска процесса загрузки, при необходимости добавив любые дополнительные заголовки. Метод "create" также требует указания функции-колбэка, которая будет вызвана по завершении загрузки.

import { DirectUpload } from "@rails/activestorage"

class Uploader {
  constructor(file, url, token) {
    const headers = { 'Authentication': `Bearer ${token}` }
    // INFO: Отправка заголовков является необязательным параметром. Если вы решите не отправлять заголовки,
    //       аутентификация будет выполняться с использованием куки или данных сессии.
    this.upload = new DirectUpload(this.file, this.url, this, headers)
  }

  upload(file) {
    this.upload.create((error, blob) => {
      if (error) {
        // Обрабатываем ошибку
      } else {
        // Используем blob.signed_id как указание на файл в следующем запросе
      }
    })
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress",
      event => this.directUploadDidProgress(event))
  }

  directUploadDidProgress(event) {
    // Используем event.loaded и event.total для обновления полосы прогресса
  }
}

Для реализации пользовательской аутентификации необходимо создать новый контроллер в приложении Rails, похожий на следующий:

class DirectUploadsController < ActiveStorage::DirectUploadsController
  skip_forgery_protection
  before_action :authenticate!

  def authenticate!
    @token = request.headers['Authorization']&.split&.last

    head :unauthorized unless valid_token?(@token)
  end
end

Использование прямых загрузок иногда может привести к тому, что загруженный файл никогда не будет прикреплен к записи. Рассмотрите очистку неприкрепленных загрузок.

10. Тестирование

Используйте file_fixture_upload для тестирования загрузки файла в интеграционном тесте или тесте контроллера. Rails обрабатывает файлы так же, как и любые другие параметры.

class SignupController < ActionDispatch::IntegrationTest
  test "can sign up" do
    post signup_path, params: {
      name: "David",
      avatar: file_fixture_upload("david.png", "image/png")
    }

    user = User.order(:created_at).last
    assert user.avatar.attached?
  end
end

10.1. Очистка файлов созданных во время тестов

10.1.1. Системные тесты

Системные тесты очищают тестовые данные, откатывая транзакцию. Поскольку destroy никогда не вызывается на объекте, прикрепленные файлы никогда не очищаются. Если необходимо очистить файлы, можно сделать это в колбэке after_teardown. Выполнение этого здесь гарантирует, что все соединения, созданные во время теста, будут завершены и не будет получено сообщение об ошибке из Active Storage, в котором говорится, что он не может найти файл.

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # ...
  def after_teardown
    super
    FileUtils.rm_rf(ActiveStorage::Blob.service.root)
  end
  # ...
end

Если используете [параллельные тесты][/testing#parallel-testing] и DiskService, следует настроить каждый процесс для использования своей папки для Active Storage. Таким образом, колбэк teardown удалит только файлы из тестов, релевантных процессу.

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # ...
  parallelize_setup do |i|
    ActiveStorage::Blob.service.root = "#{ActiveStorage::Blob.service.root}-#{i}"
  end
end

Если системные тесты проверяют удаление модели с прикрепленными файлами, и используется Active Job, необходимо установить тестовую среду для использования встроенного адаптера очереди, поэтому задание на purge выполняется немедленно, а не когда-нибудь потом.

# Использование встроенной обработки задания, чтобы все произошло немедленно
config.active_job.queue_adapter = :inline
10.1.2. Интеграционные тесты

Подобно системным тестам, файлы, загруженные во время интеграционных тестов, не будут автоматически очищены. Если необходимо очистить файлы, можно сделать это в колбэке teardown.

class ActionDispatch::IntegrationTest
  def after_teardown
    super
    FileUtils.rm_rf(ActiveStorage::Blob.service.root)
  end
end

Если используете [параллельные тесты][/testing#parallel-testing] и DiskService, следует настроить каждый процесс для использования своей папки для Active Storage. Таким образом, колбэк teardown удалит только файлы из тестов, релевантных процессу.

class ActionDispatch::IntegrationTest
  parallelize_setup do |i|
    ActiveStorage::Blob.service.root = "#{ActiveStorage::Blob.service.root}-#{i}"
  end
end

10.2. Добавление вложений в фикстуры

Можно добавлять вложения в существующие [фикстуры][/testing#the-low-down-on-fixtures]. Сначала нужно создать отдельный сервис хранения:

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

Это сообщит Active Storage, куда "загружать" файлы фикстур, поэтому это должна быть временная директория. Сделав ее директорией, отличной от обычного сервиса test, можно отделить файлы фикстур от файлов, загруженных в течение теста.

Затем создайте файлы фикстур для классов Active Storage:

# active_storage/attachments.yml
david_avatar:
  name: avatar
  record: david (User)
  blob: david_avatar_blob
# active_storage/blobs.yml
david_avatar_blob: <%= ActiveStorage::FixtureSet.blob filename: "david.png", service_name: "test_fixtures" %>

Затем поместите файл в директорию фикстур (путь по умолчанию test/fixtures/files) со соответствующим именем. Подробности смотрите в документации по ActiveStorage::FixtureSet.

Как только все настроено, можно получить доступ к вложениям в ваших тестах:

class UserTest < ActiveSupport::TestCase
  def test_avatar
    avatar = users(:david).avatar

    assert avatar.attached?
    assert_not_nil avatar.download
    assert_equal 1000, avatar.byte_size
  end
end
10.2.1. Очистка фикстур

Хотя файлы, загруженные в тестах, очищаются в конце каждого теста, файлы фикстур нужно очищать всего лишь раз: когда завершатся все ваши тесты.

Если используете параллельные тесты, вызывайте parallelize_teardown:

class ActiveSupport::TestCase
  # ...
  parallelize_teardown do |i|
    FileUtils.rm_rf(ActiveStorage::Blob.services.fetch(:test_fixtures).root)
   end
  # ...
end

Если не запускаете параллельные тесты, используйте Minitest.after_run или эквивалент для вашего тестового фреймворка (например, after(:suite) для RSpec):

# test_helper.rb

Minitest.after_run do
  FileUtils.rm_rf(ActiveStorage::Blob.services.fetch(:test_fixtures).root)
end

10.3. Настройка сервисов

Вы можете добавить файл config/storage/test.yml для настройки сервисов, используемых в тестовой среде. Это полезно, когда используется опция service.

class User < ApplicationRecord
  has_one_attached :avatar, service: :s3
end

При отсутствии config/storage/test.yml служба s3, настроенная в config/storage.yml, будет использоваться даже во время тестирования.

Это означает, что будет использоваться конфигурация по умолчанию, и файлы будут загружаться провайдеру сервиса, указанного в config/storage.yml.

В этом случае, можно добавить config/storage/test.yml и использовать сервис Disk вместо сервиса s3, чтобы предотвратить отправление запросов.

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

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

11. Реализация поддержки других облачных сервисов

Если необходимо поддерживать облачный сервис, отличный от имеющихся, необходимо реализовать Service. Каждый сервис расширяет ActiveStorage::Service, реализуя методы, требуемые для загрузки и скачивания файлов в облако.

12. Очистка неприкрепленных загрузок

Бывают случаи, когда файл загружен, но никогда не прикреплен к записи. Это может произойти при использовании прямых загрузок. Можно запросить неприкрепленные записи с помощью скоупа unattached. Ниже пример с помощью пользовательской задачи rake.

namespace :active_storage do
  desc "Purges unattached Active Storage blobs. Run regularly."
  task purge_unattached: :environment do
    ActiveStorage::Blob.unattached.where(created_at: ..2.days.ago).find_each(&:purge_later)
  end
end

Запрос, сгенерированный ActiveStorage::Blob.unattached, может быть медленным и потенциально разрушительным для приложений с большими базами данных.