久しぶりに仕事で、pythonを触ることになったのですが、DjangoDRF(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.pypolls/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.pyINSTALLED_APPSpollsの定義を追加します。

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のレコードとして、polls0001_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にシリアライズするような手段がなさそうです
    • 数時間ググったり、色々試したりしましたが発見できませんでした
    • 仕方ないので、questionchoicesを個別に取得して、一つのJSONとして返すようにしました
  • Question.objects.prefetch_related('choices')は使っていません
    • これは、one-to-manyのフィールドを事前に取得してキャッシュしておく機構なのですが、今回はchoicesを一度しか使ってないので、逆に実行されるSQLが増えてしまって、やめておきました
  • 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.pyINSTALLED_APPSに設定を追加し、DjangoにDRFを利用することを伝えます。

INSTALLED_APPS = [
    "rest_framework",
]

投票アプリの雛形作成

新しく、drfpollsというアプリを作ります。

poetry run python manage.py startapp drfpolls

apps/apps/settings.pyINSTALLED_APPSに設定を追加します。

INSTALLED_APPS = [
    "drfpolls.apps.DrfpollsConfig",
]

モデルは、pollsと同じものを使い回す予定です。

ルーティング定義を追加

apps/apps/urls.pydrfpolls/へのルーティング定義を追加します。

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は、内部的にCreateModelMixinListModelMixinなどをミクスインしています。結果的に、いくつかの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.pyvoteという関数を追加しました。

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のプロフェッショナルで構成され、人の入れ替わりが少ない)、有力なフレームワークだと思いました。