갈루아의 반서재


Django Channels로 채팅룸 만들기



먼저 아래 그림을 보자. 아래는 전형적인 requests와 responses의 도해이다. 브라우저는 request를 생성하고, Django는 브라우저로 돌려보낼 response를 담고 있는 view를 호출한다.


반면 Django Channels은 request/response cycle을 채널을 관통하는 메시지(message)라는 컨셉으로 대체한다. 채널은 Django로 하여금 전형적인 HTTP views 와 유사한 방식으로 웹소켓을 지원한다. HTTP request는 여전히 같은 방식으로 작동하지만 채널을 경유하게 된다. 결론적으로 Channels하에서 Django는 아래와 같은 구조를 띠게 된다. 





1. Install Channels & Configure INSTALLED_APPS


1) pip 를 이용해 channels 설치 후 INSTALLED_APPS 에 추가 (작성일 현재 버전은 1.0.3 이다)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# pip install channels
Collecting channels
  Downloading channels-1.0.3-py2.py3-none-any.whl (65kB)
    100|████████████████████████████████| 71kB 441kB/s
Collecting daphne>=1.0.0 (from channels)
  Downloading daphne-1.0.2-py2.py3-none-any.whl
Requirement already satisfied: Django>=1.8 in /root/anaconda/envs/envalicia/lib/python3.5/site-packages (from channels)
Collecting asgiref>=1.0.0 (from channels)
  Downloading asgiref-1.0.0-py2.py3-none-any.whl
Collecting autobahn>=0.12 (from daphne>=1.0.0->channels)
  Downloading autobahn-0.17.1-py2.py3-none-any.whl (256kB)
    100|████████████████████████████████| 266kB 980kB/s
Collecting twisted>=16.0 (from daphne>=1.0.0->channels)
  Downloading Twisted-17.1.0.tar.bz2 (3.0MB)
    100|████████████████████████████████| 3.0MB 320kB/s
Requirement already satisfied: six in /root/anaconda/envs/envalicia/lib/python3.5/site-packages (from asgiref>=1.0.0->channels)
Collecting txaio>=2.5.2 (from autobahn>=0.12->daphne>=1.0.0->channels)
  Downloading txaio-2.6.1-py2.py3-none-any.whl
Collecting zope.interface>=4.0.2 (from twisted>=16.0->daphne>=1.0.0->channels)
  Downloading zope.interface-4.3.3.tar.gz (150kB)
    100|████████████████████████████████| 153kB 1.2MB/s
Collecting constantly>=15.1 (from twisted>=16.0->daphne>=1.0.0->channels)
  Downloading constantly-15.1.0-py2.py3-none-any.whl
Collecting incremental>=16.10.1 (from twisted>=16.0->daphne>=1.0.0->channels)
  Downloading incremental-16.10.1-py2.py3-none-any.whl
Collecting Automat>=0.3.0 (from twisted>=16.0->daphne>=1.0.0->channels)
  Downloading Automat-0.5.0-py2.py3-none-any.whl
Requirement already satisfied: setuptools in /root/anaconda/envs/envalicia/lib/python3.5/site-packages/setuptools-23.0.0-py3.5.egg (from zope.interface>=4.0.2->twisted>=16.0->daphne>=1.0.0->channels)
Collecting attrs (from Automat>=0.3.0->twisted>=16.0->daphne>=1.0.0->channels)
  Downloading attrs-16.3.0-py2.py3-none-any.whl
Building wheels for collected packages: twisted, zope.interface
  Running setup.py bdist_wheel for twisted ... done
  Stored in directory: /root/.cache/pip/wheels/65/e3/44/cd3da92c03926aabc80e658e11d6e64619abce3ef44c1c34df
  Running setup.py bdist_wheel for zope.interface ... done
  Stored in directory: /root/.cache/pip/wheels/00/aa/8b/f1d1eb398423e59894b45ee151344e243808156c2d182c9f4e
