ReactとDjangoでシンプルな画像付きブログアプリを作成する

# はじめに
この度、より新しいフロントエンドの技術の知識を入れるため、Reactを取り組むことにしました。
2週間ほど勉強し、フロントはReact、APIをDjangoでシンプルなブログアプリを作ったので、その手順をまとめます。

私の開発経験として、webフレームワークはRuby on Railsを1年半程で、フロンエンドはjQueryのみで、Djangoも今回初めて使用しました。

![blog.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/355306/90ed145a-ba3a-64f9-c456-cdce9d2fe0bf.gif)


# 概要
タイトル、本文の入力と、画像が選択できる記事の投稿と削除。投稿した記事の一覧と詳細を閲覧できるページがあるシンプルなブログアプリを作成します。
流れとして、Dockerでフロントエンド、API、DB用のコンテナを作成し、APIの実装、フロントエンドの実装という順番で進めていきます。

# コンテナ作成
・アプリケーションのディレクトリ作成

```
$ mkdir -p blog/frontend blog/api
$ cd blog
```

・API用のDockerfile作成

```
$ vi api/Dockerfile

FROM python:3.7
ENV PYTHONUNBUFFERED 1
RUN mkdir /api
WORKDIR /api
ADD ./requirements.txt .
RUN pip3 install -r requirements.txt
EXPOSE 8000
```

・インストールパッケージ一覧ファイル作成

```
$ vi api/requirements.txt

Django>=3.0.5
mysqlclient==1.4.6
djangorestframework==3.11.0
django-filter==2.2.0
Pillow==7.1.1
django-cors-headers==3.2.1
```

・フロントエンド用のDockerfile作成

```
$ vi frontend/Dockerfile

FROM node:13.13.0
RUN mkdir /frontend
WORKDIR /frontend
EXPOSE 3000
```

・docker-compose作成

```
$ vi docker-compose.yml

version: '3'
services:
 api:
   container_name: blog-api
   build:
     context: ./api
     dockerfile: Dockerfile
   command: sh -c "cd /api/blog && python3 manage.py runserver 0.0.0.0:8000"
   ports:
     - 8000:8000
   volumes:
     - ./api:/api
   depends_on:
     - db
     - front
   tty: true
 db:
   container_name: blog-db
   image: mysql:5.7
   restart: always
   environment:
     MYSQL_DATABASE: blog
     MYSQL_USER: root
     MYSQL_PASSWORD: password
     MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
   volumes:
     - ./mysql:/var/lib/mysql
   ports:
     - 3306:3306
 front:
    container_name: blog-front
    build:
      context: ./frontend
      dockerfile: Dockerfile
    command: sh -c "cd /frontend/blog && npm start"
    ports:
     - "3000:3000"
    volumes:
     - ./frontend:/frontend
    tty: true
```

・イメージ作成

```
$ docker-compose build
```

・DjangoとReactのアプリケーション作成

```
$ cd api
$ docker-compose run --rm api sh -c "django-admin startproject blog"
$ cd ../frontend
$ docker-compose run --rm front sh -c "npm install -g create-react-app && create-react-app blog"
```

・Djangoの設定変更

```python:blog/settings.py
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['0.0.0.0']

DATABASES = {
    #'default': {
    #    'ENGINE': 'django.db.backends.sqlite3',
    #    'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    #}
   'default': {
   'ENGINE': 'django.db.backends.mysql',
   'NAME': 'blog',
   'USER': 'root',
   'HOST': 'blog-db',
   'POST': 3306,
   'OPTIONS': {
                'charset': 'utf8mb4',
              },
   }
}
```

・Reactで使うパッケージ追加

```
$ docker-compose run --rm front npm --prefix ./blog install axios @material-ui/core react-router-dom @babel/core @material-ui/icons
```

・コンテナ立ち上げ

```
$ docker-compose up
```

localhost:3000/, 0.0.0.0:8000/ に接続したら、それぞれのアプリケーションが起動していることが確認できます。

# APIの作成

・記事用のアプリ作成

```
$ cd api/blog/
$ python manage.py startapp articles
$ cd articles
```

