"장고 실시간 채팅(Channels와 redis server 이용)"의 두 판 사이의 차이

 
(같은 사용자의 중간 판 19개는 보이지 않습니다)
1번째 줄: 1번째 줄:
 
== 개요 ==
 
== 개요 ==
https://channels.readthedocs.io/en/latest/index.html<nowiki/>를 참고하였다.
+
장고 채널 기능의 활용을 위해 간단한 채팅 앱 만들어보기.
  
장고는 기본적으로 동기식으로, 채팅을 위해선 비동기식 처리가 필요하다.
+
=== 사전 준비 ===
 
+
{| class="wikitable"
이를 위해 Django Channels가 마련되어 있다.
+
!과정
 
+
!설명
ASGI(Async Server Gateway Interface)프로토콜은 WSGI를 계승하여 이와 잘 호환되도록 설계되어 있다.
+
!방법
 
+
|-
ASGI는 비동기 요청인 웹 소켓을 처리하는 이벤트로 connect, send, receive, disconnect가 있다.
+
|장고
 +
|장고가 설치되어 있다고 가정한다.
 +
|[http://id8436.iptime.org:2786/mediawiki/index.php/1.%20%EC%9E%A5%EA%B3%A0%20%EA%B0%9C%EC%9A%94 1. 장고 개요]
 +
|-
 +
|채널 설치
 +
|채널이 설치되어 있다고 가정한다.
 +
|[http://id8436.iptime.org:2786/mediawiki/index.php/%EC%9E%A5%EA%B3%A0%20channels 장고 channels]
 +
|}
 +
= 기초준비 =
  
== 기초준비 ==
+
== 채팅어플 만들기 ==
 
 
=== 채팅어플 만들기 ===
 
 
패키지설치부터 진행하기엔 중간점검이 어려워 틀 만들기를 먼저 수행한다.
 
패키지설치부터 진행하기엔 중간점검이 어려워 틀 만들기를 먼저 수행한다.
 
{| class="wikitable"
 
{| class="wikitable"
21번째 줄: 27번째 줄:
 
|어플리케이션생성
 
|어플리케이션생성
 
|채팅을 위한 앱을 생성한다.
 
|채팅을 위한 앱을 생성한다.
앱을 생성하고 __init__.py와 views.py를 제외한 모든 것들을 지운다.
 
 
|django-admin startapp chat
 
|django-admin startapp chat
 
|-
 
|-
28번째 줄: 33번째 줄:
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
 
INSTALLED_APPS = [
 
INSTALLED_APPS = [
 +
    ...
 
     'chat',
 
     'chat',
 
</syntaxhighlight>
 
</syntaxhighlight>
 
|-
 
|-
|URL매핑
+
|폴더 정리
|기본이 되는 urls.py 안에서 해당 앱으로 매핑을 시켜준다.
+
| - 앱을 생성하고 생성된 __init__.py와 views.py를 제외한 모든 것들을 지운다.
|path('chat/', include('chat.urls')),
+
(채팅기능만을 위해선 나머지는 필요 없어, 지워도 된다. 근데 그냥 두자.)
 +
 
 +
- 탬플릿을 담기 위해 templates/chat 디렉토리를 생성해준다.
 +
|
 
|-
 
|-
|URL매핑2
+
|urls.py 생성 및 매핑
|그리고 안의 urls.py 작성.
+
|앱 내에서 사용되는 url을 다루기 위해 urls.py를 생성한다.
|<syntaxhighlight lang="python">
+
|chat/urls.py의 내용.<syntaxhighlight lang="python">
from dfango.urls import path
+
from django.urls import path
 
from . import views
 
from . import views
 +
app_name = 'chat'  # 보통 앱이름을 써서 url을 구분하지만,
  
 
urlpatterns = [
 
urlpatterns = [
     path('/', views.index, name='index'),
+
     path('', views.index, name='index'),
 +
]
 +
</syntaxhighlight>기초 urls.py 수정<syntaxhighlight lang="python">
 +
from django.contrib import admin
 +
from django.urls import include, path
 +
 
 +
urlpatterns = [
 +
    path('chat/', include('chat.urls')),
 +
    path('admin/', admin.site.urls),
 
]
 
]
 
</syntaxhighlight>
 
</syntaxhighlight>
|-
+
|}
|view 작성
+
여기까지 하고 runserver 후 제대로 작동하는지 확인하자.
|
 
|<syntaxhighlight lang="python">
 
from django.shortcuts import render
 
  
def index(request):
+
== 채팅룸 구현 ==
    return render(request, 'chat/index.html', {})
+
=== 채팅 인덱스 구현 ===
</syntaxhighlight>
+
들어갈 채팅룸을 입력하는 공간.
 +
{| class="wikitable"
 +
!과정
 +
!설명
 +
!방법
 
|-
 
|-
|탬플릿 준비
+
|채팅 인덱스 탬플릿 작성
|앱 하위에 templates>chat 디렉터리까지 만든다.
+
|채팅룸을 입력하기 위한 인덱스 탬플릿.
index.html 이라는 이름으로 만들자.
+
templates/chat/index.html로 작성하자.
 
|<syntaxhighlight lang="html+django">
 
|<syntaxhighlight lang="html+django">
<!-- chat/templates/chat/index.html -->
 
 
<!DOCTYPE html>
 
<!DOCTYPE html>
 
<html>
 
<html>
66번째 줄: 84번째 줄:
 
     <title>Chat Rooms</title>
 
     <title>Chat Rooms</title>
 
</head>
 
</head>
 +
<body>
 +
    What chat room would you like to enter?<br>
 +
    <input id="room-name-input" type="text" size="100"><br>
 +
    <input id="room-name-submit" type="button" value="Enter">
  
<body>
 
    What chat room would you like to enter?<br/>
 
    <input id="room-name-input" type="text" size="100"/><br/>
 
    <input id="room-name-submit" type="button" value="Enter"/>
 
   
 
 
     <script>
 
     <script>
 
         document.querySelector('#room-name-input').focus();
 
         document.querySelector('#room-name-input').focus();
88번째 줄: 105번째 줄:
 
</html>
 
</html>
 
</syntaxhighlight>
 
</syntaxhighlight>
|}
 
여기까지 하고 runserver 후 제대로 작동하는지 확인하자.
 
 
=== 패키지 설치 ===
 
{| class="wikitable"
 
!과정
 
!설명
 
!방법
 
 
|-
 
|-
|패키지 설치
+
|작성
|Channels 패키지를 설치한다.
+
|
|pip install -U channels
 
|-
 
|앱 등록
 
|settings.py에 추가.
 
channels는 runserver 명령을 제어하여 기존 서버를 대체한다.
 
|INSTALLED_APPS 하위, 가장 처음에 <code>'channels'</code>넣는다.<syntaxhighlight lang="python">
 
INSTALLED_APPS = [
 
    'channels',  # 다른 서드파티 앱과 충돌할 수 있어 가장 처음에 둔다.
 
    ...  # 공식 튜토리얼에선 'chat'을 상위로 올리지만, 아래에 있어도 상관 없다.
 
    ...
 
    'chat',
 
</syntaxhighlight>
 
|-
 
|라우팅 설정 작성
 
|가장 상위의 디렉터리에 routing.py를 다음과 같이 작성한다.
 
(취향에 따라 달리 작성해도 된다.)
 
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
from channels.routing import ProtocolTypeRouter
+
from django.shortcuts import render
  
application = ProtocolTypeRouter({
+
def index(request):
     # (http->django views is added by default)
+
     return render(request, 'chat/index.html')
})
 
 
</syntaxhighlight>
 
</syntaxhighlight>
|-
 
|설정 추가
 
|settings.py 안에 우측의 내용을 추가한다.
 
라우팅파일의 위치만 잘 잡아주면 된다.
 
|<syntaxhighlight lang="python">
 
ASGI_APPLICATION = 'routing.application'  # routing.py 파일의 application을 불러온다.
 
</syntaxhighlight>(라우팅 파일 안의 application을 가져온다.)
 
 
|}
 
|}
  
== 채팅룸 구현 ==
+
=== 채팅룸 구현 ===
 
{| class="wikitable"
 
{| class="wikitable"
 
!과정
 
!과정
 
!설명
 
!설명
 
!방법
 
!방법
|-
 
|URL매핑
 
|앱 내의 urls.py에 추가.
 
|<syntaxhighlight lang="python">
 
urlpatterns = [
 
    ...
 
    path('<str:room_name>/', views.room, name='room'),
 
    ...
 
]
 
</syntaxhighlight>
 
|-
 
|뷰 작성
 
|room 뷰 작성
 
mark_safe와 json을 가져온다.
 
|<syntaxhighlight lang="python">
 
from django.shortcuts import render
 
from django.utils.safestring import mark_safe
 
import json
 
 
def room(request, room_name):
 
    return render(request, 'chat/room.html', {
 
        'room_name_json': mark_safe(json.dumps(room_name))
 
    })
 
</syntaxhighlight>
 
 
|-
 
|-
 
|탬플릿 작성
 
|탬플릿 작성
|/char/room.html을 만든다.
+
|templates/chat/room.html
 
|<syntaxhighlight lang="html+django">
 
|<syntaxhighlight lang="html+django">
 
<!DOCTYPE html>
 
<!DOCTYPE html>
170번째 줄: 131번째 줄:
 
     <title>Chat Room</title>
 
     <title>Chat Room</title>
 
</head>
 
</head>
 
 
<body>
 
<body>
     <textarea id="chat-log" cols="100" rows="20"></textarea><br/>
+
     <textarea id="chat-log" cols="100" rows="20"></textarea><br>
     <input id="chat-message-input" type="text" size="100"/><br/>
+
     <input id="chat-message-input" type="text" size="100"><br>
     <input id="chat-message-submit" type="button" value="Send"/>
+
     <input id="chat-message-submit" type="button" value="Send">
</body>
+
    {{ room_name|json_script:"room-name" }}
 +
    <script>
 +
        const roomName = JSON.parse(document.getElementById('room-name').textContent);
  
<script>
+
        const chatSocket = new WebSocket(
    var roomName = {{ room_name_json }};
+
            'ws://'
 +
            + window.location.host
 +
            + '/ws/chat/'
 +
            + roomName
 +
            + '/'
 +
        );
  
    var chatSocket = new WebSocket(
+
        chatSocket.onmessage = function(e) {
        'ws://' + window.location.host +
+
            const data = JSON.parse(e.data);
        '/ws/chat/' + roomName + '/');
+
            document.querySelector('#chat-log').value += (data.message + '\n');
 +
        };
  
    chatSocket.onmessage = function(e) {
+
        chatSocket.onclose = function(e) {
        var data = JSON.parse(e.data);
+
            console.error('Chat socket closed unexpectedly');
        var message = data['message'];
+
         };
         document.querySelector('#chat-log').value += (message + '\n');
 
    };
 
  
    chatSocket.onclose = function(e) {
+
         document.querySelector('#chat-message-input').focus();
         console.error('Chat socket closed unexpectedly');
+
        document.querySelector('#chat-message-input').onkeyup = function(e) {
    };
+
            if (e.keyCode === 13) {  // enter, return
 
+
                document.querySelector('#chat-message-submit').click();
    document.querySelector('#chat-message-input').focus();
+
             }
    document.querySelector('#chat-message-input').onkeyup = function(e) {
+
         };
        if (e.keyCode === 13) {  // enter, return
 
            document.querySelector('#chat-message-submit').click();
 
        }
 
    };
 
 
 
    document.querySelector('#chat-message-submit').onclick = function(e) {
 
        var messageInputDom = document.querySelector('#chat-message-input');
 
        var message = messageInputDom.value;
 
        chatSocket.send(JSON.stringify({
 
             'message': message
 
        }));
 
 
 
         messageInputDom.value = '';
 
    };
 
</script>
 
  
 +
        document.querySelector('#chat-message-submit').onclick = function(e) {
 +
            const messageInputDom = document.querySelector('#chat-message-input');
 +
            const message = messageInputDom.value;
 +
            chatSocket.send(JSON.stringify({
 +
                'message': message
 +
            }));
 +
            messageInputDom.value = '';
 +
        };
 +
    </script>
 +
</body>
 
</html>
 
</html>
 +
</syntaxhighlight>
 +
|-
 +
|뷰 작성
 +
|뷰에 다음 함수를 추가한다.
 +
|<syntaxhighlight lang="python">
 +
def room(request, room_name):
 +
    return render(request, 'chat/room.html', {
 +
        'room_name': room_name
 +
    })
 +
</syntaxhighlight>
 +
|-
 +
|URL
 +
|다음의 내용을 urls.py에 추가한다.
 +
|<syntaxhighlight lang="python">
 +
path('<str:room_name>/', views.room, name='room'),
 
</syntaxhighlight>
 
</syntaxhighlight>
 
|}
 
|}
 
+
=== 참가자(컨슈머) 구현 ===
=== 참가자 구현 ===
 
 
{| class="wikitable"
 
{| class="wikitable"
 
!과정
 
!과정
228번째 줄: 203번째 줄:
 
클라이언트로부터 메시지를 받아서 그대로 전달.
 
클라이언트로부터 메시지를 받아서 그대로 전달.
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
 +
import json
 
from channels.generic.websocket import WebsocketConsumer
 
from channels.generic.websocket import WebsocketConsumer
import json
 
  
 
class ChatConsumer(WebsocketConsumer):
 
class ChatConsumer(WebsocketConsumer):
250번째 줄: 225번째 줄:
 
|/chat/routing.py 작성
 
|/chat/routing.py 작성
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
from django.conf.urls import url
+
from django.urls import re_path
 +
 
 
from . import consumers
 
from . import consumers
  
 
websocket_urlpatterns = [
 
websocket_urlpatterns = [
     url(r'^ws/chat/(?P<room_name>[^/]+)/$', consumers.ChatConsumer),
+
     re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
 
]
 
]
 
</syntaxhighlight>
 
</syntaxhighlight>
 
|-
 
|-
 
|라우팅 등록
 
|라우팅 등록
|/routing.py 수정
+
|channels를 설치하며 작성한 agsi.py 안에 내용을 채워준다.
 
chat 안의 라우팅을 등록해준다.
 
chat 안의 라우팅을 등록해준다.
  
그리고 임포트하는 모듈을 수정해준다.
+
그리고 이를 사용하기 위한 라이브러리를 불러온다.
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
 +
......
 +
 +
from channels.routing import URLRouter
 +
from channels.security.websocket import AllowedHostsOriginValidator
 
from channels.auth import AuthMiddlewareStack
 
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
 
 
import chat.routing
 
import chat.routing
  
application = ProtocolTypeRouter({
+
application = ProtocolTypeRouter(
    # (http->django views is added by default)
+
    {
    'websocket': AuthMiddlewareStack(
+
        "http": django_asgi_app,
        URLRouter(
+
        "websocket": AllowedHostsOriginValidator(
            chat.routing.websocket_urlpatterns
+
            AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns))
         )
+
         ),
     ),
+
     }
})
+
)
 
</syntaxhighlight>
 
</syntaxhighlight>
|}
+
|}여기까지 하고 점검해보자. /chat/lobby/ 에 접속한 후 채팅을 치면 떠야 한다.
여기까지 하고 채팅을 쳐 보면... 채팅이 나와야 정상.
+
 
 +
같은 브라우저를 띄워두고 한쪽에서 채팅을 치면 다른 쪽에서 안뜨는데, 다른 브라우저에선 안뜬다. 접속한 모두에게 반영될 수 있도록 채널레이어를 구현한다.
 +
 
 +
= 채널 레이어 구현 =
 +
consumer 인스턴스는 자동으로 유일한 channel name을 구성하기 때문에 서로 소통하려면 layer가 필요하다.
 +
 
 +
== 레디스 ==
 +
레디스 서버를 이용한 실시간 채팅 구현.
  
=== 체널레이어 구현 ===
+
꽤 오래된 버전인 5를 사용하는데, 아마도 이는.. 5에서 처음으로 consumer groups이 구현된 버전인데, 이걸로 구현했기 때문이 아닐까 싶다.(최신버전에서도 될진.. 검증이 필요하다.)
 +
===사전설정===
 
{| class="wikitable"
 
{| class="wikitable"
 
!과정
 
!과정
286번째 줄: 273번째 줄:
 
!방법
 
!방법
 
|-
 
|-
|패키지 설치
+
| 서버 설치
|Channels가 Redis인터페이스를 인식하도록.
+
|설치 후 딱히 옵션을 변경할 내용은 없다.
 +
어차피 로컬에서 모든 작동이 수행되기 때문에.
 +
 
 +
네이티브로 설치하는 게 아니라 도커를 사용할 거라면 아래로 넘어가자.
 +
 
 +
단순 apt-get install redis로 설치해 5 이상의 버전이 되어도 작동한다.
 +
|[http://id8436.iptime.org:8080/mediawiki/index.php/Radis Redis] 문서 참조.
 +
|-
 +
|도커
 +
|도커가 설치되어 있다면 아래 명령으로 바로 설치, 서비스가 시작된다.
 +
 
 +
docker run -p 6379:6379 -d redis:5
 +
|아래 문서를 통해 도커 설치를 참고하자.
 +
http://id8436.iptime.org:8080/mediawiki/index.php/Docker
 +
|-
 +
|연동 라이브러리 설치
 +
|서버와 채널을 연동할 라이브러리를 설치한다.
 
|pip install channels_redis
 
|pip install channels_redis
 
|-
 
|-
|settings.py 설정
+
|세팅
|ASGI 아래 추가하자.
+
|settings.py 안에 추가.
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
ASGI_APPLICATION = 'routing.application' # routing.py 파일의 application을 불러온다.
+
ASGI_APPLICATION = "mysite.asgi.application" # 이부분은 채널을 설치할 때 작성한 부분.
 
CHANNEL_LAYERS = {
 
CHANNEL_LAYERS = {
     'default': {
+
     "default": {
         'BACKEND': 'channels_redis.core.RedisChannelLayer',
+
         "BACKEND": "channels_redis.core.RedisChannelLayer",
         'CONFIG': {
+
         "CONFIG": {
             "hosts": [('127.0.0.1', 6379)],
+
             "hosts": [("127.0.0.1", 6379)],
 
         },
 
         },
 
     },
 
     },
 
}
 
}
 +
</syntaxhighlight>을 추가해준다.
 +
|}
 +
==기존 컨슈머 변형==
 +
위에서 작성한 컨슈머는 중간 확인을 위한 동기식 함수인데, 비동기식으로 다시 쓰이면 성능이 좋아진다. 아래와 같이 변형하자.<syntaxhighlight lang="python">
 +
import json
 +
 +
from channels.generic.websocket import AsyncWebsocketConsumer
 +
 +
 +
class ChatConsumer(AsyncWebsocketConsumer):
 +
    async 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
 +
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)
 +
 +
        await self.accept()
 +
 +
    async def disconnect(self, close_code):
 +
        # Leave room group
 +
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
 +
 +
    # Receive message from WebSocket
 +
    async def receive(self, text_data):
 +
        text_data_json = json.loads(text_data)
 +
        message = text_data_json["message"]
 +
 +
        # Send message to room group
 +
        await self.channel_layer.group_send(
 +
            self.room_group_name, {"type": "chat_message", "message": message}
 +
        )
 +
 +
    # Receive message from room group
 +
    async def chat_message(self, event):
 +
        message = event["message"]
 +
 +
        # Send message to WebSocket
 +
        await self.send(text_data=json.dumps({"message": message}))
 
</syntaxhighlight>
 
</syntaxhighlight>
|}
+
 
== 개요 ==
+
= 서버에 반영 =
레디스 서버를 이용한 실시간 채팅 구현
+
튜토리얼엔 나오지 않지만(공식문서 한참 뒤에 나온다..), 서버에 반영하게 되면 ws/ 경로에 대하여 새로운 설정을 해주어야 한다.(필자는 이걸 몰라서 이것저것 건드리다가 sql 날려먹었다.. 젠장)
===사전설정===
+
 
 +
일반적으로 WSGI 통신을 gunicorn으로 처리한다면, ASGI통신은 daphne으로 처리한다.(daphne에서도 wsgi통신이 가능하지만, 보안상 권장되진 않는다.)
 
{| class="wikitable"
 
{| class="wikitable"
 
!과정
 
!과정
312번째 줄: 355번째 줄:
 
!방법
 
!방법
 
|-
 
|-
| 서버 설치
+
| 설치
 +
|daphne은 channels를 설치하면 자동으로 설치가 된다.
 
|
 
|
|apt-get install redis-server
 
 
|-
 
|-
|서버 테스트
+
|확인
|서버가 잘 설치되었는지 테스트.
+
|실행 명령은 우측과 같다.(asgi의 경로를 지정해주면 된다.)
서버 구동에 대한 메시지가 뜬다.
+
 
|redis-server
+
포트번호는 필요에 따라 지정하면 된다.
|-
+
 
|채널 라이브러리 설치
+
해당 포트로 접속해보면 사이트와 채팅이 잘 작동한다.
|장고에서 사용할 채널 라이브러리를 설치한다.
+
|<code>daphne -b 0.0.0.0 -p 8001 config.asgi:application</code>
|pip install -U channels
 
|-
 
|연동 라이브러리 설치
 
|서버와 채널을 연동할 라이브러리를 설치한다.
 
|pip install channels_redis
 
 
|-
 
|-
|앱을 만든다.
+
|서버에서 실행
|
+
|서비스를 위해선 백그라운드로 진행해주어야 한다.
|django-admin startapp chat
+
실행 여부는 <code>ps -ef | grep daphne</code>로 확인해보자. 실행이 안되면 nohup 떼서 에러메시지 확인.
 +
|<code>nohup daphne -b 0.0.0.0 -p 8001 config.asgi:application &</code>
 
|-
 
|-
|앱 등록
+
|웹서버에 반영
|settings.py에 추가.
+
|/static을 반영하듯, /ws로 시작되는 주소는 다핀으로 넘겨준다.
|INSTALLED_APPS 가장 위에 'channels', 'chat'을 맨 위에 추가해준다.
+
이후 관련 서비스들을 재시작하면 설정들이 반영된다.
다른 서드파티 앱과 충돌할 수 있어 가장 처음에 둔다.
 
|-
 
|세팅
 
|settings.py 안에 추가.
 
|<syntaxhighlight lang="python">
 
ASGI_APPLICATION = 'config.routhing.application'
 
  
CHANNEL_LAYERS = {
+
채팅도 물론 성공적...!
    'default':{
+
|<syntaxhighlight lang="bash">
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
+
location /ws {
        'CONFIG': {
+
                proxy_pass http://localhost:8001;
            "hosts": [('127.0.0.1', 6379)],
+
                proxy_http_version 1.1;
 +
                proxy_set_header Upgrade $http_upgrade;
 +
                proxy_set_header Connection "upgrade";
 
         }
 
         }
    }
+
</syntaxhighlight>
}
 
 
 
os.environ["DJANGO_ALLOW_ASYNC_UNDAFE"] = "true"
 
</syntaxhighlight>을 추가해준다.(대강 스테틱 설정 아래면 적당)
 
 
|}
 
|}
==모델작성==
+
웹소켓에서도 https 인증을 적용할 수 있다.([https://victorydntmd.tistory.com/265 추후에 반영해보자.])   
chat 앱의 models.py 작성<syntaxhighlight lang="python">
 
from django.db import models
 
from django.conf import settings
 
  
class Room(models.Model):
+
== 관련에러 ==
    room_name = models.CharField(max_length=100, blank=True)
 
    users = models.ManyToManyfield(
 
        settings.AUTH_USER_MODEL,  # 유저모델과 연결한다.
 
        blank=True,
 
        related_name = 'rooms')  # 룸이라는 인덱스 지정.
 
   
 
    def __str__(self):
 
        return self.room_name
 
       
 
class Message(models.Model):
 
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=1)
 
    room = models.ForeignKey(Room, related_name='messages', default=1, on_delete=models.CASCADE)
 
    content = models.TextField(blank=True)
 
    created_at = models.DateTimeField(auto_now_add=True)
 
   
 
    def __str__(self):
 
        return self.content
 
</syntaxhighlight>chat앱의 admin.py 작성<syntaxhighlight lang="python">
 
from django.contrib import admin
 
from .models import Room, Message
 
  
@admin.register(Room)
+
=== The app module <module '앱이름' (<_frozen_importlib_external._NamespaceLoader object at 0x7f2faed9a8f0>)> has multiple filesystem locations (['/./앱경로', '/앱경로']); you must configure this app with an AppConfig subclass with a 'path' class attribute. ===
class RoomAdmin(admin.ModelAdmin):
+
생각지도 못한 문제점이었다... 앱은 하나의 모듈처리가 되어 디렉토리 안에 __init__.py가 담기는데, 이게 없는 경우 경로를 제대로 찾지 못해 발생하는 에러이다. 앱 디렉토리 안에 __init__.py를 제대로 넣어주면 해결됨.
    list_display = ['id', 'room_name']
 
    list_display_links = ['room_name']
 
   
 
@admin.register(Room)
 
class MessageAdmin(admin.ModelAdmin):
 
    list_display = ['user', 'room', 'content', 'created_at']
 
    list_display_links = ['user', 'room', 'content', 'created_at']
 
</syntaxhighlight>대화모델 작성(보통 친구모델 등에 작성된다.)(근데 그냥 chat 앱 안에 작성하면 안되나...?)<syntaxhighlight lang="python">
 
...
 
from .chat.models import Room, Message
 
  
class chat_connection(models.Model):
+
= 마무리. 각 요소들이 어떤 역할을 하는지. =
    target = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, on_delete=models.CASCADE)  # 상대방
+
순서대로 어떤 경로를 거쳐 작동하는지 살펴보자.
    room = models.ForeignKey(Room, blank=True, on_delete=models.SET_NULL, null=True)  # null옵션이 있어야 하나..? 없어도 될듯, 강의에선 친구모델 위에 더하느라 붙인듯.
 
    user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, on_delete=models.CASCADE)  # 나
 
    created_at = models.DateField(auto_now_add=True)
 
   
 
    def __str__(self):
 
        return self.user.username + "채팅"  # 사용자명을 반환한다.
 
</syntaxhighlight>
 
==뷰 작성==
 
<syntaxhighlight lang="python">
 
from django.shortcuts import render, get_object_or_404
 
forom django.contrib.auth import get_user_model
 
from chat.models import *
 
import logging
 
  
def start_chat(request):
+
=== asgi.py ===
    from_user =  # 본인의 유저모델 가져오기
+
{| class="wikitable"
    to_user = 유저모델.objects.get(pk=target_request_id)  # 상대의 유저모델 가져오기(이름이나 pk로 불러오게 하면 될듯)
+
!항목
   
+
!설명
    room_name = "{},{}".format(from_user.username, to_user.username)
+
|-
    room = Room.objects.create(room_name=room_name)  # 룸을 만든다.
+
| ProtocolTypeRouter
   
+
|연결의 종류를 파악하여 처리. http로 넘길지, 웹소켓 처리로 넘길지.
def chat_list(request):
 
    user = request.user
 
    user_profile = user.user_profile
 
    friends = user.friends.all()  # 모든 친구 불러오기...(채팅자 목록이라고 보면 됨.)
 
   
 
    context = {'user_profile': user_profile,
 
                'friends':friends,
 
    }
 
   
 
    return render(request, 'chat/chat_list.html', context)
 
   
 
def room(request, room_id):
 
    user = request.user
 
    user_profile = user.profile
 
    friends = user.friends.all()  # 모든 친구 불러오기...(채팅자 목록이라고 보면 됨.)
 
   
 
    room = Room.objects.get(pk=room_id)  # 룸 모델에서 해당 pk에 맞는 룸 불러오기
 
    friends_uer = room.users.all().exclude(pk=user.id).first()
 
   
 
    context = {'current_user': user,
 
        'user_profile': user_profile,
 
        'friends':friends,
 
        'room':room,
 
        'friends_user': friends_user,
 
    }
 
   
 
    return render(request, 'chat/room.html', context)
 
  
   
+
* http 연결일 경우 보통 django_asgi_app<code>(get_asgi_application())</code> 가 실행된다.
</syntaxhighlight>
+
* websocket 연결(ws:// 혹은 wss://)일 경우 <code>AuthMiddlewareStack</code>으로 앱별로 <code>routing.py</code>에 적어놓은 코드로 라우팅 한다.
==url 작성==
+
|-
config의 url을 넘김 처리하고..
+
|
 +
|
 +
*
 +
|}
  
chat앱 안의 urls.py를 작성한다.<syntaxhighlight lang="python">
+
=== routing.py ===
from .views import *
+
들어온 경로와 컨슈머를 연결해준다. urls.py와 view.py의 관계.
  
app_name= 'chat'
+
다음과 같은 형태로 컨슈머를 연결한다.<syntaxhighlight lang="python">
 
+
websocket_urlpatterns = [
urlpatterns = [
+
     re_path(r'ws/notification/(?P<center_name>\w+)/$', consumers.NotificationConsumer.as_asgi()),
     path('', chat_list, name='chat_list'),
 
    paht('<str:room_id', room, name='room'),
 
 
]
 
]
 
</syntaxhighlight>
 
</syntaxhighlight>
==탬플릿 작성 ==
 
chat 앱 안의 templates 폴더를 만든 다음 넣는다.<syntaxhighlight lang="html+django">
 
<!--확장코드-->
 
  
<!--들어가야 할 것들-->
+
=== [컨슈머는 컨슈머 문서로 옮김] ===
<script src="{% static 'js/messenger.js' %}"></script>
+
[[분류:장고 웹소켓]]
 
 
<div id="user_list">
 
    {% for friend in friends %}
 
        {% if friend.room %}
 
        <li name="{{ friend.room.id }}"><a href="/chat/{{ friend.room.id }}/"
 
        <div>{{ friend.user }}</div>
 
</div>
 
 
 
<div id="time_line">
 
    <div id="main_section">
 
        <div id="feed">
 
            <div id="text_field">
 
                <input type="text" id="txt">
 
                <button type="submit" id="btn">전송</button>
 
            </div>
 
        </div>
 
    </div>
 
</div>
 
</syntaxhighlight>
 
[[분류:장고 기능구현(중급)]]
 

2023년 1월 18일 (수) 13:30 기준 최신판

1 개요편집

장고 채널 기능의 활용을 위해 간단한 채팅 앱 만들어보기.

1.1 사전 준비편집

과정 설명 방법
장고 장고가 설치되어 있다고 가정한다. 1. 장고 개요
채널 설치 채널이 설치되어 있다고 가정한다. 장고 channels

2 기초준비편집

2.1 채팅어플 만들기편집

패키지설치부터 진행하기엔 중간점검이 어려워 틀 만들기를 먼저 수행한다.

과정 설명 방법
어플리케이션생성 채팅을 위한 앱을 생성한다. django-admin startapp chat
앱 등록 settings.py에 추가.
INSTALLED_APPS = [
    ...
    'chat',
폴더 정리 - 앱을 생성하고 생성된 __init__.py와 views.py를 제외한 모든 것들을 지운다.

(채팅기능만을 위해선 나머지는 필요 없어, 지워도 된다. 근데 그냥 두자.)

- 탬플릿을 담기 위해 templates/chat 디렉토리를 생성해준다.

urls.py 생성 및 매핑 앱 내에서 사용되는 url을 다루기 위해 urls.py를 생성한다. chat/urls.py의 내용.
from django.urls import path
from . import views
app_name = 'chat'  # 보통 앱이름을 써서 url을 구분하지만, 

urlpatterns = [
    path('', views.index, name='index'),
]
기초 urls.py 수정
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('chat/', include('chat.urls')),
    path('admin/', admin.site.urls),
]

여기까지 하고 runserver 후 제대로 작동하는지 확인하자.

2.2 채팅룸 구현편집

2.2.1 채팅 인덱스 구현편집

들어갈 채팅룸을 입력하는 공간.

과정 설명 방법
채팅 인덱스 탬플릿 작성 채팅룸을 입력하기 위한 인덱스 탬플릿.

templates/chat/index.html로 작성하자.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Rooms</title>
</head>
<body>
    What chat room would you like to enter?<br>
    <input id="room-name-input" type="text" size="100"><br>
    <input id="room-name-submit" type="button" value="Enter">

    <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/' + roomName + '/';
        };
    </script>
</body>
</html>
뷰 작성
from django.shortcuts import render

def index(request):
    return render(request, 'chat/index.html')

2.2.2 채팅룸 구현편집

과정 설명 방법
탬플릿 작성 templates/chat/room.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
    <textarea id="chat-log" cols="100" rows="20"></textarea><br>
    <input id="chat-message-input" type="text" size="100"><br>
    <input id="chat-message-submit" type="button" value="Send">
    {{ 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);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };

        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) {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        };

        document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                'message': message
            }));
            messageInputDom.value = '';
        };
    </script>
</body>
</html>
뷰 작성 뷰에 다음 함수를 추가한다.
def room(request, room_name):
    return render(request, 'chat/room.html', {
        'room_name': room_name
    })
URL 다음의 내용을 urls.py에 추가한다.
path('<str:room_name>/', views.room, name='room'),

2.2.3 참가자(컨슈머) 구현편집

과정 설명 방법
참가자 파일 작성 /chat/consumers.py 작성.

모든 요청을 받아들이는 비동기적인 웹소켓.

클라이언트로부터 메시지를 받아서 그대로 전달.

import json
from channels.generic.websocket import WebsocketConsumer

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.accept()

    def disconnect(self, close_code):
        pass

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

        self.send(text_data=json.dumps({
            'message': message
        }))
라우팅 작성 /chat/routing.py 작성
from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
]
라우팅 등록 channels를 설치하며 작성한 agsi.py 안에 내용을 채워준다.

chat 안의 라우팅을 등록해준다.

그리고 이를 사용하기 위한 라이브러리를 불러온다.

......

from channels.routing import URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from channels.auth import AuthMiddlewareStack
import chat.routing

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns))
        ),
    }
)

여기까지 하고 점검해보자. /chat/lobby/ 에 접속한 후 채팅을 치면 떠야 한다.

같은 브라우저를 띄워두고 한쪽에서 채팅을 치면 다른 쪽에서 안뜨는데, 다른 브라우저에선 안뜬다. 접속한 모두에게 반영될 수 있도록 채널레이어를 구현한다.

3 채널 레이어 구현편집

consumer 인스턴스는 자동으로 유일한 channel name을 구성하기 때문에 서로 소통하려면 layer가 필요하다.

3.1 레디스편집

레디스 서버를 이용한 실시간 채팅 구현.

꽤 오래된 버전인 5를 사용하는데, 아마도 이는.. 5에서 처음으로 consumer groups이 구현된 버전인데, 이걸로 구현했기 때문이 아닐까 싶다.(최신버전에서도 될진.. 검증이 필요하다.)

3.1.1 사전설정편집

과정 설명 방법
서버 설치 설치 후 딱히 옵션을 변경할 내용은 없다.

어차피 로컬에서 모든 작동이 수행되기 때문에.

네이티브로 설치하는 게 아니라 도커를 사용할 거라면 아래로 넘어가자.

단순 apt-get install redis로 설치해 5 이상의 버전이 되어도 작동한다.

Redis 문서 참조.
도커 도커가 설치되어 있다면 아래 명령으로 바로 설치, 서비스가 시작된다.

docker run -p 6379:6379 -d redis:5

아래 문서를 통해 도커 설치를 참고하자.

http://id8436.iptime.org:8080/mediawiki/index.php/Docker

연동 라이브러리 설치 서버와 채널을 연동할 라이브러리를 설치한다. pip install channels_redis
세팅 settings.py 안에 추가.
ASGI_APPLICATION = "mysite.asgi.application"  # 이부분은 채널을 설치할 때 작성한 부분.
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("127.0.0.1", 6379)],
        },
    },
}
을 추가해준다.

3.2 기존 컨슈머 변형편집

위에서 작성한 컨슈머는 중간 확인을 위한 동기식 함수인데, 비동기식으로 다시 쓰이면 성능이 좋아진다. 아래와 같이 변형하자.

import json

from channels.generic.websocket import AsyncWebsocketConsumer


class ChatConsumer(AsyncWebsocketConsumer):
    async 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
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)

        await self.accept()

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

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

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

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

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

4 서버에 반영편집

튜토리얼엔 나오지 않지만(공식문서 한참 뒤에 나온다..), 서버에 반영하게 되면 ws/ 경로에 대하여 새로운 설정을 해주어야 한다.(필자는 이걸 몰라서 이것저것 건드리다가 sql 날려먹었다.. 젠장)

일반적으로 WSGI 통신을 gunicorn으로 처리한다면, ASGI통신은 daphne으로 처리한다.(daphne에서도 wsgi통신이 가능하지만, 보안상 권장되진 않는다.)

과정 설명 방법
설치 daphne은 channels를 설치하면 자동으로 설치가 된다.
확인 실행 명령은 우측과 같다.(asgi의 경로를 지정해주면 된다.)

포트번호는 필요에 따라 지정하면 된다.

해당 포트로 접속해보면 사이트와 채팅이 잘 작동한다.

daphne -b 0.0.0.0 -p 8001 config.asgi:application
서버에서 실행 서비스를 위해선 백그라운드로 진행해주어야 한다.

실행 여부는 ps -ef | grep daphne로 확인해보자. 실행이 안되면 nohup 떼서 에러메시지 확인.

nohup daphne -b 0.0.0.0 -p 8001 config.asgi:application &
웹서버에 반영 /static을 반영하듯, /ws로 시작되는 주소는 다핀으로 넘겨준다.

이후 관련 서비스들을 재시작하면 설정들이 반영된다.

채팅도 물론 성공적...!

location /ws {
                proxy_pass http://localhost:8001;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
        }

웹소켓에서도 https 인증을 적용할 수 있다.(추후에 반영해보자.)

