blog.takurinton.dev

asgiをぽよってみたつづき

2020-11-30

どうも

こんにちは、僕です。
今回は前回の記事の続きを書いていきたいと思います。まだまだリファクタリングしないといけないのですが、現状動くものをのせる的な感じで。

前回なにしたか

前回はぽよりました。(適当)
まあ、簡単にソケットでチャットを作りましょうみたいなことをしました。

今回はなにするか

そこに今回はユーザーを定義してさらにチャットを保存していくみたいな実装をしました。
簡単な実装しかしていないのでなんとも言えないですが、楽しく実装することができたかなと思います。
それでは書いていきます。

ユーザーモデルを定義する

今回はカスタムユーザーモデルを使用します。このモデルはDjangoの標準のユーザーモデルをカスタマイズして認証機能などをつけることができる機能のことです。基本的にはAbstractBaseUserとBaseUserManager、PermissionsMixinを継承して実装していきます。ここら辺の細かい説明に関してはアドカレのネタに残しておきたいので今度説明します。(12月4日公開予定かな?)

それでは早速書いていきます。

# models.py

from django.db import models
import datetime

from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.validators import UnicodeUsernameValidator

class UserManager(BaseUserManager):
    def _create_user(self, username, password, **extra_fields): 
        if not username: 
            raise ValueError('username is requied')
            
        # username = self.model.nomalize_username(username)
        user = self.model(username=username, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, username, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(username, password, **extra_fields)

    def create_superuser(self, username, password, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)

        if extra_fields.get('is_staff') is not True:
            raise ValueError('Superuser must have is_staff=True.')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Superuser must have is_superuser=True.')
        return self._create_user(username, password, **extra_fields)

class User(AbstractBaseUser, PermissionsMixin):
    username_validator = UnicodeUsernameValidator()

    username = models.CharField(max_length=150, unique=True, validators=[username_validator])
    is_staff = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField( default=datetime.datetime.now)

    objects = UserManager()

    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = []

class Room(models.Model):
    name = models.CharField(max_length=128)
    is_status = models.BooleanField(default=True)
    created_at = models.DateTimeField(default=datetime.datetime.now())
    def __str__(self):
        return self.name

class Chat(models.Model):
    username = models.ForeignKey('User', on_delete=models.CASCADE)
    content = models.TextField()
    room_name = models.ForeignKey('Room', on_delete=models.CASCADE)
    send_time = models.DateTimeField(default=datetime.datetime.now())

前回はmodels.pyにはなにも書きませんでしたが、今回は情報を保存する場所が欲しかったためDBを定義しました。だいぶコード量が多いですね。これはBaseUserManagerを上書きして使用しているためだいぶ増えてしまいました。しかし、ユーザーネームとパスワードだけのシンプルなモデルを作成することが目標だったのでこれで良きです。
また、他のモデルに関しては、部屋を保存するRoom、チャットを保存するChatというモデルをそれぞれ定義しました。いい感じ。
チャットには部屋名とユーザー名が欲しかったのでそれぞれを外部キーとして持たせてあげています。
こんな感じでモデルは問題ないと思います(適当)

次にsettings.pyに以下の記述をします。

# settings.py

...
AUTH_USER_MODEL = 'chatapp.User'
LOGIN_URL = 'chatapp:login'
LOGIN_REDIRECT_URL = 'chatapp:top'
...

これらを記述することで、Djangoのデフォルトで作成されるユーザーモデルではなく、自作のモデルを使用することを宣言できます。また、ログインしている時としていない時の挙動を変えることもできます。(まあ、結局自分で宣言しないといけないんだけどね)

ユーザー登録を実装する

まずはユーザー登録機能を実装します。先ほども言いましたが、アドカレにここら辺書くのでさらっといきます。
ユーザー登録をするにはFormを作成してそこにリクエストがきたら追加するみたいな感じにします。

# forms.py

from django.contrib.auth.forms impor UserCreationForm
from .models import User

class CreateUserForm(UserCreationForm):
    class Meta:
        model = User
        fields = ('username', )

こんな感じ。
次にcontroller.pyを書き換えていきます。

# controller.py

...
from django.views import generic 
from .forms import CreateUserForm

class CreateUser(generic.CreateView): 
    template_name = 'register.html'
    form_class = CreateUserForm

    def form_valid(self, form):
        user = form.save(commit=False)
        user.save()
        login(self.request, user, backend='django.contrib.auth.backends.ModelBackend')
        return redirect('chatapp:top')
...

以上のようにCreateViewを継承することによって簡単にユーザー登録をするための仕組みを開発することができます。
なんとも便利な。 内部処理が気になりますね。(白目)

次に見た目を作っていきます。
forms.pyもDjangoのいいところだと思っていて、非常に簡単に見た目の実装もすることができます。DRF信者としてはこの機能は無駄でしかないのですがね( ;∀;)

<!-- register.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>register</title>
</head>
<body>
    <h1>create user</h1>
    <form action="" method="POST">
        {{ form.non_field_errors }}
        {% for field in form %}
        <div>
            <label for="{{ field.id_for_label }}">{{ field.label_tag }}</label>
            {{ field }}
            {{ field.errors }}
        </div>
        {% endfor %}
        {% csrf_token %}
        <button type="submit">作成</button>
    </form>
</body>
</html>

こんな感じです。formはイテラブルなオブジェクトで、入力フォームの要素が入っています。今だったらusernameとpasswordが表示されます。これはとても便利。もちろん自分でフォームをカスタマイズすることもできるのでこれは積極利用すべきだと感じています。
ユーザー登録はここまでです。次いきましょう!

ユーザーのログインを実装する

次にログインをする機能を実装しようと思います。
これも簡単です、あんなに簡単にユーザー作成できたのだから。

# forms.py

from django.contrib.auth.forms import AuthenticationForm, UserCreationForm

class LoginForm(AuthenticationForm):
    class Meta:
        model = User

これだけです。Formが完成しました。
次にcontrollerをいじっていきます。

# controller.py

from django.contrib.auth.views import LoginView

class Login(LoginView):
    template_name = 'login.html'
    form_class = LoginForm

わあ、なんて簡単なの!素敵!
これは完璧すぎて惚れますね。最後に見た目を作成します。

<!-- login.html -->

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>login</title>
</head>
<body>
    <form action="" method="POST">
        {{ form.non_field_errors }}
        {% for field in form %}
            {{ field }}
            {{ field.errors }}
            <hr>
        {% endfor %}
        <button type="submit">ログイン</button>
        <input type="hidden" name="next" value="{{ next }}" />
        {% csrf_token %}
    </form>
</body>
</html>

さっきと大差ないですね。ログインしたら先ほどsettings.pyで設定した場所に飛ぶようになっています。今だったらtopです。index.htmlに飛びます。
これでログイン機能も完成しました。

チャットルームをアレンジする

やっと本題です。ここまで長かったですね。大変大変。
前回チャットルームを作成しましたが、あれは即席のものであるため、データが保存されません。そのため、ここではチャットの内容と部屋をそれぞれ保存してDBに格納したいと思います。
ちなみに、これはベストプラクティスとは到底言えません。誰か知見をお持ちの方、コメント欄から修正すべきところを指摘していただけると泣いて喜びます。

それでは実装していきます。

まずはcontroller.py

# controller.py

from .models import Chat, Room

def index(request):
    rooms = Room.objects.all()
    return render(request, 'index.html', 
        {
            'rooms': rooms, 
            'user': request.user,
        }    
    )

def room(request, room_name):
    try:
        room = Room.objects.get(name=room_name)
    except:
        room = Room(name=room_name)
        room.save()
    
    messages = Chat.objects.filter(room_name=room)
    return render(request, 'room.html', 
        {
            'room_name': room.name, 
            'messages': messages, 
            'user': request.user, 
        }
    )

前回定義したindexとroomをいい感じにアレンジします。
ここでは、ルートのページには部屋一覧を取得して表示するようにしています。
また、roomでは、アクセスしたところに部屋があれば作成、なければ作るという処理を行っています。なんか割と可愛い実装になりました。
また、Djangoでは現在のログインユーザーを判定するときにrequestのuserを参照すると取得することができます。クラスベースのViewでrequestがないときでも、selfで参照可能です。

# 例

from django.views.generic import TemplateView
class IndexView(TemplateView):
    user = self.request.user        
    templates = 'index.html'

みたいな感じです。便利〜推し〜

次にconsumer.pyをいじります。ここではチャットを保存する処理を実装します。

# consumer.py
import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer

from django.contrib.auth import get_user_model
from .models import Chat, User, Room

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()

    def disconnect(self, close_code):
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )

    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        _message = text_data_json['message']

        message = _message.split(' : ')

        user = get_auth_user(message[0])
        content = message[1]
        room_name = self.room_group_name.split('_')[1]
        room = fetch_room(room_name)
        send_message(user, content, room)

        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    def chat_message(self, event):
        message = event['message']
        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))