・モデル編集

```python:models.py

import uuid
from django.db import models

class Article(models.Model):
    uuid  = models.UUIDField('uuid', primary_key=True, default=uuid.uuid4, editable=False)
    title = models.CharField(verbose_name="タイトル", max_length=100)
    body  = models.TextField('本文')
    image = models.ImageField(upload_to='post_images', null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
```

・APIの入出力設定を行うために、serializers.pyを新規作成します。

```python:serializers.py
from rest_framework import serializers
from .models import Article

class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = '__all__'
        read_only_fields = ('created_at', 'updated_at')

```

・URLの設定を行うために、urls.pyを新規作成します

```python:urls.py
from django.urls import re_path, include
from rest_framework import routers

from .views import ListArticle


router = routers.SimpleRouter()
router.register(r'articles', ListArticle)

urlpatterns = [
    re_path('', include(router.urls))
]
```

・viewファイルの編集

```python:views.py

from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer
from rest_framework.response import Response

class ListArticle(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

    # 記事一覧取得
    def list(self, request):
        data = ArticleSerializer(Article.objects.all().order_by('created_at').reverse(), many=True).data

        return Response(status=200, data=data) 

    # 記事詳細取得
    def retrieve(self, request, pk=None):
        article_id = pk
        data = ArticleSerializer(Article.objects.filter(uuid=article_id), many=True).data

        return Response(status=200, data=data)
```

以上で、記事用のアプリの設定は終了です。
次に、今作成したアプリの読み込みや、その他設定を行います。

```python:blog/settions.py

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

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', #追加
]

# タイムゾーン変更
# TIME_ZONE = 'UTC'
TIME_ZONE = 'Asia/Tokyo'

# reactのアプリケーションからのアクセスを可能にする
CORS_ORIGIN_REGEX_WHITELIST = (
    r'^(http?://)?localhost',
    r'^(http?://)?127.',
)

# 画像の保存先設定
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
```

```python:blog/urls.py

from django.contrib import admin
from django.urls import path
from django.urls import re_path, include  #追加
from django.conf import settings #追加
from django.contrib.staticfiles.urls import static #追加
from django.contrib.staticfiles.urls import staticfiles_urlpatterns #追加

urlpatterns = [
    path('admin/', admin.site.urls),
    re_path(r'^api/',   include('articles.urls')), #追加
]

# 画像にアクセス
urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
```

・DBマイグレート

```
$ docker-compose run --rm api python blog/manage.py makemigrations
$ docker-compose run --rm api python blog/manage.py migrate
```

以上でAPI側の実装は完了になります。
試しに、ユーザーを作成し、記事を作成してみます。

```
$ docker-compose run --rm api python blog/manage.py createsuperuser
# 以下設定してください
```

0.0.0.0:8000/api/articles/ にアクセスし、画面下部の入力フォームで、タイトルや本文を入力して、記事が登録できることを確認します。

# フロントエンド作成

・環境変数設定

```:.env

REACT_APP_END_POINT=http://0.0.0.0:8000
```

・分割した関数とUIを保存するディレクトリを作成します

```
$ cd frontend/blog/src/
$ mkdir components function
```

・ヘッダー用ファイル作成

```react:components/Header.js
import React from 'react'
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { Typography, Button } from '@material-ui/core';
import { Link } from 'react-router-dom';

/**
* @author
* @function Header
**/

const Header = (props) => {
  return(
      <AppBar position="static">
          <Toolbar>
              <Link to="/">
                <Typography variant="h4" type="title" color="inherit" >
                    Blog
                </Typography>
              </Link>
              <div style={{marginLeft: 'auto'}}>
                  <Link to="/new">
                    <Button color="inherit">新規作成</Button>
                  </Link>
              </div>
          </Toolbar>
      </AppBar>
   )

 }

export default Header

```

・記事一覧用ファイル作成

