Django Websocket
#python #django #websockets

虽然许多教程通过聊天应用程序演示了如何在Django应用中实现Websocket,但我们将在本指南中采取不同的路线。

我们将依靠Websocket的实时功能来创建一个简单的多人游戏。对于本教程,我们将在前端上使用React,并利用Django频道和后端的Daphne来处理WebSocket连接。

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 &lt;canvas ref={canvasRef} /&gt;;
};

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可以实时获得多么的实时是很有趣的。