С Rails API загрузка изображений не так проста, как кажется

Я дал себе неделю на то, чтобы написать бэкэнд Rails API для Supagram, легкого браузерного клона Instagram с сообщениями, лайками и хронологической лентой активности пользователей, на которых вы подписаны.

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

Позвольте мне объяснить вам проблему. Этот сервер требовался для:

  • Принять файл изображения из интерфейса React
  • Свяжите изображение с вновь созданной записью поста в базе данных
  • Загрузите изображение в сеть доставки контента, например Cloudinary или AWS, для хранения и извлечения.
  • Получите URL-адрес изображения и верните его в качестве подтверждения создания публикации и в последующих запросах GET к каналу активности.

Существующие ресурсы о том, как это сделать, крайне фрагментированы. Многое нужно было предположить или подстроить под конкретный контекст Rails API. Вот подробное пошаговое руководство, которое избавит вас от боли.

1. Принятие изображения из внешнего интерфейса JavaScript

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

Я буду использовать компоненты React для демонстрации, но FormData - это веб-API, он не ограничен какой-либо конкретной структурой или даже самим JavaScript.

Здесь простая HTML-форма принимает заголовок и изображение для загрузки. Имена полей должны соответствовать параметрам, которые конечная точка API ожидает получить, в данном случае caption и image.

При отправке мы предотвращаем поведение формы по умолчанию (которое обновляет страницу) и используем конструктор FormData JavaScript для создания объекта FormData из event.target - всей формы.

После этого мы делаем наш первый вызов API:

Об объекте конфигурации для этого запроса следует обратить внимание на два важных момента:

  • В заголовках нет ключа "Content-Type" - тип содержимого multipart/form-data, что подразумевается самим объектом FormData.
  • Тело не натянуто. FormData API выполняет всю необходимую обработку для отправки изображения через Интернет.

Заголовок авторизации является необязательным и будет зависеть от требований конечной точки, на которую вы отправляете сообщение. В этом примере конечная точка, которую я использую, представлена ​​POSTS_URL.

2. Свяжите изображение с недавно созданной записью поста в базе данных.

На задней панели я использовал ActiveStorage для создания ассоциаций между изображениями и объектами-собственниками. Начиная с Rails 5.2, это стандартная жемчужина для ассоциаций файлов, которая постепенно заменяет старые решения, такие как CarrierWave и Paperclip.

Для начала просто запустите rails active_storage:install. Он создаст миграции для двух новых таблиц в вашей базе данных, active_storage_blobs и active_storage_attachments. Они управляются автоматически; их не нужно трогать. Запустите rails db:migrate, чтобы завершить процесс.

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

Пост-миграция / модель

Изучите эту миграцию для моей модели поста. В этом есть что-то странное.

Вы заметите, что здесь нет ничего об изображении. Ни изображение, ни ссылка на изображение не хранятся в таблице сообщений.

Теперь посмотрим на модель:

Основная строка здесь has_one_attached :image. Это указывает ActiveStorage связать файл с данным экземпляром Post.

Имя присоединенного объекта должно соответствовать параметру, отправляемому из внешнего интерфейса. Я назвал его :image, потому что так я назвал соответствующее поле формы загрузки. Вы можете называть это как угодно, если только передняя и задняя части согласны.

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

Хотите узнать об операторе include и моем get_image_url методе? Давайте проверим конечную точку создания записи, прежде чем вернуться к ней.

Конечная точка создания записи

Метод post_params, возможно, здесь самый важный. Данные из нашего внешнего интерфейса оказались в хэше параметров Rails с телом, которое выглядит примерно так: { "caption" => "Great caption", "image" => <FormData> }.

Ключи этого хэша должны соответствовать атрибутам, ожидаемым моделью.

Моя конкретная модель публикации требует user_id, который не был отправлен в теле запроса, а вместо этого декодируется из токена Authorization в заголовках запроса. Это происходит за кулисами get_current_user(), и вам не о чем беспокоиться.

