Python 3.8.10＋Django 4.2.6で「Hotwire Django：Tutorial」を試してみた

この記事は、[Python Advent Calendar 2023 シリーズ1](https://qiita.com/advent-calendar/2023/python) の21日目です

昨日は、 @yutowac さんで [「Gemini ProでKaggle初心者でもサクッとモデルをつくる」](https://qiita.com/yutowac/items/16b869d65d6885f5cffb) でした

---

[piacere](https://twitter.com/piacere_ex) です、ご覧いただいてありがとございます :bow:

普段は、[Elixir／Phoenix／LiveView](https://qiita.com/advent-calendar/2023/elixir)メインで開発してますが、Python／TensorFlow／Keras等によるAI・ML開発は2017年頃から続けていて、Django／Flaskも年2～3本くらい既存システムのバージョンアップ案件やリプレイス案件をこなしていたりと、何かと触ってはいます（実はPython 2.0からの付き合いで結構古い）が、Pythonの新し目のWeb技術はそこまで触れていません

そこで、前々から何となくは知ってたものの、ちゃんと触ったことが無かった「HotWire」について、Djangoとの組み合わせで調べてみたいと思います

Elixirにてサーバサイド主体のリアルタイムWeb／SPAを叶える「LiveView」と似た性質を持っていると言われるHotWireなので、とても楽しみです

なお、LiveViewについては以前コラム書いていますので、良かったらご覧ください :information_desk_person_tone1:

https://qiita.com/piacerex/items/3f8ee18c9443d63955bf

Rails HotWireについてもコラム化しています

https://qiita.com/piacerex/items/b9cd99e2e051d24ce180

<font color="red">【202312/23追記】</font>
PHP／Laravelの同種であるLiveWireについてもコラム化しました

https://qiita.com/piacerex/items/52a610957a143e4687dd

あと、このコラムが、面白かったり、役に立ったら、![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) をお願いします :bow:

# イントロダクション

DjangoでHotWireを実現するライブラリ「Turbo Django」を使っていきます

https://github.com/hotwire-django/turbo-django

上記README.mdにもあるチュートリアルを見ながら進めます（なお、本コラムはPython 3.8.10＋Django 4.2.6での実施結果が書かれています）

**Hotwire Django：Tutorial**

https://turbo-django.readthedocs.io/en/latest/tutorial/index.html

# Part 1 - Setup Project

Django 3.1以上が入っていない方は、ここで入れましょう（Turbo Djungoの動作要件であるPython 3.8以上は入っている前提）

Turbo DjungoやChannels、ChannelsRedisも一緒に入れます

```bash
 pip install django turbo-django channels channels_redis
```

Redisも使うようなので、入っていない方は入れ、バックグラウンドで起動し、クライアントから接続してポート番号が `6379` であることを確認しておいてください

```bash
sudo apt install redis
redis-server &
redis-cli
127.0.0.1:6379> 
```

Django PJを作成し、起動します

```bash
django-admin startproject turbotutorial
cd turbotutorial
./manage.py runserver
```

ブラウザで `http://localhost:8000` にアクセスすると、親の顔より良く見たDjangoデフォルトページが表示されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/e92c2d62-0632-dfe3-29a9-8e8e1787933c.png)

chatアプリケーションを作成します

```bash
./manage.py startapp chat
```

```diff_python:turbotutorial/settings.py
"""
Django settings for turbotutorial project.
"""
…
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
+   'turbo',
+   'channels',
+   'chat'
]
…
-WSGI_APPLICATION = 'django_hotwire_sample.wsgi.application'
+ASGI_APPLICATION = 'turbotutorial.asgi.application'
…

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+CHANNEL_LAYERS = {
+   "default": {
+       "BACKEND": "channels_redis.core.RedisChannelLayer",
+       "CONFIG": {
+           "hosts": [("127.0.0.1", 6379)],  # Set to your local redis host
+       },
+   },
+}
```

# Part 2 - Models, Views, and Templates

シンプルなCRUDを作っていきます

https://turbo-django.readthedocs.io/en/latest/tutorial/part_2.html

## Models

モデルファイルを作ります

```diff_python:chat/models.py
from django.db import models

class Room(models.Model):
  name = models.CharField(max_length=255)

class Message(models.Model):
  room = models.ForeignKey(Room, related_name="messages", on_delete=models.CASCADE)
  text = models.CharField(max_length=255)
  created_at = models.DateTimeField(auto_now_add=True)
```

モデルをScaffoldします

```bash
./manage.py makemigrations
./manage.py migrate
./manage.py shell
```

Python REPLでデータ作成します

```diff_python
>>> from chat.models import Room
>>> Room.objects.create(name="Test Room")
<Room: Room object (1)>
```

コラムには無い下記データも追加しておきます

```diff_python
>>> Room.objects.create(name="Test Room-2")
<Room: Room object (2)>
>>> from chat.models import Message
>>> Message.objects.create(text="Hello.", room_id=1)
>>> Message.objects.create(text="How are you?", room_id=1)
```

## Views and URLs

Viewを追加します

```diff_python:chat/views.py
from django.shortcuts import render, reverse, get_object_or_404

from django.views.generic import CreateView, ListView, DetailView

from chat.models import Room, Message

class RoomList(ListView):
  model = Room
  context_object_name = "rooms"

class RoomDetail(DetailView):
  model = Room
  context_object_name = "room"

class MessageCreate(CreateView):
  model = Message
  fields = ["text"]
  template_name = "chat/components/send_message_form.html"

  def get_success_url(self):
    # Redirect to the empty form
    return reverse("message_create", kwargs={"pk": self.kwargs["pk"]})

  def form_valid(self, form):
    room = get_object_or_404(Room, pk=self.kwargs["pk"])
    form.instance.room = room
    return super().form_valid(form)
```

routerに下記を追加します

```diff_python:turbotutorial/urls.py
"""
URL configuration for django_hotwire_sample project.
"""
…
from django.contrib import admin
from django.urls import path
+from chat import views

urlpatterns = [
  path('admin/', admin.site.urls),
+ path("", views.RoomList.as_view(), name="index"),
+ path("<slug:pk>/", views.RoomDetail.as_view(), name="room_detail"),
+ path("<slug:pk>/message_create", views.MessageCreate.as_view(), name="message_create"),
]
```

## Templates

Template群を追加します

コラムだと `turbotutorial/chat/templates/` というパスが指定されていますが、手元では `chat/templates/chat/` としています

```diff_python:chat/templates/chat/room_list.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Chat Rooms</title>
</head>
<body>
    <h1>Room List</h1>
    <ul>
    {% for room in rooms %}
        <li><a href="{% url 'room_detail' room.id %}">{{ room.name }}</a></li>
    {% empty %}
        <li>No Rooms Available</li>
    {% endfor %}
    </ul>
</body>
</html>
```

```diff_python:chat/templates/chat/room_detail.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Room Detail</title>
</head>
<body>

    <a href="{% url 'room_list' %}">Home</a>

    <h1>{{ room.name }}</h1>

    <ul id="messages">
        {% for message in room.messages.all %}
            <li>{{message.created_at}}: {{message.text}}</li>
        {% endfor %}
    </ul>

</body>
</html>
```

```diff_python:chat/templates/chat/room_form.html
<form method="post" action=".">
   {% csrf_token %}
   {{ form.as_p }}
   <input type="submit" value="Submit">
</form>
```

ブラウザで確認してみましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/d5452189-1f6d-abf2-aac0-ea31650b40af.png)

おや？ … エラーが出ていますね…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/ef40a7ce-40f4-6587-beae-fdf5c934cc44.png)

router（'url.py'）を確認してみましょう

```diff_python:turbotutorial/urls.py
…
urlpatterns = [
  path('admin/', admin.site.urls),
  path("", views.RoomList.as_view(), name="index"),
  path("<slug:pk>/", views.RoomDetail.as_view(), name="room_detail"),
  path("<slug:pk>/message_create", views.MessageCreate.as_view(), name="message_create"),
]
```

あぁ、`room_list` では無く、`index` ですね（コラム記載ミス？） … 下記の通り、修正します

```diff_python:chat/templates/chat/room_detail.html
…
<body>

-   <a href="{% url 'room_list' %}">Home</a>
+   <a href="{% url 'index' %}">Home</a>

    <h1>{{ room.name }}</h1>
…
```

ひとまず、エラーは出なくなりました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/e35ab839-b327-edeb-a849-e61fdf23de91.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/0a234837-8393-bf9a-2621-ef1bb19cb2cc.png)

