由于項目有個需要實時顯示狀態的需求,搜索了各種實現方法,看來只有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。
- 使用pip安裝asgi_redis,在終端中輸入
$ pip install asgi_redis
安裝之后我們就可以使用redis作為channels的后端了
- 修改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部分。
最簡單的例子
- 在合適的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'],
})
- 在同一個app下創建一個router.py,在其中編寫代碼如下
from channels.routing import route
from .consumers import ws_message
channel_routing = [
route("websocket.receive", ws_message),
]
這里的意思就是當接收到前端發來的消息時,后端會觸發ws_message函數,這里寫的是一個回音壁程序,就是把原數據在發送回去。
- 前端代碼如下,在瀏覽器的控制臺或者一個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查看運行效果,如果不出意外的話應該可以看到效果。
利用組的概念實現多個瀏覽器(用戶)之間的交互
- 在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組,當收到消息時候,在組內所有用戶發送信息,最后關閉連接的時候退出組。
- 由于將一次連接分為了三個部分,其路由也得配置三遍,所以在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),
]
- 測試用前端代碼如下:
// 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中攜帶的方式,
我們需要實現一個修飾器去解決對token驗證的問題,以備其他的使用
- 在合適的地方創建一個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是否有效,以及是否還建立連接。
不過其中代碼在錯誤處理的時候有些問題,我這里簡單的處理為用日志打印和關閉連接。有知道怎么反饋異常信息的可以在評論區告知我。
- 在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這里也可以,使用類視圖可以讓代碼看著更簡潔明了
- 類視圖可以將三種狀態,連接,收到消息,關閉的時候寫到一個類中,原來的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
- 配置路由也需要做出一些變化
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中搭建測試成功
- 上述文字皆為個人看法,如有錯誤或建議請及時聯系我