"장고 실시간 채팅(Channels와 redis server 이용)"의 두 판 사이의 차이
(→채팅룸 구현) |
|||
(같은 사용자의 중간 판 22개는 보이지 않습니다) | |||
1번째 줄: | 1번째 줄: | ||
== 개요 == | == 개요 == | ||
− | + | 장고 채널 기능의 활용을 위해 간단한 채팅 앱 만들어보기. | |
− | + | === 사전 준비 === | |
+ | {| class="wikitable" | ||
+ | !과정 | ||
+ | !설명 | ||
+ | !방법 | ||
+ | |- | ||
+ | |장고 | ||
+ | |장고가 설치되어 있다고 가정한다. | ||
+ | |[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번째 줄: | ||
|어플리케이션생성 | |어플리케이션생성 | ||
|채팅을 위한 앱을 생성한다. | |채팅을 위한 앱을 생성한다. | ||
− | |||
|django-admin startapp chat | |django-admin startapp chat | ||
|- | |- | ||
28번째 줄: | 33번째 줄: | ||
|<syntaxhighlight lang="python"> | |<syntaxhighlight lang="python"> | ||
INSTALLED_APPS = [ | INSTALLED_APPS = [ | ||
+ | ... | ||
'chat', | 'chat', | ||
</syntaxhighlight> | </syntaxhighlight> | ||
|- | |- | ||
− | | | + | |폴더 정리 |
− | | | + | | - 앱을 생성하고 생성된 __init__.py와 views.py를 제외한 모든 것들을 지운다. |
− | + | (채팅기능만을 위해선 나머지는 필요 없어, 지워도 된다. 근데 그냥 두자.) | |
+ | |||
+ | - 탬플릿을 담기 위해 templates/chat 디렉토리를 생성해준다. | ||
+ | | | ||
|- | |- | ||
− | | | + | |urls.py 생성 및 매핑 |
− | | | + | |앱 내에서 사용되는 url을 다루기 위해 urls.py를 생성한다. |
− | |<syntaxhighlight lang="python"> | + | |chat/urls.py의 내용.<syntaxhighlight lang="python"> |
− | from | + | from django.urls import path |
from . import views | from . import views | ||
+ | app_name = 'chat' # 보통 앱이름을 써서 url을 구분하지만, | ||
+ | |||
+ | urlpatterns = [ | ||
+ | path('', views.index, name='index'), | ||
+ | ] | ||
+ | </syntaxhighlight>기초 urls.py 수정<syntaxhighlight lang="python"> | ||
+ | from django.contrib import admin | ||
+ | from django.urls import include, path | ||
urlpatterns = [ | urlpatterns = [ | ||
− | path('/', | + | path('chat/', include('chat.urls')), |
+ | path('admin/', admin.site.urls), | ||
] | ] | ||
</syntaxhighlight> | </syntaxhighlight> | ||
− | | | + | |} |
− | + | 여기까지 하고 runserver 후 제대로 작동하는지 확인하자. | |
− | |||
− | |||
− | |||
− | + | == 채팅룸 구현 == | |
− | + | === 채팅 인덱스 구현 === | |
− | + | 들어갈 채팅룸을 입력하는 공간. | |
+ | {| class="wikitable" | ||
+ | !과정 | ||
+ | !설명 | ||
+ | !방법 | ||
|- | |- | ||
− | |탬플릿 | + | |채팅 인덱스 탬플릿 작성 |
− | | | + | |채팅룸을 입력하기 위한 인덱스 탬플릿. |
− | index. | + | templates/chat/index.html로 작성하자. |
|<syntaxhighlight lang="html+django"> | |<syntaxhighlight lang="html+django"> | ||
− | |||
<!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"> | ||
− | |||
− | |||
− | |||
− | |||
− | |||
<script> | <script> | ||
document.querySelector('#room-name-input').focus(); | document.querySelector('#room-name-input').focus(); | ||
88번째 줄: | 105번째 줄: | ||
</html> | </html> | ||
</syntaxhighlight> | </syntaxhighlight> | ||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
|- | |- | ||
− | | | + | |뷰 작성 |
− | + | | | |
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | | | ||
− | |||
|<syntaxhighlight lang="python"> | |<syntaxhighlight lang="python"> | ||
− | from | + | from django.shortcuts import render |
− | + | def index(request): | |
− | + | return render(request, 'chat/index.html') | |
− | |||
</syntaxhighlight> | </syntaxhighlight> | ||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
|} | |} | ||
− | == 채팅룸 구현 == | + | === 채팅룸 구현 === |
{| class="wikitable" | {| class="wikitable" | ||
!과정 | !과정 | ||
!설명 | !설명 | ||
!방법 | !방법 | ||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
|- | |- | ||
|탬플릿 작성 | |탬플릿 작성 | ||
− | |/ | + | |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" | + | <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"> |
− | < | + | {{ 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> | </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 | ||
− | |||
class ChatConsumer(WebsocketConsumer): | class ChatConsumer(WebsocketConsumer): | ||
250번째 줄: | 225번째 줄: | ||
|/chat/routing.py 작성 | |/chat/routing.py 작성 | ||
|<syntaxhighlight lang="python"> | |<syntaxhighlight lang="python"> | ||
− | from django | + | from django.urls import re_path |
+ | |||
from . import consumers | from . import consumers | ||
websocket_urlpatterns = [ | websocket_urlpatterns = [ | ||
− | + | re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()), | |
] | ] | ||
</syntaxhighlight> | </syntaxhighlight> | ||
|- | |- | ||
|라우팅 등록 | |라우팅 등록 | ||
− | | | + | |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 | ||
− | |||
import chat.routing | import chat.routing | ||
− | application = ProtocolTypeRouter({ | + | application = ProtocolTypeRouter( |
− | + | { | |
− | + | "http": django_asgi_app, | |
− | + | "websocket": AllowedHostsOriginValidator( | |
− | + | AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns)) | |
− | ) | + | ), |
− | + | } | |
− | + | ) | |
</syntaxhighlight> | </syntaxhighlight> | ||
− | |} | + | |}여기까지 하고 점검해보자. /chat/lobby/ 에 접속한 후 채팅을 치면 떠야 한다. |
− | 여기까지 하고 채팅을 | + | |
+ | 같은 브라우저를 띄워두고 한쪽에서 채팅을 치면 다른 쪽에서 안뜨는데, 다른 브라우저에선 안뜬다. 접속한 모두에게 반영될 수 있도록 채널레이어를 구현한다. | ||
+ | |||
+ | = 채널 레이어 구현 = | ||
+ | consumer 인스턴스는 자동으로 유일한 channel name을 구성하기 때문에 서로 소통하려면 layer가 필요하다. | ||
+ | |||
+ | == 레디스 == | ||
+ | 레디스 서버를 이용한 실시간 채팅 구현. | ||
− | === | + | 꽤 오래된 버전인 5를 사용하는데, 아마도 이는.. 5에서 처음으로 consumer groups이 구현된 버전인데, 이걸로 구현했기 때문이 아닐까 싶다.(최신버전에서도 될진.. 검증이 필요하다.) |
+ | ===사전설정=== | ||
{| class="wikitable" | {| class="wikitable" | ||
!과정 | !과정 | ||
286번째 줄: | 273번째 줄: | ||
!방법 | !방법 | ||
|- | |- | ||
− | | | + | | 서버 설치 |
− | | | + | |설치 후 딱히 옵션을 변경할 내용은 없다. |
+ | 어차피 로컬에서 모든 작동이 수행되기 때문에. | ||
+ | |||
+ | 네이티브로 설치하는 게 아니라 도커를 사용할 거라면 아래로 넘어가자. | ||
+ | |||
+ | 단순 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 | + | |세팅 |
− | + | |settings.py 안에 추가. | |
|<syntaxhighlight lang="python"> | |<syntaxhighlight lang="python"> | ||
− | ASGI_APPLICATION = | + | ASGI_APPLICATION = "mysite.asgi.application" # 이부분은 채널을 설치할 때 작성한 부분. |
CHANNEL_LAYERS = { | CHANNEL_LAYERS = { | ||
− | + | "default": { | |
− | + | "BACKEND": "channels_redis.core.RedisChannelLayer", | |
− | + | "CONFIG": { | |
− | "hosts": [( | + | "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> | ||
+ | |||
+ | = 서버에 반영 = | ||
+ | 튜토리얼엔 나오지 않지만(공식문서 한참 뒤에 나온다..), 서버에 반영하게 되면 ws/ 경로에 대하여 새로운 설정을 해주어야 한다.(필자는 이걸 몰라서 이것저것 건드리다가 sql 날려먹었다.. 젠장) | ||
+ | |||
+ | 일반적으로 WSGI 통신을 gunicorn으로 처리한다면, ASGI통신은 daphne으로 처리한다.(daphne에서도 wsgi통신이 가능하지만, 보안상 권장되진 않는다.) | ||
+ | {| class="wikitable" | ||
+ | !과정 | ||
+ | !설명 | ||
+ | !방법 | ||
+ | |- | ||
+ | | 설치 | ||
+ | |daphne은 channels를 설치하면 자동으로 설치가 된다. | ||
+ | | | ||
+ | |- | ||
+ | |확인 | ||
+ | |실행 명령은 우측과 같다.(asgi의 경로를 지정해주면 된다.) | ||
+ | |||
+ | 포트번호는 필요에 따라 지정하면 된다. | ||
+ | |||
+ | 해당 포트로 접속해보면 사이트와 채팅이 잘 작동한다. | ||
+ | |<code>daphne -b 0.0.0.0 -p 8001 config.asgi:application</code> | ||
+ | |- | ||
+ | |서버에서 실행 | ||
+ | |서비스를 위해선 백그라운드로 진행해주어야 한다. | ||
+ | 실행 여부는 <code>ps -ef | grep daphne</code>로 확인해보자. 실행이 안되면 nohup 떼서 에러메시지 확인. | ||
+ | |<code>nohup daphne -b 0.0.0.0 -p 8001 config.asgi:application &</code> | ||
+ | |- | ||
+ | |웹서버에 반영 | ||
+ | |/static을 반영하듯, /ws로 시작되는 주소는 다핀으로 넘겨준다. | ||
+ | 이후 관련 서비스들을 재시작하면 설정들이 반영된다. | ||
+ | |||
+ | 채팅도 물론 성공적...! | ||
+ | |<syntaxhighlight lang="bash"> | ||
+ | location /ws { | ||
+ | proxy_pass http://localhost:8001; | ||
+ | proxy_http_version 1.1; | ||
+ | proxy_set_header Upgrade $http_upgrade; | ||
+ | proxy_set_header Connection "upgrade"; | ||
+ | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
|} | |} | ||
− | [[분류: | + | 웹소켓에서도 https 인증을 적용할 수 있다.([https://victorydntmd.tistory.com/265 추후에 반영해보자.]) |
+ | |||
+ | == 관련에러 == | ||
+ | |||
+ | === 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를 제대로 넣어주면 해결됨. | ||
+ | |||
+ | = 마무리. 각 요소들이 어떤 역할을 하는지. = | ||
+ | 순서대로 어떤 경로를 거쳐 작동하는지 살펴보자. | ||
+ | |||
+ | === asgi.py === | ||
+ | {| class="wikitable" | ||
+ | !항목 | ||
+ | !설명 | ||
+ | |- | ||
+ | | ProtocolTypeRouter | ||
+ | |연결의 종류를 파악하여 처리. http로 넘길지, 웹소켓 처리로 넘길지. | ||
+ | |||
+ | * http 연결일 경우 보통 django_asgi_app<code>(get_asgi_application())</code> 가 실행된다. | ||
+ | * websocket 연결(ws:// 혹은 wss://)일 경우 <code>AuthMiddlewareStack</code>으로 앱별로 <code>routing.py</code>에 적어놓은 코드로 라우팅 한다. | ||
+ | |- | ||
+ | | | ||
+ | | | ||
+ | * | ||
+ | |} | ||
+ | |||
+ | === routing.py === | ||
+ | 들어온 경로와 컨슈머를 연결해준다. urls.py와 view.py의 관계. | ||
+ | |||
+ | 다음과 같은 형태로 컨슈머를 연결한다.<syntaxhighlight lang="python"> | ||
+ | websocket_urlpatterns = [ | ||
+ | re_path(r'ws/notification/(?P<center_name>\w+)/$', consumers.NotificationConsumer.as_asgi()), | ||
+ | ] | ||
+ | </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'),
]
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 |
아래 문서를 통해 도커 설치를 참고하자. |
연동 라이브러리 설치 | 서버와 채널을 연동할 라이브러리를 설치한다. | 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
|
서버에서 실행 | 서비스를 위해선 백그라운드로 진행해주어야 한다.
실행 여부는 |
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로 넘길지, 웹소켓 처리로 넘길지.
|
|
5.2 routing.py편집
들어온 경로와 컨슈머를 연결해준다. urls.py와 view.py의 관계.
다음과 같은 형태로 컨슈머를 연결한다.
websocket_urlpatterns = [
re_path(r'ws/notification/(?P<center_name>\w+)/$', consumers.NotificationConsumer.as_asgi()),
]