# Part 3 - Your First Turbo Frame

さて本題です

https://turbo-django.readthedocs.io/en/latest/tutorial/part_3.html

## Listen to Turbo Streams

Django HotWireでは、`asgi.py` をWebSocketサーバ化するようです

```diff_python:turbodjango/asgi.py
"""
ASGI config for django_hotwire_sample project.
"""
…
from django.core.asgi import get_asgi_application
+from channels.routing import ProtocolTypeRouter
+from turbo.consumers import TurboStreamsConsumer

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbodjango.settings')

-application = get_asgi_application()
+application = ProtocolTypeRouter({
+ "http": get_asgi_application(),
+ "websocket": TurboStreamsConsumer.as_asgi()
+})
```

## A Component-Based Mindset

「Turbo Frame」を使うと、ページの一部（フレームと言います）をリクエストに応じて更新できます

利用するには、`<head>` 内でturbo用HTMLを `incclude` し、`<turbo-frame>` タグで囲んだフレーム部を追加します

なお、コラムの `src="{% url 'message_create_form' room.id %}"` は、ハンドラ名が間違っていたので、下記の通り、`src="{% url 'message_create' room.id %}"` で記載してください

```diff_python:chat/templates/chat/room_detail.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Room Detail</title>
+   {% include "turbo/head.html" %} <!-- Add this to load the Turbo javascript library-->
</head>
<body>

    <h1>{{ room.name }}</h1>

    <ul id="messages">
        {% for message in room.messages.all %}
            <li>{{message.created_at}}: {{message.text}}</li>
        {% endfor %}
    </ul>

+   <!-- Add this to the end of the page-->
+   <turbo-frame id="send-message" src="{% url 'message_create' room.id %}"></turbo-frame>
</body>
</html>
```

