この記事の1章から5章まではRails、6章から8章まではReactの解説です。
※当記事で作成する投稿サービスの「全体の手順」は「【手順解説】ReactとRails APIで投稿サービスを作る(認証なし)」を参考にしてください。
1. [Rails]データベースにポストデータを追加する
下記ポストデータモデルを参考に、MySQLにポストデータ用のテーブルとカラムを作成します。
※Ruby on Railsではidが自動的に主キーとして設定されるため、idは省略可能です。また、created_at、updated_atも省略可能です。
下記コマンドを実行し、データベースを作成します。
$ docker-compose exec app rails db:create
すでに「【初学者向け】DockerでRails APIとMySQLの環境構築をする(M1 Mac)」記事の「10. データベースを作成する」でデータベースを作成している場合は、「rails db:create」を実行する必要はありません。
データベースをすでに作成している状態で「rails db:create」を実行すると、すでに作成済みです、と表示が出ます。
※「4-1. 「rails g model」コマンドでポストモデルを作成する」で解説するrials g model
コマンドを使用すれば、Modelとマイグレーションファイルを同時に自動生成してくれますが、今回は手順が分かりやすいように、別々に生成コマンドを実行します。
下記コマンドを実行し、マイグレーションファイル(データベースへの変更が記載されたファイル)を作成します。
$ docker-compose exec app rails generate migration CreatePosts title:string content:text username:string
上記コマンドを実行すると、下記のマイグレーションファイルが作成されます。
※下記[7.0]の部分は、[7.1]の場合もあります。Railsのgemのバージョンは「gem “rails”, “~> 7.1.3″」で固定されているため、マイグレーションの作成日時によっては、[7.1]になります。2024年2月17日時点では[7.1]でした。
【db/migrate/20230714035442_create_posts.rb】
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :title
t.text :content
t.string :username
t.timestamps
end
end
end
下記のようにマイグレーションファイルを修正します。
【db/migrate/20230714035442_create_posts.rb】
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :title, limit: 255, null: false
t.text :content, limit: 5000
t.string :username, null: false
t.timestamps
end
end
end
下記コマンドを実行し、マイグレーションファイル(データベースへの変更が記載されたファイル)の内容をデータベースに反映させます。
$ docker-compose exec app rails db:migrate
【db/schema.rb】
ActiveRecord::Schema[7.0].define(version: 2023_07_14_035442) do
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "title", null: false
t.text "content"
t.string "username", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
end
2. [Rails]コントローラを作成する
コントローラの役割は、アプリケーションのビジネスロジックやフロントエンドとバックエンドの間のやり取りを制御することです。
例えば、ユーザーのリクエストを受け取り、適切なデータベース操作を行ったり、ビューにデータを渡したり、APIのレスポンスを返したりする処理を担当します。
※Railsでは、rails g controller
コマンドを実行しcontrollerファイルを作成することで、「Viewフォルダの自動生成」や「ルーティングの自動設定」等を同時に行ってくれます。
※RailsをAPIモードで構築している場合は、「Viewフォルダの自動生成」は行われません。
「lib/templates/rails/controller/」配下に「controller.rb」テンプレートを配置します。rails g controller
コマンドを実行したとき、テンプレートを自動的に読み込んでControllerを作成してくれます。
※下記コードでは、APIをV1という名前のモジュールとして提供することで、後々のバージョン変更時に、簡単に別のV2という新しいバージョンを作成できるようにしています。
【lib/templates/rails/controller/controller.rb】
module V1
class <%= class_name.gsub('V1::', '') %>Controller < ApiController
end
end
ApiControllerの役割は、RailsAPIのすべてのコントローラーが共通して利用する基本的な機能や設定を提供することです。
例えば、認証や認可、エラーハンドリングの設定などをApiControllerに記述します。
今回は、moduleとclassだけ用意します。
【app/controllers/v1/api_controller.rb】
module V1
class ApiController < ApplicationController
end
end
「2-1. Controllerをモジュール化するためのテンプレートを作成」で作成したテンプレートを基に、rails g controller
コマンドを実行し、コントローラファイルを作成します。
Controllerファイルと同時に、ルーティングファイル(route.rb)も自動生成されます。
$ docker-compose exec app rails generate controller v1/posts
もし自動的に読み込まれない場合は、$ docker-compose exec app rails generate controller v1/posts --template=lib/templates/rails/controller/controller.rb
のように、明示的にテンプレートを指定してください。
【app/controllers/v1/posts_controller.rb】
module V1
class PostsController < ApiController
def index
posts = Post.all
render json: posts
end
def show
post = Post.find(params[:id])
render json: post
end
def create
post = Post.new(post_params)
if post.save
render json: post, status: :created
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
end
def update
post = Post.find(params[:id])
if post.update(post_params)
render json: post
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
post = Post.find(params[:id])
post.destroy
render json: {}, status: :no_content
end
private
def post_params
params.require(:post).permit(:title, :content, :username)
end
end
end
3. [Rails]ルーティングを設定する
「2. [Rails]コントローラを作成する」で自動生成された「route.rb」を編集します。
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
namespace :v1 do
# 基本的なメソッドを一括で表したい場合
resources :posts
# 特定のメソッドを使用しない場合
# resources :posts, only: [:index, :show, :create]
# 個別にメソッドを定義したい場合
# get '/posts', to: 'posts#index'
# post '/posts', to: 'posts#create'
# get '/posts/:id', to: 'posts#show'
# patch '/posts/:id', to: 'posts#update'
# delete '/posts/:id', to: 'posts#destroy'
end
end
resources :posts
は、index, create, show, update, destoryメソッドそれぞれにルーティングされます。
「config/routes.rb」にルーティングを記載することで、例えば「get ‘/posts’, to: ‘posts#index’」の「’/posts’」のURLにアクセスがあった場合、「app/controllers/v1/posts_controller.rb」内のindexメソッドの処理を実行することができます。
ルーティングに関する詳しい解説は、こちら(準備中)を参考にしてください。
Railsでは、コントローラとルーティングが密結合していることにより、素早いアプリケーション開発を実現できます。
4. [Rails]モデルを作成する
モデルの役割は、データベースとのやり取りやビジネスロジックの実装などを担当します。
例えば、データベースのテーブルとマッピングし、データの作成や読み取り、更新、削除といった処理を担当します。
また、モデルはデータのバリデーションやDBのテーブル同士のリレーション(アソシエーション)の定義なども行います。
※「1-3. [Postモデル]マイグレーションファイルを作成/編集」において、すでにマイグレーションファイルを作成しているため、今回はrails g model
コマンドに--skip
オプションをつけ、マイグレーションファイル自動生成のコンフリクトを防止します。
まずはrails g model
コマンドを実行し、モデルファイルを作成します。
$ docker-compose exec app rails generate model Post --skip
今回の投稿データは、titleとusername(作成者)を必須とし、content(内容)は必須ではないことにします。
class Post < ApplicationRecord
validates :title, presence: true
validates :username, presence: true
end
※今回は、基本的なバリデーションのみ実装します。Railsのバリデーションについてより詳しく知りたい方は、こちら(準備中)を参考にしてください。
※今回は、アソシエーションについては詳しく解説しません。アソシエーションについて詳しく知りたい方は、こちら(準備中)を参考にしてください。
5. [Rails]rack-corsを用いて、別のURLとのAPI通信を可能にする
アプリケーションのバックエンドを作成するとき、セキュリティの面から、バックエンドと通信できるURLを制御するのが基本です。
rack-corsとは、APIで使用されているURLとは別のURLとの通信を行う設定をするgemです。
例えば、Rails APIで「http://localhost:3000」、フロントエンドで「http://localhost:3001」のURLを使用しているとき、Rails API側でフロントエンドのURLを設定することで、「http://localhost:3001」との通信ができるようになります。
Railsの雛形を作成する際、rails new
コマンドで自動生成されたGemfileでは、rack-corsはコメントアウトされています。
このrack-corsのコメントアウトを解除して、bundle install
を実行し、プロジェクト内でrack-corsを使用できるようにします。
【[更新前]Gemfile】
・・・省略・・・
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
# gem "rack-cors"
・・・省略・・・
【[更新後]Gemfile】
・・・省略・・・
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem "rack-cors"
・・・省略・・・
$ docker-compose exec app bundle install(Dockerコンテナを起動している時のコマンド)
$ docker-compose run app bundle install(Dockerコンテナを起動していない時のコマンド)
rack-corsの設定を行う、「config/initializers/cors.rb」はデフォルトで下記のように設定がコメントアウトされています。
【[更新前]config/initializers/cors.rb】
# Rails.application.config.middleware.insert_before 0, Rack::Cors do
# allow do
# origins "example.com"
#
# resource "*",
# headers: :any,
# methods: [:get, :post, :put, :patch, :delete, :options, :head]
# end
# end
以下の手順に沿って、rack-corsの設定をオンにします。
- コードのコメントアウトを解除
- 「origins “example.com”」を「origins ENV[‘FRONTEND_ORIGIN’]」に変更
- 「.env」ファイルに、「FRONTEND_ORIGIN」の変数と値をセットする。
「config/initializers/cors.rb」で「origins ENV[‘FRONTEND_ORIGIN’]」をallowにすることによって、APIがフロントエンドのURLからのリクエストを許可します。
【[更新後]config/initializers/cors.rb】
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins ENV['FRONTEND_ORIGIN']
resource "*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
「【初学者向け】DockerでRails APIとMySQLの環境構築をする(M1 Mac)」で作成した「.env」ファイルに、「FRONTEND_ORIGIN」の変数をセットしてください。
※すでにセットしている場合はスキップして大丈夫です。
【.env】
DB_USER=root
DB_PASSWORD=12345678
DB_HOST=db
DB_PORT=3306
# 以下をセット
FRONTEND_ORIGIN=http://localhost:3001
そして最後に、コンテナを再ビルドします。以下の手順を実行してください。
docker-compose down
を実行し、コンテナを終了するdocker-compose build
を実行し、ビルドするdocker-compose up -d
を実行し、コンテナを立ち上げる
Rails側の設定はこれで終わりです。次はReactの設定に移ります。
6. [React]API通信用関数を作成する
まずは、Rails APIとの通信を行う、API通信用関数を作成します。
今回は、axiosを用いて通信を簡単にします。
axiosはAPI通信を簡略化するメソッドを提供してくれます。
基本的にReactではキャメルケース、Railsではスネークケースを採用しているため、そのまま値を送信し合っていると、値の管理が煩雑になりがちです。
そこで、リクエストやデータの送受信時に値の形式を変換し揃えるため、camelcase-keysとsnakecase-keysを用います。
※キャメルケース等について詳しく知りたい方は、こちらを参考にしてください。
yarn add
コマンドを実行し、プロジェクトにそれぞれのpackageを導入してください。
$ docker-compose exec front yarn add axios camelcase-keys snakecase-keys
プロジェクトのルートに「.env」ファイルを作成し、「REACT_APP_API_URL」の変数をセットしてください。
REACT_APP_API_URL="http://localhost:3000/v1"
「src/infra/api.js」ファイルを用意し、そこに下記のように関数を作成します。
【src/infra/api.js】
import axios from "axios";
import camelcaseKeys from "camelcase-keys";
import snakecaseKeys from "snakecase-keys";
axios.defaults.baseURL = process.env.REACT_APP_API_URL;
axios.defaults.headers.common["Content-Type"] = "application/json";
// posts
export const postPost = async (params) =>
axios.post("/posts", snakecaseKeys(params));
export const getPost = async (params) =>
axios({
method: "get",
url: `/posts/${params}`,
}).then((response) => camelcaseKeys(response, { deep: true }));
export const updatePost = async (params, data) =>
axios({
method: "put",
url: `/posts/${params}`,
data: snakecaseKeys(data),
}).then((response) => camelcaseKeys(response, { deep: true }));
export const deletePost = async (params) =>
axios({
method: "delete",
url: `/posts/${params}`,
});
export const getPosts = async () =>
axios({
method: "get",
url: "/posts",
}).then((response) => camelcaseKeys(response, { deep: true }));
今回はこれらのAPI通信用関数を、機能別のページで呼び出して使用します。
7. [React]ルーティングを設定する
Reactでルーティングを設定するには、ルーティング用のpackageをinstallする必要があります。
Reactでルーティングを設定する際のデファクトスタンダードである「react-router-dom」をプロジェクトにinstallします。
$ docker-compose exec front yarn add react-router-dom
App.jsにルーティングを一括で定義します。
今回は、ブラウザから「localhost: 30001(ルートURL)」にアクセスしたときに、「<Posts />」ページ(投稿一覧)が表示されるように設定します。
【src/App.js】
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
// Posts
import { Post } from "./pages/posts/Post";
import { PostCreate } from "./pages/posts/PostCreate";
import { PostEdit } from "./pages/posts/PostEdit";
import { Posts } from "./pages/posts/Posts";
const App = () => {
return (
<Router>
<Routes>
<Route path="/" element={<Posts />} />
<Route
path="/posts/:id"
element={<Post />}
/>
<Route
path="/posts/create"
element={<PostCreate />}
/>
<Route
path="/posts/:id/edit"
element={<PostEdit />}
/>
</Routes>
</Router>
);
};
export default App;
※次にUIの構築を行いますが、1ページずつ表示を確認しながら学習を進めたい方は、App.jsで定義したルーティングにおいて、表示していないページのルーティングをコメントアウトしながら進めてください。
8. [React]UI(ページ)を構築する
今回はコンポーネントを分けずに、機能別にそれぞれ1枚のページでUIを作成します。Reactコンポーネントを分割する方法については、こちら(準備中)を参考にしてください。
最終的なフォルダ構成は以下になります。
※今回は「API通信用カスタムHooks」等を作成せずに、1つのUIの中でstateやAPI通信用の関数定義、エラーハンドリングを行います。実際の開発ではこれらを別々のファイルに分けることで、開発の効率をアップさせます。
※「API通信用カスタムHooksの作り方」は、こちら(準備中)を参考にしてください。
【src/components/posts/PostCreate.jsx】
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
import { postPost } from "../../infra/api";
export const PostCreate = () => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [username, setUserName] = useState('');
const navigateToPosts = useNavigate();
const handleTitleChange = (e) => {
setTitle(e.target.value);
};
const handleContentChange = (e) => {
setContent(e.target.value);
};
const handleUserNameChange = (e) => {
setUserName(e.target.value);
};
const postPostFunc = async (post) => {
try {
await postPost(post);
navigateToPosts("/");
} catch (error) {
console.log(error);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
const newPost = {
title: title,
content: content,
username: username,
};
await postPostFunc(newPost);
setTitle('');
setContent('');
setUserName('');
};
return (
<div>
<h2>投稿作成</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Title:</label>
<input type="text" value={title} onChange={handleTitleChange} required />
</div>
<div>
<label>Content:</label>
<input type="text" value={content} onChange={handleContentChange} required />
</div>
{/* 実際の開発では、現在ログインしているユーザーのidを裏でサーバーに送ったりします。 */}
<div>
<label>UserName:</label>
<input type="text" value={username} onChange={handleUserNameChange} required />
</div>
<button type="submit">作成</button>
</form>
</div>
);
};
【src/components/posts/Posts.jsx】
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { getPosts } from "../../infra/api";
export const Posts = () => {
const navigate = useNavigate();
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchPostData = async () => {
try {
const response = await getPosts();
const postData = response.data;
setPosts(postData);
setLoading(false);
} catch (error) {
setError(error);
setLoading(false);
}
};
useEffect(() => {
fetchPostData();
}, []);
return (
<div>
<h2>投稿一覧</h2>
<button onClick={() => navigate(`/posts/create`)}>投稿作成</button>
{loading ? (
<p>Loading...</p>
) : error ? (
<p>Error: {error.message}</p>
) : posts.length > 0 ? (
<>
{posts.map((post) => (
<div key={post.id}>
<p>
Title: <Link to={`/posts/${post.id}`}>{post.title}</Link>
</p>
<p>Content: {post.content}</p>
<p>UserName: {post.username}</p>
</div>
))}
</>
) : (
<p>投稿がありません。</p>
)}
</div>
);
};
【src/components/posts/Post.jsx】
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getPost, deletePost } from "../../infra/api";
export const Post = () => {
const { id } = useParams();
const navigate = useNavigate();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchPostData = async () => {
try {
const response = await getPost(id);
const postData = response.data;
setPost(postData);
setLoading(false);
} catch (error) {
setError(error);
setLoading(false);
}
};
useEffect(() => {
fetchPostData();
}, [id]);
return (
<div>
<h2>投稿詳細</h2>
{loading ? (
<p>Loading...</p>
) : error ? (
<p>Error: {error.message}</p>
) : (
<div>
<p>Title: {post.title}</p>
<p>Content: {post.content}</p>
<p>UserName: {post.username}</p>
</div>
)}
</div>
);
};
【src/components/posts/PostEdit.jsx】
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getPost, updatePost } from "../../infra/api";
export const PostEdit = () => {
const { id } = useParams();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [username, setUserName] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchPostData = async () => {
try {
const response = await getPost(id);
const postData = response.data;
setTitle(postData.title);
setContent(postData.content);
setUserName(postData.username);
setLoading(false);
} catch (error) {
setError(error);
setLoading(false);
}
};
useEffect(() => {
fetchPostData();
}, [id]);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const updatedPostData = {
title,
content,
username,
};
await updatePost(id, updatedPostData);
navigate(`/posts/${id}`);
} catch (error) {
setError(error);
setLoading(false);
}
};
return (
<div>
<h2>投稿編集</h2>
{loading ? (
<p>Loading...</p>
) : error ? (
<p>Error: {error.message}</p>
) : (
<form onSubmit={handleSubmit}>
<div>
<label>Title:</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<label>Content:</label>
<textarea value={content} onChange={(e) => setContent(e.target.value)} required />
</div>
<div>
<label>UserName:</label>
<input type="text" value={username} onChange={(e) => setUserName(e.target.value)} required />
</div>
<button type="submit">更新</button>
</form>
)}
</div>
);
};
「8-3. 投稿詳細ページを作成」で作成した投稿詳細ページに、「投稿編集ボタン(投稿編集ページへのリンク)」と「投稿削除ボタン」を追加します。
今回は、見た目のわかりやすさのため、「投稿編集ページへのリンク」をボタンの形式にしています。
【src/components/posts/Post.jsx】
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
# 投稿削除用のdeletePost関数を呼び出す
import { getPost, deletePost } from "../../infra/api";
export const Post = () => {
const { id } = useParams();
const navigate = useNavigate();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
・・・省略・・・
# 投稿削除用の関数を追加
const handleDelete = async () => {
setLoading(true);
try {
await deletePost(id);
navigate("/");
} catch (error) {
setError(error);
setLoading(false);
}
};
return (
<div>
<h2>投稿詳細</h2>
{loading ? (
<p>Loading...</p>
) : error ? (
<p>Error: {error.message}</p>
) : post ? (
<div>
<p>Post Title: {post.title}</p>
<p>Post Content: {post.content}</p>
<p>Post UserName: {post.username}</p>
# 投稿編集ボタンを追加
<button onClick={() => navigate(`/posts/${id}/edit`)}>編集</button>
# 投稿削除ボタンを追加
<button onClick={handleDelete}>削除</button>
</div>
) : (
<p>Post not found.</p>
)}
</div>
);
};
※実際のアプリケーション開発では、ユーザーや投稿を削除する際は、「本当に削除しますか?」等の「確認画面」を設けるのが一般的です。
Topページ(投稿一覧)以外のページに、「Topページに戻る」リンクと「前のページに戻る」リンクを追加し、ページ間のつながりを作ります。
※今回は、「前のページに戻る」リンクの遷移先を「Link to={`/posts/${id}`}」のように直接指定していますが、useHistoryを使用して、ブラウザの履歴から前のページを取得して遷移する方法もあります。
【src/components/posts/PostCreate.jsx】
import { useState } from 'react';
# Linkコンポーネントをreact-router-domから呼び出す。
import { Link, useNavigate } from "react-router-dom";
import { postPost } from "../../infra/api";
export const PostCreate = () => {
・・・省略・・・
return (
<div>
<h2>投稿作成</h2>
# 「Topページに戻る」リンクを追加
<div>
<Link to={`/`}>Topページに戻る</Link>
</div>
# 「前のページに戻る」リンクを追加
<div>
<Link to={`/`}>前のページに戻る</Link>
</div>
<form onSubmit={handleSubmit}>
・・・省略・・・
</form>
</div>
);
};
【src/components/posts/Post.jsx】
import { useEffect, useState } from "react";
# Linkコンポーネントをreact-router-domから呼び出す。
import { Link, useParams, useNavigate } from "react-router-dom";
import { getPost, deletePost } from "../../infra/api";
export const Post = () => {
・・・省略・・・
return (
<div>
<h2>投稿詳細</h2>
# 「Topページに戻る」リンクを追加
<div>
<Link to={`/`}>Topページに戻る</Link>
</div>
# 「前のページに戻る」リンクを追加
<div>
<Link to={`/`}>前のページに戻る</Link>
</div>
{loading ? (
・・・省略・・・
)}
</div>
);
};
【src/components/posts/PostEdit.jsx】
import { useEffect, useState } from "react";
# Linkコンポーネントをreact-router-domから呼び出す。
import { Link, useParams, useNavigate } from "react-router-dom";
import { getPost, updatePost } from "../../infra/api";
export const PostEdit = () => {
・・・省略・・・
return (
<div>
<h2>投稿編集</h2>
<div>
<Link to={`/`}>Topページに戻る</Link>
</div>
<div>
<Link to={`/posts/${id}`}>前のページに戻る</Link>
</div>
{loading ? (
・・・省略・・・
)}
</div>
);
};
これでページ間のリンクが作成できました。
作成したアプリケーションをブラウザから確認してみましょう。
9. 作成したアプリケーションを確認する
$ docker-compose up -d
コマンドを実行し、Dockerコンテナを起動します。
そして「localhost: 3001」にアクセスし、投稿一覧、投稿作成、投稿詳細、投稿編集ページそれぞれが表示されていることを確認します。
投稿してみます。
作成した投稿が表示されることが確認できました!
終わりに
現在のアプリケーションでは「認証機能を実装していないため、他のユーザーの投稿を作成、編集、削除ができてしまう」等の問題があります。
この記事を終えた後のステップアップ記事である「【手順解説】ReactとRails APIでタスク管理サービスを作る(準備中)」では、「認証機能の実装」について詳しく解説しています。