虽然许多教程通过聊天应用程序演示了如何在Django应用中实现Websocket,但我们将在本指南中采取不同的路线。
我们将依靠Websocket的实时功能来创建一个简单的多人游戏。对于本教程,我们将在前端上使用React,并利用Django频道和后端的Daphne来处理WebSocket连接。
- 1. Django Websockets in Action
- 2. Creating the Django Project
- 3. Setting Up the Django Template
- 4. Django Channels and Daphne
- 5. Setting Up the React Game Component
- 6. Load Testing the Daphne Server
django Websocket在行动中
在我们深入研究教程之前,我想分享多人游戏的现场演示!
You can find the live demo here.
虽然简单,但该游戏表明Django Channels和Daphne可以支持高度交互式的实时应用程序。继续阅读以查看如何实现类似的东西。
创建Django项目
在本节中,您将设置Django项目并验证所有先决条件是否就位。
首先使用 django-admin 工具来创建一个新的django项目。
django-admin startproject multiplayer_channels_demo
现在在 Multiplayer_channels_demo 目录中创建一个新应用程序。
cd hero_builder
python3 manage.py startapp channels_demo
运行SQLite开发数据库的初始迁移。
python3 manage.py migrate
启动开发服务器,以验证到目前为止的所有功能。
python3 manage.py runserver
您现在应该能够访问 http://localhost:8000 。 Django将为占位符页面提供。
Seting Django模板
即使我们的重点是Websocket,我们仍然需要一个Django模板来提供初始页面。第一页将包含打开WebSocket连接的JavaScript。
如果尚未
mkdir -p channels_demo/templates/channels_demo
现在,我们将创建新模板目录中的 index.html 模板。该模板包含一个名为多人渠道 - 通道的自定义元素,该元素将创建HTML5画布并与Django通信。
该组件是由React构建的,但已转换为Web Component,以便可以将其以最小的依赖关系删除到模板中。 React Runtime是从CDN加载的,因为自定义元素不包括这些依赖项。
我们将在教程后面更深入地对React组件进行更深入的深度。
<!DOCTYPE html>
{% load static %}
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="{% static 'channels_demo/css/index.css' %}"/>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="{% static 'channels_demo/js/main.js' %}"></script>
<title>Multiplayer Demo</title>
</head>
<body>
<multiplayer-channels-demo></multiplayer-channels-demo>
</body>
</html>
在创建模板时,我们需要路由和视图功能。 Open views.py 在 channels_demo 目录中并插入以下内容。
from django.shortcuts import render
def index(request):
return render(request, "channels_demo/index.html")
我们还需要一个应用程序 urls.py 文件。在 channels_demo 下创建一个,并使用以下内容进行更新。
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
]
最后, urls.py Multiplayer_channels_demo 需要更新以包括新路由。
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("multiplayer/", include("channels_demo.urls")),
path("admin/", admin.site.urls),
]
django频道和达芙妮
到目前为止,我们有一个香草Django项目。接下来,我们将在我们的应用程序中添加Django频道和Daphne。
我们将使用要求.txt 文件来管理项目依赖性。在顶级项目文件夹中创建一个名为 unignts.txt 的文件,然后将以下内容放入其中。
Django==4.1.7
daphne==4.0.0
channels==4.0.0
使用pip在与教程一起进一步移动之前。
pip install -r requirements.txt
接下来,我们需要更新设置。请注意,必须首先列出达芙妮,以便正确扩展 RunServer 管理命令。
INSTALLED_APPS = [
"daphne",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"channels_demo",
]
在我们继续前进之前,在 settings.py 中还需要再增加一个。达芙妮(Daphne)需要一个满足Asynchronous Server Gateway Interface (ASGI)的入口点,以服务我们的Django应用程序。幸运的是,新的django项目是标准的,我们可以使用 asgi.py 文件,但我们需要告诉达芙妮如何找到它。
ASGI_APPLICATION = "multiplayer_channels_demo.asgi.application"
最后,我们需要在 asgi.py 文件中进行最小设置才能开始。这种最小的设置最初不会具有任何Websocket功能,但将允许Daphne提供常规的Django视图。打开文件并写下以下内容。
import os
from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "multiplayer_channels_demo.settings")
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
}
)
您应该能够再次启动开发服务器,但是这次Daphne将充当服务器。
python3 manage.py runserver
输出应与第一次相似,但是如果您仔细观察,则应看到达芙妮被列为服务器。
Performing system checks...
System check identified no issues (0 silenced).
September 03, 2023 - 15:56:03
Django version 4.1.7, using settings 'multiplayer_channels_demo.settings'
Starting ASGI/Daphne version 4.0.0 development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
Routing和WebSocketConsumer
现在,基本要素已经到位,我们将添加一个Websoket消费者和相关的路由。在Django Channels术语中,消费者就像普通的Django视图。同样,通常与我们在每个Django项目中通常看到的 urls.py 文件分开进行路由。在 channels_demo 目录下打开一个新文件,名为 routing.py.py 并插入以下内容。
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/game/$", consumers.MultiplayerConsumer.as_asgi()),
]
接下来,我们将定义最简单的 websocketConsumer 类,我们可以用来开始。打开 consumer.py 在与 routing.py.py 文件中的目录中,输入以下内容。
import json
import uuid
from channels.generic.websocket import WebsocketConsumer
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
class MultiplayerConsumer(WebsocketConsumer):
game_group_name = "game_group"
players = {}
def connect(self):
self.player_id = str(uuid.uuid4())
self.accept()
async_to_sync(self.channel_layer.group_add)(
self.game_group_name, self.channel_name
)
self.send(
text_data=json.dumps({"type": "playerId", "playerId": self.player_id})
)
def disconnect(self, close_code):
async_to_sync(self.channel_layer.group_discard)(
self.game_group_name, self.channel_name
)
def receive(self, text_data):
text_data_json = json.loads(text_data)
这是一个非常基本的消费者,但它说明了重要的方法。每个连接都有一个专用的乘数实例。 连接方法在初始连接时被调用,断开连接时断开连接,并且每当从Websocket收到消息时,接收。 发送方法直接与连接的客户端通信。它在上面的代码中用于为新玩家提供自己独特的玩家ID。
在我们可以在此端点上连接之前,我们需要更新 asgi.py 来处理路由WebSocket连接。到目前为止,仅设置了通道来处理正常视图。打开 asgi.py 文件并进行以下更新。
import os
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from channels_demo.routing import websocket_urlpatterns
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "multiplayer_channels_demo.settings")
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": URLRouter(websocket_urlpatterns),
}
)
urlrouter 类负责将请求委派给正确的消费者。
在此基本设置功能之前,还需要再进行一次调整。 consumer.py 文件中的代码通过参考 self.channel_layer 变量来利用频道层,该变量从 websocketConsumer 类中继承。 channel_layer 允许我们的代码将连接组织为组并向这些组广播消息。
在我们可以执行任何操作之前,django频道要求我们定义一个后端。后端频道负责存储连接信息。如果您有一台服务器,并且不在乎丢失重新启动的连接,那么 inmemorychannellayer 可能就足够了。但是,对于多个服务器和持久性,您可能需要使用 redischannellayer 。
现在添加以下内容。
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer"
}
}
在生产中使用Nginx和Daphne
现在,我们将花点时间准备生产准备。达芙妮已经准备就绪,但通常不用于提供静态文件或提供SSL终止。
我们可以使用Docker为开发和生产创造一个一致的环境。第一步是创建一个dockerfile来表示应用程序及其依赖关系。
FROM python:3.8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /usr/src/app
COPY requirements.txt /usr/src/app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /usr/src/app/
RUN mkdir -p /usr/src/app/
CMD ["daphne", "multiplayer_channels_demo.asgi:application", "-u", "/usr/src/app/daphne.sock"]
Daphne Server将从 daphne.sock 中读写,而不是在端口上聆听。 NGINX将转发请求到套接字文件。打开一个名为 nginx.conf 的文件,然后插入以下内容。
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
server {
listen 8443 ssl http2;
ssl_certificate /etc/letsencrypt/live/your_domain_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_domain_name/privkey.pem;
ssl_prefer_server_ciphers off;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://unix:/usr/src/app/daphne.sock;
}
location /static {
alias /usr/src/app/staticfiles;
}
}
}
请确保使用您的SSL证书路径更新 your_domain_name 占位符。您可以快速轻松地使用LetsEncrypt to obtain a certificate。如果您不想使用SSL,则很容易剥离并收听普通的HTTP连接。此设置将除/static 开头的所有请求转发到Daphne服务器。
现在,我们将创建一个名为 docker-compose.yml 的文件,将所有这些文件绑在一起。我们将拥有一个应用程序容器和一个NGINX容器。容器将共享卷,以便两者都可以访问相同的 daphne.sock 文件。
version: "3.7"
services:
app:
build:
context: .
restart: unless-stopped
command: daphne multiplayer_channels_demo.asgi:application -u /usr/src/app/daphne.sock
volumes:
- $PWD:/usr/src/app
nginx:
image: nginx:latest
restart: always
ports:
- "8443:8443"
volumes:
- /etc/letsencrypt:/etc/letsencrypt
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- $PWD:/usr/src/app
depends_on:
- app
有了以上和Docker Compose installed,您应该能够发布 docker -compose -d 命令以在后台启动两个容器。
在达芙妮消费者中实现游戏逻辑
我们有一个容器的Django和Daphne应用程序,但是此时它没有其他作用。
现在,我们将在我们之前创建的乘数级类中填写游戏逻辑。逻辑包括一些重要概念:
- 新连接将获得独特的播放器ID。
- 客户端将鼠标向下发送,并将鼠标发布事件发送到Django。
- 客户端将鼠标光标和播放器精灵之间的角度发送到每50ms每50ms的django。
- 游戏服务器在50ms间隔上执行简单的物理更新。
- django将所有精灵的位置和面向所有连接的位置和面向所有连接范围每50ms。
在真实的游戏中,客户很可能还会进行自己的物理计算,并且游戏服务器的更新优先。这样,即使滞后,游戏似乎也可以继续运转。恢复连接后,可以将游戏状态与服务器进行对帐。
对于这个简单的示例,所有物理计算均在多层次库中类中完成。实际上,大多数代码都致力于模拟。 Websocket相关的代码仍然相对简单。
import json
import uuid
import asyncio
import math
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import async_to_sync
class MultiplayerConsumer(AsyncWebsocketConsumer):
MAX_SPEED = 5
THRUST = 0.2
game_group_name = "game_group"
players = {}
update_lock = asyncio.Lock()
async def connect(self):
self.player_id = str(uuid.uuid4())
await self.accept()
await self.channel_layer.group_add(
self.game_group_name, self.channel_name
)
await self.send(
text_data=json.dumps({"type": "playerId", "playerId": self.player_id})
)
async with self.update_lock:
self.players[self.player_id] = {
"id": self.player_id,
"x": 500,
"y": 500,
"facing": 0,
"dx": 0,
"dy": 0,
"thrusting": False,
}
if len(self.players) == 1:
asyncio.create_task(self.game_loop())
async def disconnect(self, close_code):
async with self.update_lock:
if self.player_id in self.players:
del self.players[self.player_id]
await self.channel_layer.group_discard(
self.game_group_name, self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json.get("type", "")
player_id = text_data_json["playerId"]
player = self.players.get(player_id, None)
if not player:
return
if message_type == "mouseDown":
player["thrusting"] = True
elif message_type == "mouseUp":
player["thrusting"] = False
elif message_type == "facing":
player["facing"] = text_data_json["facing"]
async def state_update(self, event):
await self.send(
text_data=json.dumps(
{
"type": "stateUpdate",
"objects": event["objects"],
}
)
)
async def game_loop(self):
while len(self.players) > 0:
async with self.update_lock:
for player in self.players.values():
if player["thrusting"]:
dx = self.THRUST * math.cos(player["facing"])
dy = self.THRUST * math.sin(player["facing"])
player["dx"] += dx
player["dy"] += dy
speed = math.sqrt(player["dx"] ** 2 + player["dy"] ** 2)
if speed > self.MAX_SPEED:
ratio = self.MAX_SPEED / speed
player["dx"] *= ratio
player["dy"] *= ratio
player["x"] += player["dx"]
player["y"] += player["dy"]
await self.channel_layer.group_send(
self.game_group_name,
{"type": "state_update", "objects": list(self.players.values())},
)
await asyncio.sleep(0.05)
Seting React游戏组件
您可能会从早些时候回想起Django模板包含多人渠道 - 通道元素。在幕后,此元素是已转换为Web组件的React组件。
我在progressively enhancing Django templates with Web Components.的较早文章中介绍了这一技术
简而言之,我们将构建一个正常的反应组件并使用R2WC执行转换。
以下是处理所有客户端逻辑的 CanvasComponent ,包括与Django后端的通信。
import React, { useRef, useEffect, useState } from "react";
const calculateAngle = (playerX, playerY, mouseX, mouseY) => {
const dx = mouseX - playerX;
const dy = mouseY - playerY;
return Math.atan2(dy, dx);
};
const CanvasComponent = () => {
const canvasRef = useRef(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [gameObjects, setGameObjects] = useState([]);
const [playerId, setPlayerId] = useState(null);
const gameObjectsRef = useRef(gameObjects);
const playerIdRef = useRef(playerId);
const requestRef = useRef(null);
const moveSpriteRef = useRef(null);
const idleSpriteRef = useRef(null);
const wsRef = useRef(null);
const coordinateWidth = 1000;
const coordinateHeight = 1000;
// Load the idle and move sprites that are used to render the players.
useEffect(() => {
const idleImage = new Image();
idleImage.onload = () => (idleSpriteRef.current = idleImage);
idleImage.src = "/static/channels_demo/images/sprite-idle.png";
const moveImage = new Image();
moveImage.onload = () => (moveSpriteRef.current = moveImage);
moveImage.src = "/static/channels_demo/images/sprite-move.png";
}, []);
// Resize the canvas to fit the width and height of the parent
// container. The "logical" canvas is always 1000/1000
// pixels, so a transform is needed when the physical canvas
// size does not match.
const resizeCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
const boundingRect = canvas.parentNode.getBoundingClientRect();
const pixelRatio = window.devicePixelRatio || 1;
canvas.width = boundingRect.width;
canvas.height = boundingRect.height;
ctx.setTransform(
(canvas.width / coordinateWidth) * pixelRatio,
0,
0,
(canvas.height / coordinateHeight) * pixelRatio,
0,
0
);
};
// Render each player sprite. Use context save/restore to apply transforms
// for that sprite specifically without affecting anything else.
const drawGameObject = (ctx, obj) => {
if (!moveSpriteRef.current) return;
const spriteWidth = 187;
const spriteHeight = 94;
ctx.save();
ctx.translate(obj.x, obj.y);
ctx.rotate(obj.facing);
const sprite = obj.thrusting
? moveSpriteRef.current
: idleSpriteRef.current;
ctx.drawImage(
sprite,
0,
0,
spriteWidth,
spriteHeight,
-spriteWidth / 4,
-spriteHeight / 4,
spriteWidth / 3,
spriteHeight / 3
);
ctx.restore();
};
// Server messages are either game state updates or the initial
// assignment of a unique playerId for this client.
const handleWebSocketMessage = (event) => {
const messageData = JSON.parse(event.data);
if (messageData.type === "stateUpdate") {
setGameObjects(messageData.objects);
} else if (messageData.type === "playerId") {
setPlayerId(messageData.playerId);
}
};
// Notify game server of mouse down.
const handleMouseDown = (event) => {
if (event.button !== 0 || !playerIdRef.current) return;
wsRef.current.send(
JSON.stringify({ type: "mouseDown", playerId: playerIdRef.current })
);
};
// Notify game server of mouse release.
const handleMouseUp = (event) => {
if (event.button !== 0 || !playerIdRef.current) return;
wsRef.current.send(
JSON.stringify({ type: "mouseUp", playerId: playerIdRef.current })
);
};
// Callback for requestAnimationFrame. Clears the canvas and renders the
// black background before rendering each individual player sprite.
const animate = (time) => {
requestRef.current = requestAnimationFrame(animate);
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, coordinateWidth, coordinateHeight);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, coordinateWidth, coordinateHeight);
gameObjectsRef.current.forEach((obj) => drawGameObject(ctx, obj));
};
// Refresh the gameObjectsRef when gameObjects is updated. This is necessary
// because the animate callback triggers the "stale closure" problem.
useEffect(() => {
gameObjectsRef.current = gameObjects;
}, [gameObjects]);
// Refresh the playerIdRef when playerId is updated. This is necessary
// because the animate callback triggers the "stale closure" problem.
useEffect(() => {
playerIdRef.current = playerId;
}, [playerId]);
// Sets up an interval that calculates the angle between the mouse cursor
// and the player sprite. Sends that angle to the game server on a 50ms
// interval.
useEffect(() => {
let mousePosition = { x: 0, y: 0 };
const updateMousePosition = (event) => {
const canvas = canvasRef.current;
const boundingRect = canvas.getBoundingClientRect();
const scaleX = canvas.width / boundingRect.width;
const scaleY = canvas.height / boundingRect.height;
mousePosition.x = (event.clientX - boundingRect.left) * scaleX;
mousePosition.y = (event.clientY - boundingRect.top) * scaleY;
};
window.addEventListener("mousemove", updateMousePosition);
const intervalId = setInterval(() => {
const playerId = playerIdRef.current;
const gameObjects = gameObjectsRef.current;
if (!playerId) return;
const playerObj = gameObjects.find((obj) => obj.id === playerId);
if (playerObj) {
const facing = calculateAngle(
playerObj.x,
playerObj.y,
mousePosition.x * (coordinateWidth / canvasRef.current.width),
mousePosition.y * (coordinateHeight / canvasRef.current.height)
);
wsRef.current.send(
JSON.stringify({
type: "facing",
playerId,
facing,
})
);
}
}, 50);
return () => {
clearInterval(intervalId);
window.removeEventListener("mousemove", updateMousePosition);
};
}, []);
// Entrypoint. Establish websocket, register input callbacks, and start
// the animation frame cycle.
useEffect(() => {
const canvas = canvasRef.current;
wsRef.current = new WebSocket("ws://localhost:8000/ws/game/");
wsRef.current.onmessage = handleWebSocketMessage;
canvas.addEventListener("mousedown", handleMouseDown);
canvas.addEventListener("mouseup", handleMouseUp);
requestRef.current = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(requestRef.current);
canvas.removeEventListener("mousedown", handleMouseDown);
canvas.removeEventListener("mouseup", handleMouseUp);
wsRef.current.close();
};
}, []);
// Call resizeCanvas on initial load and also whenever browser is resized.
useEffect(() => {
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
return () => {
window.removeEventListener("resize", resizeCanvas);
};
}, [containerSize]);
return <canvas ref={canvasRef} />;
};
export default CanvasComponent;
负载测试达芙妮服务器
目前我们有一个有效的游戏服务器和客户端,但是服务器可以托管多少个客户?
我决定编写一个脚本来加载NGINX后面的单个Daphne服务器,在具有1个VCPU的主机上。负载测试脚本模拟了多个客户端连接,因为它可以连接,然后在50ms间隔上发送更新,就像真实的客户端一样。脚本以一个连接开头,每秒添加一个附加连接,直到运行90秒为止。
from collections import defaultdict
import asyncio
import json
import math
import time
import websockets
time_since_last_update = defaultdict(list)
async def send_facing_updates(websocket, player_id):
while True:
current_time = time.time()
new_facing = math.sin(current_time)
facing_msg = {"type": "facing", "playerId": player_id, "facing": new_facing}
await websocket.send(json.dumps(facing_msg))
await asyncio.sleep(0.05)
async def receive_updates(websocket, client_id, player_id):
last_update_time = time.time()
while True:
message = await websocket.recv()
data = json.loads(message)
current_time = time.time()
delta_time = current_time - last_update_time
last_update_time = current_time
time_since_last_update[client_id].append(delta_time)
async def game_client(client_id):
uri = "ws://localhost:8000/ws/game/"
async with websockets.connect(uri) as websocket:
init_msg = await websocket.recv()
init_data = json.loads(init_msg)
player_id = init_data.get("playerId", None)
if player_id is None:
return
send_task = send_facing_updates(websocket, player_id)
receive_task = receive_updates(websocket, client_id, player_id)
await asyncio.gather(send_task, receive_task)
async def main():
client_id = 0
tasks = []
start_time = time.time()
while True:
if time.time() - start_time > 90:
for task in tasks:
task.cancel()
break
task = asyncio.create_task(game_client(client_id))
tasks.append(task)
client_id += 1
await asyncio.sleep(1)
await asyncio.gather(*tasks, return_exceptions=True)
print(json.dumps(time_since_last_update))
if __name__ == "__main__":
asyncio.get_event_loop().run_until_complete(main())
脚本完成后,它将输出JSON,其中包含每个连接每个服务器更新之间经过的时间。只要服务器保持上升,所经过的时间应接近50ms。但是,随着负载的增加,经过的时间开始超过50ms。
这是该测试的结果图。
X轴上右侧的距离越远,打开的连接越多。
我们从1个连接开始,并以90个连接结束。除了几个滞后尖峰之外,我们在更新之间主要保持50ms,直到脚本打开50个左右的连接。到那时,滞后将是明显的,更新需要250ms或更多。在80连接标记处,滞后大幅增加,一些更新的整个秒数都在整整一秒钟内进行。那时我们的游戏将是非常不可玩的。
结论
我希望本教程能使您对Django,Django Channels和Daphne可以实现的目标有所了解!许多教程专注于聊天应用程序等用例,虽然没有错,但要推动极限并查看Django可以实时获得多么的实时是很有趣的。