Next.js×DjangoアプリケーションをDocker環境で開発する（後編）


## はじめに
本記事は後編である。
前編では、フロントエンド/バックエンドをそれぞれ立ち上げるところまで実装した。

https://qiita.com/ten-056/items/a1a646f7d6f741b0abbd

本編ではテストとして、記事を投稿できるアプリケーションを実装する。

また本番環境へのデプロイを想定して、Django側のアプリケーション起動を
`python manage.py runserver 0.0.0.0:8000`
ではなく、`Gunicorn`を用いて起動し、Nginxコンテナとバインドする形に変更する。

## nginxの導入

DjangoプロジェクトをNginxとバインドしてアクセス可能にするためにNginxのDockerコンテナを作成する。また、DjangoにGunicornを導入する。

### ディレクトリの作成

`sample_project/` 配下にnginxコンテナ用のディレクトリを作成する。

```bash:host
mkdir nginx
```

### 各種ファイルの作成

以下の2つのファイルを作成する。

1つ目のファイルはDockerfileで、nginxのバージョン1.18.0-alpineをベースに、nginxの設定ファイルをコンテナ内にコピーする。また、デフォルトの設定ファイルを削除する。

```docker:nginx/Dockerfile
FROM nginx:1.18.0-alpine

RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d
```

2つ目のファイルは、nginxの設定ファイルで、backendサービスを指すupstreamブロックと、クライアントからのリクエストをbackendサービスにプロキシするserverブロックを設定する。

```:nginx/nginx.conf
upstream project {
   server backend:8000;
}

server {

   listen 80;
   
   location / {
       proxy_pass http://project;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header Host $host;
       proxy_redirect off;
   }
}
```

## .env.devファイルの作成とdocker-compose.ymlの編集

今後、開発環境と本番環境で変数を分けて利用するため、env.devに環境変数をまとめる。

### .env.devファイルの作成

docker-compose.ymlファイルと並列で、.env.devファイルを作成する。

`SECRET_KEY`は、`/backend/sample_project/settings.py`の`"SECRET_KEY"`と同じ値を使用する。

```bash:.env.dev
NEXT_PUBLIC_BE_BASEURL=http://localhost:1317

DEBUG=True
SECRET_KEY='[/backend/sample_project/settings.pyの"SECRET_KEY"から引用]'
ALLOWED_HOSTS=127.0.0.1 [::1] localhost

DATABASE_NAME='sample_project'
DATABASE_USER='root'
DATABASE_PASSWORD='password'
DATABASE_HOST='db'
DATABASE_PORT='5432'

ALLOWED_ORIGINS=http://localhost:3000

POSTGRES_USER=root
POSTGRES_PASSWORD=password
POSTGRES_DB=sample_project

DATABASE=postgres

```

#### docker-compose.ymlを編集

これに伴い、docker-compose.ymlも編集する。

```yaml:docker-compose.yml
version: '3'
services:
  frontend:
    build:
      context: frontend
    tty: true
    volumes:
      - ./frontend:/frontend
    ports:
      - 3000:3000
    env_file:
      - ./.env.dev
    environment:
      - WATCHPACK_POLLING=true
  backend:
    build:
      context: backend
    command: gunicorn sample_project.wsgi:application --bind 0.0.0.0:8000
    tty: true
    volumes:
      - ./backend:/backend
    expose:
      - 8000
    depends_on:
      - db
    env_file:
      - ./.env.dev
  db:
    image: postgres
    env_file:
      - ./.env.dev
    volumes:
      - postgres_data:/var/lib/postgresql/data
  nginx:
    build: ./nginx
    ports:
      - 1317:80
    depends_on:
      - backend

volumes:
  postgres_data:
  pgadmin4_data:

```

### Django側の変更

#### settings.py の編集

`settings.py`を編集して、環境変数から設定値を取得し、新たに追加したアプリケーションと各種ミドルウェアを登録します。また、データベースの設定も行います。

```python:backend/sample_project/settings.py
import os

...（略）

# SECRET_KEY = "既存の値をenvファイルに利用"
SECRET_KEY = os.environ.get("SECRET_KEY")

DEBUG = os.environ.get("DEBUG")

ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS").split(" ")

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework', # 追加
    'corsheaders', # 追加
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'corsheaders.middleware.CorsMiddleware', # 追加
]

...（略）

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': os.environ.get("DATABASE_NAME"),
        'USER': os.environ.get("DATABASE_USER"),
        'PASSWORD': os.environ.get("DATABASE_PASSWORD"),
        'HOST': os.environ.get("DATABASE_HOST"),
        'PORT': os.environ.get("DATABASE_PORT"),
    }
}

...（略）

CORS_ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS").split(" ")

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
```

