Django使用Channels實現websocket

由于項目有個需要實時顯示狀態的需求,搜索了各種實現方法,看來只有websocket最靠譜,但django原生是不支持websocket的,最終發現了chango-channels這個項目。可以幫我們實現我們的需求。

Channels

首先放上官方文檔

安裝配置

安裝channels

如果使用的django是1.9包括以上的話,可以不用輸入文檔中-U參數,直接使用pip在終端中輸入如下命令即可

$ pip install channels

配置channels

想要使用channels,我們首先需要在setting里配置一下channels。

在INSTALLED_APPS中添加channels

INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    ...
    'channels',
)

配置channels路由和通道后端

簡單的話我們可以使用內存作為后端,路由配置放在合適的地方
配置如下:

CHANNEL_LAYERS  =  { 
    “default” : { 
        “BACKEND” : “asgiref.inmemory.ChannelLayer” ,
        # 這里是路由的路徑,怎么填寫都可以,只要能找到
        “ROUTING” : “你的工程名.routing.channel_routing” ,
    },
}

由于我們已經使用了redis作為緩存系統,在這里我們也就正好使用redis在作為我們的通道后端。
為了使用redis作為channels的后端,我們還需要安裝一個庫asgi_redis。

  1. 使用pip安裝asgi_redis,在終端中輸入
$ pip install asgi_redis

安裝之后我們就可以使用redis作為channels的后端了

  1. 修改channels的BACKEND
    settings.py修改
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "asgi_redis.RedisChannelLayer",
        "CONFIG": {
            "hosts": [os.environ.get('REDIS_URL', 'redis://127.0.0.1:6379/2')],
        },
        # 配置路由的路徑
        "ROUTING": "你的工程名.routing.channel_routing",
    },
}

使用channels

使用channels,筆者主要是用來解決websocket連接和傳輸,這里不討論http部分。

最簡單的例子

  1. 在合適的app下創建一個customers.py,在其中編寫代碼如下
def ws_message(message):
    # ASGI WebSocket packet-received and send-packet message types
    # both have a "text" key for their textual data.
    message.reply_channel.send({
        "text": message.content['text'],
    })
  1. 在同一個app下創建一個router.py,在其中編寫代碼如下
from channels.routing import route
from .consumers import ws_message

channel_routing = [
    route("websocket.receive", ws_message),
]

這里的意思就是當接收到前端發來的消息時,后端會觸發ws_message函數,這里寫的是一個回音壁程序,就是把原數據在發送回去。

  1. 前端代碼如下,在瀏覽器的控制臺或者一個html的js代碼區域編寫如下代碼
// Note that the path doesn't matter for routing; any WebSocket
// connection gets bumped over to WebSocket consumers
socket = new WebSocket("ws://127.0.0.1:8000/chat/");
socket.onmessage = function(e) {
    consoe.log(e.data);
}
socket.onopen = function() {
    socket.send("hello world");
}
// Call onopen directly if socket is already open
if (socket.readyState == WebSocket.OPEN) socket.onopen();

然后就可以執行python manage.py runserver查看運行效果,如果不出意外的話應該可以看到效果。

利用組的概念實現多個瀏覽器(用戶)之間的交互

  1. customers.py中編寫代碼如下
from channels import Group

# Connected to websocket.connect
def ws_add(message):
    # Accept the connection
    message.reply_channel.send({"accept": True})
    # Add to the chat group
    Group("chat").add(message.reply_channel)

# Connected to websocket.receive
def ws_message(message):
    Group("chat").send({
        "text": "[user] %s" % message.content['text'],
    })

# Connected to websocket.disconnect
def ws_disconnect(message):
    Group("chat").discard(message.reply_channel)

分為三個部分,分別是websocket連接的時候進行的操作,收到消息的時候進行的操作,和關閉鏈接的時候進行的操作,這里利用了組的概念,在觸發連接的時候,把其加入chat組,當收到消息時候,在組內所有用戶發送信息,最后關閉連接的時候退出組。

  1. 由于將一次連接分為了三個部分,其路由也得配置三遍,所以在router.py中編寫代碼如下
