Docker + Next.js(React Hook Form) + RailsAPI(CarrierWave) で画像をアップロードする

3/10/2023

3/12/2023

画像アップロードアプリの作成

https://github.com/ryota-sb/images-uploader


◎ Docker環境準備

  1. docker-compose.yml 作成
  2. Next.js 環境作成
  3. Rails 環境作成
  4. docker-compose でコンテナ立ち上げ


1. docker-compose.yml

/docker-compose.yml

version: "3"
services:
  frontend:
    build: ./frontend/
    volumes:
      - ./frontend/app:/usr/src/app
    command: "yarn dev"
    ports:
      - "8000:3000"

  backend:
    build: ./backend/
    volumes:
      - ./backend:/app
    command: /bin/sh -c "rm -f tmp/pids/server.pid && rails s -p 3000 -b '0.0.0.0'"
    ports:
      - "3000:3000"
    depends_on:
      - db
    tty: true
    stdin_open: true

  db:
    image: postgres
    volumes:
      - ./postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"


2. Next.js 環境作成


/frontend/Dockerfile

FROM node:16-bullseye-slim

WORKDIR /usr/src/app


3. Rails 環境作成


参考記事 →
※ backendディレクトリを作成し、その中にDockerfile、Gemfile、Gemfile.lock、entrypoint.shを作成してください。

4. docker-compose.yml でコンテナの立ち上げ


各コンテナの立ち上げ(frontend、backend、db)

$ docker-compose up -d --build


Next.jsのプロジェクトファイル作成(プロジェクト名は、appにしてください)

$ docker-compose exec frontend yarn create next-app --ts -e with-tailwindcss


◎ Railsで画像アップロードのAPIを作成

  1. Postモデル、postsコントローラーの作成
  2. 開発環境のホストの設定
  3. CarrierWave導入
  4. CarrierWaveの設定ファイル作成
  5. cors対策


1. Postモデル、postsコントローラー作成、ルーティング作成

Postモデル作成

$ docker-compose exec backend rails g Post model title:string image:string


/backend/app/models/post.rb

class Post < ApplicationRecord
  mount_uploader :image, ImageUploader
end

mount_uploaderは、PostモデルのimageカラムとCarrierWaveのImageUploaderを紐付けるための記述です。

postsコントローラー作成

$ docker-compose exec backend rails g controller api/v1/posts index create update


/backend/app/controllers/api/v1/posts.rb

class Api::V1::PostsController < ApplicationController
  def index
    posts = Post.all
    render json: posts
  end

  def create
    post = Post.create(post_params)
    if post.save
      render json: post
    else
      render json: post.errors, status: :unprocessable_entity
    end
  end

  def update
    post = Post.find(params[:id])
    if post.update(post_params)
      render json: post
    else
      render json: post.errors, status: :unprocessable_entity
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :image)
  end
end


ルーティング作成

/backend/config/routes.rb

Rails.application.routes.draw do
  namespace "api" do
    namespace "v1" do
      resources :posts, only: [:index, :create, :update]
    end
  end
end


2. 開発環境のホストの設定

/backend/config/environments/development.rb

 Rails.application.configure do
  config.cache_classes = false
  config.eager_load = false
  config.consider_all_requests_local = true
  config.hosts << "backend" # 追記
... 省略
end

ホスト名には、RailsAPIのコンテナ名を指定する

3. CarrierWave導入


gemインストール

/backend/Gemfile

gem 'carrierwave'


CarrierWave適用

$ docker-compose exec backend bundle install


Uploader作成

$ docker-compose exec backend rails g uploader Image


Uploaderファイル修正

/backend/app/uploders/image_uploader.rb

class ImageUploader < CarrierWave::Uploader::Base
  storage :file
  
  # 保存されるディレクトリの指定
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end
 
  # 保存できる画像の拡張子の指定
  def extension_allowlist
    %w(jpg jpeg gif png)
  end
end


4. CarrierWaveの設定ファイル作成

carrierwave.rbを新規作成する
/backend/config/initializers/carrierwave.rb

CarrierWave.configure do |config|
  config.asset_host = "http://backend:3000"
  config.storage = :file
  config.cache_storage = :file
end

asset_host に設定する値は、localhostではなく、RailsAPIのコンテナ名のbackendを指定する

5. cors対策


gemインストール

/backend/Gemfile

gem 'rack-cors'


cors有効化

/backend/config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:8000'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

origins にローカル環境のオリジン名を指定します

◎ Next.jsでRailsAPIを通して画像をアップロードする

  1. React Hook Form 導入
  2. type定義
  3. Imageコンポーネントで外部の画像を扱うための設定
  4. 完成コードと部分ごとの解説
  5. 完成画面


1. React Hook Form導入


React Hook Form インストール

$ docker-compose exec frontend npm install react-hook-form


2. type定義


types/index.tsを作成する
/frontend/app/types/index.ts

export type InputValue = {
  title: string;
  image: { url: string };
};

export type PostData = InputValue & {
  id: number;
  created_at: Date;
  update_at: Date;
};


3. Imageコンポーネントで外部の画像を扱うための設定


/frontend/app/next.config.js

module.exports = {
  reactStrictMode: true,
  images: {
    domains: ["backend"],
  },
};

images: { ... } を追記し、domainsにRailsAPIのコンテナ名のbackendを指定する

4. 完成コードと部分ごとの解説


完成コード
/frontend/app/pages/index.tsx

import { NextPage } from "next";
import Head from "next/head";
import Image from "next/image";
import { useRouter } from "next/router";