Successfully built twisted zope.interface
Installing collected packages: txaio, autobahn, zope.interface, constantly, incremental, attrs, Automat, twisted, asgiref, daphne, channels
Successfully installed Automat-0.5.0 asgiref-1.0.0 attrs-16.3.0 autobahn-0.17.1 channels-1.0.3 constantly-15.1.0 daphne-1.0.2 incremental-16.10.1 twisted-17.1.0 txaio-2.6.1 zope.interface-4.3.3
cs


2) 별도의 앱을 만든다. Django app "chat" 생성

1
# python manage.py startapp chat
cs


3) INSTALLED_APPS에 'channels'과 방금 만든 앱인 'chat'을 추가한다.

settings.py

1
2
3
4
INSTALLED_APPS = [
    'channels',
  'chat',
]
cs




2. Define Model and View


1) 채팅룸과 메시지를 관리하는 모델을 정의한다. 생성되는 채팅룸과 주고받는 메시지는 데이터베이스에 저장된다.

chat/models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from __future__ import unicode_literals
 
from django.db import models
from django.utils import timezone
 
class Room(models.Model):
    name = models.TextField()
    label = models.SlugField(unique=True)
 
    def __unicode__(self):
        return self.label
 
class Message(models.Model):
    room = models.ForeignKey(Room, related_name='messages')
    handle = models.TextField()
    message = models.TextField()
    timestamp = models.DateTimeField(default=timezone.now, db_index=True)
 
    def __unicode__(self):
        return '[{timestamp}] {handle}: {message}'.format(**self.as_dict())
 
    @property
    def formatted_timestamp(self):
        return self.timestamp.strftime('%b %-d %-I:%M %p')
    
    def as_dict(self):
        return {'handle': self.handle, 'message': self.message, 'timestamp': self.formatted_timestamp}
cs


2) 뷰 생성

메인페이지로 가는 about, haikunator를 이용해 랜덤으로 방이름을 만들고 배정하는 new_room 과 실제 채팅이 이루어지는 chat_room 으로 뷰를 구성한다.

chat/views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import random
import string
from django.db import transaction
from django.shortcuts import render, redirect
from haikunator import Haikunator
from .models import Room
 
def about(request):
    return render(request, "chat/about.html")
 
def new_room(request):
    """
    Randomly create a new room, and redirect to it.
    """
    new_room = None
    while not new_room:
        with transaction.atomic():
            label = Haikunator.haikunate()
            if Room.objects.filter(label=label).exists():
                continue
            new_room = Room.objects.create(label=label)
    return redirect(chat_room, label=label)
 
def chat_room(request, label):
    """
    Room view - show the room, with latest messages.
    The template for this view has the WebSocket business to send and stream
    messages, so see the template for where the magic happens.
    """
    # If the room with the given label doesn't exist, automatically create it
    # upon first visit (a la etherpad).
    room, created = Room.objects.get_or_create(label=label)
 
    # We want to show the last 50 messages, ordered most-recent-last
    messages = reversed(room.messages.order_by('-timestamp')[:50])
 
    return render(request, "chat/room.html", {
        'room': room,
        'messages': messages,
})
cs


3) url을 구성한다. chat 앱에 url을 구성하고 프로젝트단에서 해당 url을 include 한다.

chat/urls.py

1
2
3
4
5
6
7
8
from django.conf.urls import include, url
from . import views
 
urlpatterns = [
    url(r'^$',  views.about, name='about'),
    url(r'^new/$', views.new_room, name='new_room'),
    url(r'^(?P<label>[\w-]{,50})/$', views.chat_room, name='chat_room'),
]
cs

urls.py
1
2
3
4
5
6
7
8
from django.conf.urls import url, include
from django.contrib import admin
 
urlpatterns = [
 
    url(r'^chat/', include('chat.urls')),
    
]
cs


4) 템플릿 파일을 생성한다.

