LoginSignup
12
6

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と関連付ける)するモデルとの中間テーブルです

モデルの編集

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

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

コントローラの編集

lectures_controller.rb
before_action :set_lecture, only: [:show, :create_image, :show_image]

def create_image  
  if params[:lecture][:image]
    @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),
        type: image.blob.content_type
	    }
  end
    render json: { images: images }
  else
    render json: { error: 'No image attached' }, status: 404
  end
end

private
  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 }) => {
  const { id } = useParams(); 
  const [uploadStatus] = useState(''); 
  const [files, setFiles] = useState([]); 

  const maxSize = 1048576; // 1MB bytes

  const onDrop = useCallback((acceptedFiles) => {
    acceptedFiles.forEach((file) => {
      if (file.size > maxSize) {
        handleAjaxError("1MB以下のファイルを選択してください");
      } else if (!file.type.startsWith('image/') && !file.type === 'application/pdf') {
        handleAjaxError("画像ファイルを選択してください");
      } else {
        const newFile = {
          ...file,
          preview: URL.createObjectURL(file),
        };
        setFiles((prevFiles) => [...prevFiles, newFile]);      }
    });
  }, []);

  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);
            handleAjaxError('投稿に失敗しました');
          });
      } else {
        handleAjaxError('1MB以下の画像ファイルを選択してください');
      }
    });
  };
  useEffect(() => () => {
    files.forEach(file => URL.revokeObjectURL(file.preview));
  }, [files]);

  const { getRootProps, getInputProps } = useDropzone({ onDrop, accept: 'image/*, application/pdf' });

  return (
    <div className='imageUploadCon'>
      <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;

これはファイルサイズが1MBであり、PDFの時のみ送信できるようにしています。FormDataメソッドはデータを送信するためのキーと値のセットをFormDataオブジェクトとして作成します。appendでlecture[image]というキーとfileという値を追加しています。それらをfetchでPOSTしています。

Lecture.jsx
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) {
      handleAjaxError("過去問はありません");
      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を変えています。

12
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
12
6