Когда вы передаете post_params() в Post.create(), ActiveStorage срабатывает, сохраняет файл на основе FormData, содержащегося в параметре image, и связывает файл с новой записью Post. Если вы используете локальное хранилище, изображения по умолчанию будут сохраняться в root/storage. Однако, вероятно, это не то, что вам нужно.

3. Загрузите изображение в CDN для хранения и извлечения.

Локальное хранилище занимает много места и не может конкурировать со скоростью доставки выделенных сетей доставки контента, таких как Cloudinary и AWS. Какими бы ни были ваши цели, рекомендуется ознакомиться с этими основными услугами.

Cloudinary исключительно удобен для пользователя и легко интегрируется с ActiveStorage, поэтому я применил именно такой подход в этом проекте. С этого момента я предполагаю, что у вас уже есть (бесплатная) учетная запись Cloudinary. Если вы предпочитаете использовать другую услугу, не волнуйтесь - подход в основном одинаков для всех основных поставщиков.

Сначала добавьте гем cloudinary в свой Gemfile и запустите bundle install.

Затем в /config откройте файл конфигурации ActiveRecord storage.yml и добавьте следующее. Больше ничего не модифицируйте.

Затем перейдите к config/environments/development.rb и ./production.rb и установите config.active_storage.service на :cloudinary в каждом. В вашей тестовой среде по умолчанию будет по-прежнему использоваться локальное хранилище.

Наконец, загрузите файл конфигурации cloudinary.yml с панели управления Cloudinary и поместите его в папку /config.

Внимание. Этот файл содержит секретный ключ для вашей учетной записи Cloudinary. Не делитесь этим файлом и не помещайте его в репозиторий git, иначе ваша учетная запись может быть взломана. Включите /config/cloudinary.yml в свой .gitignore файл. Если вы случайно раскрыли эти детали (я говорю по собственному опыту), немедленно деактивируйте скомпрометированный ключ и сгенерируйте новый через свою панель управления Cloudinary. Обновите cloudinary.yml, чтобы отразить новый секретный ключ.

После этого ActiveStorage будет автоматически загружать и извлекать изображения из облака.

4. Получите URL-адрес изображения и верните его.

Это наименее интуитивно понятная часть работы с ActiveStorage. Получить изображения достаточно просто. Вытащить их снова без руководства - все равно что собрать пьяный 12-гранный кубик Рубика.

Это становится особенно запутанным, если вы, как и я, хотите перенести логику построения ответа конечной точки в специальный класс сериализатора.

В моем методе respond_to_post() контроллера сообщений я сначала проверяю, является ли новое сообщение действительным, и если это так, создаю экземпляр PostSerializer из нового сообщения и текущего пользователя и визуализирую JSON с помощью serialize_new_post() method сериализатора.

В PostSerializer я собираю сведения о публикации, включая URL-адрес, который будет перенаправлять нашего конечного пользователя на изображение, размещенное в Cloudinary. Если кажется странным явно передавать переменную экземпляра @post методу экземпляра serialize_post, игнорируйте это - это требование других функций PostSerializer, которые не имеют отношения к этому сообщению. Если вам интересно, то полный исходный код здесь. Точно так же неважно содержимое метода serialize_user_details.

Но как именно post.get_image_url() работает и откуда оно взялось?

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

Чтобы получить доступ к URL-адресу, который ActiveStorage создает для каждого изображения, мы используем url_for() метод Rails. Но есть загвоздка: у моделей обычно нет доступа к url_helpers Rails. Необходимо include Rails.application.routes.url_helpers быть на вершине класса, прежде чем вы сможете его использовать.

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

ArgumentError (Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true)

Чтобы решить эту проблему, перейдите к config/environments/development.rb и добавьте Rails.application.routes.default_url_options = { host: "http://localhost:3000" } (или ваш предпочтительный порт разработки, если не 3000). В ./production.rb сделайте то же самое, используя корневой веб-сайт вашего производственного сервера в качестве значения хоста.

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

Завершите свою работу, отправьте ее на Github и вздохните с облегчением.