#### requirements.txtの編集

`django-cors-headers`はDjangoプロジェクトでCross-Origin Resource Sharing (CORS)ヘッダーを操作するためのパッケージです。これは、異なるオリジンからのリクエストを許可するために必要です。バージョン3.11.0を使用します。

`gunicorn`はPython WSGI HTTPサーバーであり、DjangoやFlaskなどのWebアプリケーションを動かすために使われます。その効率性と簡単な使用方法から、本番環境でよく用いられます。バージョン20.1.0を使用します。

```plaintext:backend/requirements.txt
Django
psycopg2
djangorestframework
django-cors-headers==3.11.0
gunicorn==20.1.0
```

## Dockerコンテナの再起動

一度全てdockerコンテナを削除する。

```bash:host
docker compose down -v
```

そして、composeファイルを書き換えているので、ビルドからやり直す。

```bash:host
docker compose up -d --build
```

[http://localhost:1317](http://localhost:1317) にアクセスしてが表示されることを確認する（cssがくずれているかも）。

## バックエンド

### 下準備

バックエンドの実装では、まずDjangoのコマンド`startapp`を使用して`article`というアプリケーションを作成する。

```bash:host
docker exec -it sample_project-backend-1 bash
```

```bash:#backend
python manage.py startapp article
```

### Articleの作成

#### settings.pyへの反映

`sample_project/settings.py`を編集して`article`というアプリをプロジェクトに反映させる。

```python:backend/sample_project/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'corsheaders',
    'article' # 追加
]
```

#### models.pyの編集

`models.py`を編集して`Article`という名前のモデルを作成します。このモデルには`article_id`、`article_heading`、`article_body`というフィールドがあります。

```python:backend/article/models.py
from django.db import models

class Article(models.Model):
    article_id = models.AutoField(primary_key=True)
    article_heading = models.CharField(max_length=250)
    article_body = models.TextField()
```

#### マイグレートの実行

モデルの作成後は、`makemigrations`と`migrate`コマンドを使用してデータベースに反映します。

```bash:#backend
python manage.py makemigrations
python manage.py migrate
```

#### シリアライザの作成

article/ 配下にserializers.pyを作成する。

次に、`article/` 配下に`serializers.py`を作成し、モデルをJSON形式に変換するためのシリアライザを定義します。

```python:backend/article/serializers.py
from rest_framework import serializers
from .models import Article
class ArticleSerializer(serializers.ModelSerializer):
  class Meta:
    model = Article
    fields = '__all__'
```

#### ViewSetの作成

さらに、`article/` 配下に`viewsets.py`を作成し、APIのエンドポイントとなるViewSetを作成します。ViewSetでは、各種CRUD操作が定義されます。

```python:backend/article/viewsets.py
from rest_framework import viewsets, filters
from .models import Article
from .serializers import ArticleSerializer

class ArticleViewSet(viewsets.ModelViewSet):
  queryset = Article.objects.all()
  serializer_class = ArticleSerializer
  filter_backends = (filters.SearchFilter,)
  search_fields = ('article_id', 'article_heading', 'article_body')
```

### ルータの作成

その後、`sample_project/` 配下に`routers.py`を作成し、APIのルートを定義します。ここでは`article`という名前でViewSetを登録しています。

```python:backend/sample_project/routers.py
from rest_framework import routers
from article.viewsets import ArticleViewSet

router = routers.DefaultRouter()
router.register('article', ArticleViewSet)
```

最後に、Djangoの`urls.py`を編集して、作成したルータを登録します。

```python:backend/sample_project/urls.py
from django.contrib import admin
from django.urls import path, include
from .routers import router

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include(router.urls))
]
```

これにより、以下のようなRESTfulなAPIエンドポイントが作成されます：

- **GET**：/api/article/ （記事の一覧を取得）
- **POST**：/api/article （新規記事を投稿）
- **DELETE**：/api/article/{article_id}/ （指定したIDの記事を削除）
- **GET**：/api/article/{article_id}/ （指定したIDの記事の詳細を取得）
- **PUT**：/api/article/{article_id}/ （指定したIDの記事を更新）
- **PATCH**：/api/article/{article_id}/ （指定したIDの記事の一部を更新）

### 動作確認
#### gunicornを再起動する

djangoのコードを変更した場合、以下の方法でgunicornを再起動する。

```bash:#backend
ps -a | grep gunicorn
```

表示されるPIDの一番小さいプロセスIDがmasterプロセスのため、killを行う。

```bash:#backend
kill -HUP <一番小さいプロセスID>
```

HUPオプションを付加することにより、単純にプロセスをkillするのではなく、プロセスを再起動（reload）させることができる。

#### アクセスしてみる

[http://localhost:1317/api/article](http://localhost:1317/api/article) にアクセスし、（cssは崩れているが）以下のような画面が出ればOK。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3628846/12ad8556-9c89-433d-0329-59cd7b6d16b3.png)

## フロントエンド

### 下準備

#### CSSファイルの削除

`src/styles/globals.css` に用意されているCSSを消す。以下の状態にする。

```css:frontend/sample_project/src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

#### フロントエンドのコンテナに入る

```bash:host
docker exec -it nextdjango-frontend-1 bash
```

npm installをしておく。

```bash:#frontend
cd sample_project
npm install
```

#### axiosのインストール

frontendのdockerコンテナにexecで入り、axiosをインストールする。

```bash:#frontend
npm install axios --save
```

### メイン画面の作成

`src/pages/index.tsx` に簡単なCRUD機能を実装する。編集機能は未実装。

中で用いる環境変数は後述する。

```tsx:src/pages/index.tsx
import { useEffect, useState } from "react";
import axios from "axios";

// Create a type for articles
type ArticleType = {
  article_id: number;
  article_heading: string;
  article_body: string;
}

export default function Home() {
  const [articles, setArticles] = useState<ArticleType[]>([]);
  const [heading, setHeading] = useState("");
  const [body, setBody] = useState("");

  // Fetch articles from the backend
  const getArticles = async () => {
    try {
      const response = await axios.get(`${process.env.NEXT_PUBLIC_BE_BASEURL}/api/article/`);
      setArticles(response.data.reverse());
    } catch (error) {
      console.error(error);
    }
  };

  // Post new article to the backend
  const postNewArticle = async () => {
    try {
      await axios.post(`${process.env.NEXT_PUBLIC_BE_BASEURL}/api/article/`, {
        article_heading: heading,
        article_body: body
      });
      getArticles();  // Refresh the article list
      setHeading(""); // Clear form
      setBody("");
    } catch (error) {
      console.error(error);
    }
  };

  // delete
  const deleteArticle = async (article_id: number) => {
    try {
      await axios.delete(`${process.env.NEXT_PUBLIC_BE_BASEURL}/api/article/${article_id}/`);
      getArticles(); // Refresh the list after deletion
    } catch (error) {
      console.error(error);
    }
  };

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

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold text-center my-4">記事投稿ページ</h1>
      <div className="mb-8">
        <input
          type="text"
          placeholder="タイトル"
          className="input input-bordered w-full mb-4 px-4 py-2 rounded text-lg border-2 border-gray-300 focus:border-blue-500 focus:outline-none"
          value={heading}
          onChange={(e) => setHeading(e.target.value)}
        />
        <textarea
          placeholder="本文"
          className="textarea textarea-bordered w-full mb-4 px-4 py-2 rounded text-lg border-2 border-gray-300 focus:border-blue-500 focus:outline-none"
          value={body}
          onChange={(e) => setBody(e.target.value)}
        />
        <button
          onClick={postNewArticle}
          className="btn btn-primary w-1/2 py-2 text-lg border-2 rounded border-gray-300 hover:border-blue-500 mx-auto block"
        >
          送信
        </button>
      </div>
      <div>
        {articles.map((article) => (
          <div key={article.article_id} className="p-4 mb-2 shadow-lg rounded-lg relative">
            <button
              onClick={() => deleteArticle(article.article_id)}
              className="absolute top-0 right-0 p-2 text-lg"
            >
              ✗
            </button>
            <h2 className="text-2xl font-bold">{article.article_heading}</h2>
            <p>{article.article_body}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

```

### 動作確認

開発用に立ち上げる。

```bash:#frontend
npm run dev
```

[http://localhost:3000](http://localhost:3000)にアクセスして確認する。

色々入力したり、リロードしたりして、テストする。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3628846/95bc4ff2-4072-1bf5-768a-0b4174590b8f.png)

ここまでで、CRUD（更新機能は未実装）アプリケーションをDocker環境で開発できる状態が整った。

余力があれば本番環境（VPS）へのデプロイについても投稿したい。