Загрузка файлов в Amazon S3 с помощью Rails API и Javascript Frontend

Это руководство проведет вас через метод интеграции хостинга S3 с Rails-as-an-API. Я также расскажу о том, как интегрироваться с фронтендом. Примечание, хотя некоторые настройки ориентированы на Heroku, это применимо для любой серверной части Rails API. Есть много коротких руководств, но они призваны четко объединить все воедино. В конце я поместил советы по устранению неполадок для некоторых ошибок, с которыми я столкнулся.

Для этого руководства у меня было приложение Rails API в одном рабочем каталоге и приложение React в другом каталоге. Я предполагаю, что вы уже знаете основы создания запросов между вашим интерфейсом и серверной частью, и предполагаю, что вы знаете, как запускать их локально. Это руководство довольно длинное, и на его изучение может уйти несколько часов. Если вы предпочитаете не использовать Medium, исходный пост находится в моем блоге.

Фон

Мы будем загружать файл прямо из интерфейса. Одним из преимуществ этого является то, что мы экономим на больших запросах. Если мы загрузили на бэкэнд, а затем бэкэнд отправил его на S3, это будут два экземпляра потенциально большого запроса. Еще одно преимущество заключается в настройке Heroku: в Heroku есть «эфемерная файловая система». Ваши файлы могут оставаться в системе на короткое время, но они всегда будут исчезать в системном цикле. Вы можете попробовать загрузить файлы в Heroku, а затем сразу же загрузить их в S3. Однако, если файловая система за это время зациклится, вы загрузите неполный файл. Это менее актуально для файлов меньшего размера, но мы будем осторожны в целях данного руководства.

Наш бэкэнд будет выполнять две роли: он будет сохранять метаданные о файле и обрабатывать все шаги аутентификации, которые требует S3. Он никогда не коснется самих файлов.

Поток будет выглядеть так:

  1. Интерфейс отправляет запрос на сервер Rails на авторизованный URL-адрес для загрузки.
  2. Сервер (используя Active Storage) создает авторизованный URL-адрес для S3, а затем передает его обратно во внешний интерфейс.
  3. Интерфейс загружает файл на S3, используя авторизованный URL.
  4. Интерфейс подтверждает загрузку и отправляет запрос на сервер для создания объекта, отслеживающего необходимые метаданные.

Шаги 1 и 2 представлены на диаграмме 2.1. Шаги 3 и 4 представляют собой диаграммы 2.2 и 2.3 соответственно.

Настройка S3

Сначала мы настроим ресурсы S3, которые нам нужны. Создайте две корзины S3, prod и dev. Вы можете оставить все по умолчанию, но обратите внимание на bucket region. Это понадобится вам позже.

Затем мы настроим совместное использование ресурсов между источниками (CORS). Это позволит вам делать запросы POST & PUT к вашей корзине. Зайдите в каждое ведро, Permissions - ›CORS Configuration. На данный момент мы будем использовать конфигурацию по умолчанию, которая позволяет все. Мы ограничимся позже.

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>POST</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration

Затем мы создадим некоторые учетные данные безопасности, чтобы позволить нашему бэкэнду делать необычные вещи с нашим бакетом. Щелкните раскрывающееся меню с именем своей учетной записи и выберите My Security Credentials. Это приведет вас к AWS IAM.

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

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

Бэкэнд Rails API

Опять же, я предполагаю, что вы знаете, как создать базовый Rails API. Я прикреплю свой файл к модели user, но вы можете прикрепить его к чему угодно.

Переменные среды

Добавьте два драгоценных камня в свой Gemfile: gem 'aws-sdk-s3' и gem 'dotenv-rails', затем bundle install. Первая жемчужина - это комплект для разработки программного обеспечения S3. Второй гем позволяет Rails использовать .env файл.