(1) about 에서는 새로운 채팅방을 생성하는 링크를 포함한다.

templates/chat/about.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<p class="deck">
This is a demo using <a href="http://channels.readthedocs.org/en/latest/">
Django Channels</a> to implement a simple WebSocket-based chat server.
You can see the <a href="https://github.com/jacobian/channels-example">
code on GitHub</a>, or try the app:
</p>
<p>
<a class="button button-primary" href="{% url 'new_room' %}">Create new chat room</a>
</p>
<p class="quiet">
Or, you can visit <code>{{ request.scheme }}://{{ request.get_host }}/chat/any-path-you-want</code> 
to create a arbitrary room or join one whose name you know.
</p>
cs


(2) room 에서는 실제 메시지 교환이 이루어진다.

template/chat/room.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<!DOCTYPE html>
<html lang="en">
{% load static %}
<head>
    <meta charset="utf-8">
    <title>{{ room.label }}</title>
 
    <link href="{% static 'assets/css/normalize.css' %}"  rel="stylesheet">
    <link href="{% static 'assets/css/skeleton.css' %}"  rel="stylesheet">
    <link href="{% static 'assets/css/chat.css' %}"  rel="stylesheet">
</head>
 
<body>
 
<p class="quiet">
Anyone with this URL can join the room and chat: 
<code>{{ request.scheme }}://{{ request.get_host }}/chat/{{ room.label }}</code> 
</p>
{% if user.is_authenticated %}
<p>
<label for="handle">Your name:</label>
<input id="handle" type="text" placeholder="handle" value="{{ user.username }}">
</p>
{% else %}
<p>
<label for="handle">Your name:</label>
<input id="handle" type="text" placeholder="handle">
</p>
{% endif %}
<form id="chatform">
{% csrf_token %}
<table id="chat">
  <tbody>
    {% for message in messages %}
      <tr>
        <td>{{ message.formatted_timestamp }}</td>
        <td>{{ message.handle }}</td>
        <td>{{ message.message }}</td>
      </tr> 
    {% endfor %}
  </tbody>
  <tfoot>
  <tr>
    <td>Say something:</td>
    <td colspan=2>
      <input id="message" type="text" placeholder="message">
      <button type="submit" id="go">Say it</button>
    </td>
  </tfoot>
</table>
</form>
 
<script src="{% static 'assets/js/jquery-2.1.0.min.js' %}"></script>
<script src="{% static 'assets/js/reconnecting-websocket.min.js' %}"></script>
<script src="{% static 'assets/js/chat.js' %}"></script>
 
</body>
</html>
cs


연결된 css 파일은 아래에서 확인할 수 있다.


assets/css/chat.css

https://github.com/jacobian/channels-example/blob/master/static/chat.js


assets/css/normalize.css

https://github.com/jacobian/channels-example/blob/master/static/normalize.css


assets/css/skeleton.css

https://github.com/jacobian/channels-example/blob/master/static/skeleton.css




3. Look at client code (chat.js)


웹소켓을 생성하고, 폼이 제출되었을 때 웹소켓을 통해 데이터를 전송한다. 그리고 웹소켓에 새로운 데이터가 도착했을 때 이를 보여준다.


static/assets/js/chat.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$(function() {
    // When we're using HTTPS, use WSS too.
    var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws";
    var chatsock = new ReconnectingWebSocket(ws_scheme + '://' + window.location.host + window.location.pathname);
    
    chatsock.onmessage = function(message) {
        var data = JSON.parse(message.data);
        var chat = $("#chat")
        var ele = $('<tr></tr>')
 
        ele.append(
            $("<td></td>").text(data.timestamp)
        )
        ele.append(
            $("<td></td>").text(data.handle)
        )
        ele.append(
            $("<td></td>").text(data.message)
        )
        
        chat.append(ele)
    };
 
    $("#chatform").on("submit"function(event) {
        var message = {
            handle: $('#handle').val(),
            message: $('#message').val(),
        }
        chatsock.send(JSON.stringify(message));
        $("#message").val('').focus();
        return false;
    });
});
cs