from channels.routing import route
from .consumers import ws_add, ws_message, ws_disconnect

channel_routing = [
    route("websocket.connect", ws_add),
    route("websocket.receive", ws_message),
    route("websocket.disconnect", ws_disconnect),
]
  1. 測試用前端代碼如下:
// Note that the path doesn't matter right now; any WebSocket
// connection gets bumped over to WebSocket consumers
socket = new WebSocket("ws://127.0.0.1:8000/chat/");
socket.onmessage = function(e) {
     consoe.log(e.data);
}
socket.onopen = function() {
    socket.send("hello world");
}
// Call onopen directly if socket is already open
if (socket.readyState == WebSocket.OPEN) socket.onopen();

然后就可以執行python manage.py runserver查看運行效果,
建議同時打開兩個瀏覽器選項卡同時運行上述JavaScript代碼,就能看到對方發來的消息啦。

上述代碼還有一個問題,就是無論是誰訪問同一個url都可以進到這個組里,我們也不能知道是誰進入了這個組中,得到他的一些信息,所以就需要一些認證功能,不能讓任何人都能加入該組,所以我們需要認證

channels的認證

channels自帶了很多很好用的修飾器來幫我們解決這個問題,我們可以訪問到當前的session回話,或者cookie。

  • 使用http_session修飾器就可以訪問用戶的session會話,拿到request.session
  • 使用http_session_user修飾器就可以獲取到session中的用戶信息,拿到message.user
  • 使用channel_session_user修飾器,就可以在通道中直接拿到message.user
  • channel_session_user_from_http修飾器可以將以上修飾器的功能集合起來,直接獲取到所需的用戶

以下是一個用戶只能和用戶名第一個字符相同的人聊天的程序代碼

from channels import Channel, Group
from channels.sessions import channel_session
from channels.auth import channel_session_user, channel_session_user_from_http

# Connected to websocket.connect
@channel_session_user_from_http
def ws_add(message):
    # Accept connection
    message.reply_channel.send({"accept": True})
    # Add them to the right group
    Group("chat-%s" % message.user.username[0]).add(message.reply_channel)

# Connected to websocket.receive
@channel_session_user
def ws_message(message):
    Group("chat-%s" % message.user.username[0]).send({
        "text": message['text'],
    })

# Connected to websocket.disconnect
@channel_session_user
def ws_disconnect(message):
    Group("chat-%s" % message.user.username[0]).discard(message.reply_channel)

由于筆者的項目使用的是Json Web Token作為身份認證,對于服務器來說沒有session,所以需要自己實現一個認證。

Json Web Token認證

本來在http中使用ajax是將token放在請求頭中的,但是在websocket中這樣的方式并不可以,所以退而求其次,我們只能將其放在url中或者發送的數據中了。
又因為筆者不想每次發消息都攜帶token,所以選擇了在url中攜帶的方式,

最后發到服務器的url形式是這樣的"ws://127.0.0.1:8000/chat/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QxMjMiLCJvcmlnX2lhdCI6MTUwMzA0MzUyOCwidXNlcl9pZCI6MSwiZW1haWwiOiIxNzkxNTM4NjA5QHFxLmNvbSIsImV4cCI6MTUwMzEyOTkyOH0.jNYjNxUqXb1Ig6e3tdB9Xq2jH5LrqQe8zFLH40J9694"

我們需要實現一個修飾器去解決對token驗證的問題,以備其他的使用

  1. 在合適的地方創建一個ws_authentication.py
# coding=utf-8
from functools import wraps

from django.utils.translation import ugettext_lazy as _

from rest_framework import exceptions
from channels.handler import AsgiRequest

import jwt
from django.contrib.auth import get_user_model
from rest_framework_jwt.settings import api_settings

import logging

logger = logging.getLogger(__name__)  # 為loggers中定義的名稱

User = get_user_model()
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
jwt_decode_handler = api_settings.JWT_DECODE_HANDLER
jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER


def token_authenticate(token, message):
    """
    Tries to authenticate user based on the supplied token. It also checks
    the token structure and validity.
    """

    payload = check_payload(token=token, message=message)
    user = check_user(payload=payload, message=message)

    """Other authenticate operation"""
    return user, token


# 檢查負載
def check_payload(token, message):
    payload = None
    try:
        payload = jwt_decode_handler(token)
    except jwt.ExpiredSignature:
        msg = _('Signature has expired.')
        logger.warn(msg)
        # raise ValueError(msg)
        _close_reply_channel(message)
    except jwt.DecodeError:
        msg = _('Error decoding signature.')
        logger.warn(msg)
        _close_reply_channel(message)
    return payload


# 檢查用戶
def check_user(payload, message):
    username = None
    try:
        username = payload.get('username')
    except Exception:
        msg = _('Invalid payload.')
        logger.warn(msg)
        _close_reply_channel(message)
    if not username:
        msg = _('Invalid payload.')
        logger.warn(msg)
        _close_reply_channel(message)
        return
        # Make sure user exists
    try:
        user = User.objects.get_by_natural_key(username)
    except User.DoesNotExist:
        msg = _("User doesn't exist.")
        logger.warn(msg)
        raise exceptions.AuthenticationFailed(msg)

    if not user.is_active:
        msg = _('User account is disabled.')
        logger.warn(msg)
        raise exceptions.AuthenticationFailed(msg)
    return user


# 關閉websocket
def _close_reply_channel(message):
    message.reply_channel.send({"close": True})


# 驗證request中的token
def ws_auth_request_token(func):
    """
    Checks the presence of a "token" request parameter and tries to
    authenticate the user based on its content.
    The request url must include token.
    eg: /v1/channel/1/?token=abcdefghijklmn
    """

    @wraps(func)
    def inner(message, *args, **kwargs):
        try:
            if "method" not in message.content:
                message.content['method'] = "FAKE"
            request = AsgiRequest(message)
        except Exception as e:
            raise ValueError("Cannot parse HTTP message - are you sure this is a HTTP consumer? %s" % e)

        token = request.GET.get("token", None)

        print request.path, request.GET

        if token is None:
            _close_reply_channel(message)
            raise ValueError("Missing token request parameter. Closing channel.")

        # user, token = token_authenticate(token)
        user, token = token_authenticate(token, message)

        message.token = token
        message.user = user

        return func(message, *args, **kwargs)

    return inner

由于筆者使用了django-restframework-jwt,其中的token驗證方法是和其一樣的,如果你的驗證方式不一樣,可以自行替換。

有了上述代碼,我們就可以在連接的時候判斷token是否有效,以及是否還建立連接。

不過其中代碼在錯誤處理的時候有些問題,我這里簡單的處理為用日志打印和關閉連接。有知道怎么反饋異常信息的可以在評論區告知我。

  1. consumers.py中使用修飾器去認證token
from channels import Group
from .ws_authentication import ws_auth_request_token

# Connected to websocket.connect
@ws_auth_request_token
def ws_add(message):
    # Accept the connection
    message.reply_channel.send({"accept": True})
    # Add to the chat group
    Group("chat").add(message.reply_channel)

# Connected to websocket.receive
def ws_message(message):
    Group("chat").send({
        "text": "[user] %s" % message.content['text'],
    })

# Connected to websocket.disconnect
def ws_disconnect(message):
    Group("chat").discard(message.reply_channel)

這樣就能輕易的驗證了。

使用類視圖

django有一種類視圖,在channels這里也可以,使用類視圖可以讓代碼看著更簡潔明了

  1. 類視圖可以將三種狀態,連接,收到消息,關閉的時候寫到一個類中,原來的consumers.py代碼就可以改為如下代碼
from channels.generic.websockets import WebsocketConsumer