```react:components/Home.js
import React, {useState, useEffect}  from 'react';
import axios from 'axios';
import Card from '@material-ui/core/Card';
import CardMedia from '@material-ui/core/CardMedia';
import CardContent from '@material-ui/core/CardContent';
import { Typography } from '@material-ui/core';
import { NavLink } from 'react-router-dom';
import convertFormat from '../function/Convert.js';

/**
* @author
* @function Home
**/

const Home = (props) => {
  const [articleList, setArticle] = useState([]);

  useEffect(() => {axios
      .get(`${process.env.REACT_APP_END_POINT}/api/articles/`)
        .then(res => {
            const articleList = res.data;
            setArticle(articleList);
        })
        .catch(err => {
            console.log(err);
      });
  }, [props.match.params]);

  return(
     <div>
        {
          articleList.map(article => {
            return (
              <Card
                    key={`ranking-item-${article.uuid }`}
                    style={{ maxWidth: '500px', margin: '32px auto'}}
                    src={`post/${article.uuid}`}
               >
                 <NavLink key={article.uuid} to={`/post/${article.uuid}`}>
                   <CardMedia
                     image={article.image ? process.env.REACT_APP_END_POINT+article.image: 'http://design-ec.com/d/e_others_50/l_e_others_500.png'}
                     title={article.title}
                     style={{ height: '200px' }}
                   />
                   <CardContent>
                     <Typography type="title" variant="h4">
                       {article.title}
                     </Typography>
                     <Typography className='right-placement' type="title" >
                       {convertFormat(article.created_at)}
                     </Typography>
                   </CardContent>
                 </NavLink>
              </Card>
            )
          })
        }
      </div>
   )

 }

export default Home
```

・記事詳細用ファイル作成

```react:components/Post.js
import React, {useState, useEffect} from 'react';
import axios from 'axios';
import Card from '@material-ui/core/Card';
import CardMedia from '@material-ui/core/CardMedia';
import CardContent from '@material-ui/core/CardContent';
import { Typography, Button } from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import convertFormat from '../function/Convert.js'

/**
* @author
* @function Post
**/

const Post = (props) => {
    const [articleList, setArticle] = useState([]);

    useEffect(() => {
        console.log(`${process.env.REACT_APP_END_POINT}/api/articles/${props.match.params.postId}/`)
        axios
        .get(`${process.env.REACT_APP_END_POINT}/api/articles/${props.match.params.postId}/`)
          .then(res => {
              const articleList = res.data[0];
              setArticle(articleList);
          })
          .catch(err => {
              console.log(err);
        });
    }, [props.match.params.postId]);

    const deleteArticle = () => {
        
        if(window.confirm('この記事を削除してよろしいですか？')) {
            axios.delete(`${process.env.REACT_APP_END_POINT}/api/articles/${articleList.uuid}/`)
            .then(res => {
                window.location.href="/";
            }).catch(err => {
                console.log(err);
            });
        }
    }

  return(
    <div>
        <Card
             key={`ranking-item-${articleList.uuid }`}
             style={{ maxWidth: '1000px', margin: '32px auto'}}
             src={`post/${articleList.uuid}`}
        >
            { articleList.image && 
          <CardMedia
            image={process.env.REACT_APP_END_POINT+articleList.image}
            title={articleList.title}
            style={{ height: '400px' }}
          />
            }
          <CardContent style={{ textAlign: 'left' }}>
            <Typography variant="h3" type="title" style={{margin:'0 0 20px 0'}}>
              {articleList.title}
            </Typography>
            
            <Typography className='right-placement' type="title" >
              {convertFormat(articleList.created_at)}
            </Typography>

            <Typography variant="h5" type="title" style={{margin:'0 0 20px 0'}}>
              {articleList.body}
            </Typography>

            <div className="right-placement">
            <Button onClick={deleteArticle} variant="contained" color="secondary" size="small">
              <DeleteIcon />
                削除
            </Button>
            </div>
          </CardContent>
        </Card>    
    </div>
   )
 }

export default Post
```

・記事登録フォーム用ファイル作成