assets/js/reconnecting-websocket.min.js

1
!function(a,b){"function"==typeof define&&define.amd?define([],b):"undefined"!=typeof module&&module.exports?module.exports=b():a.ReconnectingWebSocket=b()}(this,function(){function a(b,c,d){function l(a,b){var c=document.createEvent("CustomEvent");return c.initCustomEvent(a,!1,!1,b),c}var e={debug:!1,automaticOpen:!0,reconnectInterval:1e3,maxReconnectInterval:3e4,reconnectDecay:1.5,timeoutInterval:2e3};d||(d={});for(var f in e)this[f]="undefined"!=typeof d[f]?d[f]:e[f];this.url=b,this.reconnectAttempts=0,this.readyState=WebSocket.CONNECTING,this.protocol=null;var h,g=this,i=!1,j=!1,k=document.createElement("div");k.addEventListener("open",function(a){g.onopen(a)}),k.addEventListener("close",function(a){g.onclose(a)}),k.addEventListener("connecting",function(a){g.onconnecting(a)}),k.addEventListener("message",function(a){g.onmessage(a)}),k.addEventListener("error",function(a){g.onerror(a)}),this.addEventListener=k.addEventListener.bind(k),this.removeEventListener=k.removeEventListener.bind(k),this.dispatchEvent=k.dispatchEvent.bind(k),this.open=function(b){h=new WebSocket(g.url,c||[]),b||k.dispatchEvent(l("connecting")),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","attempt-connect",g.url);var d=h,e=setTimeout(function(){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","connection-timeout",g.url),j=!0,d.close(),j=!1},g.timeoutInterval);h.onopen=function(){clearTimeout(e),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onopen",g.url),g.protocol=h.protocol,g.readyState=WebSocket.OPEN,g.reconnectAttempts=0;var d=l("open");d.isReconnect=b,b=!1,k.dispatchEvent(d)},h.onclose=function(c){if(clearTimeout(e),h=null,i)g.readyState=WebSocket.CLOSED,k.dispatchEvent(l("close"));else{g.readyState=WebSocket.CONNECTING;var d=l("connecting");d.code=c.code,d.reason=c.reason,d.wasClean=c.wasClean,k.dispatchEvent(d),b||j||((g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onclose",g.url),k.dispatchEvent(l("close")));var e=g.reconnectInterval*Math.pow(g.reconnectDecay,g.reconnectAttempts);setTimeout(function(){g.reconnectAttempts++,g.open(!0)},e>g.maxReconnectInterval?g.maxReconnectInterval:e)}},h.onmessage=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onmessage",g.url,b.data);var c=l("message");c.data=b.data,k.dispatchEvent(c)},h.onerror=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onerror",g.url,b),k.dispatchEvent(l("error"))}},1==this.automaticOpen&&this.open(!1),this.send=function(b){if(h)return(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","send",g.url,b),h.send(b);throw"INVALID_STATE_ERR : Pausing to reconnect websocket"},this.close=function(a,b){"undefined"==typeof a&&(a=1e3),i=!0,h&&h.close(a,b)},this.refresh=function(){h&&h.close()}}return a.prototype.onopen=function(){},a.prototype.onclose=function(){},a.prototype.onconnecting=function(){},a.prototype.onmessage=function(){},a.prototype.onerror=function(){},a.debugAll=!1,a.CONNECTING=WebSocket.CONNECTING,a.OPEN=WebSocket.OPEN,a.CLOSING=WebSocket.CLOSING,a.CLOSED=WebSocket.CLOSED,a});
cs



4. Setting up Channels


채널 레이어 타입에 있어 백엔드는 여러가지 중에서 선택이 가능하며, 어떠한 ASGI-호환 채널 레이어도 사용할 수 있다.

ASGI (Asynchronous Server Gateway Interface) Draft Spec


1) Redis-Server 설치

documentation https://redis.io/topics/quickstart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379. Connection refused.
 
# apt-get install redis-server
패키지 목록을 읽는 중입니다... 완료
의존성 트리를 만드는 중입니다
상태 정보를 읽는 중입니다... 완료
다음 패키지를 더 설치할 것입니다:
  libjemalloc1 redis-tools
다음 새 패키지를 설치할 것입니다:
  libjemalloc1 redis-server redis-tools
0개 업그레이드, 3개 새로 설치, 0개 제거 및 251개 업그레이드 안 함.
410 k바이트 아카이브를 받아야 합니다.
이 작업 후 1,272 k바이트의 디스크 공간을 더 사용하게 됩니다.
계속 하시겠습니까? [Y/n] Y
받기:1 http://kr.archive.ubuntu.com/ubuntu/ trusty/universe libjemalloc1 amd64 3.5.1-2 [76.8 kB]
받기:2 http://kr.archive.ubuntu.com/ubuntu/ trusty/universe redis-tools amd64 2:2.8.4-2 [65.7 kB]
받기:3 http://kr.archive.ubuntu.com/ubuntu/ trusty/universe redis-server amd64 2:2.8.4-2 [267 kB]
내려받기 410 k바이트, 소요시간 0초 (1,497 k바이트/초)
Selecting previously unselected package libjemalloc1.
(데이터베이스 읽는중 ...현재 110319개의 파일과 디렉터리가 설치되어 있습니다.)
Preparing to unpack .../libjemalloc1_3.5.1-2_amd64.deb ...
Unpacking libjemalloc1 (3.5.1-2) ...
Selecting previously unselected package redis-tools.
Preparing to unpack .../redis-tools_2%3a2.8.4-2_amd64.deb ...
Unpacking redis-tools (2:2.8.4-2) ...
Selecting previously unselected package redis-server.
Preparing to unpack .../redis-server_2%3a2.8.4-2_amd64.deb ...
Unpacking redis-server (2:2.8.4-2) ...
Processing triggers for man-db (2.6.7.1-1ubuntu1) ...
Processing triggers for ureadahead (0.100.0-16) ...
libjemalloc1 (3.5.1-2) 설정하는 중입니다 ...
redis-tools (2:2.8.4-2) 설정하는 중입니다 ...
redis-server (2:2.8.4-2) 설정하는 중입니다 ...
Starting redis-server: redis-server.
Processing triggers for libc-bin (2.19-0ubuntu6.5) ...
Processing triggers for ureadahead (0.100.0-16) ...
cs


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# redis-server
[1444614 Feb 16:05:41.880 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
[1444614 Feb 16:05:41.881 * Max number of open files set to 10032
                _._
           _.-``__ ''-._
      _.-``    `.  `_.  ''-._           Redis 2.8.4 (00000000/064 bit
  .-`` .-```.  ```\/    _.,_ ''-._
 (    '      ,       .-`  | `,    )     Running in stand alone mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 14446
  `-._    `-._  `-./  _.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |           http://redis.io
  `-._    `-._`-.__.-'_.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |
  `-._    `-._`-.__.-'_.-'    _.-'
      `-._    `-.__.-'    _.-'
          `-._        _.-'
              `-.__.-'
 
[1444614 Feb 16:05:41.882 # Server started, Redis version 2.8.4
[1444614 Feb 16:05:41.883 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
[1444614 Feb 16:05:41.883 * The server is now ready to accept connections on port 6379
 
cs



2) Redis layer 설치

백엔드로는 일반적으로 Redis layer를 추천한다. Redis layer 의 경우 싱글 서버 뿐만 아니라 샤딩 모드에서도 뛰어난 성능을 보이기 때문이다. Redis layer를 사용하기 위해서는 아래와 같이 설치하면 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# pip install -U asgi_redis
Collecting asgi_redis
  Downloading asgi_redis-1.0.0-py2.py3-none-any.whl
Requirement already up-to-date: six in /root/anaconda/envs/envalicia/lib/python3.5/site-packages (from asgi_redis)
Collecting redis>=2.10 (from asgi_redis)
  Downloading redis-2.10.5-py2.py3-none-any.whl (60kB)
    100|████████████████████████████████| 61kB 732kB/s
Requirement already up-to-date: asgiref>=1.0.0 in /root/anaconda/envs/envalicia/lib/python3.5/site-packages (from asgi_redis)
Collecting msgpack-python (from asgi_redis)
  Downloading msgpack-python-0.4.8.tar.gz (113kB)
    100|████████████████████████████████| 122kB 1.3MB/s
Building wheels for collected packages: msgpack-python
  Running setup.py bdist_wheel for msgpack-python ... done
  Stored in directory: /root/.cache/pip/wheels/2c/e7/e7/9031652a69d594665c5ca25e41d0fb3faa024e730b590e4402
Successfully built msgpack-python
Installing collected packages: redis, msgpack-python, asgi-redis
Successfully installed asgi-redis-1.0.0 msgpack-python-0.4.8 redis-2.10.5
cs



3) 채널 레이어 선택

다음으로 채널 레이어를 정의하게 된다. 채널 레이어는 채널이 메시지를 전달할 때 사용하는 전송 메커니즘이다. 특정한 속성을 가진 메시지 큐의 일종이다. 여기서는 Redis를 채널 레이어로 사용할 것이다. 아래와 같이 설정한다. 기본적으로 localhost:6379 로 Redis 서버에 접속한다.

settings.py

1
2
3
4
5
6
7
8
9
CHANNEL_LAYERS = {
    "default": {
        "BACKEND""asgi_redis.RedisChannelLayer",
        "CONFIG": {
            "hosts": [os.environ.get('REDIS_URL''redis://localhost:6379')],
        },
        "ROUTING""chat.routing.channel_routing",
    },
}
cs


4) 채널 라우팅

채널 라우팅은 URL 라우팅과 아주 유사한 개념이다. URL 라우팅이 URLs 을 view functions과 매칭시키듯이, 채널 라우팅 역시 channels 을 consumer functions 과 매칭시킨다. 그리고 urls.py 와 유사한 방식으로 routing.py 를 통해 정의한다.

chat/routing.py
1
2
3
4
5
6
7
8
9
from channels.routing import route
from chat.consumers import ws_connect, ws_receive, ws_disconnect
from django.conf.urls import include
 
channel_routing = [
    route("websocket.connect", ws_connect),    
    route("websocket.receive", ws_receive),
    route("websocket.disconnect", ws_disconnect),
]
cs


5) 채널 실행
asgi.py 파일에 핸들러를 정의하고 아래와 같이 실행한다.
chat/asgi.py

1
2
3
4
5
import os
import channels.asgi
 
os.environ.setdefault("DJANGO_SETTINGS_MODULE""vikander.settings")
channel_layer = channels.asgi.get_channel_layer()
cs

1
2
3
4
5
6
# daphne -b 0.0.0.0 -p 8001 chat.asgi:channel_layer
2017-02-20 19:48:45,219 INFO     Starting server at tcp:port=8001:interface=0.0.0.0, channel layer chat.asgi:channel_layer.
2017-02-20 19:48:45,220 INFO     Using busy-loop synchronous mode on channel layer
2017-02-20 19:48:45,220 INFO     Listening on endpoint tcp:port=8001:interface=0.0.0.0
***.***.**.**:50933 - - [20/Feb/2017:19:48:53"WSCONNECTING /chat/icy-cloud-4559/" - -
***.***.**.**:50933 - - [20/Feb/2017:19:48:54"WSCONNECT /chat/icy-cloud-4559/" - -
cs

1
2
3
4
5
6
# python manage.py runworker
2017-02-20 19:48:54,012 - INFO - runworker - Using single-threaded worker.
2017-02-20 19:48:54,012 - INFO - runworker - Running worker against channel layer default (asgi_redis.core.RedisChannelLayer)
2017-02-20 19:48:54,013 - INFO - worker - Listening on channels http.request, websocket.connect, websocket.disconnect, websocket.receive
chat connect room=icy-cloud-4559 client=***.***.**.**:50932 path=/chat/icy-cloud-4559/ reply_channel=websocket.send!zJSGinjeeuYx
chat connect room=icy-cloud-4559 client=***.***.**.**:50933 path=/chat/icy-cloud-4559/ reply_channel=websocket.send!yPpOUcMXHjEa
cs




5. WebSocket Consumers


채널은 웹소켓 연결을 3개의 채널과 매핑한다.

  • 먼저 메시지는 새로운 클라이언트(예를 들면 브라우저 등)가 웹소켓을 통해 접속했을 때 websocket.connect 채널로 보내진다. 그 때 해당 클라이언트가 해당 룸에 들어왔다고 기록한다.

  • 각각의 메시지는 websocket.receive 채널을 통해 전달된다(채널은 일방향이다). 메시지가 도착하면, 해당 메시지를 룸에 있는 다른 클라이언트에 브로캐스팅한다.
  • 마지막으로 클라이언트가 접속을 끊으면, 메시지는 websocket.disconnect 로 보내진다. 그리고 해당 room에서 해당 클라이언트를 제거하게 된다. 그러면 각각의 채널을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import re
import json
import logging
from channels import Channel, Group
from channels.sessions import channel_session
from .models import Room, Message
 
from django.http import HttpResponse
from channels.handler import AsgiHandler
from channels.auth import http_session_user, channel_session_user, channel_session_user_from_http
 
log = logging.getLogger(__name__)
    
@channel_session
def ws_connect(message):
    # Extract the room from the message. This expects message.path to be of the
    # form /chat/{label}/, and finds a Room if the message path is applicable,
    # and if the Room exists. Otherwise, bails (meaning this is a some othersort
    # of websocket). So, this is effectively a version of _get_object_or_404.
    try:
        prefix, label = message['path'].strip('/').split('/')
        if prefix != 'chat':
            log.debug('invalid ws path=%s', message['path'])
            return
        room = Room.objects.get(label=label)
    except ValueError:
        log.debug('invalid ws path=%s', message['path'])
        return
    except Room.DoesNotExist:
        log.debug('ws room does not exist label=%s', label)
        return
 
    log.debug('chat connect room=%s client=%s:%s path=%s reply_channel=%s'
        room.label, message['client'][0], message['client'][1], message['path'], message.reply_channel)
    
    # Need to be explicit about the channel layer so that testability works
    # This may be a FIXME?
    message.reply_channel.send({"accept": True})
    Group('chat-'+label, channel_layer=message.channel_layer).add(message.reply_channel)
 
    message.channel_session['room'= room.label
         
@channel_session
def ws_receive(message):
    # Look up the room from the channel session, bailing if it doesn't exist
    try:
        label = message.channel_session['room']
        room = Room.objects.get(label=label)
        log.debug('recieved message, room exist label=%s', room.label)
 
    except KeyError:
        log.debug('no room in channel_session')
        return
    except Room.DoesNotExist:
        log.debug('recieved message, buy room does not exist label=%s', label)
        return
 
    # Parse out a chat message from the content text, bailing if it doesn't
    # conform to the expected message format.
    try:
        data = json.loads(message['text'])
    except ValueError:
        log.debug("ws message isn't json text=%s", text)
        return
     
    if set(data.keys()) != set(('handle''message')):
        log.debug("ws message unexpected format data=%s", data)
        return
 
    if data:
        log.debug('chat message room=%s handle=%s message=%s'
            room.label, data['handle'], data['message'])
        m = room.messages.create(**data)
 
        # See above for the note about Group
        Group('chat-'+label, channel_layer=message.channel_layer).send({'text': json.dumps(m.as_dict())})
 
@channel_session
def ws_disconnect(message):
    try:
        label = message.channel_session['room']
        room = Room.objects.get(label=label)
        Group('chat-'+label, channel_layer=message.channel_layer).discard(message.reply_channel)
    except (KeyError, Room.DoesNotExist):
        pass
cs


본 포스팅에서는 consumers.py 에서 해당 기능을 구현하고 있지만 반드시 그래야 하는 것은 아니다. 먼저 ws_connect 부터 살펴보자. 클라이언트는 /chat/{label}/ 형식의 URL 을 통해 웹소켓에 연결된다. 여기서 label 은 채팅룸의 라벨 속성에 매핑된다. 모든 웹소켓 메시지는 동일한 세트의 channel consumer로 보내지므로, 메시지 경로를 파싱함으로써 어떤 룸을 지칭하는 것인지 파악할 필요가 있다.


Consumer는 message['path'] 를 통해 웹소켓 경로를 파싱한다. 이 점이 기존의 URL 라우팅과 다른 점이다.

우리가 알아야할 또 하나는 어떻게 메시지가 클라이언트에게 돌아가는가하는 점이다. 이는 메시지의 reply_channel 이라는 것을 통해서 가능하다. 모든 메시지는 reply_channel 이라는 속성을 가지고 있으며, 이를 통해 메시지를 클라이언트에게 다시 보낼 수 있다.


하지만 단지 해당 채널으로 메시지가 전송되는 것만으로는 부족하다. 해당 채팅룸에 있는 모든 구성원에게 메시지가 전달이 되어야 하는 것이다. 이를 위해서는 채널 그룹을 이용한다. 그룹이라는 것은 간단히 말해 해당 메시지가 브로드캐스팅될 채널들의 연결이다. 따라서 해당 채팅룸에 특정된 그룹에 메시지의 reply_channel 을 추가해야 한다.


마지막으로 이미 연결은 활성화된 상태이므로 이어지는 메시지들(receive/disconnect)은 URL을 더 이상 포함하지 않게 된다. 따라서 웹소켓 연결이 매핑된 룸이 어떤 룸인지 기억하는 방식이 필요하다. 이를 위해 채널 세션을 사용한다. 채널 세션은 장고의 세션 프레임워크와 매우 유사하다.세션이 작동하게 하기 위해서는 consumer에 @channel_session 데코레이터만 추가하면 된다.


클라이언트가 연결되었고 이제 ws_receive를 살펴보자. 해당 웹소켓에 메시지가 수신될 때마다 consumer가 호출된다. channel_session에서 룸을 추출하고 데이터베이스에서 이 룸을 찾는다. JSON 메시지를 파싱하고 그 메시지를 데이터베이스에 메시지 객체로 저장한다.


그리고 이 새로운 메시지를 채팅룸에 있는 모든 이들에게 브로드캐스팅한다. 그리고 이를 위해서 앞서 사용한 동일한 채널 그룹을 이용한다. Group.send()는 그룹에 추가된 모든 reply_channel에 이 메시지를 보낸다.



아래는 완성된 모습이다.

chat/about.html

chat/room.html



Reference


Channels document https://channels.readthedocs.io/en/stable/

Demo using django channels http://slides.com/juandavidhernandezgiraldo/get-started-with-django-channels

Django ChannelsでできるリアルタイムWeb http://qiita.com/massa142/items/cbd508efe0c45b618b34

Django Channels: работа с WebSocket и не только https://khashtamov.com/2016/04/django-channels-websocket/
Django channels websocket.receive is not handled http://stackoverflow.com/questions/38322197/django-channels-websocket-receive-is-not-handled
Code Self Study Wiki Django Channels http://blog.codeselfstudy.com/wiki/Django_Channels