class MyConsumer(WebsocketConsumer):

    # Set to True to automatically port users from HTTP cookies
    # (you don't need channel_session_user, this implies it)
    http_user = True

    # Set to True if you want it, else leave it out
    strict_ordering = False

    def connection_groups(self, **kwargs):
        """
        Called to return the list of groups to automatically add/remove
        this connection to/from.
        """
        return ["test"]

    def connect(self, message, **kwargs):
        """
        Perform things on connection start
        """
        # Accept the connection; this is done by default if you don't override
        # the connect function.
        self.message.reply_channel.send({"accept": True})

    def receive(self, text=None, bytes=None, **kwargs):
        """
        Called when a message is received with either text or bytes
        filled out.
        """
        # Simple echo
        self.send(text=text, bytes=bytes)

    def disconnect(self, message, **kwargs):
        """
        Perform things on connection close
        """
        pass

然后在不同狀態出發的函數中填入自己需要的邏輯即可

如果你想使用channel_session或者channel_session_user,那么只要在類中設置

 channel_session_user = True

如果你想使用session里的用戶,那么也需要在類中添加一個參數

http_user = True
  1. 配置路由也需要做出一些變化
from channels import route, route_class

channel_routing = [
    route_class(consumers.ChatServer, path=r"^/chat/"),
]

或者更簡單一點

from . import consumers

channel_routing = [
    consumers.ChatServer.as_route(path=r"^/chat/"),
]

在channels類視圖中使用token認證

在類視圖中添加修飾器較為麻煩,筆者認為將認證方法寫在**connect(self, message, kwargs)中即可。

所以consumers.py代碼如下

class MyConsumer(WebsocketConsumer):
    # Set to True if you want it, else leave it out
    strict_ordering = False

    http_user = True
    # 由于使用的是token方式,需要使用session將user傳遞到receive中
    channel_session_user = True

    def connection_groups(self, **kwargs):
        """
        Called to return the list of groups to automatically add/remove
        this connection to/from.
        """
        return ['test']

        def connect(self, message, **kwargs):
        """
        Perform things on connection start
        """
        try:
            request = AsgiRequest(message)
        except Exception as e:
            self.close()
            return
        token = request.GET.get("token", None)
        if token is None:
            self.close()
            return
        user, token = token_authenticate(token, message)
        message.token = token
        message.user = user
        message.channel_session['user']=user
        self.message.reply_channel.send({"accept": True})
        print '連接狀態', message.user

    def receive(self, text=None, bytes=None, **kwargs):
        print '接收到消息', text, self.message.channel_session['user']
        """
        Called when a message is received with decoded JSON content
        """
        # Simple echo
        value = cache.get('test')
        print value
        while True:
            if cache.get('test') is not None and cache.get('test') != value:
                value = cache.get('test')
                break
            time.sleep(1)
        self.send(json.dumps({
            "text": cache.get('test')
        }))

    def disconnect(self, message, **kwargs):
        """
        Perform things on connection close
        """
        pass

只需要看connect(self, message, kwargs)函數中代碼即可,(self, text=None, bytes=None, kwargs)中為我要實現的一個簡單邏輯。

筆者發現,channels中的三個狀態,其中每個自身只能發一次信息,無論我在一次方法中send幾次,所以我沒辦法,只能在前端的onmessage處理完數據,在發一次信息,后臺將線程休眠等到參數變化在發送到前端。前端代碼改為如下

socket = new WebSocket("ws://127.0.0.1:8000"+
            "/chat/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QxMjMiLCJvcmlnX2lhdCI6MTUwMzA3Mzg0NiwidXNlcl9pZCI6MSwiZW1haWwiOiIxNzkxNTM4NjA5QHFxLmNvbSIsImV4cCI6MTUwMzE2MDI0Nn0.Za0BlGKn2JMpFoU0GYVZXIC-rwi8uWN420bIwy0bUFc"
        );
        socket.onmessage = function (e) {
            console.log(e.data);
            // socket.send("test")
        }
        socket.onopen = function () {
            socket.send({'test':'hello world'});
            
        }
        // Call onopen directly if socket is already open
        if (socket.readyState == WebSocket.OPEN) socket.onopen();

配合redis就可以實現django的websocket了,也可以滿足我的需求,實時更新。

注:

  • 上述環境在ubuntu16.04 lts django1.9中搭建測試成功
  • 上述文字皆為個人看法,如有錯誤或建議請及時聯系我
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容