Actvie Storageとは?
Rails5.2から追加されたRailsのストレージ機能です。AWS S3、Google Cloud Storageなどのクラウドストレージサービスにアップロードするのに便利らしいです。
Railsで画像などをアップロードする機能としてCarrier Waveも候補になるかと思います。
viewをReactやVueで書く場合はCarrier Waveを使った方がいいかも?
ActiveStorage vs CarrierWave - Qiita
しかし、普段Active Storageを使っていたり、SPAでも使用したい人は多いと思いますので私の勉強も含めて、AWS S3のセットアップから行っていきたいと思います。
そこまでrailsやAWSに詳しくないので動いたコードを共有します。セキュリティやコードに改善点があればご指摘お願いします。
1.AWSのセットアップ
- AWSのアカウントを作成します。
AWS公式のアカウント作成ガイドがあるので読みましょう
- 上のサービスや検索からS3を検索します。
- バケットを作成します
- バケット名は自由で、リージョンはアジアパシフィック(東京)にします。ブロックパブリックアクセスをOFFにします。他の設定は何もせず、デフォルトの設定で良いかと思います
- バケットポリシーを作成します。権限や公開設定のルールです。今回の方法は公開された、PUBLICなURLから画像を取得するので公開するようなポリシーにします。
{
"Version": "2008-10-17",
"Statement": [
{
"Sid": "AllowPublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
your-bucket-name
にはあなたのバケットの名前を入力してください
-
ユーザーのアクセスキーを作成します。ユーザーからセキュリティ認証情報をクリックし、アクセスキーを作成します。作成するとアクセスキーとシークレットキーが作成されるので、メモなどにコピーして取っておいてください。
2.Railsでの設定
認証情報の追加
AWSのAPIを操作するために、先ほど作成したアクセスキーとシークレットキーを記載します。
$ EDITOR=vim rails credentials:edit
vimなどのエディタでrailsのcredentialに
aws:
access_key_id: 先ほど控えたもの
secret_access_key: 先ほど控えたもの
として、:wq
で保存します
config/storage.ymlの編集
storage.ymlでストレージを定義します。
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: ap-northeast-1
bucket: バケットの名前
これは元々storage.yml内のコメントアウトされている内容をそのまま使っているので、コメントアウトして、アクセスキーとシークレットキーを入力しましょう
config/environments/development.rbの編集
localになっているかと思います。
config.active_storage.service = :local
production環境でAmazon S3を利用するため、amazonにします。
config.active_storage.service = :amazon
Gemの追加
gem 'aws-sdk-s3'
Active Storageを有効にする
$ rails active_storage:install
$ rails db:migrate
実装
Active Storageの仕組み
active StorageのREADMEによると、
Active Storageでは、
Attachment
joinモデルを経由するポリモーフィック関連付けを使い、実際のBlob
に接続します1。Blob
モデルは添付ファイル(attachment)のメタデータ(ファイル名やコンテンツの種類など)と識別子のキーをストレージサービスに保存します。
とあり、active_storage_blobsはファイルのメタデータ(データ本体)を管理します。active_storage_attachmentsは中間テーブルとしてアタッチ(activestorageと関連付ける)するモデルとの中間テーブルです
Rails側-モデルの編集
私の場合、授業の過去問を共有するサイトを開発しており、画像をLectureモデルに対して複数添付したかったので、has_many_attachedを追加しました。
class Lecture < ApplicationRecord
has_many :reviews
has_many_attached :images
end
Rails側-コントローラの編集
before_action :set_lecture, only: [:show, :create_image, :show_image]
# 講義に画像をアップロードするアクション
def create_image
if params[:lecture][:image]
# ActiveStorageを利用して画像を講義(@lecture)に添付
@lecture.images.attach(params[:lecture][:image])
render json: @lecture, status: :created
else
render json: { error: 'No image provided' }, status: :unprocessable_entity
end
end
# 講義に関連付けられている画像を取得するアクション
def show_image
# 講義に画像が添付されているか確認
if @lecture.images.attached?
images = @lecture.images.map do |image|
{
url: rails_blob_url(image), # 画像のURLを取得
type: image.blob.content_type # 画像のMIMEタイプを取得
}
end
render json: { images: images }
else
render json: { error: 'No image attached' }, status: 404
end
end
private
# パラメータから指定された講義を検索して@lectureに設定する
def set_lecture
@lecture = Lecture.find(params[:id])
puts @lecture.inspect
end
画像をアップロードするメソッド。のちにReactからFormDataでPOSTするlecture[image]
がパラメータにあった場合、
@モデル名.attach(params[:])
とすることで、画像を追加しています。
React-画像アップロードフォーム
ReactにはuseDropZoneというライブラリを使用しています。
// コンポーネントと状態管理
const ImageUpload = ({ onImageUpload }) => {
// React RouterからURLパラメータ(id)を取得。
const { id } = useParams();
const [uploadStatus] = useState('');
const [files, setFiles] = useState([]);
const maxSize = 1048576; // 1MB bytes
// ファイル選択処理
// ファイルサイズが1MB以下、ファイル形式が画像またはPDFの条件を満たしたファイルに対し、URL.createObjectURLを使用してプレビューURLを作成。
const onDrop = useCallback((acceptedFiles) => {
acceptedFiles.forEach((file) => {
if (file.size > maxSize) {
throw new Error("1MB以下のファイルを選択してください");
} else if (!file.type.startsWith('image/') && !file.type === 'application/pdf') {
throw new Error("画像ファイルを選択してください");
} else {
const newFile = {
...file,
preview: URL.createObjectURL(file),
};
setFiles((prevFiles) => [...prevFiles, newFile]); }
});
}, []);
// アップロード処理
// 各ファイルについて、再度検証。条件を満たすファイルをFormDataに追加。
const handleUpload = () => {
files.forEach((file) => {
if (file.size <= maxSize && (file.type.startsWith('image/') || file.type === 'application/pdf')) {
const formData = new FormData();
formData.append('lecture[image]', file);
fetch(`/api/lectures/${id}/images`, {
method: 'POST',
body: formData,
})
.then((response) => response.json())
.then((data) => {
success('投稿しました');
onImageUpload(data);
})
.catch((error) => {
console.error(error);
throw new Error('投稿に失敗しました');
});
} else {
throw new Error('1MB以下の画像ファイルを選択してください');
}
});
};
useEffect(() => () => {
files.forEach(file => URL.revokeObjectURL(file.preview));
}, [files]);
// ファイル選択UIの設定
// react-dropzoneを利用してドラッグ&ドロップ領域とファイル入力の属性を生成。
const { getRootProps, getInputProps } = useDropzone({ onDrop, accept: 'image/*, application/pdf' });
これはファイルサイズが1MBであり、PDFの時のみ送信できるようにしています。FormDataメソッドはデータを送信するためのキーと値のセットをFormDataオブジェクトとして作成します。appendでlecture[image]というキーとfileという値を追加しています。それらをfetchでPOSTしています。
React-画像アップロードフォーム-UIについて
return (
<div className='imageUploadCon'>
{/* ファイル選択エリア */}
{/* getRootProps() と getInputProps() は react-dropzone から提供される関数。必要なプロパティを追加する。 */}
<div {...getRootProps()}>
<input {...getInputProps()} />
<p>ここにファイルをドラッグ&ドロップ、またはクリックしてファイルを選択してください。</p>
</div>
{uploadStatus && <p>{uploadStatus}</p>} {/* アップロードのステータスを表示 */}
<div className='imageUploadButton'>
<button type='button' onClick={handleUpload}>アップロード</button> {/* アップロードボタン */}
</div>
{/* 選択された画像のプレビューを表示 */}
<div className='imagePreviewCon'>
{files.map(file => {
if (file.type.startsWith('image/')) {
return <img src={file.preview} alt={file.name} key={file.name} />;
} if (file.type === 'application/pdf') {
return <iframe src={file.preview} title={file.name} key={file.name} />;
}
return null;
})}
</div>
</div>
);
};
ImageUpload.propTypes = {
onImageUpload: PropTypes.func.isRequired,
};
export default ImageUpload;
React-アップロード画像の表示画面
// 取得した画像やPDFファイルのデータとファイル数を保持。
const [images, setImages] = useState([]);
const [imageCount, setImageCount] = useState(0);
// サーバーからのデータ取得
useEffect(() => {
const fetchImages = async () => {
const response = await fetch(`/api/lectures/${id}/images`);
if (!response.ok) throw Error(response.statusText);
const data = await response.json();
const imageUrls = data.images ? data.images.map(image => ({ url: image.url, type: image.type })) : [];
setImages(imageUrls);
setImageCount(imageUrls.length);
};
fetchImages();
}, [id]);
// モーダル管理
const openModal = () => {
if (imageCount === 0) {
throw new Error("過去問はありません");
return;
}
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
// ファイルの表示
<div className='imageContainer'>
{images.map(image => {
console.log(image);
if (image.type && image.type.startsWith('image/')) {
return <a key={image.url} href={image.url} target='_blank' rel="noopener noreferrer">
<img src={image.url} alt="過去問" />
</a>;
} if (image.type && image.type === 'application/pdf') {
return <a key={image.url} href={image.url} target='_blank' rel="noopener noreferrer">
<div className="pdfContainer">
<Document file={image.url} key={image.url}>
<Page pageNumber={1} scale={0.3} renderTextLayer={false} />
</Document>
</div>
</a>;
}
return null;
})}
</div>
アップロードした画像を取得するLectureコンポーネントの処理はあまり変わったところはありません。fecthでPOSTしたデータを取得し、imageプロパティがあれば、urlとタイプを含むオブジェクトの配列を作り、それらのタイプから返すJSXを変えています。