RailsをバックエンドとしたSPAでのファイルアップロード機能の作り方に悩んだ話
Ruby on RailsにはActive Storageと呼ばれるファイルアップロードをサポートする機能が用意されています。Railsだけでシステムを開発する際、これは非常に便利な機能です。
一方、SPAを作る場合ファイルアップロードの実装方法はいくつかの選択肢があります。ググってみても実装方針が人それぞれで、意見が分かれているように感じました。
そこで、それぞれの実装案を比較した上で、今回私が実装した署名付きURLを使った実装方法を解説したいと思います。
今回取り上げるシステム構成
今回はフロントエンドにReact(Next.js)を使い、バックエンドにAPIモードのRailsを利用しています。ファイルのアップロード先としてAWSのS3を利用します。
実装案
案1. Railsのフォームヘルパーと使ったときと同様、FormDataを構築してPOSTする
Railsのレールをできる限り崩さず、フォームヘルパーを使ったときと同様のFormDataを作ってPOSTするという方法です。Railsの視点だと一番嬉しい方法かもしれませんが、フロントエンドの視点に立つとPOSTするデータはJSONとしたいです。
メリット
- 普段Railsを使うときと同様にActive Storageを使える
デメリット
- フロントエンド側でFormDataを作るのが面倒
案2. ファイルをJSONに埋め込んでPOSTする
POSTするファイルをbase64エンコードしてJSONにPOSTするのはどうでしょう?Rails側でbase64デコード処理が、フロントエンド側でbase64エンコード処理が必要となり、双方実装コストが高い方法です。
メリット
- Active Storageを使い続けられる
デメリット
- フロントエンド側でbase64エンコードするのが面倒
- Railsでbase64デコードが必要
案3. Active Storageのダイレクトアップロードを使用する
RailsのActive Storageにはダイレクトアップロードという機能が用意されています。これを使えばフロントエンド側からファイルをダイレクトにS3へアップロードし、Keyなどの必要な情報のみをRailsに渡すことができます。
メリット
- RailsにファイルをPOSTしなくて良い
デメリット
- ダイレクトアップロード自体がRailsに依存している
案4. 署名付きURLを使って画像をS3に直接アップロードし、KeyのみPOSTする
S3は署名付きURLを使ったファイルのアップロードに対応しています。Railsから署名付きURLを発行することで、フロントエンド側でS3に直接ファイルをアップロードできます。案3と同様、アップロード後にKeyなどの必要な情報のみをRailsに渡します。
メリット
- フロントエンド側がRailsに依存しない
デメリット
- Active Storageの恩恵を受けられない
- PUT先となる署名付きURLの発行処理を独自実装する必要がある
実装案の選定
「マイクロサービス化しやすい」というのはSPAを採用する動機の一つだと思っています。案1〜3は実装コスト的にもデメリットが大きいのですが「フロントエンドがRailsに依存した実装になっている点」がイマイチだと感じました。
このため、今回は案4で実装を進めました。
実装する
手順1. フロントエンド:UIの実装
今回、UI側はReact(Next.js)を使って実装しています。
ドラッグ&ドロップでのファイルアップロードに対応した方が便利だろうと思い、react-dropzoneを使いました。
また、Form自体のPOSTやバリデーションはreact-hook-formを使っています。
const BookForm: React.FC<Props> = ({ onSubmit, onError }) => {
const [imageUrl, setImageUrl] = useState('')
const [imageKey, setImageKey] = useState('')
const { register, handleSubmit, errors } = useForm()
const onDrop = useCallback((acceptedFiles) => {
// 後で実装
}, [])
const { getRootProps, getInputProps } = useDropzone({ onDrop })
return (
<form onSubmit={handleSubmit(onSubmit, onError)}>
<label className="block mb-4">
<span>画像</span>
{imageKey && <img src={imageUrl} className="h-32 m-4" />}
<div className="border-dashed border-2 h-32 rounded flex justify-center items-center" {...getRootProps()} >
<input {...getInputProps()} /><p className="block text-gray-400">Drop the files here ...</p>
</div>
<input type="hidden" name="key" ref={register({ required: true })} defaultValue={imageKey} />
<small className="mb-2 text-red-600 block">{errors.key && <span>This field is required</span>}</small>
</label>
<input type="submit" value="Save" className="mt-4 px-6 py-2 text-white bg-accent rounded hover:bg-accent-dark" />
</form>
)
}
export default BookForm
手順2. バックエンド:署名付きURLの発行
Rails側でリクエストを受けたときに署名付きURLを作成して返すようにします。
class Image
Aws.config.update(
region: 'ap-northeast-1' ,
credentials: Aws::Credentials.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY'])
)
def self.signed_url(filename, operation)
signer = Aws::S3::Presigner.new
signer.presigned_url(operation, bucket: ENV['S3_BUCKET_NAME'], key: filename)
end
end
手順3. フロントエンド:ファイルをS3へPOSTし、KeyをRailsへPOSTする
ファイルがドロップされたときのコールバック処理を実装します。
最初にRailsの /images
へPOSTすることで、署名付きURLを発行します。その署名付きURLへPUTすることでファイルをアップロードします。
アップロード後に setImageKey
および setImageUrl
を使って、Reactのstateを更新します。
const onDrop = useCallback((acceptedFiles) => {
acceptedFiles.forEach(async (file) => {
const {
data: { signedUrl, key },
} = await axios.post('/images')
await axios.put(signedUrl, file, {
headers: {
'Access-Control-Allow-Origin': location.href,
'Content-Type': file.type,
},
})
const res = await axios.get(`/images/${key}`)
setImageUrl(res.data.signedUrl)
setImageKey(key)
})
}, [])
手順4. バックエンド:画像のURLを生成する
画像を表示するときはバックエンド側で画像のURLを生成してフロントエンド側に渡します。
署名付きURLを使用する場合
上述の Image.signed_url
の第2引数を :get_object
として呼び出し、フロントエンド側に署名付きURLを発行します。
署名付きURLは期限が限られているので、画像を表示する度に毎回生成しなおす必要があります。URLをどこかに永続化していたり、フロントエンド側でSSGしていたりすると、時間が経つことでリンク切れする懸念があるので注意が必要です。
バケットを公開する場合
署名付きURLを使いたくないケースで、バケット自体を公開しても問題ない場合は、直接ObjectのURLを参照することで公開します。今回は前段にCloudFrontを配置して、CDNのURLを返すようにしました。
class Image
# 省略
def self.cdn_url(filename)
"#{ENV['CLOUDFRONT_ORIGIN']}/#{filename}"
end
end
まとめ
Railsだけで開発しているときはActive Storageは非常に便利な機能だったのですが、SPAを開発するシチュエーションではかえって使いづらいと感じました。
署名付きURL自体はS3に限らずAzure Blog StorageやGCP Cloud Storageでも提供されている方法なので、クラウドサービスが変わってもこの方法は利用できそうですね。
Discussion