[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

Django 2.0 + Channels 2.xを使ってWebsocketを扱う(その3)

少し空きましたが前回に引き続きChannelsを触っていきます。前回はChannelsでWebSocketでのエコーサーバ的なところまで実装しましたので、Room等を作っていきます。

Channel Layerの有効化

Roomを実装するためには複数のコネクションがメッセージを共有できる必要があります。ChannelsではChannel Layerというもが利用できます。これは複数のConsumer Instance間でメッセージを共有する仕組みを提供します。

channel layerにはGroupという概念があります。Groupには名前をつける事ができ、同じ名前を指定することで相互にメッセージをやり取りすることができます。

今回利用しているリポジトリではdocker-compose.ymlにすでにredisサーバの定義がかいてありますので、実はすでにredisが起動しています。これを利用します。

mysite/settings.pyに以下を追記して、Redisをバックエンドに使用できるようにします。

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('redis', 6379)],
        },
    },
}

これで再起動すればChannel Layerが有効化されます。defaultという名前からわかるようにバックエンドは複数定義できますが殆どのケースでdefaultだけ定義しておけばいいようです。

Groupを利用したChatRoomの実装

有効化したChannel Layerを利用していきます。mysite/chat/consumer.pyに定義していたChatConsumerを以下の様に書き換えます。

from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
import json


class ChatConsumer(WebsocketConsumer):
    """
    WebSocketでの通信をハンドルする
    """
    def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()

    def disconnect(self, close_code):
        # Leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    def receive(self, text_data=None, bytes_data=None):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    # Receive message from room group
    def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))

connect時にself.scope['url_route']['kwargs']['room_name']の部分で部屋名を取得している。これはmysite/chat/routing.pyに定義していた以下の部分に関係します。

# chat/routing.py
from django.urls import path

from . import consumers


websocket_urlpatterns = [
    path('ws/chat/<str:room_name>/', consumers.ChatConsumer),
]

self.scopeにはそのコネクションの情報が入っており、self.scope['url_route']['kwargs']にはURLのパターンから取得出来る情報が入っています。今回のケースではws/chat/<str:room_name>/というURLパターンですのでws/chat/hogeというアクセスではroom_name=hogeが代入されています。

ここで取得したRoom名を元に以下の部分でself.channel_layer.group_addを利用してGroupに参加しています。

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

関数自体はself.channel_layer.group_addですがasync_to_sync(from asgiref.sync import async_to_sync)でラップされています。これはasync_to_syncは名前の通りですが、非同期関数を同期関数に変換するものです。今回継承しているWebsocketConsumerは同期処理の実装なので、その中で呼び出す処理も同期処理である必要がありますが、channel_layer関連の処理は非同期なのでそのままでは呼び出せないので、ラップしているわけです。

disconnectは切断時の処理ですが、呼び出す関数がself.channel_layer.group_discardになる程度でほとんど変わりません。切断時に参加していたGroupから離脱するだけです。

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

        # Send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

この部分では前回まで単にself.sendで返送していた部分がself.channel_layer.group_sendに変わっています。自身が参加しているroom_group_nameを指定してメッセージを送信しています。こうすることで同じグループに参加しているコネクション全てにメッセージが送信されます。

Group宛に送信されたメッセージの受信時はここで処理が行われています。

    # Receive message from room group
    def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))

関数名は送信時に指定している'type': 'chat_message',と一致します。受け取ったeventにはgroup_sendで指定したデータが入っているので、それを取得してからself.sendでクライアント側に戻しています。

これで複数のルーム間でメッセージが共有されチャットルームとしての必要な機能が実現されました。

f:id:denzow:20180403002303g:plain

今回のCommitは以下です。

github.com

まとめ

これで最低限チャットとしての機能が実装されました。次回はConsumerを非同期実装に差し替えていく予定です。