Docker / React / Express / Prisma で簡単な Web アプリを作成

# 環境

```
wsl --version          | WSL バージョン: 2.2.4.0
docker --version       | Docker version 26.1.4, build 5650f9b
docker compose version | Docker Compose version v2.27.1-desktop.1
npm --version          | 10.7.0
node --version         | v20.15.0
```

# ディレクトリ構成

```
docker-react-express-prisma/
├── backend/
│   └── prisma/
└── frontend/
```

# 初期環境構築

## プロジェクトディレクトリ作成

```
mkdir docker-react-express-prisma
cd docker-react-express-prisma
```

## フロントエンド

- プロジェクトルートで以下を実行

```shell
npx create-react-app frontend --template typescript
```

- プロジェクトルートに Dockerfile 作成

```Dockerfile
# ビルドステージ
FROM node as build-stage
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build

# プロダクションステージ
FROM nginx:alpine
COPY --from=build-stage /app/build /usr/share/nginx/html
```

## バックエンド

- プロジェクトルートで以下を実行

```shell
mkdir backend
cd backend
npm init -y
npm install express prisma @prisma/client
```

- プロジェクトルートに Dockerfile 作成

```Dockerfile
FROM node
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

# Prismaのクライアントを生成
RUN npx prisma generate

# データベースのマイグレーション
RUN npx prisma migrate dev --name init

# コンテナがリッスンするポートを指定
EXPOSE 3001

# アプリケーションのエントリーポイントを指定
CMD ["node", "index.js"]
```

- バックエンドルートに以下を作成

```index.js
// expressモジュールをインポート
const express = require('express');
// PrismaClientをインポート
const { PrismaClient } = require('@prisma/client');

// expressアプリケーションを作成
const app = express();

// JSON形式のリクエストボディを解析するミドルウェアを使用
app.use(express.json());

// PrismaClientのインスタンスを作成
const prisma = new PrismaClient();

// Prismaを使ってユーザー情報を取得し、JSON形式で返す
app.get('/users', async (req, res) => {
  const users = await prisma.user.findMany();
  res.json(users);
});

app.get('/', (req, res) => {
  res.send('Hello, World!');
});

// サーバーがリッスンするポートを設定
const PORT = process.env.PORT || 3000;
// サーバーを起動して、指定したポートでリクエストを受け付ける
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

```

## データベース

- バックエンドルートで以下を実行

```shell
npx prisma init
```

- `backend/prisma/` ディレクトリで以下のファイルを編集

```schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id    Int     @id @default(autoincrement())
  name  String
  email String  @unique
}
```

- バックエンドルートでマイグレーション実行

```shell
npx prisma migrate dev --name init
```

- `backend/prisma/seed` ディレクトリに以下のファイルを作成

```seed.js
// PrismaClientをインポート
const { PrismaClient } = require('@prisma/client');
// PrismaClientのインスタンスを作成
const prisma = new PrismaClient();

// Prismaを使ってユーザーを作成
async function main() {
  const user = await prisma.user.create({
    data: {
      name: 'Alice',
      email: 'alice@prisma.io',
    },
  });
  console.log({ user });
}

// main関数を呼び出し、エラーハンドリングと接続の終了を行う
main()
  .catch(e => {
    throw e;
  })
  .finally(async () => {
    // 最後にPrismaの接続を切断
    await prisma.$disconnect();
  });
```

- シード実行

```shell
node seed.js
```

## コンテナ起動

- プロジェクトルートに以下を作成

```docker-compose.yml
version: '3.8'

services:
  backend:
    build: ./backend
    ports:
      - "3001:3000"
    volumes:
      - ./backend:/app
    environment:
      - DATABASE_URL=file:./dev.db

  frontend:
    build: ./frontend
    ports:
      - "3000:80"
```

- コンテナビルド & 起動

```shell
docker-compose build
docker-compose up -d
```

- `http://localhost:3000` にアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621414/ffeb2145-0ef7-a737-4e13-bbbd49179028.png)

- `http://localhost:3001` にアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621414/aa61794b-b6dc-0ff5-b3f5-c1905c3d2ed4.png)

# フロントエンドでデータを取得する

- パッケージインストール

```shell
cd frontend
npm install axios cors
```

- index.js に CORS パッケージを追加

```diff:index.js
const express = require('express');
const { PrismaClient } = require('@prisma/client');
+ const cors = require('cors');

// expressアプリケーションを作成
const app = express();

+ // CORSミドルウェアを使用
+ app.use(cors());

// JSON形式のリクエストボディを解析するミドルウェアを使用
app.use(express.json());

// PrismaClientのインスタンスを作成
const prisma = new PrismaClient();

// Prismaを使ってユーザー情報を取得し、JSON形式で返す
app.get('/users', async (req, res) => {
  const users = await prisma.user.findMany();
  res.json(users);
});

app.get('/', (req, res) => {
  res.send('Hello, World!');
});

// サーバーがリッスンするポートを設定
const PORT = process.env.PORT || 3000;
// サーバーを起動して、指定したポートでリクエストを受け付ける
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

```

- App.tsx でデータ取得 API を実行

```react:App.tsx
import React, { useState } from 'react';
import axios from 'axios';
import './App.css';

const App: React.FC = () => {
  // ユーザー情報を保持するためのstateを定義
  const [users, setUsers] = useState([]);

  // ユーザー情報を取得する非同期関数を定義
  const fetchUsers = async () => {
    try {
      // axiosを使ってバックエンドからユーザー情報を取得
      const response = await axios.get('http://localhost:3001/users');
      // 取得したユーザー情報をstateにセット
      setUsers(response.data);
    } catch (error) {
      console.error('Error fetching users:', error);
    }
  };

  return (
    <div className="App">
      <header className="App-header">
        <h1>User List</h1>
        {/* ユーザー情報を取得するボタン */}
        <button onClick={fetchUsers}>Fetch Users</button>
        {/* ユーザー情報をリスト表示 */}
        <ul>
          {users.map((user: any) => (
            <li key={user.id}>
              {user.name} ({user.email})
            </li>
          ))}
        </ul>
      </header>
    </div>
  );
}

// Appコンポーネントをエクスポート
export default App;
```

```App.css
.App {
  text-align: center;
}

.App-header {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

button {
  margin: 20px;
  padding: 10px 20px;
  font-size: 16px;
}

ul {
  list-style: none;
  padding: 0;
}

li {
  margin: 10px 0;
}
```

- コンテナビルド & 起動

```shell
docker-compose build
docker-compose up -d
```

- `http://localhost:3000` にアクセスし、ボタン押下
    - シードデータが表示されることを確認

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621414/da5d728c-ad2a-ea51-2681-e6f39748818c.png)