はじめに
この度、より新しいフロントエンドの技術の知識を入れるため、Reactを取り組むことにしました。
2週間ほど勉強し、フロントはReact、APIをDjangoでシンプルなブログアプリを作ったので、その手順をまとめます。
私の開発経験として、webフレームワークはRuby on Railsを1年半程で、フロンエンドはjQueryのみで、Djangoも今回初めて使用しました。
概要
タイトル、本文の入力と、画像が選択できる記事の投稿と削除。投稿した記事の一覧と詳細を閲覧できるページがあるシンプルなブログアプリを作成します。
流れとして、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の設定変更
# 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
・モデル編集
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を新規作成します。
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を新規作成します
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ファイルの編集
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)
以上で、記事用のアプリの設定は終了です。
次に、今作成したアプリの読み込みや、その他設定を行います。
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/'
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/ にアクセスし、画面下部の入力フォームで、タイトルや本文を入力して、記事が登録できることを確認します。
フロントエンド作成
・環境変数設定
REACT_APP_END_POINT=http://0.0.0.0:8000
・分割した関数とUIを保存するディレクトリを作成します
$ cd frontend/blog/src/
$ mkdir components function
・ヘッダー用ファイル作成
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
・記事一覧用ファイル作成
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
・記事詳細用ファイル作成
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
・記事登録フォーム用ファイル作成
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>
);
}
}
・共通関数用ファイル作成
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編集
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;
・最後にスタイルを編集します。
// 追加
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