Ключ доступа и регион (из AWS) необходимы в Rails. При локальной разработке мы передадим эти значения с помощью файла .env. Находясь на Heroku, мы можем устанавливать значения с помощью heroku config, который мы рассмотрим в конце этого руководства. Мы не будем использовать Procfile. Создайте .env файл в корне своего каталога и обязательно добавьте его в свой gitignore. Вы не хотите, чтобы секреты вашей учетной записи AWS попадали на Github. Ваш .env файл должен включать:

AWS_ACCESS_KEY_ID=YOURACCESSKEY
AWS_SECRET_ACCESS_KEY=sEcReTkEyInSpoNGeBoBCaSe
S3_BUCKET=your-app-dev
AWS_REGION=your-region-1

Настройка хранилища

Запустите rails active_storage:install. Active Storage - это библиотека, которая помогает с загрузкой в ​​различные облачные хранилища. Выполнение этой команды создаст миграцию для таблицы, которая будет обрабатывать метаданные файлов. Убедитесь, что rails db:migrate.

Затем мы изменим файлы, которые отслеживают среду Active Storage. Должен быть файл config/storage.yml. Мы добавим возможность хранения Amazon S3. Его значения взяты из нашего .env файла.

amazon:
  service: S3
  access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
  secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
  region: <%= ENV['AWS_REGION'] %>
  bucket: <%= ENV['S3_BUCKET'] %>

Затем перейдите к config/enviroments и обновите свои production.rb и development.rb. Для обоих из них измените службу Active Storage на недавно добавленную:

config.active_storage.service = :amazon

Наконец, нам нужен инициализатор для сервиса AWS S3, чтобы настроить его с помощью ключа доступа. Создайте config/initializers/aws.rb и вставьте следующий код:

require 'aws-sdk-s3'

Aws.config.update({
  region: ENV['AWS_REGION'],
  credentials: Aws::Credentials.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY']),
})

S3_BUCKET = Aws::S3::Resource.new.bucket(ENV['S3_BUCKET'])

Теперь мы готовы хранить файлы. Далее мы поговорим о модели Rails и настройке контроллера.

Модель

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

В моем user.rb файле модели нам нужно прикрепить файл к модели. Мы также создадим вспомогательный метод, который будет использовать общедоступный URL-адрес файла, который станет актуальным позже.

class User < ApplicationRecord
  has_one_attached :resume

  def resume_url
    if resume.attached?
      resume.blob.service_url
    end
  end
end

Убедитесь, что для модели нет соответствующего столбца в таблице. В моей схеме user не должно быть столбца resume.

Контроллер прямой загрузки

Затем мы создадим контроллер для обработки аутентификации с S3 через Active Storage. Этот контроллер будет ожидать POST-запроса и вернет объект, который включает подписанный URL-адрес для интерфейса, на который нужно PUT. Запустите rails g controller direct_upload, чтобы создать этот файл. Дополнительно добавьте маршрут к routes.rb:

post '/presigned_url', to: 'direct_upload#create'

Содержимое файла direct_upload_controller.rb можно найти здесь.

Настоящая магия обрабатывается функцией ActiveStorage::Blob.create_before_direct_upload!. Все остальное просто немного форматирует ввод или вывод. Взгляните на blob_params; наш интерфейс будет нести ответственность за их определение.

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

На этом этапе может быть полезно убедиться, что конечная точка работает. Вы можете протестировать эту функциональность с помощью чего-то вроде curl или Postman. Я использовал почтальона.

Запустите локальный сервер с rails s, затем вы сможете протестировать свою direct_upload#create конечную точку, отправив запрос POST. Вам понадобится несколько вещей:

  • На Unix-машине вы можете получить размер файла с помощью ls -l.
  • Если у вас другой тип файла, не забудьте изменить значение content_type.
  • S3 также ожидает «контрольную сумму», чтобы можно было проверить, что он получил неповрежденный файл. Это должен быть хэш файла MD5, закодированный в base64. Вы можете получить это, запустив openssl md5 -binary filename | base64.

Ваш POST-запрос к /presigned_url может выглядеть так:

{
    "file": {
        "filename": "test_upload",
        "byte_size": 67969,
        "checksum": "VtVrTvbyW7L2DOsRBsh0UQ==",
        "content_type": "application/pdf",
        "metadata": {
            "message": "active_storage_test"
        }
    }
}

Ответ должен иметь предварительно подписанный URL и идентификатор:

{
    "direct_upload": {
        "url": "https://your-s3-bucket-dev.s3.amazonaws.com/uploads/uuid?some-really-long-parameters",
        "headers": {
            "Content-Type": "application/pdf",
            "Content-MD5": "VtVrTvbyW7L2DOsRBsh0UQ=="
        }
    },
    "blob_signed_id": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBSQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--8a8b5467554825da176aa8bca80cc46c75459131"
}

Ответ direct_upload.url должен иметь несколько параметров. Не беспокойтесь об этом слишком сильно; если что-то не так, вы просто получите сообщение об ошибке.

Срок действия вашей прямой загрузки теперь составляет 10 минут. Если это выглядит правильно, мы можем использовать объект direct_upload, чтобы сделать запрос PUT к S3. Используйте тот же URL-адрес и убедитесь, что вы добавили заголовки. Тело запроса - это файл, который вы хотите включить.

Вы должны получить простой пустой ответ с кодом 200. Если вы перейдете в корзину S3 в консоли AWS, вы должны увидеть папку и файл. Обратите внимание, что на самом деле вы не можете просматривать файл (вы можете только просматривать его метаданные). Если вы попытаетесь щелкнуть «URL-адрес объекта», появится сообщение Доступ запрещен. Это хорошо! У нас нет разрешения на чтение файла. Ранее в моей модели user.rb я поместил вспомогательную функцию, которая использует Active Storage для получения общедоступного URL-адреса. Мы рассмотрим это чуть позже.

Пользовательский контроллер

Если вы помните наш поток:

  1. Интерфейс отправляет на сервер запрос на авторизованный URL-адрес для загрузки.
  2. Сервер (используя Active Storage) создает авторизованный URL-адрес для S3, а затем передает его обратно во внешний интерфейс. Готово.
  3. Интерфейс загружает файл на S3, используя авторизованный URL.
  4. Интерфейс подтверждает загрузку и отправляет запрос на сервер для создания объекта, отслеживающего необходимые метаданные.

Бэкэнду по-прежнему требуется немного функциональности. Он должен иметь возможность создать новую запись, используя загруженный файл. Например, я использую файлы резюме и прикрепляю их к пользователям. Для создания нового пользователя ожидается first_name, last_name и email. Резюме примет форму signed_blob_id, которую мы видели ранее. Active Storage нужен только этот идентификатор для подключения файла к экземпляру вашей модели. Вот как выглядит мой users_controller#create, и я тоже подвел суть:

def create
   resume = params[:pdf]
   params = user_params.except(:pdf)
   user = User.create!(params)
   user.resume.attach(resume) if resume.present? && !!user
   render json: user.as_json(root: false, methods: :resume_url).except('updated_at')
end

private
def user_params
   params.permit(:email, :first_name, :last_name, :pdf)
end

Самая большая новость - это звонок resume.attach. Также обратите внимание, что мы возвращаем json пользователя и включаем наш созданный метод resume_url. Это то, что позволяет нам просматривать резюме.

Ваши параметры могут выглядеть иначе, если ваша модель отличается. Мы можем снова проверить это с помощью Postman или curl. Вот json-запрос POST, который я бы отправил конечной точке /users:

{
    "email": "[email protected]",
    "first_name": "Test",
    "last_name": "er",
    "pdf": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBLdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3fe2ec7e27bb9b5678dd9f4c7786032897d9511b"
}

Это очень похоже на создание обычного пользователя, за исключением того, что мы вызываем attach для идентификатора файла, который передается с запросом. Идентификатор взят из ответа на наш первый запрос, поле blob_signed_id. Вы должны получить ответ, представляющий пользователя, но имеющий поле resume_url. Вы можете перейти по этому общедоступному URL, чтобы увидеть загруженный файл! Этот URL-адрес взят из blob.service_url, который мы включили в модель user.rb.