def send_message(user, content, room_name):
    message = Chat(username=user, content=content, room_name=room_name)
    message.save()

def get_auth_user(username):
    try:
        user = User.objects.get(username=username)
    except:
        user = ''
    return user

def fetch_room(room_name):
    try:
        room = Room.objects.get(name=room_name)
    except:
        room = None
    return room

丸々持ってきました。変更点だけでもよかったのですが、わかりにくいと思ったので。
ここでは、チャットを取得してデータベースに格納しています。バッチ処理とかしたかったのですが、あまりいい方法が思いつかなかったのでシンプルな感じにしました。
下の方にある関数send_messageではメッセージをDBに保存する、あとの2つは文字列からそれと一致するオブジェクトを取得する関数になっています。これ見つからなかったら空文字返してるけどダメですよねこれ。要修正。

上で取得するmessageの形式は

username : message

のような形になっています。そのため、' : ' で分割してリストに保存することでユーザー名とメッセージを取得することができます。
先ほどviewsではrequest.userでログイン中のユーザーを取得することができると言いましたが、ここではhttp通信を使用していないので使えません。そのため名前とメッセージをペアで取得し、その文字列を使用してユーザーのオブジェクトを取得する方式にしました。
ここら辺が自分の中で実装に自信がないところなのでDjango詳しい方教えてください。

