Django使用Channels實現WebSocket--上篇

運維咖啡吧發表於2019-04-18

WebSocket - 開啟通往新世界的大門

WebSocket是什麼?

WebSocket是一種在單個TCP連線上進行全雙工通訊的協議。WebSocket允許服務端主動向客戶端推送資料。在WebSocket協議中,客戶端瀏覽器和伺服器只需要完成一次握手就可以建立永續性的連線,並在瀏覽器和伺服器之間進行雙向的資料傳輸。

WebSocket有什麼用?

WebSocket區別於HTTP協議的一個最為顯著的特點是,WebSocket協議可以由服務端主動發起訊息,對於瀏覽器需要及時接收資料變化的場景非常適合,例如在Django中遇到一些耗時較長的任務我們通常會使用Celery來非同步執行,那麼瀏覽器如果想要獲取這個任務的執行狀態,在HTTP協議中只能通過輪訓的方式由瀏覽器不斷的傳送請求給伺服器來獲取最新狀態,這樣傳送很多無用的請求不僅浪費資源,還不夠優雅,如果使用WebSokcet來實現就很完美了

WebSocket的另外一個應用場景就是下文要說的聊天室,一個使用者(瀏覽器)傳送的訊息需要實時的讓其他使用者(瀏覽器)接收,這在HTTP協議下是很難實現的,但WebSocket基於長連線加上可以主動給瀏覽器發訊息的特性處理起來就遊刃有餘了

初步瞭解WebSocket之後,我們看看如何在Django中實現WebSocket

Channels

Django本身不支援WebSocket,但可以通過整合Channels框架來實現WebSocket

Channels是針對Django專案的一個增強框架,可以使Django不僅支援HTTP協議,還能支援WebSocket,MQTT等多種協議,同時Channels還整合了Django的auth以及session系統方便進行使用者管理及認證。

我下文所有的程式碼實現使用以下python和Django版本

  • python==3.6.3
  • django==2.2

整合Channels

我假設你已經新建了一個django專案,專案名字就叫webapp,目錄結構如下

project
    - webapp
        - __init__.py
        - settings.py
        - urls.py
        - wsgi.py
    - manage.py
複製程式碼
  1. 安裝channels
pip install channels==2.1.7
複製程式碼
  1. 修改settings.py檔案,
# APPS中新增channels
INSTALLED_APPS = [
    'django.contrib.staticfiles',
    'channels',
]

# 指定ASGI的路由地址
ASGI_APPLICATION = 'webapp.routing.application'
複製程式碼

channels執行於ASGI協議上,ASGI的全名是Asynchronous Server Gateway Interface。它是區別於Django使用的WSGI協議 的一種非同步服務閘道器介面協議,正是因為它才實現了websocket

ASGI_APPLICATION 指定主路由的位置為webapp下的routing.py檔案中的application

  1. setting.py的同級目錄下建立routing.py路由檔案,routing.py類似於Django中的url.py指明websocket協議的路由
from channels.routing import ProtocolTypeRouter

application = ProtocolTypeRouter({
    # 暫時為空,下文填充
})
複製程式碼
  1. 執行Django專案
C:\python36\python.exe D:/demo/tailf/manage.py runserver 0.0.0.0:80
Performing system checks...
Watching for file changes with StatReloader

System check identified no issues (0 silenced).
April 12, 2019 - 17:44:52
Django version 2.2, using settings 'webapp.settings'
Starting ASGI/Channels version 2.1.7 development server at http://0.0.0.0:80/
Quit the server with CTRL-BREAK.
複製程式碼

仔細觀察上邊的輸出會發現Django啟動中的Starting development server已經變成了Starting ASGI/Channels version 2.1.7 development server,這表明專案已經由django使用的WSGI協議轉換為了Channels使用的ASGI協議

至此Django已經基本整合了Channels框架

構建聊天室

上邊雖然在專案中整合了Channels,但並沒有任何的應用使用它,接下來我們以聊天室的例子來講解Channels的使用

假設你已經建立好了一個叫chat的app,並新增到了settings.py的INSTALLED_APPS中,app的目錄結構大概如下

chat
    - migrations
        - __init__.py
    - __init__.py
    - admin.py
    - apps.py
    - models.py
    - tests.py
    - views.py
