Мне потребовалась неделя, чтобы написать back-end основу для Supagram при помощи Rails API. Supagram — это легкий браузерный клон Instagram, в котором есть те же посты, лайки и отслеживание хронологической активности подписчиков.
Самой большой трудностью, которую я предвидел, была полиморфная база данных взаимоотношений между пользователями в качестве подписчиков, тех, на кого они подписаны, “лайкающих” и пр. Я и не догадывался, что наиболее простой в настройке деталью окажется сущность Instagram — загрузка изображений.
Давайте рассмотрим проблему поподробнее. Сервер использовался для того, чтобы:
Существующие источники, касающиеся того, как это реализовать, очень фрагментированы, поэтому многое приходилось додумывать и выяснять методом подбора к специфическому контексту Rails API. Так получилась эта окончательная пошаговая инструкция, способная сильно упростить для вас похожие задачи.
Существует два распространенных способа отправлять данные о загрузке изображений на сервер: в качестве 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.Заголовок авторизации указывается на выбор и будет зависеть от требований конечной точки, к которой вы обращаетесь. В своем примере я использую конечную точку, выраженную как POSTS_URL
.
В бэкенд я использовал 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
.
Локальное хранение занимает много места и не может тягаться по скорости доставки со специально разработанными сетями по доставке содержимого типа Cloudinary и AWS. Какие бы цели вы ни преследовали, будет неплохо ознакомиться с этими серьезными сервисами.
Cloudinary исключительно удобна для пользователя и легко интегрируется в ActiveStorage, поэтому я выбрал именно такой метод в данном проекте. Далее, если у вас уже есть бесплатный аккаунт этого сервиса, отлично, но если вы предпочтете использовать другой, он тоже пойдет, т.к. существенных отличий нет.
Для начала добавьте гем 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 будет автоматически загружать и извлекать изображения из облака.
Это наименее интуитивно понятная часть в работе с 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.
Комментарии