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

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

Давайте рассмотрим проблему поподробнее. Сервер использовался для того, чтобы:

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

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

1. Прием изображения из JavaScript фронтенд

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

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

import React from 'react';
import API from "../adapters/API";

const PostForm = props => {

  const handleSubmit = event => {
    event.preventDefault()
    const formData = new FormData(event.target)
    API.submitPost(formData)
      .then(data => props.setPost(data.post))
      .catch(console.error);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="caption">
        Caption
        <input type="text" name="caption" />
      </label>
      <label htmlFor="image" >
        Upload image
        <input type="file" name="image" accept="image/*" />
      </label>
      <input type="submit" value="Submit" />
    </form>
  );
  
}

export default PostForm;

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

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

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

const submitPost = formData => {
  const config = {
    method: "POST",
    headers: {
      "Authorization": localStorage.getItem("token"),
      "Accept": "application/json"
    },
    body: formData
  }
  return fetch(POSTS_URL, config)
    .then(res => res.json());
}

Есть две важные вещи, касающиеся конфигурации объекта для этого запроса:

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

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

2. Ассоциация изображения с только что созданной записью Post в базе данных

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

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

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

Post миграция/модель

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

class CreatePosts < ActiveRecord::Migration[6.0]
  def change
    create_table :posts do |t|
      t.references :user, null: false, foreign_key: true
      t.text :caption

      t.timestamps
    end
  end
end

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

Теперь изучите модель:

class Post < ApplicationRecord
  include Rails.application.routes.url_helpers

  has_one_attached :image

  belongs_to :user
  has_many :likes, dependent: :destroy
  has_many :likers, through: :likes, source: :user

  validates :image, {
    presence: true
  }
  
  def get_image_url
    url_for(self.image)
  end

end

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

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

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

Если вам интересно утверждение include и мой метод get_image_url, то давайте для начала рассмотрим конечную точку создания поста. 

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

class PostsController < ApplicationController

  def create
    @user = get_current_user()
    params[:user_id] = @user.id

    @post = Post.create(post_params())
    respond_to_post()
  end

  private def post_params
    params.permit(:user_id, :caption, :image)
  end

  private def respond_to_post()
    if @post.valid?()
      post_serializer = PostSerializer.new(post: @post, user: @user)
      render json: post_serializer.serialize_new_post()
    else
      render json: { errors: post.errors }, status: 400
    end
  end
  
end

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

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

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

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

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

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

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

Для начала добавьте гем cloudinary в ваш Gemfile и запустите bundle install

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

cloudinary:
service: Cloudinary

Больше ничего не меняйте.

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

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

Найдите загрузочную YML ссылку в правом верхнем углу раздела Account Details.

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

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

4. Получение URL изображения и его возвращение

Это наименее интуитивно понятная часть в работе с ActiveStorage. Загрузка изображения является достаточно простым процессом, в то время как вызов его обратно без подсказки напоминает разгадывание 12-стороннего кубика Рубика пьяным.

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

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

private def respond_to_post()
  if @post.valid?()
    post_serializer = PostSerializer.new(post: @post, user: @user)
    render json: post_serializer.serialize_new_post()
  else
    render json: { errors: post.errors }, status: 400
  end
end

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

class PostSerializer

  def initialize(post: nil, user:)
    @post = post
    @user = user
  end

  def serialize_new_post()
    user_details = serialize_user_details()
    serialized_new_post = serialize_post(@post).merge(user_details)
    serialized_new_post.to_json()
  end
  
  private def serialize_post(post)
    {
      post: {
        id: post.id,
        image_url: post.get_image_url(),
        caption: post.caption,
        most_recent_likes: post.get_most_recent_likes(),
        like_count: post.likes.length,
        created_at: post.created_at,
        liked_by_current_user: post.liked_by?(@user),
        author: {
          id: post.user.id,
          username: post.user.username,
          followed_by_current_user: post.user.followed_by?(@user)
        }
      }
    }
  end
  
  private def serialize_user_details
  end
  
end

Как же конкретно работает post.get_image_url() и откуда он появляется?

Этот метод я определил на самой модели Post, где URL изображение выступает в роли псевдо-атрибута поста. Я решил, что пост должен знать URL своего изображения.

class Post < ApplicationRecord
  include Rails.application.routes.url_helpers

  has_one_attached :image

 #...
 
  def get_image_url
    url_for(self.image)
  end

end

Для получения доступа к URL, который ActiveStorage создает для каждого изображения, мы используем метод Rails url_for(). Но здесь есть загвоздка: модели не могут нормально получать доступ к 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 и выдохните с облегчением.

Читайте также:


Перевод статьи Angus Morrison: How To Upload Images to a Rails API — And Get Them Back Again.

Предыдущая статьяКак выбрать модель машинного обучения
Следующая статьяКак создать GraphQL-сервер с запросами, мутациями и подписками