久しぶりに仕事で、pythonを触ることになったのですが、DjangoとDRF(Django Rest Framework)が使われていました。
ソースコードを一見して正直どこで何が行われているのか全くわからなかったので、チュートリアルを見ながら簡単なAPIを作成し、その後、DRFを使ってAPIを作り直してみて、勉強しようと思います。
Python: 3.10.2
Django: 4.0.3
DRF: 3.13.1
作成したソースコードは以下で公開しています。
https://github.com/rinoguchi/django_rest_framework
Djangoで投票アプリ構築
まずは、DRFは利用せず素のDjangoのみで、チュートリアルに従って投票(polls)アプリを作ってみたいと思います。
環境構築
poetryを使って環境構築しました。
pythonの仮想環境を作って、djangoをインストールするところまで、やってみました。
# pythonのバージョンを最新に
pyenv install pyenv
pyenv local 3.10.2
# poetryをインストール
pip install poetry
echo "export PATH=~/.poetry/bin:$PATH" > ~/.bash_profile
# poetry初期化 -> pyproject.tomlが作成される
poetry init
# djangoの依存関係を追加
poetry add django
# pythonのREPLをpoetry経由で起動
poetry run python
> Python 3.10.2 (main, Mar 1 2022, 11:29:47) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
> Type "help", "copyright", "credits" or "license" for more information.
# djangoのバージョン確認
>>> import django
>>> print(django.get_version())
> 4.0.3
Djangoプロジェクト作成
django-admin
コマンドを使って、プロジェクトを作成します。
poetry run django-admin startproject apps # poetryを利用しない場合は、`poetry run`は不要
自動生成されたフォルダおよびファイルは以下の通りです。
.
└── apps
├── manage.py
└── apps
├── asgi.py
├── settings.py
├── urls.py
└── wsgi.py
- manage.py
- とても重要なファイル。アプリケーションを操作する様々なコマンドを提供している
- settings.py
- アプリケーションの設定ファイル
- 設定内容はこちらを参照
- urls.py
- ルーティング定義
- wsgi.py
- WSGIとはWeb Server Gateway Interfaceの略で、PythonでWebサーバとアプリケーション間の標準化インタフェースのことらしい
- このファイルは、プロジェクトを提供するWSGI互換Webサーバのエントリポイント
- asgi.py
- ASGIは、Asynchronous Server Gateway Interfaceの略で、WSGIの後継者で、非同期対応のWebサーバとアプリケーション間の標準インターフェイスのことらしい
- このファイルは、プロジェクトを提供するASGI互換Web サーバのエントリポイント
サーバの起動
poetry run python manage.py runserver # poetryを利用しない場合は、`poetry run`は不要
https://rinoguchi.net にアクセスして、「The install worked successfully! Congratulations!」と表示されたので、ここまでは問題なくできたようです。
投票アプリの雛形作成
Djangoではプロジェクトの下に複数のアプリケーションを追加することができます。こちらもmanager.py
を使ってアプリの雛形を作成します。
poetry run python manage.py startapp polls # poetryを利用しない場合は、`poetry run`は不要
先程のapps
フォルダの隣にpolls
フォルダが作成されました。
├── apps
│ ├── apps
│ └── polls
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ ├── models.py
│ ├── tests.py
│ └── views.py
├── poetry.lock
└── pyproject.toml
ビュー作成
views.py
を書き換えます。
「polls index」というレスポンスを返すだけの関数を定義しています。
from django.http import HttpResponse
def index(request):
return HttpResponse("polls index")
ルーティング定義
まず、polls/urls.py
を作成します。
直下のURLに対してindex
という関数をマッピングしているのがわかります。
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
]
次に、apps/urls.py
にpolls/urls.py
への参照を定義します。
polls/
というURLに対して、polls/urls
をマッピングしているのがわかります。
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('polls/', include('polls.urls')), # <--追加
path('admin/', admin.site.urls),
]
curlコマンドで、今回作成したURLにアクセスしてみると、無事、実装した文字列が返ってきました。
curl https://rinoguchi.net/polls/
> polls index
データベースの準備
MySQL立ち上げ
docker-composeでMySQLを立ち上げます。
まず、docker-compose.yml
記載します。
version: '3'
services:
db:
image: mysql:latest
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: polls
MYSQL_USER: polls_user
MYSQL_PASSWORD: polls_password
TZ: 'Asia/Tokyo'
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- ./docker/db/data:/var/lib/mysql
- ./docker/db/my.cnf:/etc/mysql/conf.d/my.cnf
- ./docker/db/sql:/docker-entrypoint-initdb.d
ports:
- 3306:3306
次に、以下のコマンドで立ち上げます。
docker-compose up -d
試しに、msql
コマンドでアクセスしてみると、無事にログインできました。
mysql -h 127.0.0.1 -u polls_user -p polls
> Enter password:
> Welcome to the MariaDB monitor. Commands end with ; or \g.
> Your MySQL connection id is 10
> Server version: 8.0.28 MySQL Community Server - GPL
>
> Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
>
> Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
>
> MySQL [polls]>
設定
Webアプリが立ち上げたデータベースにアクセスするには、データベースを伝えてあげる必要があります。
apps/apps/settings.py
にMySQLのpolls
データベースにアクセスするための情報を記載します。
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'polls',
'USER': 'polls_user',
'PASSWORD': 'polls_password',
'HOST': '127.0.0.1',
'PORT': '3306',
'OPTIONS': {
'charset': 'utf8mb4',
}
}
}
本来、環境変数から取得すると思いますが、今回はお試しなのでベタ書きです。
また、MySQLを利用するので、MySQLのクライアントライブラリをインストールします。
poetry add mysqlclient
Djangoのプロジェクトは、以下のようにデフォルトで色々と機能が提供されており、データベースを利用するようになっています。
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
マイグレーション実行
今回、データベースをSQLiteからMySQLに変更したので、DBマイグレーションを行う必要があります。
poetry run python manage.py migrate # poetryを利用しない場合は、`poetry run`は不要
> Operations to perform:
> Apply all migrations: admin, auth, contenttypes, sessions
> Running migrations:
> Applying contenttypes.0001_initial... OK
> Applying auth.0001_initial... OK
> Applying admin.0001_initial... OK
> Applying admin.0002_logentry_remove_auto_add... OK
> Applying admin.0003_logentry_add_action_flag_choices... OK
> Applying contenttypes.0002_remove_content_type_name... OK
> Applying auth.0002_alter_permission_name_max_length... OK
> Applying auth.0003_alter_user_email_max_length... OK
> Applying auth.0004_alter_user_username_opts... OK
> Applying auth.0005_alter_user_last_login_null... OK
> Applying auth.0006_require_contenttypes_0002... OK
> Applying auth.0007_alter_validators_add_error_messages... OK
> Applying auth.0008_alter_user_username_max_length... OK
> Applying auth.0009_alter_user_last_name_max_length... OK
> Applying auth.0010_alter_group_name_max_length... OK
> Applying auth.0011_update_proxy_permissions... OK
> Applying auth.0012_alter_user_first_name_max_length... OK
> Applying sessions.0001_initial... OK
mysql
コマンドで確認するといくつかテーブルが作成されることが確認できました。
MySQL [polls]> show tables;
> +----------------------------+
> | Tables_in_polls |
> +----------------------------+
> | auth_group |
> | auth_group_permissions |
> | auth_permission |
> | auth_user |
> | auth_user_groups |
> | auth_user_user_permissions |
> | django_admin_log |
> | django_content_type |
> | django_migrations |
> | django_session |
> +----------------------------+
> 10 rows in set (0.005 sec)
テーブル(モデル)作成
モデル作成
モデルとは、データベースのレイアウトとそれに付随するメタデータを表現するものです。
クラスがテーブル、フィールドがカラムを表しています。
from django.db import models
class Question(models.Model):
question_text = models.CharField(max_length=200)
publish_date = models.DateTimeField("date published")
class Choice(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="choices")
choice_text = models.CharField(max_length=200)
votes = models.IntegerField(default=0)
- 今回は、Question(質問)とChoice(選択肢)の二つのモデル(テーブル)を定義しています
- Choiceの
question
が、外部キー制約でQuestion(のid)を参照しています - 合わせて、
related_name
でQuestionモデル側にchoices
プロパティが擬似的に存在するようにしてあります- これにより、
Question.choices
のようなイメージで、親側からone-to-manyのプロパティにアクセスできるようになります
- これにより、
設定
モデルを定義してマイグレーションを行うことでDDLが実行されますが、そのためにはpolls
アプリケーションをプロジェクトに追加する必要があります。
具体的には、apps/apps/settings.py
のINSTALLED_APPS
にpolls
の定義を追加します。
INSTALLED_APPS = [
'polls.apps.PollsConfig',
# ...省略...
]
マイグレーション実行
以下のコマンドでマイグレーションファイルを作成します。
poetry run python manage.py makemigrations polls # poetryを利用しない場合は、`poetry run`は不要
> Migrations for 'polls':
> polls/migrations/0001_initial.py
> - Create model Question
> - Create model Choice
ログの通り、apps/polls/migrations/0001_initial.py
というファイルが作成されています。
中身は以下の通りです。
Djangoではマイグレーション内容は、SQLではなくpythonのプログラムとして表現するようです。
# Generated by Django 4.0.3 on 2022-03-20 06:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Question',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question_text', models.CharField(max_length=200)),
('pub_date', models.DateTimeField(verbose_name='date published')),
],
),
migrations.CreateModel(
name='Choice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('choice_text', models.CharField(max_length=200)),
('votes', models.IntegerField(default=0)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='polls.question')),
],
),
]
正直、pythonをSQLに脳内変換する必要があるのでちょっと微妙ですが、書いてあることはわかります。
具体的なSQLを知りたい場合は、以下のコマンドを実行すれば良いらしいです。
poetry run python manage.py sqlmigrate polls 0001 # poetryを利用しない場合は、`poetry run`は不要
> -- Create model Question
> CREATE TABLE `polls_question` (`id` bigint AUTO_INCREMENT NOT NULL PRIMARY KEY, `question_text` varchar(200) NOT NULL, `pub_date` datetime(6) NOT NULL);
> -- Create model Choice
> CREATE TABLE `polls_choice` (`id` bigint AUTO_INCREMENT NOT NULL PRIMARY KEY, `choice_text` varchar(200) NOT NULL, `votes` integer NOT NULL, `question_id` bigint NOT NULL);
> ALTER TABLE `polls_choice` ADD CONSTRAINT `polls_choice_question_id_c5b4b260_fk_polls_question_id` FOREIGN KEY (`question_id`) REFERENCES `polls_question` (`id`);
では、実際にマイグレーションを実行してみます。
poetry run python manage.py migrate # poetryを利用しない場合は、`poetry run`は不要
> Operations to perform:
> Apply all migrations: admin, auth, contenttypes, polls, sessions
> Running migrations:
> Applying polls.0001_initial... OK
問題なく実行されました。
mysqlコマンドで確認すると以下のようにテーブルが作成されていることを確認できました。
MySQL [polls]> desc polls_choice;
+-------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+--------------+------+-----+---------+----------------+
| id | bigint | NO | PRI | NULL | auto_increment |
| choice_text | varchar(200) | NO | | NULL | |
| votes | int | NO | | NULL | |
| question_id | bigint | NO | MUL | NULL | |
+-------------+--------------+------+-----+---------+----------------+
4 rows in set (0.005 sec)
MySQL [polls]> desc polls_question;
+---------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+---------------+--------------+------+-----+---------+----------------+
| id | bigint | NO | PRI | NULL | auto_increment |
| question_text | varchar(200) | NO | | NULL | |
| pub_date | datetime(6) | NO | | NULL | |
+---------------+--------------+------+-----+---------+----------------+
3 rows in set (0.005 sec)
ちなみに、もう一回マイグレーションを実行すると、同じマイグレーションファイルがもう一度実行されることはありません。
poetry run python manage.py migrate
> Operations to perform:
> Apply all migrations: admin, auth, contenttypes, polls, sessions
> Running migrations:
> No migrations to apply.
つまり、実行済みのマイグレーションファイルが管理されているということなのですが、これはdjango_migrations
というテーブルで管理されているようです。
データを確認すると、一番最後のid=19
のレコードとして、polls
の0001_initial
(マイグレーションファイル名)が2022-03-20 06:29:06
に適用されたことが記録されています。
MySQL [polls]> select * from django_migrations;
+----+--------------+------------------------------------------+----------------------------+
| id | app | name | applied |
+----+--------------+------------------------------------------+----------------------------+
| 1 | contenttypes | 0001_initial | 2022-03-13 10:52:22.904357 |
| 2 | auth | 0001_initial | 2022-03-13 10:52:24.068071 |
| 3 | admin | 0001_initial | 2022-03-13 10:52:24.348645 |
| 4 | admin | 0002_logentry_remove_auto_add | 2022-03-13 10:52:24.367090 |
| 5 | admin | 0003_logentry_add_action_flag_choices | 2022-03-13 10:52:24.384456 |
| 6 | contenttypes | 0002_remove_content_type_name | 2022-03-13 10:52:24.625532 |
| 7 | auth | 0002_alter_permission_name_max_length | 2022-03-13 10:52:24.742140 |
| 8 | auth | 0003_alter_user_email_max_length | 2022-03-13 10:52:24.800678 |
| 9 | auth | 0004_alter_user_username_opts | 2022-03-13 10:52:24.814271 |
| 10 | auth | 0005_alter_user_last_login_null | 2022-03-13 10:52:24.926167 |
| 11 | auth | 0006_require_contenttypes_0002 | 2022-03-13 10:52:24.936311 |
| 12 | auth | 0007_alter_validators_add_error_messages | 2022-03-13 10:52:24.953623 |
| 13 | auth | 0008_alter_user_username_max_length | 2022-03-13 10:52:25.074838 |
| 14 | auth | 0009_alter_user_last_name_max_length | 2022-03-13 10:52:25.195808 |
| 15 | auth | 0010_alter_group_name_max_length | 2022-03-13 10:52:25.234605 |
| 16 | auth | 0011_update_proxy_permissions | 2022-03-13 10:52:25.253413 |
| 17 | auth | 0012_alter_user_first_name_max_length | 2022-03-13 10:52:25.367748 |
| 18 | sessions | 0001_initial | 2022-03-13 10:52:25.452248 |
| 19 | polls | 0001_initial | 2022-03-20 06:29:06.803184 |
+----+--------------+------------------------------------------+----------------------------+
19 rows in set (0.005 sec)
Admin画面でデータ登録
Djangoでは、モデルで管理されているテーブルに対するCRUD操作を行うためのAdmin画面が提供されています。
これを追加って、Question(質問)とChoice(選択肢)のデータを登録してみたいと思います。
まずは、apps/polls/admin.py
を以下のように編集し、Admin画面で二つのモデルを編集できるようにします。
from django.contrib import admin
from .models import Question, Choice
admin.site.register(Question)
admin.site.register(Choice)
次に、以下のコマンドで、adminユーザを作成します。
poetry run python manage.py createsuperuser
> Username (leave blank to use 'xxx'): admin
> Email address: xxx@gmail.com
> Password: **********
> Password (again): **********
> Superuser created successfully.
あとは、Djangoアプリケーションを起動して、
poetry run python manage.py runserver
以下のAdmin画面のURLにアクセスして、nameとpasswordを入力します。
https://rinoguchi.net/admin/
無事に、QuestionとChoiceのCRUD画面へのリンクが表示されました。
この画面から、以下のように、二つの質問とそれぞれ4つの選択肢のデータを登録してみました。
MySQL [polls]> select * from polls_question q join polls_choice c on c.question_id = q.id order by q.id, c.id;
+----+--------------------------------+----------------------------+----+--------------+-------+-------------+
| id | question_text | pub_date | id | choice_text | votes | question_id |
+----+--------------------------------+----------------------------+----+--------------+-------+-------------+
| 1 | 旅行に行きたい国は? | 2022-03-20 07:02:46.000000 | 1 | 中国 | 0 | 1 |
| 1 | 旅行に行きたい国は? | 2022-03-20 07:02:46.000000 | 2 | 韓国 | 0 | 1 |
| 1 | 旅行に行きたい国は? | 2022-03-20 07:02:46.000000 | 3 | アメリカ | 0 | 1 |
| 1 | 旅行に行きたい国は? | 2022-03-20 07:02:46.000000 | 4 | その他 | 0 | 1 |
| 2 | 好きなスポーツは? | 2022-03-20 07:11:06.000000 | 5 | 野球 | 0 | 2 |
| 2 | 好きなスポーツは? | 2022-03-20 07:11:06.000000 | 6 | サッカー | 0 | 2 |
| 2 | 好きなスポーツは? | 2022-03-20 07:11:06.000000 | 7 | バスケ | 0 | 2 |
| 2 | 好きなスポーツは? | 2022-03-20 07:11:06.000000 | 8 | その他 | 0 | 2 |
+----+--------------------------------+----------------------------+----+--------------+-------+-------------+
API作成
必要なデータも揃ったところで、チュートリアルに従ってアプリの画面作成をやっていこうと思ったのですが、実際のところサーバサイドレンダリングはやらなそうなので、API作成だけやってみようと思います。
以下の三つのAPIを作成してみました。
- 質問一覧API
GET /polls/questions/
- 質問API
GET /polls/questions/<question_id>/
- 投票API
POST /polls/questions/<question_id>/vote/
ルーティング定義を追加
apps/polls/urls.py
に以下の定義を追加しました。
設定方法は、URLと対応する関数を指定するだけでとても簡単です。
from django.urls import path
from . import views
urlpatterns = [
path("questions/", views.get_questions, name="questions"),
path("questions/<int:id>/", views.get_question, name="question"),
path("questions/<int:id>/vote/", views.vote, name="vote"),
]
一つポイントなのですが、こちらに記載されているように、リクエストメソッドは関係なくGETでもPOSTでもおなじ関数に紐づけられるという点に注意が必要です。
The URLconf doesn’t look at the request method. In other words, all request methods – POST, GET, HEAD, etc. – will be routed to the same function for the same URL.
質問一覧API
GET /polls/questions/
に対応する関数をapps/polls/views.py
に記載します。
from django.http import HttpRequest, JsonResponse
from polls.models import Question
def get_questions(request: HttpRequest):
questions = list(Question.objects.all().order_by("id").values())
return JsonResponse(
questions, safe=False, json_dumps_params={"ensure_ascii": False}
)
-
values()
は辞書型のリストを返すので、プロパティ名と値のセットがJSONに出力されます -
safe=False
を指定しないと以下のエラーが発生しますTypeError: In order to allow non-dict objects to be serialized set the safe parameter to False.
-
json_dumps_params={"ensure_ascii": False}
を指定することで、日本語がunicodeエンコーディングされず、期待通りUTF-8でそのまま返却されます
動作確認すると、ちゃんとレスポンスが返ってきました。
curl https://rinoguchi.net/polls/questions/
[{"id": 1, "question_text": "旅行に行きたい国は?", "pub_date": "2022-03-20T07:02:46Z"}, {"id": 2, "question_text": "好きなスポーツは?", "pub_date": "2022-03-20T07:11:06Z"}]
質問API
GET /polls/questions/<question_id>/
に対応する関数をapps/polls/views.py
に記載します。
def get_question(request: HttpRequest, id: int):
question = Question.objects.get(id=id)
response = dict()
response["id"] = question.id
response["question_text"] = question.question_text
response["pub_date"] = question.pub_date
response["choices"] = list(question.choices.all().values())
return JsonResponse(response, safe=False, json_dumps_params={"ensure_ascii": False})
- Djangoでは、one-to-manyのフィールド(
Question.choices
)を含んだ形でQuestionオブジェクトを取得して、それを一発でJSONにシリアライズするような手段がなさそうです- 数時間ググったり、色々試したりしましたが発見できませんでした
- 仕方ないので、
question
とchoices
を個別に取得して、一つのJSONとして返すようにしました
-
Question.objects.prefetch_related('choices')
は使っていません- これは、one-to-manyのフィールドを事前に取得してキャッシュしておく機構なのですが、今回は
choices
を一度しか使ってないので、逆に実行されるSQLが増えてしまって、やめておきました
- これは、one-to-manyのフィールドを事前に取得してキャッシュしておく機構なのですが、今回は
-
Question.objects.fields('choices')
も使っていません- 良さそうなのですが、Choiceオブジェクトではなく、Choide.idだけが含まれる形だったので使えませんでした
-
filter
ではなくget
を使っています-
get()
はfilter()[0]
のシノニムっぽい。複数件返ってきた場合はエラーになります
-
動作確認すると、期待通りレスポンスが返ってきました。
curl https://rinoguchi.net/polls/questions/1/
{"id": 1, "question_text": "旅行に行きたい国は?", "pub_date": "2022-03-20T07:02:46Z", "choices": [{"id": 1, "question_id": 1, "choice_text": "中国", "votes": 0}, {"id": 2, "question_id": 1, "choice_text": "韓国", "votes": 0}, {"id": 3, "question_id": 1, "choice_text": "アメリカ", "votes": 0}, {"id": 4, "question_id": 1, "choice_text": "その他", "votes": 0}]}
投票API
POST /polls/questions/<question_id>/vote
に対応する関数をapps/polls/views.py
に記載します。
@csrf_exempt
def vote(request: HttpRequest, id: int):
choice = Choice.objects.get(
question__id=id, id=json.loads(request.body).get("choice_id")
)
choice.votes += 1
choice.save()
return HttpResponse(status=200)
-
@csrf_exempt
でcsrf-tokenのチェックを除外する指定です -
json.loads()
で受け取ったjsonをパースしています -
choice.save()
でUPDATE文が実行されます - 本来、Questionオブジェクトを返却したいですが、サクッとできないので、status=200を返す形にしてますw
動作確認すると、期待通りレスポンスが返ってきました。
curl --request POST --url http://127.0.0.1:8000/polls/questions/1/vote/ --header 'Content-Type: application/json' --data '{"choice_id": 1}' -v
# 省略
< HTTP/1.1 200 OK
DBの投票カウントもちゃんとカウントアップされていました。
MySQL [polls]> select * from polls_choice where question_id = 1 and id = 1;
+----+-------------+-------+-------------+
| id | choice_text | votes | question_id |
+----+-------------+-------+-------------+
| 1 | 中国 | 1 | 1 |
+----+-------------+-------+-------------+
長かったですが、素のDjangoを使って投票アプリのAPIサーバを作るのは一旦これで終わりにします。
なかなかに、クセが強くて時間がかかりましたが、なんとなく基本的なところは把握できた気はしますね。
DRFでAPIを作り直す
ここまでは素のDjangoで実装してきましたが、REST APIを作成する場合はDRF(Django Rest Framework)を使うと実装量をかなり減らせるようで、そちらを試してみたいと思います。
インストール
まずは、ライブラリをインストールして、
poetry add djangorestframework
次に、apps/apps/settings.py
のINSTALLED_APPS
に設定を追加し、DjangoにDRFを利用することを伝えます。
INSTALLED_APPS = [
"rest_framework",
]
投票アプリの雛形作成
新しく、drfpolls
というアプリを作ります。
poetry run python manage.py startapp drfpolls
apps/apps/settings.py
のINSTALLED_APPS
に設定を追加します。
INSTALLED_APPS = [
"drfpolls.apps.DrfpollsConfig",
]
モデルは、polls
と同じものを使い回す予定です。
ルーティング定義を追加
apps/apps/urls.py
にdrfpolls/
へのルーティング定義を追加します。
urlpatterns = [
path("drfpolls/", include("drfpolls.urls")),
]
drfpolls/
配下のルーティング定義は、apps/drfpolls/urls.py
に記載します。
from django.urls import include, path
from rest_framework import routers
from drfpolls import views
router = routers.DefaultRouter()
router.register(r"questions", views.QuestionViewSet)
urlpatterns = [
path("", include(router.urls)),
]
-
/drfpolls/questions/
というURLに対して、QuestionViewSet
を割り当てています-
ViewSet
というだけあって、いくつかのURLをこのQuestionViewSet
が提供してくれます
-
ビューの作成
apps/drfpolls/views.py
を作成します。
from rest_framework import viewsets
from polls.models import Question
from drfpolls.serializers import QuestionSerializer
class QuestionViewSet(viewsets.ModelViewSet):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
-
ModelViewSet
は、内部的にCreateModelMixin
やListModelMixin
などをミクスインしています。結果的に、いくつかのAPIが自動的に作成されます-
ListModelMixin
=> 一覧取得(GET /drfpolls/questions/
) -
RetrieveMixin
=> 取得(GET /drfpolls/questions/1/
) -
CreateModelMixin
=> 作成(POST /drfpolls/questions/
) -
UpdateModelMixin
=> 更新(PATCH /drfpolls/questions/1/
) -
DestroyModelMixin
=> 削除(DELETE /drfpolls/questions/1/
) - 上記の一部だけを提供したい場合は、
ModelViewSet
ではなく、CreateModelMixin
などを個別にミクスインすればOKです
-
-
queryset
により、一覧取得や取得で利用する元データを指定しています-
Question
モデルの全データを指定しています -
all()
ではなく、filter
を使って、何かの条件で絞り込んだデータを指定することもできます
-
シリアライザーの作成
シリアライザーの役割は以下の二つのようです。
- リクエスト(JSON)をvalidateして、チェックOKならモデルに変換する(さらにDBにも保存する)
- DBからデータをモデルとして取得し、レスポンス(JSON)に変換して返却する
apps/drfpolls/serializers.py
を作成します。
from rest_framework import serializers
from polls.models import Question
class QuestionSerializer(serializers.ModelSerializer):
class Meta:
model = Question
fields = ["id", "question_text", "pub_date", "choices"]
read_only_fields = ["choices"]
depth = 1
- チュートリアルだと、
HyperlinkedModelSerializer
を利用しているが、シンプルにしたかったので、ModelSerializer
に変更しました-
HyperlinkedModelSerializer
は関連するモデルの情報をそのリソースのURLの形式で返却するが、ModelSerializer
は単純にIDを返却します
-
-
fields
で出力対象のフィールドを指定しています。choices
はone-to-manyの関連モデルです -
read_only_fields
で、choices
が更新対象外であることを指定しています -
depth = 1
とすることで、関連モデルのプロパティもレスポンスに含まれるようにしています- これを指定しないと、
choices
は[1,2,3,4]
のようなIDの配列になります
- これを指定しないと、
ここまでの実装で、だいたい動くようになりました。
-
一覧取得
curl http://127.0.0.1:8000/drfpolls/questions/ curl http://127.0.0.1:8000/drfpolls/questions/ [{"id":1,"question_text":"旅行に行きたい国は?","pub_date":"2022-03-20T07:02:46Z","choices":[{"id":1,"choice_text":"中国","votes":4,"question":1},{"id":2,"choice_text":"韓国","votes":0,"question":1},{"id":3,"choice_text":"アメリカ","votes":0,"question":1},{"id":4,"choice_text":"その他","votes":0,"question":1}]},{"id":2,"question_text":"好きなスポーツは?","pub_date":"2022-03-20T07:11:06Z","choices":[{"id":5,"choice_text":"野球","votes":0,"question":2},{"id":6,"choice_text":"サッカー","votes":0,"question":2},{"id":7,"choice_text":"バスケ","votes":0,"question":2},{"id":8,"choice_text":"その他","votes":0,"question":2}]}]
-
取得
curl http://127.0.0.1:8000/drfpolls/questions/1/ {"id":1,"question_text":"旅行に行きたい国は?","pub_date":"2022-03-20T07:02:46Z","choices":[{"id":1,"choice_text":"中国","votes":4,"question":1},{"id":2,"choice_text":"韓国","votes":0,"question":1},{"id":3,"choice_text":"アメリカ","votes":0,"question":1},{"id":4,"choice_text":"その他","votes":0,"question":1}]}
-
作成
curl -X POST -H "Content-Type: application/json" -d '{"question_text":"好きな本は?","pub_date":"2022-04-17T07:02:46Z"}' http://127.0.0.1:8000/drfpolls/questions/ {"id":3,"question_text":"好きな本は?","pub_date":"2022-04-17T07:02:46Z","choices":[]}
-
更新
curl -X PATCH -H "Content-Type: application/json" -d '{"question_text":"好きなゲームは?","pub_date":"2022-04-17T07:02:46Z"}' http://127.0.0.1:8000/drfpolls/questions/3/ {"id":3,"question_text":"好きなゲームは?","pub_date":"2022-04-17T07:02:46Z","choices":[]}
- 削除
curl -X DELETE http://127.0.0.1:8000/drfpolls/questions/3/
この実装量で、これだけのAPIがいい感じで動いてくれるのは、慣れれば相当生産性が上がりそうですね(初見殺しだけど)。
投票APIの実装
最後に、投票API(POST /drfpolls/questions/<question_id>/vote
)を作ってみました。
具体的には、apps/drfpolls/views.py
にvote
という関数を追加しました。
from urllib.request import Request
from django.http import HttpResponse
from rest_framework import viewsets
from polls.models import Question
from drfpolls.serializers import QuestionSerializer
from rest_framework.decorators import action
class QuestionViewSet(viewsets.ModelViewSet):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
@action(detail=True, methods=["post"])
def vote(self, request: Request, *args, **kwargs):
question = self.get_object()
choice_id = request.data.get("choice_id") # type: ignore
choice = question.choices.get(id=choice_id)
choice.votes = choice.votes
choice.save()
return HttpResponse(status=200)
-
@action
で装飾することで、関数名のURLにマッピングされたアクションになります-
detail=True
としているので、詳細を取得するURL扱いになります。つまり今回だとdrfpolls/questions/99/vote/
というURLにマッピングされます
-
-
ModelViewSet
の内部でGenericViewSet
がミクスインされているので、self.get_object()
で対象のオブジェクトを取得できます。今回だとQuestionオブジェクトを取得できます - リクエストJSONから
choice_id
を取得して、Questionオブジェクトから対象のChoiceを選択し、投票数(votes
)を+1
して保存しています- 本来、DBへの保存はシリアライザーでやるべきな気がするものの、面倒なのでここで実装しちゃってます
動作確認すると、無事動いてくれました。データも問題なく登録されていました。
curl -X POST -H "Content-Type: application/json" -d '{"choice_id": 3}' http://127.0.0.1:8000/drfpolls/questions/1/vote/ -v
#省略
< HTTP/1.1 200 OK
Tips
SQLをログ出力
Djangoでは、モデルを経由してSQLを実行するため、どんなSQLが実行されているのか全くわかりません。
実際のSQLを意識せずに実装すると間違ったデータを取得したり、ひどい性能になったりするので、SQLを常に見れるようにしたいです。
SQLをログに出力するのは、settings.py
に設定するだけでOKでした。以下の記事の通りでうまくいきました。(ありがとうございます)
https://qiita.com/fumihiko-hidaka/items/0f619749580da5ad9ce5
REPL起動
pythonで実装するときREPLを起動して動作確認することが多いと思います。
以下のコマンドでREPLを起動すると、Djangoを起動した時と同じ状態でREPLを起動できます。
poetry run python manage.py shell # poetryを利用しない場合は、`poetry run`は不要
さいごに
今回、Djangoのチュートリアルを大体一通りやってみて、かつ、作ったAPIをDRFを使って再作成しました。
少ないソースコードで多くの機能が実装されるので、Djangoに染まればかなりの生産性で実装可能だと思います。
一方で、初見殺し感はハンパないので、新規メンバーがキャッチアップするのはだいぶ大変そうです。
好みはあると思いますが、チームの特性次第では(例えば、Djangoのプロフェッショナルで構成され、人の入れ替わりが少ない)、有力なフレームワークだと思いました。