```react:components/registerForm.js
import React from 'react';
import axios from 'axios';
import { Button } from '@material-ui/core';

export default class registerForm extends React.Component{

    constructor(props) {
        super(props);
        this.state = {
            title: '',
            body: '',
            image: null,
        };

        this.titleChange = this.titleChange.bind(this);
        this.bodyChange = this.bodyChange.bind(this);
        this.handleSubmit = this.handleSubmit.bind(this);
    }

    addArticle(title, body, image) {
        let form_data = new FormData();

        if(image){
          form_data.append('image', image, image.name);
        }

        form_data.append('title', title);
        form_data.append('body', body);

        axios.post(`${process.env.REACT_APP_END_POINT}/api/articles/`, form_data,{headers: {
                'content-type': 'multipart/form-data'
            }
         }).then(res => {
            window.location.href="/";
        }).catch(err => {
            console.log(err);
        });
    }

    titleChange(event) {
        this.setState({
            title: event.target.value,
        });
    }

    bodyChange(event) {
        this.setState({
            body: event.target.value,
        });
    }

    imageChange = (e) => {
      this.setState({
        image: e.target.files[0],
      })
    };

    handleSubmit(event) {
        event.preventDefault();
        if(this.state.title === ''){
            return;
        }
        if(this.state.body === ''){
            return;
        }
        this.setState({
            title: '',
            body: '',
            image: ''
        });
        this.addArticle(this.state.title, this.state.body, this.state.image);
    }

    render() {
        return (
            <div className="form central-placement">
                <form onSubmit={this.handleSubmit}>
                    <div className="form-element">
                        <input type="text" value={this.state.title} onChange={this.titleChange} className="form-element--title" placeholder="タイトル" />
                    </div>
                    <div className="form-element">
                        <textarea type="text" value={this.state.body} onChange={this.bodyChange} className="form-element--body" placeholder="本文" />
                    </div>
                    <div className="form-element">
                        <input type="file"
                           accept="image/png, image/jpeg"  onChange={this.imageChange} />
                    </div>
                    <div className="right-placement">
                        <Button variant="contained" color="primary" type="submit">新規作成</Button>
                    </div>
                </form>
            </div>
        );
    }
}

```

・共通関数用ファイル作成

```react:function/Convert.js
import React from 'react';

// YYYY-MM-DD hh:mm:ss 形式で返す
export default function convertFormat(created_at) {
  if (created_at){
    var convertedCreatedAt = created_at.split(/[T.]/);
    return convertedCreatedAt[0] + ' ' + convertedCreatedAt[1];
  }
  return '';
}
```

・App.js編集

```react:App.js
import React from 'react';
import Home from './components/Home';
import Post from './components/Post';
import Header from './components/Header';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import registerForm from './components/registerForm';

function App() {
  return (
    <Router>
      {console.log(process.env)}
      <div className="App">
        <Header />
        <Route path="/" exact component={Home} />
        <Route path="/post/:postId/" component={Post} />
        <Route path="/new" component={registerForm} />
      </div>
    </Router>
  );
}

export default App;
```

・最後にスタイルを編集します。

```css:index.css
// 追加
a {
  text-decoration: none;
}

.central-placement {
  display: flex;
  justify-content: center;
}

.right-placement {
  display: flex;
  justify-content: flex-end;
}

form {
  width: 300px;
}

.form-element {
  margin: 20px 0;
}

.form-element--title {
  width: 100%;
  height: 25px;
}

.form-element--body {
  width: 100%;
  height: 50px; 
  resize: none;
}
```

以上でフロント側の実装は完了です。
再度、localhost:3000にアクセスすると、ブログアプリが使用できます。

# 終わりに
細かいことを省略してコードを羅列する形で申し訳ないです。(また改めて追加したいと思います。。)

まだ、Reactをやり始めたばかりですが、jQueryでDOM操作する時代に比べて、フロントエンドの敷居が高くなったなという印象です。
ですが、ReactやVueに移行していく流れがあるので、引き続きReactの勉強も行なっていきたいと思います。


#参考文献
・React入門 React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで
・https://www.arimakaoru.com/blog/create-todoapp-react-django-rest-framework
・https://github.com/Rizwan17/reactjs-blog