複製程式碼

我們構建一個標準的Django聊天頁面,相關程式碼如下

url:

from django.urls import path
from chat.views import chat

urlpatterns = [
    path('chat', chat, name='chat-url')
]
複製程式碼

view:

from django.shortcuts import render

def chat(request):
    return render(request, 'chat/index.html')
複製程式碼

template:

{% extends "base.html" %}

{% block content %}
  <textarea class="form-control" id="chat-log" disabled rows="20"></textarea><br/>
  <input class="form-control" id="chat-message-input" type="text"/><br/>
  <input class="btn btn-success btn-block" id="chat-message-submit" type="button" value="Send"/>
{% endblock %}
複製程式碼

通過上邊的程式碼一個簡單的web聊天頁面構建完成了,訪問頁面大概樣子如下:

Django使用Channels實現WebSocket--上篇

接下來我們利用Channels的WebSocket協議實現訊息的傳送接收功能

  1. 先從路由入手,上邊我們已經建立了routing.py路由檔案,現在來填充裡邊的內容
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import chat.routing

application = ProtocolTypeRouter({
    'websocket': AuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})
複製程式碼

ProtocolTypeRouter: ASIG支援多種不同的協議,在這裡可以指定特定協議的路由資訊,我們只使用了websocket協議,這裡只配置websocket即可

AuthMiddlewareStack: django的channels封裝了django的auth模組,使用這個配置我們就可以在consumer中通過下邊的程式碼獲取到使用者的資訊

def connect(self):
    self.user = self.scope["user"]
複製程式碼

self.scope類似於django中的request,包含了請求的type、path、header、cookie、session、user等等有用的資訊

URLRouter: 指定路由檔案的路徑,也可以直接將路由資訊寫在這裡,程式碼中配置了路由檔案的路徑,會去chat下的routeing.py檔案中查詢websocket_urlpatterns,chat/routing.py內容如下

from django.urls import path
from chat.consumers import ChatConsumer

websocket_urlpatterns = [
    path('ws/chat/', ChatConsumer),
]
複製程式碼

routing.py路由檔案跟django的url.py功能類似,語法也一樣,意思就是訪問ws/chat/都交給ChatConsumer處理

  1. 接著編寫consumer,consumer類似django中的view,內容如下
from channels.generic.websocket import WebsocketConsumer
import json

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
        }))
複製程式碼

這裡是個最簡單的同步websocket consumer類,connect方法在連線建立時觸發,disconnect在連線關閉時觸發,receive方法會在收到訊息後觸發。整個ChatConsumer類會將所有收到的訊息加上“運維咖啡吧:”的字首傳送給客戶端

  1. 最後我們在html模板頁面新增websocket支援
{% extends "base.html" %}

{% block content %}
  <textarea class="form-control" id="chat-log" disabled rows="20"></textarea><br/>
  <input class="form-control" id="chat-message-input" type="text"/><br/>
  <input class="btn btn-success btn-block" id="chat-message-submit" type="button" value="Send"/>
{% endblock %}