親フレームと、フレームにロードされる要素は、同じIDである必要があります

```diff_python:chat/templates/chat/components/message_create_form.html
<turbo-frame id="send-message">
  <form method="post" action="{% url 'send_message' room_id %}">
     {% csrf_token %}
     {{ form.as_p }}
     <input type="submit" value="Send">
 </form>
</turbo-frame>
```

おや？ … 500エラーが出てますね（WebSocketもエラー？…）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/bab4dc21-6270-83a1-952f-540cfc8aebe1.png)

formのactionも怪しそうなので、いったん空欄にしてみます

```diff_python:chat/templates/chat/components/message_create_form.html
<turbo-frame id="send-message">
- <form method="post" action="{% url 'send_message' room_id %}">
+ <form method="post" action="">
     {% csrf_token %}
     {{ form.as_p }}
     <input type="submit" value="Send">
 </form>
</turbo-frame>
```

これで、とりあえず入力フォームは出るようになりましたが、フォーム投稿すると、今度は405エラーですね … 今回は残念ながら、ここでタイムアップです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/3ea59e49-32fe-5fee-cd66-1cd494394651.png)

# 終わりに

HotWireのおおまかな書き方は、Rails版とさほど変わりが無く、`<turbo-frame>` タグで囲んだ箇所がAJAXによる動的更新可能となるようです

加えて、WebSocketによるサーバプッシュも動きそうなので、今度、時間あるときに最後まで動かし切るのをトライしてみたいところです

ReactやVue.js、Svelteあたりに近いイディオムをサーバサイドとして実装しているLiveViewとは、だいぶ異なる構造ですが、元々のDjangoのイディオムをあまり変えずに対応できる点は、Rails同様、グッドな感じです

最後に余談ですが、実は現在、「Python入門」と言うスキル判定が搭載されたElixir製プロダクト「Bright」を先日αリリースしています

https://bright-fun.org/

Brightは、過去／現在／未来のスキルから、あなたのBright（輝き）とRight（正しさ）を引き出すツールです

現在、下記がアンロックされています

- Elixir入門
- Python入門
- PHP入門
- React入門
- Webアプリ開発 Elixir
- PM（プロジェクトマネージャ）
- WebアプリUIデザイナー
- オウンドメディアマーケター

来年頭に下記がアンロック予定です

- チームリーダー／PL（プロジェクトリーダー）
- PdM（プロダクトマネージャ）
- 新人AWS or AWSプラクティショナー
- 新人Google Cloud or Google Cloudファンダメンタルズ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/6fabf1ea-ac07-7002-e660-a44ba416631c.png)

次期リリース（5月頃）で、「Webアプリ開発 Python」もリリースするので、「HotWire」を攻略できたら、スキル科目に入れたいですね

---

明日は、@payaneco さんです

# p.s.このコラムが、面白かったり、役に立ったら…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) にて、どうぞ応援よろしくお願いします :bow: