13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React、RailsのActive StorageでAWS S3を使おう!

Last updated at Posted at 2023-07-16

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のセットアップ

  1. AWSのアカウントを作成します。

AWS公式のアカウント作成ガイドがあるので読みましょう

AWS アカウント作成の流れ【AWS 公式】

  1. 上のサービスや検索からS3を検索します。

スクリーンショット 2023-07-15 11.22.48.png

  1. バケットを作成します

スクリーンショット 2023-07-15 11.24.19.png

  1. バケット名は自由で、リージョンはアジアパシフィック(東京)にします。ブロックパブリックアクセスをOFFにします。他の設定は何もせず、デフォルトの設定で良いかと思います

スクリーンショット 2023-07-15 11.28.53.png

スクリーンショット 2023-07-15 11.50.05.png

  1. バケットポリシーを作成します。権限や公開設定のルールです。今回の方法は公開された、PUBLICなURLから画像を取得するので公開するようなポリシーにします。

スクリーンショット 2023-07-15 11.39.45.png

スクリーンショット 2023-07-15 11.42.46.png

{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "AllowPublicRead",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::your-bucket-name/*"
        }
    ]
}

your-bucket-nameにはあなたのバケットの名前を入力してください

  1. IAMユーザーを作成
    スクリーンショット 2023-07-15 12.01.01.png

  2. ユーザーのアクセスキーを作成します。ユーザーからセキュリティ認証情報をクリックし、アクセスキーを作成します。作成するとアクセスキーとシークレットキーが作成されるので、メモなどにコピーして取っておいてください。
    スクリーンショット 2023-07-15 13.16.20.png

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でストレージを定義します。

config/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/environments/development.rb
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に接続します1Blobモデルは添付ファイル(attachment)のメタデータ(ファイル名やコンテンツの種類など)と識別子のキーをストレージサービスに保存します。

とあり、active_storage_blobsはファイルのメタデータ(データ本体)を管理します。active_storage_attachmentsは中間テーブルとしてアタッチ(activestorageと関連付ける)するモデルとの中間テーブルです

Rails側-モデルの編集

私の場合、授業の過去問を共有するサイトを開発しており、画像をLectureモデルに対して複数添付したかったので、has_many_attachedを追加しました。

models/lectue.rb
class Lecture < ApplicationRecord
  has_many :reviews
  has_many_attached :images
end

Rails側-コントローラの編集

lectures_controller.rb
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というライブラリを使用しています。

imageUpload.jsx
// コンポーネントと状態管理
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について

imageUpload.jsx
  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-アップロード画像の表示画面

Lecture.jsx
// 取得した画像や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を変えています。

13
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?