1.Django Rest API と Reactアプリ
以前、「DjangoのページをReactで作る - Webpack4」という記事を書きました。DjangoのページでReactを使うための、開発環境の構築を紹介したものですが、これはどちらかと言えば、Djangoの開発環境にReactの開発環境を「従わせた」ものでした。BabelやWebpackの設定はDjangoの環境に合わせる形で手動で行いました。
今回はDjangoとReactの開発環境を完全に独立させます。特にReactではcreate-react-appを使いますので、簡単に開発環境を構築できます。
- (1)サーバは、DjangoプロジェクトでRest APIを開発・単体テスト
- (2)クライアントは、create-react-appで開発・単体テスト
- (3)サーバ側でCORS設定を行い、クライアントからRest APIにアクセスする
(1)と(2)はそれぞれ独立して開発を行い、それぞれに動作確認します。
その後(3)のCORSの設定を行い、クライアントとサーバの連結を確認します。
今回はTodoアプリを作成していきます。
環境としては、todo-reactというディレクトリの下に、djangotodoというDjangoプロジェクトと、frontendというcreate-react-appのプロジェクトを作成します。
todo-react
│
├── djangotodo
│ ├── db.sqlite3
│ ├── djangotodo
│ ├── manage.py
│ └── todos
├── frontend
├── db.json
├── node_modules
├── package-lock.json
├── package.json
├── public
└── src
2.サーバサイド - djangotodo
サーバサイドでは、だいたい以下のような作業を行います。
- DjangoでTodoプロジェクトを作る
- djangorestframeworkをインストールしRest APIを構築する
- 単体テストを行う
- 来たるべき総合テストに備えて、django-cors-headersをインストールしCORSの設定を行っておく
##2-1.Djangoプロジェクト作成
venvで環境を作ってから、Djangoのプロジェクトを開始します。
python -m venv todo-react
source todo-react/bin/activate
cd todo-react
pip freeze
pip install django
django-admin startproject djangotodo
cd djangotodo/
私の環境は、DjangoはRemoteサーバに構築していますので、サーバのドメイン名を入力してアクセスを許可します。
---
ALLOWED_HOSTS = ["www.mypress.jp"]
---
DBを初期化します。
python manage.py migrate
ここまででDjangoのアプリを立ち上げます。
python manage.py runserver 0:8080
http://www.mypress.jp:8080/ で、Djangoの初期画面を確認できます。
##2-2.Todoアプリ作成
DjangoでTodoアプリを作成します
django-admin startapp todos
TodoアプリのModelを定義します。
# todos/models.py
from django.db import models
class Todo(models.Model):
title = models.CharField(max_length=200)
description = models.TextField()
status = models.CharField(default='Unstarted', max_length=100)
def __str__(self):
"""A string representation of the model."""
return self.title
TodoアプリをINSTALLED_APPSに追加します。
---
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'todos', # New
]
---
migrationファイルを作成して、DBに反映させます。
python manage.py makemigrations todos
python manage.py migrate todos
adminを設定して、管理画面からTodoテーブルの操作を行えるようにします。
# todos/admin.py
from django.contrib import admin
from .models import Todo
admin.site.register(Todo)
管理者を追加します
python manage.py createsuperuser
サーバを起動します。
python manage.py runserver 0:8080
管理画面にアクセスします。
http://www.mypress.jp:8080/admin/
##2-3.Django Rest Frameworkの設定
Djangoには、djangorestframeworkというRest APIを簡単に構築できるライブラリがあります。
Django Rest Framework with React Tutorial
インストールします。
pip install djangorestframework
INSTALLED_APPSを更新します。
---
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework', # New
'todos',
]
# New
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
'EXCEPTION_HANDLER': 'djangotodo.todos.utils.custom_exception_handler'
}
---
EXCEPTION_HANDLERはデバッグのために設定しました。これを使うためには、以下のコードも必要になります。
from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
# Call REST framework's default exception handler first,
# to get the standard error response.
response = exception_handler(exc, context)
# Now add the HTTP status code to the response.
if response is not None:
response.data['status_code'] = response.status_code
return response
Django Rest Frameworkの設定のため、以下の3つを定義します。
- urls.py :URLルート
- serializers.py :dateをJSONに変換
- views.py :APIエンドポイントにロジックを適用
この辺を詳しく知るためには、以下の公式ドキュメントを最初に読みましょう。
Tutorial 1: Serialization
URL Pathの定義です。リクエスト時のパスは末尾がスラッシュ(/)で終わっている必要があります。
from django.urls import path, include # New
from django.contrib import admin
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('todos.urls')), # New
]
todoのURL Pathの定義です
# todos/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.ListTodo.as_view()),
path('<int:pk>/', views.DetailTodo.as_view()),
]
serializersとは、ざっくり言って、modelデータをJSONで出力するための機能です。ここではModelSerializer classを利用しているので、とてもシンプルに定義できます。SnippetSerializer classを利用する方法もありますが、この場合createやupdateの明示的な定義が必要になり複雑です。
# todos/serializers.py
from rest_framework import serializers
from .models import Todo
class TodoSerializer(serializers.ModelSerializer):
class Meta:
fields = (
'id',
'title',
'description',
'status',
)
model = Todo
rest_frameworkを使って、viewsを定義します。
# todos/views.py
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.parsers import JSONParser
from todos.models import Todo
from todos.serializers import TodoSerializer
@csrf_exempt
def todo_list(request):
"""
List all todos, or create a new todo.
"""
if request.method == 'GET':
todos = Todo.objects.all()
serializer = TodoSerializer(todos, many=True)
return JsonResponse(serializer.data, safe=False)
elif request.method == 'POST':
data = JSONParser().parse(request)
serializer = TodoSerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, status=201)
return JsonResponse(serializer.errors, status=400)
@csrf_exempt
def todo_detail(request, pk):
"""
Retrieve, update or delete a todo.
"""
try:
todo = Todo.objects.get(pk=pk)
except Todo.DoesNotExist:
return HttpResponse(status=404)
if request.method == 'GET':
serializer = TodoSerializer(todo)
return JsonResponse(serializer.data)
elif request.method == 'PUT':
data = JSONParser().parse(request)
serializer = TodoSerializer(todo, data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data)
return JsonResponse(serializer.errors, status=400)
elif request.method == 'DELETE':
todo.delete()
return HttpResponse(status=204)
今回はロジックを明示的に記述する仕方でviews.pyを定義しましたが、慣れたらgeneric class-based viewsを使った方が良いでしょう。コーディング量を劇的に減らすことが可能です。
Tutorial 3: Class-based Views
##2-4.単体テスト
ブラウザからアクセスしてみます。
http://www.mypress.jp:8080/api/
rest_frameworkはTodoの追加フォームも表示してくれます。便利です。
HTTPieコマンドを使っても簡単にテストできます。
HTTPie—aitch-tee-tee-pie—is a command line HTTP client with an intuitive UI
例えば以下のコマンドで「タスク追加」を確認できます。
http POST http://www.mypress.jp:8080/api/ title=a description=b status=Unstarted
##2-5.CORS
これは本来なら、frontendのreactアプリ作成後の、最後に設定し確認するものです。しかしサーバでの設定ですので、ここでやっておきます。
また、CORSの確認テストで試行錯誤する時には、その都度必ずブラウザのキャッシュをクリアーすることを強くお勧めします。私はこれを怠り嵌りました!
Access to XMLHttpRequest at 'http://www.mypress.jp:8080/api' from
origin 'http://www.mypress.jp:3000' has been blocked by
CORS policy: No 'Access-Control-Allow-Origin' header is present on
the requested resource.
以上のエラーを回避するためにサーバ側でCORSの設定を行う必要があります。django-cors-headersをインストールします。
pip install django-cors-headers
settings.pyを更新します。newコメントがついている4か所が修正箇所です。CorsMiddlewareはトップに置く必要があります。
---
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders', # new
'todos',
]
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
'EXCEPTION_HANDLER': 'djangotodo.todos.utils.custom_exception_handler'
}
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # new topに置く
'django.middleware.common.CommonMiddleware', # new
'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',
]
# new
CORS_ORIGIN_WHITELIST = [
'http://www.mypress.jp:3000',
]
# CORS_ORIGIN_ALLOW_ALL = False
---
以上でCORSの設定は終わりです。動作確認はReactアプリ完成後に行います。
3.フロントエンド - frontend
Reactプログラムは、reduxとredux-thunkを使い、action(非同期関数)から、Rest APIを叩きます。APIはaxiosで実装します。また最低限のUIを実装し、CSSを含めたコーディング量を減らすため、antdも利用します。一応最後に、全ソースを掲載します。少し長くなるのですが、不明な点を無くすため。
3-1.Reactプロジェクト作成
create-react-appを使って、Reactプロジェクトを作成します。必要なパッケージをインストールします。
create-react-app frontend
cd frontend
npm install --save axios react-redux redux redux-thunk redux-devtools-extension antd
package.jsonは以下の通りです。
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"antd": "^3.20.5",
"axios": "^0.19.0",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-redux": "^7.1.0",
"react-scripts": "3.0.1",
"redux": "^4.0.4",
"redux-devtools-extension": "^2.13.8",
"redux-thunk": "^2.3.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
3-2.redux-devtools-extension
redux-devtools-extensionはReduxアプリの開発ツールの一つで、ブラウザの拡張機能からReduxの状態管理を可視化してくれます。別途、Chromeの拡張機能を設定する必要があります。
以下の画面のように、Chromeの拡張機能で専用ウィンドが開き、Reduxのactionやstateが可視化されます。
3-3.antd
React のUI libraryであるantdを使います。特にListコンポネントを使うことで、ソースコードをとても簡潔にすることができました。入力フォームにはFormコンポーネントを使いました。
React UI library の antd について (1) - Button
個人的には、antdを使うことにより、面倒なstyleを指定することが少なくなるので助かります。
3-4.単体テスト
Djangoと結合する前に、json-serverを使って単体テストを行います。
frontendディレクトリ直下にdb.jsonファイルを作ります。
{
"api": [
{
"id": 1,
"title": "Reduxのお勉強",
"description": "特に非同期actionについて",
"status": "In Progress"
},
{
"id": 2,
"title": "ES6のお勉強",
"description": "Promiseについて",
"status": "In Progress"
},
{
"id": 3,
"title": "朝食",
"description": "忘れずに食べること",
"status": "Completed"
},
{
"title": "掃除",
"description": "要らない本は捨てる",
"status": "In Progress",
"id": 4
},
{
"title": "草刈り",
"description": "夏草に要注意!",
"status": "Unstarted",
"id": 5
}
]
}
frontendディレクトリ直下で、json-serverを起動します。
json-server --host www.mypress.jp --watch db.json -p 3003
状態「Unstarted」、「In Progress」、「Completed」毎にTodo一覧が表示されます。以下の画面になります。
3-5.ソースコード
frontend/src直下のソースツリーです。
├── App.js
├── actions
│ └── index.js
├── api
│ └── index.js
├── components
│ ├── FlashMessage.js
│ ├── TaskList.js
│ └── TasksPage.js
├── constants
│ └── index.js
├── index.js
└── reducers
└── index.js
###(1)トップ
主に、Reduxの設定を行い、App.jsを呼びます。
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import tasksReducer from './reducers';
import App from './App';
const rootReducer = (state = {}, action) => {
return {
tasks: tasksReducer(state.tasks, action),
};
};
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
App.jsはメイン画面の枠組みの定義です。
import React, { Component } from 'react';
import { connect } from 'react-redux';
import TasksPage from './components/TasksPage';
import FlashMessage from './components/FlashMessage';
import { createTask, editTask, deleteTask, fetchTasks } from './actions';
import 'antd/dist/antd.css';
class App extends Component {
componentDidMount() {
this.props.dispatch(fetchTasks());
}
onCreateTask = ({ title, description }) => {
this.props.dispatch(createTask({ title, description }));
};
onStatusChange = (id, status) => {
this.props.dispatch(editTask(id, { status }));
};
onDeleteTask = (id) => {
this.props.dispatch(deleteTask(id));
};
render() {
return (
<div>
{this.props.error && <FlashMessage message={this.props.error} />}
<div>
<TasksPage
tasks={this.props.tasks}
onCreateTask={this.onCreateTask}
onStatusChange={this.onStatusChange}
isLoading={this.props.isLoading}
/>
</div>
</div>
);
}
}
function mapStateToProps(state) {
const { tasks, isLoading, error } = state.tasks;
return { tasks, isLoading, error };
}
export default connect(mapStateToProps)(App);
###(2)Reducer & Action
reducerの定義です
const initialState = {
tasks: [],
isLoading: false,
error: null,
};
export default function tasks(state = initialState, action) {
switch (action.type) {
case 'FETCH_TASKS_STARTED': {
return {
...state,
isLoading: true,
};
}
case 'FETCH_TASKS_SUCCEEDED': {
return {
...state,
tasks: action.payload.tasks,
isLoading: false,
};
}
case 'FETCH_TASKS_FAILED': {
return {
...state,
isLoading: false,
error: action.payload.error,
};
}
case 'CREATE_TASK_SUCCEEDED': {
return {
...state,
tasks: state.tasks.concat(action.payload.task),
};
}
case 'EDIT_TASK_SUCCEEDED': {
const { payload } = action;
const nextTasks = state.tasks.map(task => {
if (task.id === payload.task.id) {
return payload.task;
}
return task;
});
return {
...state,
tasks: nextTasks,
};
}
case 'DELETE_TASK_SUCCEEDED': {
const { payload } = action;
const nextTasks = state.tasks.filter(task => task.id !== payload.id)
return {
...state,
tasks: nextTasks,
};
}
default: {
return state;
}
}
}
actionの定義です
import * as api from '../api';
function fetchTasksSucceeded(tasks) {
return {
type: 'FETCH_TASKS_SUCCEEDED',
payload: {
tasks,
},
};
}
function fetchTasksFailed(error) {
return {
type: 'FETCH_TASKS_FAILED',
payload: {
error,
},
};
}
function fetchTasksStarted() {
return {
type: 'FETCH_TASKS_STARTED',
};
}
export function fetchTasks() {
return dispatch => {
dispatch(fetchTasksStarted());
api
.fetchTasks()
.then(resp => {
dispatch(fetchTasksSucceeded(resp.data));
})
.catch(err => {
dispatch(fetchTasksFailed(err.message));
});
};
}
function createTaskSucceeded(task) {
return {
type: 'CREATE_TASK_SUCCEEDED',
payload: {
task,
},
};
}
export function createTask({ title, description, status = 'Unstarted' }) {
return dispatch => {
api.createTask({ title, description, status }).then(resp => {
dispatch(createTaskSucceeded(resp.data));
});
};
}
function editTaskSucceeded(task) {
return {
type: 'EDIT_TASK_SUCCEEDED',
payload: {
task,
},
};
}
export function editTask(id, params = {}) {
return (dispatch, getState) => {
const task = getTaskById(getState().tasks.tasks, id);
const updatedTask = Object.assign({}, task, params);
api.editTask(id, updatedTask).then(resp => {
dispatch(editTaskSucceeded(resp.data));
});
};
}
function getTaskById(tasks, id) {
return tasks.find(task => task.id === id);
}
function deleteTaskSucceeded(id) {
return {
type: 'DELETE_TASK_SUCCEEDED',
payload: {
id,
},
};
}
export function deleteTask(id) {
return (dispatch, getState) => {
api.deleteTask(id).then(resp => {
console.log(resp)
dispatch(deleteTaskSucceeded(id));
});
};
}
statusの定数の定義です。
export const TASK_STATUSES = ['Unstarted', 'In Progress', 'Completed'];
###(3)API
Rest APIのインターフェースモジュールです。ここで注意が必要なのは、DjangoのPOSTの場合、パスの末尾に、'/api/' のように、スラッシュが必要だということです。 '/api' ではだめです。
'/api' のGETの場合、自動的に末尾にスラッシュを付け直してリダイレクトしてOKになります。POSTでもリダイレクトしてくれますが、リダイレクト時にPOST dataが落ちてしまい、結果的にエラーとなります。
json-serverではテストが通ってもDjangoではNGになるので注意が必要です。
POSTでBAD Requestエラーとなる場合は、paramsの中身もチェックしてみましょう。私はここで躓いて、actionが正しいデータを渡してくれているのかを確認せずに、半日も悩んでしまいました。
import axios from 'axios';
// const API_BASE_URL = 'http://www.mypress.jp:3003'; // json-server用
const API_BASE_URL = 'http://www.mypress.jp:8080'; // Django用
const client = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
});
export function fetchTasks() {
return client.get('/api/');
}
export function createTask(params) {
console.log(params)
return client.post('/api/', params);
}
export function editTask(id, params) {
return client.put(`/api/${id}`, params);
}
export function deleteTask(id) {
return client.delete(`/api/${id}/`);
}
###(4)components
TasksPage.jsはTasksPageクラスの他に。タスク追加フォームであるAddTaskFormクラスを定義しています。別ファイルにすべきかと思いましたが、面倒なので一緒にしました。
タスク追加フォームには、antdのForm componentを使っています。validateが統一的に行えるので便利ですが、少しコードが複雑になります。詳しくは「React UI library の antd について (3) - redux-form」も参照してください。
import React, { Component } from 'react';
import { Form, Input, Icon, Button } from 'antd';
import TaskList from './TaskList';
import { TASK_STATUSES } from '../constants';
class TasksPage extends Component {
constructor(props) {
super(props);
this.state = {
showNewCardForm: false,
};
}
toggleForm = () => {
this.setState({ showNewCardForm: !this.state.showNewCardForm });
};
render() {
if (this.props.isLoading) {
return (
<div>
Loading...
</div>
);
}
return (
<div>
<div>
<Button type="primary" onClick={this.toggleForm}>+タスク追加</Button>
</div>
{this.state.showNewCardForm && <WrappedAddTaskForm onCreateTask={this.props.onCreateTask} />}
<div>
{TASK_STATUSES.map(status => {
const statusTasks = this.props.tasks.filter(
task => task.status === status
);
return (
<div style={{ margin: "25px 20px 25px 20px" }}>
<h2>{status}</h2>
<TaskList
key={status}
status={status}
tasks={statusTasks}
onStatusChange={this.props.onStatusChange}
onDeleteTask={this.props.onDeleteTask}
/>
</div>
);
})}
</div>
</div>
);
}
}
export default TasksPage;
class AddTaskForm extends React.Component {
componentDidMount() {
// To disabled submit button at the beginning.
this.props.form.validateFields();
}
handleSubmit = e => {
e.preventDefault();
this.props.form.validateFields((err, values) => {
if (!err) {
console.log('Received values of form: ', values);
this.props.onCreateTask(values)
}
});
};
render() {
const { getFieldDecorator, getFieldError, isFieldTouched } = this.props.form;
// Only show error after a field is touched.
const taskError = isFieldTouched('task') && getFieldError('task');
const descriptionError = isFieldTouched('description') && getFieldError('description');
const buttonDisable = getFieldError('task') || getFieldError('description')
return (
<Form layout="inline" onSubmit={this.handleSubmit}>
<Form.Item validateStatus={taskError ? 'error' : ''} help={taskError || ''}>
{getFieldDecorator('task', {
rules: [{ required: true, message: 'taskを入力してください!' }],
})(
<Input
prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />}
placeholder="task"
/>,
)}
</Form.Item>
<Form.Item validateStatus={descriptionError ? 'error' : ''} help={descriptionError || ''}>
{getFieldDecorator('description', {
rules: [{ required: true, message: 'descriptionを入力してください!' }],
})(
<Input
prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
placeholder="description"
/>,
)}
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" disabled={buttonDisable}>
タスク追加
</Button>
</Form.Item>
</Form>
);
}
}
const WrappedAddTaskForm = Form.create({ name: 'add_task_form' })(AddTaskForm);
タスク一覧の表示です。antdのList componentを使っているので、とても簡潔に記述できています。
import React from 'react';
import { List, Card } from 'antd';
import { TASK_STATUSES } from '../constants';
const TaskList = props => {
return (
<List
grid={{ gutter: 16, column: 4 }}
dataSource={props.tasks}
renderItem={item => (
<List.Item>
<Card title={item.title}>{item.description}</Card>
<select value={item.status} onChange={(e) => {onStatusChange(e, item.id)}}>
{TASK_STATUSES.map(status => (
<option key={status} value={status}>{status}</option>
))}
</select>
<Button type="danger" onClick={()=>{props.onDeleteTask(item.id)}}>
タスク削除
</Button>
</List.Item>
)}
/>
);
function onStatusChange(e, id) {
props.onStatusChange(id, e.target.value);
}
};
export default TaskList;
actionでエラーが発生した場合に、表示されるメッセージです。
import React from 'react';
export default function FlashMessage(props) {
return (
<div>
{props.message}
</div>
);
}
FlashMessage.defaultProps = {
message: 'An error occurred',
};
#4.ReactとDjangoの結合
現在は以下の状況です
- 【サーバサイド】Django単体での動作を確認済み
- 【フロントエンド】React単体での動作を確認済み
最後にサーバサイドとフロントエンドを結合して動作を確認します。
単体で成功しても、結合で失敗し時間を費やすことになるのは、よくあることです。今回も以下の2点でだいぶ時間を浪費してしまいました。
- CORSの設定のデバッグに時間を要した(ブラウザキャッシュの問題)
- POSTリクエストエラーに時間を要した(リクエストパスの末尾のスラッシュを忘れた & POSTデータの属性"title"が間違っていた)
特にjson-serverはサーバ側は、特にチェック無しで通りますが、Djangoの場合はデータの属性名が違っていたりすると、当然はじかれます。この点を忘れて迷路に迷うことにならないように注意します。
今回は以上です。