{% block js %}
<script>
  var chatSocket = new WebSocket(
    'ws://' + window.location.host + '/ws/chat/');

  chatSocket.onmessage = function(e) {
    var data = JSON.parse(e.data);
    var message = data['message'];
    document.querySelector('#chat-log').value += (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) {
    var messageInputDom = document.querySelector('#chat-message-input');
    var message = messageInputDom.value;
    chatSocket.send(JSON.stringify({
        'message': message
    }));

    messageInputDom.value = '';
  };
</script>
{% endblock %}
複製程式碼

WebSocket物件一個支援四個訊息:onopen,onmessage,oncluse和onerror,我們這裡用了兩個onmessage和onclose

onopen: 當瀏覽器和websocket服務端連線成功後會觸發onopen訊息

onerror: 如果連線失敗,或者傳送、接收資料失敗,或者資料處理出錯都會觸發onerror訊息

onmessage: 當瀏覽器接收到websocket伺服器傳送過來的資料時,就會觸發onmessage訊息,引數e包含了服務端傳送過來的資料

onclose: 當瀏覽器接收到websocket伺服器傳送過來的關閉連線請求時,會觸發onclose訊息

  1. 完成前邊的程式碼,一個可以聊天的websocket頁面就完成了,執行專案,在瀏覽器中輸入訊息就會通過websocket-->rouging.py-->consumer.py處理後返回給前端

Django使用Channels實現WebSocket--上篇

啟用Channel Layer

上邊的例子我們已經實現了訊息的傳送和接收,但既然是聊天室,肯定要支援多人同時聊天的,當我們開啟多個瀏覽器分別輸入訊息後發現只有自己收到訊息,其他瀏覽器端收不到,如何解決這個問題,讓所有客戶端都能一起聊天呢?

Channels引入了一個layer的概念,channel layer是一種通訊系統,允許多個consumer例項之間互相通訊,以及與外部Djanbo程式實現互通。

channel layer主要實現了兩種概念抽象:

channel name: channel實際上就是一個傳送訊息的通道,每個Channel都有一個名稱,每一個擁有這個名稱的人都可以往Channel裡邊傳送訊息

group: 多個channel可以組成一個Group,每個Group都有一個名稱,每一個擁有這個名稱的人都可以往Group裡新增/刪除Channel,也可以往Group裡傳送訊息,Group內的所有channel都可以收到,但是無法傳送給Group內的具體某個Channel

瞭解了上邊的概念,接下來我們利用channel layer實現真正的聊天室,能夠讓多個客戶端傳送的訊息被彼此看到

  1. 官方推薦使用redis作為channel layer,所以先安裝channels_redis
pip install channels_redis==2.3.3
複製程式碼
  1. 然後修改settings.py新增對layer的支援
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('ops-coffee.cn', 6379)],
        },
    },
}
複製程式碼

新增channel之後我們可以通過以下命令檢查通道層是否能夠正常工作

>python manage.py shell
Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> import channels.layers
>>> channel_layer = channels.layers.get_channel_layer()
>>>
>>> from asgiref.sync import async_to_sync
>>> async_to_sync(channel_layer.send)('test_channel',{'site':'https://ops-coffee.cn'})
>>> async_to_sync(channel_layer.receive)('test_channel')
{'site': 'https://ops-coffee.cn'}
>>>
複製程式碼
  1. consumer做如下修改引入channel layer
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
import json

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.room_group_name = 'ops_coffee'

        # 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):
        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
        }))
複製程式碼

這裡我們設定了一個固定的房間名作為Group name,所有的訊息都會傳送到這個Group裡邊,當然你也可以通過引數的方式將房間名傳進來作為Group name,從而建立多個Group,這樣可以實現僅同房間內的訊息互通

當我們啟用了channel layer之後,所有與consumer之間的通訊將會變成非同步的,所以必須使用async_to_sync

一個連結(channel)建立時,通過group_add將channel新增到Group中,連結關閉通過group_discard將channel從Group中剔除,收到訊息時可以呼叫group_send方法將訊息傳送到Group,這個Group內所有的channel都可以收的到

group_send中的type指定了訊息處理的函式,這裡會將訊息轉給chat_message函式去處理

  1. 經過以上的修改,我們再次在多個瀏覽器上開啟聊天頁面輸入訊息,發現彼此已經能夠看到了,至此一個完整的聊天室已經基本完成

修改為非同步

我們前邊實現的consumer是同步的,為了能有更好的效能,官方支援非同步的寫法,只需要修改consumer.py即可

from channels.generic.websocket import AsyncWebsocketConsumer
import json

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_group_name = 'ops_coffee'

        # 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
        }))
複製程式碼

其實非同步的程式碼跟之前的差別不大,只有幾個小區別:

ChatConsumer由WebsocketConsumer修改為了AsyncWebsocketConsumer

所有的方法都修改為了非同步defasync def

await來實現非同步I/O的呼叫

channel layer也不再需要使用async_to_sync

好了,現在一個完全非同步且功能完整的聊天室已經構建完成了

程式碼地址

我已經將以上的演示程式碼上傳至Github方便你在實現的過程中檢視參考,具體地址為:

github.com/ops-coffee/…


Django使用Channels實現WebSocket--上篇

相關文章推薦閱讀:

相關文章