Если все работает, ваш бэкэнд, вероятно, готов.

Интерфейс Javascript

Помните наш общий поток запросов. Если мы будем рассматривать только запросы, которые выполняет фронтенд, это будет выглядеть так:

  1. Сделайте POST-запрос для подписанного URL.
  2. Сделайте запрос PUT к S3, чтобы загрузить файл.
  3. Сделайте POST на /users, чтобы создать нового пользователя.

Все это мы уже протестировали с помощью curl / Postman. Теперь это просто нужно реализовать на фронтенде. Я также предполагаю, что вы знаете, как получить файл в Javascript с компьютера. <input> - это простейший метод, но существует множество руководств.

Единственная сложная часть этого - вычисление контрольной суммы файла. Это немного странно, и мне пришлось немного угадывать и проверять, как я это делаю. Для начала будем npm install crypto-js. Crypto JS - это криптографическая библиотека для Javascript.

Примечание. Если вы используете ванильный Javascript и не можете использовать npm, вот несколько инструкций по его импорту с CDN. Тебе понадобится:

  • rollups/md5.js
  • components/lib-typedarrays-min.js
  • components/enc-base64-min.js

Затем мы прочитаем файл с FileReader перед его хешированием в соответствии со следующим кодом. Вот ссылка на соответствующую суть.

import CryptoJS from 'crypto-js'

// Note that for larger files, you may want to hash them incrementally.
// Taken from https://stackoverflow.com/questions/768268/
const md5FromFile = (file) => {
  // FileReader is event driven, does not return promise
  // Wrap with promise api so we can call w/ async await
  // https://stackoverflow.com/questions/34495796
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
  
    reader.onload = (fileEvent) => {
      let binary = CryptoJS.lib.WordArray.create(fileEvent.target.result)
      const md5 = CryptoJS.MD5(binary)
      resolve(md5)
    }
    reader.onerror = () => {
      reject('oops, something went wrong with the file reader.')
    }
    // For some reason, readAsBinaryString(file) does not work correctly,
    // so we will handle it as a word array
    reader.readAsArrayBuffer(file)
  })
}

export const fileChecksum = async(file) => {
  const md5 = await md5FromFile(file)
  const checksum = md5.toString(CryptoJS.enc.Base64)
  return checksum
}

В конце у нас будет хеш MD5, закодированный в base64 (точно так же, как мы сделали выше с терминалом). Мы почти закончили! Единственное, что нам нужно, это актуальные запросы. Я вставлю код, но здесь - ссылка на суть кода JS-запроса.

import { fileChecksum } from 'utils/checksum'

const createPresignedUrl = async(file, byte_size, checksum) => {
  let options = {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      file: {
        filename: file.name,
        byte_size: byte_size,
        checksum: checksum,
        content_type: 'application/pdf',
        metadata: {
          'message': 'resume for parsing'
        }
      }
    })
  }
  let res = await fetch(PRESIGNED_URL_API_ENDPOINT, options)
  if (res.status !== 200) return res
  return await res.json()
}

export const createUser = async(userInfo) => {
  const {pdf, email, first_name, last_name} = userInfo

  // To upload pdf file to S3, we need to do three steps:
  // 1) request a pre-signed PUT request (for S3) from the backend

  const checksum = await fileChecksum(pdf)
  const presignedFileParams = await createPresignedUrl(pdf, pdf.size, checksum)
  
  // 2) send file to said PUT request (to S3)
  const s3PutOptions = {
    method: 'PUT',
    headers: presignedFileParams.direct_upload.headers,
    body: pdf,
  }
  let awsRes = await fetch(presignedFileParams.direct_upload.url, s3PutOptions)
  if (awsRes.status !== 200) return awsRes

  // 3) confirm & create user with backend
  let usersPostOptions = {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      email: email,
      first_name: first_name,
      last_name: last_name,
      pdf: presignedFileParams.blob_signed_id,
    })
  }
  let res = await fetch(USERS_API_ENDPOINT, usersPostOptions)
  if (res.status !== 200) return res 
  return await res.json()
}