import { useState, useEffect } from "react";

import { useForm, SubmitHandler } from "react-hook-form";

import { InputValue, PostData } from "../types";

const Home: NextPage = () => {
  const [image, setImage] = useState<File | null>(null);
  const [posts, setPosts] = useState([]);

  const router = useRouter();

  // useFormの中の必要なメソッドを使用できるように設定
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<InputValue>({
    mode: "onChange",
    defaultValues: { title: "", image: { url: "" } },
  });

  // フォームのファイル選択画像で選択されたファイルデータをsetImageに追加
  const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    setImage(file);
  };

  // ボタンを押した時にFormDataに入力内容を追加し、RailsAPIにPOSTして登録する
  const onSubmit: SubmitHandler<InputValue> = async (inputValue) => {
    const formData = new FormData();
    formData.append("post[title]", inputValue.title);
    formData.append("post[image]", image!, image!.name);

    const response = await fetch("http://localhost:3000/api/v1/posts", {
      method: "POST",
      body: formData,
    });
    const data = await response.json();
    router.reload();
  };

  // postを全取得する
  const getPosts = async () => {
    const response = await fetch("http://localhost:3000/api/v1/posts");
    const data = await response.json();
    setPosts(data);
  };

  useEffect(() => {
    getPosts();
  }, []);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center py-2">
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <div>
        <h1 className="text-5xl my-10">Images Uploader</h1>
        <form onSubmit={handleSubmit(onSubmit)}>
          <div className="flex flex-col gap-y-3">
            <div className="flex flex-col gap-y-3">
              <label htmlFor="title">タイトル</label>
              <input
                type="text"
                {...register("title", { required: true })}
                className="border border-black py-2 px-4"
              />
              {errors.title && (
                <h1 className="text-red-500">タイトルは必須です</h1>
              )}
            </div>
            <div className="flex flex-col gap-y-3">
              <label htmlFor="image">画像アップロード</label>
              <input
                type="file"
                {...register("image", { required: true })}
                className="border border-black py-2 px-4"
                onChange={onFileInputChange}
              />
              {errors.image && <h1 className="text-red-500">画像は必須です</h1>}
            </div>
          </div>
          <div className="flex justify-end">
            <button
              type="submit"
              className="mt-8 bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded"
            >
              追加する
            </button>
          </div>
        </form>

        <div className="grid grid-cols-3">
          {posts &&
            posts.map((post: PostData) => (
              <div key={post.id}>
                <h1>{post.title}</h1>
                <Image src={post.image.url} width={300} height={300} alt={""} />
              </div>
            ))}
        </div>
      </div>
    </div>
  );
};

export default Home;


useState、useEffectで状態管理

import { useState, useEffect } from "react";

// 画像の状態管理
const [image, setImage] = useState<File | null>(null);
// 投稿データの状態管理
const [posts, setPosts] = useState<PostData[]>([]);

useEffect(() => {
    getPosts();
  }, []);


ファイル選択された時に選択された画像データをsetImageを使って、image(状態変数)にデータを入れる

// フォームのファイル選択画像で選択されたファイルデータをsetImageに追加
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (!file) return;
  setImage(file);
};


React Hook Form 関連の関数定義

// useFormの中の必要なメソッドを使用できるように設定
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<InputValue>({
  mode: "onChange",
  defaultValues: { title: "", image: { url: "" } },
});

// ボタンを押した時にFormDataに入力内容を追加し、RailsAPIにPOSTして登録する
const onSubmit: SubmitHandler<InputValue> = async (inputValue) => {
  const formData = new FormData();
  formData.append("post[title]", inputValue.title);
  formData.append("post[image]", image!, image!.name);

  const response = await fetch("http://localhost:3000/api/v1/posts", {
    method: "POST",
    body: formData,
  });
  const data = await response.json();

  // 画面リロード
  router.reload();
};


React Hook Form のregisterとhandleSubmitとerrorsを使用して、フォーム作成

<form onSubmit={handleSubmit(onSubmit)}>
  <div className="flex flex-col gap-y-3">
    <div className="flex flex-col gap-y-3">
      <label htmlFor="title">タイトル</label>
      <input
        type="text"
        {...register("title", { required: true })}
        className="border border-black py-2 px-4"
      />
      {errors.title && <h1 className="text-red-500">タイトルは必須です</h1>}
    </div>
    <div className="flex flex-col gap-y-3">
      <label htmlFor="image">画像アップロード</label>
      <input
        type="file"
        {...register("image", { required: true })}
        className="border border-black py-2 px-4"
        onChange={onFileInputChange}
      />
      {errors.image && <h1 className="text-red-500">画像は必須です</h1>}
    </div>
  </div>
  <div className="flex justify-end">
    <button
      type="submit"
      className="mt-8 bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded"
    >
      追加する
    </button>
  </div>
</form>;

{...register("title", { required: true })} の部分で、titleプロパティと必須項目の指定をしている。imageの方も一緒。
{errors.title && <h1 className="text-red-500">タイトルは必須です</h1>} の部分で、入力フォームのエラーを表示。
<form onSubmit={handleSubmit(onSubmit)}>...</form> の部分でformタグ内のregister関数で取得したデータをもとにボタンが押された時の処理をする。

投稿データの全取得。useEffectで画面遷移時にこのメソッドを呼び出すようにしています

// postを全取得する
const getPosts = async () => {
  const response = await fetch("http://localhost:3000/api/v1/posts");
  const data = await response.json();
  setPosts(data);
};


5. 完成画面

Image Uploader 完成画面