4.1 관련에러편집

4.1.1 The app module <module '앱이름' (<_frozen_importlib_external._NamespaceLoader object at 0x7f2faed9a8f0>)> has multiple filesystem locations (['/./앱경로', '/앱경로']); you must configure this app with an AppConfig subclass with a 'path' class attribute.편집

생각지도 못한 문제점이었다... 앱은 하나의 모듈처리가 되어 디렉토리 안에 __init__.py가 담기는데, 이게 없는 경우 경로를 제대로 찾지 못해 발생하는 에러이다. 앱 디렉토리 안에 __init__.py를 제대로 넣어주면 해결됨.

5 마무리. 각 요소들이 어떤 역할을 하는지.편집

순서대로 어떤 경로를 거쳐 작동하는지 살펴보자.

5.1 asgi.py편집

항목 설명
ProtocolTypeRouter 연결의 종류를 파악하여 처리. http로 넘길지, 웹소켓 처리로 넘길지.
  • http 연결일 경우 보통 django_asgi_app(get_asgi_application()) 가 실행된다.
  • websocket 연결(ws:// 혹은 wss://)일 경우 AuthMiddlewareStack으로 앱별로 routing.py에 적어놓은 코드로 라우팅 한다.

5.2 routing.py편집

들어온 경로와 컨슈머를 연결해준다. urls.py와 view.py의 관계.

다음과 같은 형태로 컨슈머를 연결한다.

websocket_urlpatterns = [
    re_path(r'ws/notification/(?P<center_name>\w+)/$', consumers.NotificationConsumer.as_asgi()),
]

5.3 [컨슈머는 컨슈머 문서로 옮김]편집