Обратите внимание, что вам необходимо указать две глобальные переменные: USERS_API_ENDPOINT и PRESIGNED_URL_API_ENDPOINT. Также обратите внимание, что переменная pdf является файловым объектом Javascript. Опять же, если вы не загружаете PDF-файлы, обязательно измените соответствующий content_type.

Теперь у вас есть Javascript, необходимый для работы вашего приложения. Просто прикрепите метод createUser для формирования входных данных и убедитесь, что pdf является файловым объектом. Если вы откроете вкладку «Сеть» в инструментах разработчика своего браузера, вы должны увидеть три запроса, сделанные при вызове метода: один к конечной точке presigned_url вашего API, один к S3 и один к конечной точке создания пользователя вашего API. Последний также вернет общедоступный URL-адрес файла, поэтому вы можете просматривать его в течение ограниченного времени.

Заключительные шаги и очистка

Ковши S3

Убедитесь, что ваше prod-приложение использует не тот сегмент, который вы разрабатываете. Это сделано для того, чтобы вы могли ограничить его политику CORS. Он должен принимать запросы PUT только из одного источника: вашего производственного интерфейса. Например, вот моя производственная политика CORS:

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>https://myfrontend.herokuapp.com</AllowedOrigin>
    <AllowedMethod>POST</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>

Вам не нужно включать CORS для связи между Rails и S3, потому что технически это не запрос, это Active Storage.

Настройки производства Heroku

Возможно, вам придется обновить среду Heroku prod. После того, как вы введете свой код, не забудьте heroku run rails db:migrate. Вам также необходимо убедиться, что ваши переменные среды верны. Вы можете просмотреть их с помощью heroku config. Вы можете установить их, перейдя в настройки приложения на панели инструментов Heroku. Вы также можете установить их с помощью heroku config:set AWS_ACCESS_KEY_ID=xxx AWS_SECRET_ACCESS_KEY=yyy S3_BUCKET=bucket-for-app AWS_REGION=my-region-1.

Публичный просмотр файлов

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

Некоторые способы устранения неполадок

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

Проблемы с инициализацией сервера: убедитесь, что имена в ваших .env файлах совпадают с именами, по которым вы к ним обращаетесь.

Ошибка: отсутствует хост для ссылки для первого запроса. В моем случае это означало, что я не поставил :amazon в качестве источника Active Storage в development.rb.

StackLevelTooDeep для последнего запроса. У меня возникла эта проблема при вызове users_controller#create, потому что я не удалил поле «резюме» из своей схемы. Убедитесь, что ваша схема базы данных не включает файл. На это следует ссылаться только в модели с has_one_attached.

Запросы AWS завершаются ошибкой после изменения CORS: убедитесь, что в вашем URL-адресе в CORS XML нет завершающих слэшей.

Отладка контрольной суммы: это сложная задача. Если вы получаете сообщение об ошибке от S3 о том, что вычисленная контрольная сумма не соответствует ожидаемой, это означает, что что-то не так с вашими расчетами и, следовательно, что-то не так с Javascript, который вы получили отсюда. Если вы дважды проверите код, который скопировали у меня, и не найдете разницы, возможно, вам придется выяснить это самостоятельно. Для Javascript вы можете проверить значение MD5, вызвав для него .toString() без аргументов. В командной строке вы можете сбросить флаг --binary.

Источники и ссылки

Многое из этого было взято из сообщения в блоге Арели Вианы для Applaudo Studios. Я связал код вместе и выяснил, как будет выглядеть интерфейс. Огромный им привет!

Вот еще несколько полезных ресурсов:

Первоначально опубликовано на https://elliott-king.github.io 14 сентября 2020 г.