次に見た目の実装を行います。 htmlを編集しましょう。
これも変更点以外も全部載せます。

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Rooms</title>
</head>
<body>
    {% if user.is_authenticated %}
        Hello {{ user }}<br>
        <input id="room-name-input" type="text" size="100" pattern="/^[0-9a-zA-Z]*$/" required><br>
        <input id="room-name-submit" type="submit" value="Enter">
        <br />

        {% for room in rooms %}
            <a href="room/{{room.name}}">{{room.name}}</a>
        {% endfor %}

        <script>
            document.querySelector('#room-name-input').focus();
            document.querySelector('#room-name-input').onkeyup = function(e) {
                if (e.keyCode === 13) {  // enter, return
                    document.querySelector('#room-name-submit').click();
                }
            };

            document.querySelector('#room-name-submit').onclick = function(e) {
                var roomName = document.querySelector('#room-name-input').value;
                window.location.pathname = '/chat/room/' + roomName + '/';
            };
        </script>
    {% else %}
        <h1>For hidden</h1>
    {% endif %}
</body>
</html>

ここでは部屋を作成するか既存の部屋に入室するかを選択することができます。

 {% for room in rooms %}
        <a href="room/{{room.name}}">{{room.name}}</a>
 {% endfor %}

ここで部屋一覧を表示しています。
既存の部屋を入力してもその部屋に飛ぶようになっていて、部屋名が重複することはありません(DB側では制御していないのでなくはないかも)

<!-- room.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
    {% if user.is_authenticated %}
        <h1>{{ room_name }}</h1>
        <div id="chat-log">
            {% for message in messages %}
            {{ message.username }} : {{ message.content }} <br />
            {% endfor %}
        </div>
        <br>
        <input id="chat-message-input" type="text" size="100" required><br>
        <input id="chat-message-submit" type="submit" value="Send">
        {{ user }}
        {{ room_name|json_script:"room-name" }}
        <script>
            const roomName = JSON.parse(document.getElementById('room-name').textContent);

            const chatSocket = new WebSocket(
                'ws://'
                + window.location.host
                + '/ws/chat/'
                + roomName
                + '/'
            );

            chatSocket.onmessage = function(e) {
                const data = JSON.parse(e.data);
                message = data.message[0] + ' : ' + data.message[1]
                document.querySelector('#chat-log').innerHTML += (message + '<br />');
            };

            chatSocket.onclose = function(e) {
                console.error('Chat socket closed unexpectedly');
            };

            document.querySelector('#chat-message-input').focus();
            document.querySelector('#chat-message-input').onkeyup = function(e) {
                if (e.keyCode === 13) { 
                    document.querySelector('#chat-message-submit').click();
                }
            };

            document.querySelector('#chat-message-submit').onclick = function(e) {
                const messageInputDom = document.querySelector('#chat-message-input');
                const message = '{{ user }} : ' + messageInputDom.value;
                chatSocket.send(JSON.stringify({
                    'message': message
                }));
                messageInputDom.value = '';
            };
        </script>
        {% else %}
            <h1>For hidden</h1>
        {% endif %}
</body>
</html>

ここでは部屋でのメッセージの送受信を行っています。

{% for message in messages %}
    {{ message.username }} : {{ message.content }} <br />
{% endfor %}

ここでメッセージの一覧と送信者の名前を一覧表示しています。
いい感じになっています。他は大きな変更点はありません。

試しに入力して送信してみるといい感じに表示されるかと思います。

これのダメなところ

  • 入力フォームのバリデーションをしていない
    • 入力フォームを何もいじってないので(怠惰)例えば素のhtmlを突っ込まれたりすると心臓に良くないです。
    • 将来的にはReactにしてデプロイしたいのでここでは何もしない予定
  • 疲れながら実装してる(楽しかったけど)
    • 精神的な疲労の方が大きいかもしれないです。
  • パフォーマンスガン無視
    • バッチ処理とかキャッシュでカッコよく実装するとかをしていないのでだいぶパフォーマンスが低いです。まあ気になるほどではないんだけど。要修正。

まとめ

asgi編これにて完結です。チャット機能とユーザー認証簡単にだけど実装することができてよかった。 今回は深く触れてませんが、今週はアドカレでDjangoのユーザー認証についてまとめる予定なので頑張ります。内部処理を理解してフレームワークを使いたいので!✌️
では、また.