プログラミングの勉強方法として、自分で、簡単なアプリを作成しようと考えている方がいるかと思います。
しかし、どんなアプリを作れば良いか分からない方も多いかと思います。
今回は、そんな方の力になれれば良いと思い、
筆者が、作成したphpやTypeScriptを使って、簡単な掲示板アプリを作る方法を公開します。
作成する掲示板は掲示板は、データベースを利用する簡単な掲示板
使用言語 バックエンド:PHP ※フレームワークは使用しない
フロントエンド: React、typescript
データベース:MySQL
▼WEBシステム
投稿内容表示ページ
Reactのsrc/App.tsx
開発環境は、Dockerを使い、LEMP環境で行う。
まずは、ターミナルで、
keijibanというディレクトリを作成。
$mkdir keijiban
$cd keijiban
※ $マークは、無視していいです。(ターミナルで入力するという意味です。)
でkeijibanディレクトリに移動
frontディレクトリにReactをインストール
事前準備
# インストール
$ brew install nodebrew
# nodebrewがインストールされたか確認
$ nodebrew -v
# npmも一緒にインストールされているか確認
$ npm -v
# セットアップ
$ nodebrew setup
# 以下のexport文を~/.zshrcもしくは~/.bash_profileに追記
$ vim ~/.zshrc
# export PATH=$HOME/.nodebrew/current/bin:$PATH
# 反映
$ source ~/.zshrc
nodeのインストール
$ nodebrew install stable
# インストール済みのnodeバージョンの一覧を表示
$ nodebrew ls
$ nodebrew use 使用するnodeのバージョン
# ex) nodebrew use v18.8.0
まずは、nodeのバージョンの確認
$ node -v
yarnをインストール
$ npm install --global yarn
$ yarn --version
React.jsの場合はこちら↓
$ yarn create react-app frontend
React + TypeScriptの場合はこちら↓
$ yarn create react-app frontend --template typescript
作成したプロジェクト配下に移動し、下記のstartコマンドを実行します。
$ cd frontend
$ yarn start
実行後、ブラウザが起動し、以下の画面が表示されていればOKです。
dockerのインストール
こちらのサイトを参考にしてみるといいです。↓
Dockerが、ちゃんとインストールできているかは、
$docker --version
で確認
1.フロントエンド
APIを利用するコンポーネント
1-1. 入力フォームを実装するために使う関数から書いていきます。
import './App.css';
import React, { useEffect, useState } from 'react';
// Postインターフェースの定義
interface Post {
post_id: number; // 投稿のID
author_name: string; // 投稿者の名前
title: string; // 投稿のタイトル
content: string; // 投稿の内容
created_at: string; // 投稿日時
}
const App: React.FC = () => {
// ステートの定義
const [posts, setPosts] = useState<Post[]>([]); // 投稿リストの状態
const [page, setPage] = useState<number>(1); // 現在のページ番号
const[message, setMessage] = useState<string>(''); // メッセージの状態
const [newPost, setNewPost] = useState<{ author_name: string; title: string; content: string}>({ author_name: '', title: '', content: ''}); // 新しい投稿の状態
// コンポーネントがマウントされたときにデータをフェッチするためのuseEffect
useEffect(() => { const fetchData = async () => { try { const response = await fetch(`http://localhost:8080/index.php?page=${page}`);
// 投稿を取得するためのリクエスト
if (!response.ok) throw new Error('ネットワークエラー');
// エラーチェック
const data = awaitresponse.json();
// JSONデータをパース
setPosts(data.posts || []);
// 投稿データを設定
setMessage(data.message);
// サーバーからのメッセージを設定
} catch (error) { console.error(error);
// エラーをコンソールに表示setMessage('データの取得に失敗しました。');
// エラーメッセージを設定 } }; fetchData(); // データのフェッチを実行 }, [page]); // pageが変更されたときに再実行
1-2.投稿を送信する関数(handleSubmit)を作成
// 投稿の送信
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('http://localhost:8080/post.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(newPost).toString(),
});
if (!response.ok) throw new Error('送信エラー');
const data =await response.json();
if (data.message) {
setPosts(prevPosts => [
...prevPosts,
{
post_id: Date.now(),
author_name: newPost.author_name,
title: newPost.title,
content: newPost.content,
created_at: new Date(). toISOString(), // 現在の日時を設定
},
]);
setNewPost({ author_name: '', title: '', content: ''}); // 入力フォームをリセット
}
setMessage(data.message);
setPage(1);
} catch (error) {
console.error('送信するのにエラーが発生しました。', error);
setMessage('投稿に失敗しました。');
}
};
1-3. handleSubmit関数のあとに、投稿を削除する関数(handleDelete)を作成
const handleDelete = async (postId: number) => {
try {
const response = await fetch('http://localhost:8080/delete.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ post_id: postId.toString() }).toString(),
});
const data = await response.json();
setMessage(data.message); // メッセージを設定
// 投稿一覧を更新
setPosts(prevPosts => prevPosts.filter(post => post.post_id !== postId));
} catch (error) {
console.error('削除中にエラーが発生しました。', error);
setMessage('削除に失敗しました。');
}
1-4. タイトルと投稿内容、「投稿する」ボタン、投稿一覧までのデザイン、ページネーションまでを作成します。
return (
<div>
<h1>掲示板</h1>
<form onSubmit={handleSubmit}>
<div className="form-container">
<input type="text" value={newPost.author_name} onChange={e => setNewPost({ ...newPost, author_name: e.target.value })} placeholder="投稿者" required />
<input type='text' value={newPost.title} onChange={e => setNewPost({ ...newPost, title: e.target.value})} placeholder="タイトル" required />
<textarea value={newPost.content} onChange={e => setNewPost({ ...newPost, content: e.target.value})} placeholder="ここにメッセージを入力してください。" required />
</div>
<button type="submit" className="submit-button">投稿する</button>
</form>
{message && <div className="error">{message}</div> }
<h2>投稿一覧</h2>
{posts.map(post => (
<div className="post" key={post.post_id} >
<h3>{post.title}</h3>
<p>{post.content}</p>
<p>投稿者: {post.author_name} | 投稿日時: {post.created_at}</p>
<input type="hidden" name="post_id" value={post.post_id} />
<button onClick={() => handleDelete(post.post_id)} type="submit" className='delete-button'>削除</button>
</div>
))}
<div className="button-container">
{page > 1 && (
<button onClick={() => setPage(prev => Math.max(prev - 1, 1))} className="prev-button">前へ</button>
)}
{page < totalPages && (
<button onClick={() => setPage(prev => prev + 1)} className="next-button">次へ</button>
)}
</div>
</div>
);
これらを繋ぎ合わせると、
Reactのsrc/App.tsxの全体のコードはこちらになります
import './App.css';
import React, { useEffect, useState }from 'react';
interface Post {
post_id: number;
author_name: string;
title: string;
content: string;
created_at: string;
}
const App: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState<number>(1);
const [message, setMessage] = useState<string>('');
const [newPost, setNewPost] = useState<{ author_name: string; title: string; content: string}>({ author_name: '', title: '', content: ''});
// 総ページ数の状態を追加
const [totalPages, setTotalPages] = useState<number>(1);
useEffect(() => {
console.log('Current Page:', page);
console.log('Total Pages:', totalPages);
console.log('Show Previous:', page > 1);
console.log('Show Next:', page < totalPages);
const fetchData = async () => {
console.log('データを送信しました。');
try {
const response = await fetch(`http://localhost:8080/index.php?page=${page}`);
if (!response.ok) throw new Error('ネットワークエラー');
const data = await response.json();
setPosts(data.posts || []);
setMessage(data.message);
setTotalPages(data.total_pages); // 総ページ数を設定
console.log('Total Page :', data.total_pages);
} catch (error) {
console.error(error);
setMessage('データの取得に失敗しました。');
}
};
fetchData();
}, [page]);
// 投稿の送信
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('http://localhost:8080/post.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(newPost).toString(),
});
if (!response.ok) throw new Error('送信エラー');
const data =await response.json();
if (data.message) {
setPosts(prevPosts => [
...prevPosts,
{
post_id: Date.now(),
author_name: newPost.author_name,
title: newPost.title,
content: newPost.content,
created_at: new Date(). toISOString(), // 現在の日時を設定
},
]);
setNewPost({ author_name: '', title: '', content: ''}); // 入力フォームをリセット
}
setMessage(data.message);
setPage(1);
} catch (error) {
console.error('送信するのにエラーが発生しました。', error);
setMessage('投稿に失敗しました。');
}
};
// 投稿の削除
const handleDelete = async (postId: number) => {
try {
const response = await fetch('http://localhost:8080/delete.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ post_id: postId.toString() }).toString(),
});
const data = await response.json();
setMessage(data.message); // メッセージを設定
// 投稿一覧を更新
setPosts(prevPosts => prevPosts.filter(post => post.post_id !== postId));
} catch (error) {
console.error('削除中にエラーが発生しました。', error);
setMessage('削除に失敗しました。');
}
};
return (
<div>
<h1>掲示板</h1>
<form onSubmit={handleSubmit}>
<div className="form-container">
<input type="text" id="author_name" value={newPost.author_name} onChange={e => setNewPost({ ...newPost, author_name: e.target.value })} placeholder="投稿者" required />
<input type='text' id="title" value={newPost.title} onChange={e => setNewPost({ ...newPost, title: e.target.value})} placeholder="タイトル" required />
<textarea id="content" value={newPost.content} onChange={e => setNewPost({ ...newPost, content: e.target.value})} placeholder="ここにメッセージを入力してください。" required />
</div>
<button type="submit" className="submit-button">投稿する</button>
</form>
{message && <div className="error">{message}</div> }
<h2>投稿一覧</h2>
{posts.map(post => (
<div className="post" key={post.post_id} >
<h3>{post.title}</h3>
<p>{post.content}</p>
<p>投稿者: {post.author_name} | 投稿日時: {post.created_at}</p>
<input type="hidden" name="post_id" value={post.post_id} />
<button onClick={() => handleDelete(post.post_id)} type="submit" className='delete-button'>削除</button>
</div>
))}
<div className="button-container">
{page > 1 && (
<button onClick={() => setPage(prev => Math.max(prev - 1, 1))
} className="prev-button">前へ</button>
)}
{/*ページ番号の表示*/}
{Array.from({ length: totalPages}, (_, index) => (
<button
key={index + 1}
onClick={() => setPage(index + 1)}
className={`page-button ${page === index + 1 ? 'active-page' : ''}`}
>
{index + 1}
</button>
))}
{page < totalPages && (
<button onClick={() => setPage(prev => prev + 1)
} className="next-button">次へ</button>
)}
</div>
</div>
);
};
export default App;
このままでは、デザインが無茶苦茶な状態なので、
frontendディレクトリ内にある
srcディレクトリ内のApp.cssを書き換えて、
デザインを整えていきます。
デザインの整理
form-container {
align-items: center;
display: flex;
flex-direction: column; /* 縦に並べるための設定 */
margin-bottom: 20px;
}
h1, h2, h3 {
text-align: center;
}
textarea {
margin: 10px 0; /* 上下に余白を追加 */
width: 100%;
height: 80px;
padding: 8px;
border: 1px solid #ccc; /* ボーダーの設定 */
border-radius: 4px; /* 角を丸める */
}
input {
margin: 10px 0; /* 上下に余白を追加 */
width: 50%;
padding: 8px;
border: 1px solid #ccc; /* ボーダーの設定 */
border-radius: 4px; /* 角を丸める */
}
.post {
max-width: 600px; /* 投稿の最大幅を設定 */
margin: 20px auto; /* 上下の余白を20px、左右の余白を自動にして中央揃え */
padding: 20px; /* 内側の余白 */
border: 1px solid #ccc; /* 境界線 */
border-radius: 8px; /* 角を丸める */
background-color: #f9f9f9; /* 背景色 */
box-shadow: 0 2px 4px rgba(0,0,0, 0.1) /* 影を追加 */
}
form {
display: flex;
flex-direction: column; /* 縦に並べる */
align-items: center; /* 中央揃え */
}
button.submit-button {
text-align:center;
width: 100px;
background-color: green;
font-weight: bold;
color: white;
border: none;
padding: 10px;
border-radius: 4px;
cursor: pointer;
margin-top: 20px;
}
button.submit-button:hover {
background: darkgreen;
}
.button-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
button.prev-button {
width: 80px;
font-weight: bold;
background-color: gray;
color: white;
border: none;
padding: 10px;
border-radius: 4px;
cursor: pointer;
}
button.prev-button:hover {
background-color: darkgray;
}
button.next-button {
width: 80px;
font-weight: bold;
background-color: blue;
color: white;
border: none;
padding: 10px;
border-radius: 4px;
cursor: pointer;
}
button.next-button:hover {
background-color: darkblue;
}
button.delete-button {
display: block; /* ボタンをブロック要素に */
margin: 0 auto; /* 自動余白で中央揃えで */
width: 80px;
font-weight: bold;
background-color: red;
color: white;
border: none;
padding: 10px;
border-radius: 4px;
cursor: pointer;
}
button.delete-button:hover {
background-color: darkred;
}
.submit-button, .prev-button, .next-button, .delete-button {
align-items: center;
margin: 0 20px; /* ボタン同士の間隔を調整 */
}
.error {
margin-top: 20px;
text-align: center;
color: red;
}
.active-page {
font-weight: bold;
color: blue;
border-bottom: 2px solid blue;
}
.page-button {
font-size: 18px;
background-color: transparent; /* 背景を透明にする */
border: none; /* ボーダーをなくす */
color: blue;
cursor: pointer;
margin: 0 15px; /* 左右に10pxの余白を追加 */
}
.page-button:hover {
text-decoration: underline;
}
すると、図のようなデザインになります。
2.Docker環境の準備
Dockerを起動させただけで、バックエンドであるPHPとフロントエンドであるReact両方を立ち上げるようにします。
DockerコンテナのIPアドレスの調べ方
1.コンテナのIDまたは名前を取得します。
$ docker ps
- 特定のコンテナの詳細を表示
$ docker inspect <コンテナIDまたは名前>
- 出力の中から、"Networks"セクションを探し、"IPAddress"の値を確認する。
ディレクトリの構造
keijiban/
├── docker-compose.yml
├── Dockerfile
├── nginx/
│ └── default.conf
├── php/
│ ├── index.php
│ ├── post.php
│ └── delete.php
├── mysql/
│ └── init.sql
└── frontend/
├──(Reactアプリケーション)
├──Dockefile
2-1.docker-compose.ymlの作成
version : '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./php:/var/www/html
depends_on:
- php
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- ./php:/var/www/html
depends_on:
- mysql
mysql:
image: mysql:5.7
restart: always
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: keijiban
MYSQL_USER: user
MYSQL_PASSWORD: password
volumes:
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "3306:3306"
frontend:
build:
context: ./frontend
ports:
- "3000:80"
2-2.Dockerfileの作成
FROM php:8.3-fpm
# PHP拡張をインストール
RUN docker-php-ext-install mysqli pdo pdo_mysql
frontend ディレクトリ内にも Dockerfile を作成
frontend/Dockerfile
# Node.jsの公式イメージを使用
FROM node:16
# アプリケーションの作業ディレクトリを作成
WORKDIR /app
# 依存関係をインストールするためにpackage.jsonをコピー
COPY package*.json ./
# 依存関係をインストール
RUN npm install
# 残りのアプリケーションコードをコピー
COPY . .
# アプリケーションをビルド
RUN npm run build
# ビルドされたアプリケーションを提供するためにnginxを使用
FROM nginx:alpine
COPY --from=0 /app/build /usr/share/nginx/html
# ポート80を開放
EXPOSE 80
# Nginxを起動
CMD ["nginx", "-g", "daemon off;"]
2-3.Nginx設定ファイル
server {
listen 80;
server_name localhost;
root /var/www/html;
index index.php index.html index.htm;
location / {
# CORSの設定
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type' always;
# OPTIONSメソッドの処理
if ($request_method = 'OPTIONS') {
add_header 'Content-Length' 0;
return 204; #Contentがない場合
}
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.ht {
deny all;
}
}
2-4.掲示板アプリケーションで必要なテーブルを作成するためのSQLコードを記述(MySQL初期化スクリプト)
-- データベースを使用
CREATE DATABASE IF NOT EXISTS keijiban;
USE keijiban;
-- 投稿内容テーブルの作成
CREATE TABLE IF NOT EXISTS posts (
post_id INT AUTO_INCREMENT PRIMARY KEY,
author_name VARCHAR(50) NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 初期データの挿入
INSERT INTO posts (author_name, title, content) VALUES
('テストユーザー', '初投稿', 'これはテストメッセージです。'),
('ユーザー1', '投稿1', 'これは投稿1の内容です。'),
('ユーザー2', '投稿2', 'これは投稿2の内容です。');
3.バックエンド
3-1.データベースと連携し、 Reactにページネーションを反映させるための処理
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
$mysqli = new mysqli("mysql", "user", "password", "keijiban");
if ($mysqli->connect_error) {
die("Connection failed: " . $mysqli->connect_error);
}
// ページネーションの作成
$limit = 20;
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$offset = ($page - 1) * $limit;
// 投稿の取得
$result = $mysqli->query("SELECT * FROM posts ORDER BY created_at DESC LIMIT $limit OFFSET $offset");
if (!$result) {
die("クエリエラー: " . $mysqli->error);
}
$total_posts_result = $mysqli->query("SELECT COUNT(*) FROM posts");
$total_posts = $total_posts_result->fetch_row()[0];
$total_pages = ceil($total_posts / $limit);
// 取得したデータを配列に格納
$posts = [];
while ($row = $result->fetch_assoc()) {
// post_idを整数に変換
$row['post_id'] = (int)$row['post_id'];
$posts[] = $row;
}
// JSON形式でレスポンスを返す
echo json_encode([
'posts' => $posts,
'message' => isset($_GET['message']) ? htmlspecialchars($_GET['message']) : '',
'total_pages' => max(1, $total_pages),// 総ページを追加
])
?>
3-2. データベースに投稿者やタイトル、コメントや内容などを送り、その内容をReactに反映するための処理
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
// MySQL接続
$mysqli = new mysqli("mysql", "user", "password", "keijiban");
if ($mysqli->connect_error) {
die("Connection failed: " . $mysqli->connect_error);
}
// OPTIONSリクエストの処理
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header("HTTP/1.1 204 No Content");
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
//入力値の取得
$author_name = $_POST['author_name'] ?? '';
$title = $_POST['title'] ?? '';
$content = $_POST['content'] ?? '';
// ブレースホルダーを使ったクエリの準備
$stmt = $mysqli->prepare("INSERT INTO posts (author_name, title, content) VALUES (?, ?, ?)");
if (!$stmt) {
echo json_encode(['message' => '処理できませんでした。: ' . $mysqli->error]);
exit;
}
$stmt->bind_param("sss", $author_name, $title, $content);
if($stmt->execute()){
echo json_encode(['message' => 'メッセージを送信しました。']);
} else {
echo json_encode(['message' => 'メッセージが送れませんでした。', 'error' => $stmt->error]);
}
$stmt->close();
}
$mysqli->close();
?>
3-3.データベース内に残った投稿内容や Reactで表示されている投稿内容の削除を反映するための処理
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json");
$mysqli = new mysqli("mysql", "user", "password", "keijiban");
if ($mysqli->connect_error) {
echo json_encode(["error" => "Connection failed: " . $mysqli->connect_error]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$post_id = isset($_POST['post_id']) ? (int)$_POST['post_id'] : 0;
if ($post_id > 0) {
if ($mysqli->query("DELETE FROM posts WHERE post_id = $post_id")) {
echo json_encode(["message"=> "削除成功"]);
} else {
echo json_encode(["error" => "削除失敗"]);
}
} else {
echo json_encode(["error" => "無効なID"]);
}
} else {
echo json_encode(["error" => "無効なリクエスト"]);
}
$mysqli->close();
?>
4.Dockerの起動
$ docker-compose up -d –build
下の画像のようなメッセージが出れば成功です。
4-1. PHPファイルの存在確認
以下のコマンドを実行して、Docker内にコンテナ内に post.php が存在することを確認します。
$ docker-compose exec php ls /var/www/html
4-2. MySQLに接続できているか確認するコマンド
$ docker exec -it keijiban-mysql-1 mysql -u root -p
パスワード名:password
- データベースを選択
接続後、以下のコマンドでデータベースを選択します。
USE keijiban;
- テーブルの状態を確認
次に、テーブルの一覧を表示するために以下のコマンドを実行します。
SHOW TABLES;
特定のテーブルの内容を確認したい場合は、次のようにします。
SELECT * FROM posts;
このような内容になっていたら、大丈夫です。
5. アプリを起動
書いたコードをテストをする際は、
ブラウザで、http://localhost:3000と入力してください。
掲示板のフォームに以下の内容で入力
「投稿する」ボタンを押すと、
ちゃんと反映されました。
連続で投稿しても投稿一覧に履歴が残っていってます。
これらの画面が表示されれば、ちゃんと実装できています。
そして、20件以上投稿すると、
ページネーションもちゃんと表示されています。
もし、20件以上投稿しても、ページネーションが表示されず、投稿が増えていく場合は、
サイトを出て、再度、http://localhost:3000と入力すると、
ページネーションがちゃんと反映されます。
※ ページネーションの反映には、15分ほどかかる場合があります。
データベースを利用した簡単な掲示板アプリは、これで完成です。
では、これをgithubに上げていきましょう。
エンジニア業界に就職するには、自分がどんなものが作れるか、どれくらいコードが書けるかなどスキルを証明する必要があります。
githubは、就職する際に、それらを示すための必要なツールです。
githubに上げるやり方は、こちらのサイトを参考にすると分かりやすいと思います。↓
下のような画面が出てきたら、成功です。
ここまでの作業内容や掲示板アプリのディレクトリ構造が、この下のURLに記録されます。↓
次回は、この掲示板アプリを改良して、簡単な勤怠管理アプリを作成していきたいと思います。