Django Channels и WebSocket`ы

В течение более чем десяти лет после выхода Django в 2005 году страницы были в основном статичными, AJAX использовался только в ограниченных случаях, и все было относительно просто. За последние пять лет веб-приложения, работающие в реальном времени, эволюционировали в сторону более клиент-серверного и однорангового взаимодействия. Такой тип взаимодействия возможен благодаря WebSockets, новому протоколу, который обеспечивает полнодуплексную связь и поддерживает постоянное, открытое соединение между клиентом и сервером.

Django Channels облегчает поддержку WebSockets в Django подобно традиционным HTTP представлениям. В нем реализована встроенная поддержка асинхронных представлений, что позволяет проектам Django работать не только с HTTP, но и с протоколами, требующими длительных соединений, такими как WebSockets, MQTT, чатботы и т.д.

В этом руководстве мы покажем вам, как создать приложение реального времени с помощью Django Channels. Для демонстрации на живом примере мы создадим игру "Крестики-нолики" для двух игроков, как показано ниже. Вы можете получить полный исходный код в моем репозитории GitHub.

Example Gif Two Player Game

Настройка проекта Django

Следуйте инструкциям ниже, чтобы настроить проект Django.

Сначала установите Django и каналы. Вы также должны установить channels_redis, чтобы Channels знали, как взаимодействовать с Redis.

Выполните следующую команду:

pip install django==3.1
pip install channels==3.0
pip install channels_redis==3.2

Вы должны использовать pip3 для Linux/mac вместо pip и python3 вместо python. Я использовал django==3.1 и channels==3.0, channels_redis==3.2.0 для этого руководства.

Запустите проект Django:

django-admin startproject tic_tac_toe

Далее создайте приложение с именем game:

python manage.py startapp game

Добавьте channels и game в INSTALLED_APPS внутри вашего settings.py:

## settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'channels',
    'game'
]

Запустите migrate для применения непримененных миграций:

python manage.py migrate

Также добавьте STATICFILES_DIRS внутри вашего settings.py:

## settings.py
import os
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, "static"),
]

Теперь пришло время создать необходимые файлы для нашего проекта Django. На протяжении всего руководства вы можете ссылаться на следующую структуру каталогов:

├── db.sqlite3
├── game
│   ├── consumers.py
│   ├── routing.py
│   ├── templates
│   │   ├── base.html
│   │   ├── game.html
│   │   └── index.html
│   └── views.py
├── manage.py
├── requirements.txt
├── static
│   ├── css
│   │   └── main.css
│   └── js
│       └── game.js
└── tic_tac_toe
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

Встройте библиотеку Django Channels

Теперь давайте интегрируем Channels в проект Django.

Django >2 не имеет встроенной поддержки ASGI, поэтому вам нужно использовать альтернативный вариант Channel.

Обновите asgi.py как показано ниже:

## tic_tac_toe/asgi.py
import os

import django
from channels.http import AsgiHandler
from channels.routing import ProtocolTypeRouter

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tic_tac_toe.settings')
django.setup()

application = ProtocolTypeRouter({
  "http": AsgiHandler(),
  ## IMPORTANT::Just HTTP for now. (We can add other protocols later.)
})

Обновите settings.py и измените приложение Django с WSGI на ASGI, внеся следующие изменения. Это направит каналы на корневую конфигурацию маршрутизации.

## settings.py
# WSGI_APPLICATION = 'tic_tac_toe.wsgi.application'
# Channels
ASGI_APPLICATION = "tic_tac_toe.asgi.application"

Далее, включите канальный уровень, который позволяет нескольким экземплярам потребителей общаться друг с другом.

Обратите внимание, что вы можете использовать Redis в качестве резервного хранилища. Чтобы включить Redis, вы можете использовать метод 1, если вам нужен Redis Cloud, или метод 2 для локального Redis. В этом руководстве я использовал метод 3 - In-memory channel layer - который подходит для тестирования и локальной разработки.

Чтобы включить слой канала, добавьте следующее CHANNEL_LAYERS в settings.py:

## settings.py
CHANNEL_LAYERS = {
    'default': {
        ### Method 1: Via redis lab
        # 'BACKEND': 'channels_redis.core.RedisChannelLayer',
        # 'CONFIG': {
        #     "hosts": [
        #       'redis://h:<password>;@<redis Endpoint>:<port>' 
        #     ],
        # },

        ### Method 2: Via local Redis
        # 'BACKEND': 'channels_redis.core.RedisChannelLayer',
        # 'CONFIG': {
        #      "hosts": [('127.0.0.1', 6379)],
        # },

        ### Method 3: Via In-memory channel layer
        ## Using this method.
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    },
}

Убедитесь, что сервер разработки каналов работает правильно. Выполните следующую команду:

python manage.py runserver

Разработка индексной страницы

Начнем с создания индексной страницы, где пользователю предлагается ввести код комнаты и выбрать символ (X или O).

Создайте представление на основе функций в game/views.py:

# game/views.py
from django.shortcuts import render, redirect

def index(request):
    if request.method == "POST":
        room_code = request.POST.get("room_code")
        char_choice = request.POST.get("character_choice")
        return redirect(
            '/play/%s?&choice=%s' 
            %(room_code, char_choice)
        )
    return render(request, "index.html", {})

Далее, создайте маршрут для представления index в tic_tac_toe/urls.py:

## urls.py
from django.urls import path
from game.views import index

urlpatterns = [
    ## ... Other URLS
    path('', index),
]

Теперь создайте базовый шаблон в game/templates/base.html (игнорируйте, если вы уже создали его). Этот шаблон будет наследоваться другими представлениями шаблона.

{% comment %} base.html {% endcomment %}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Tic Tac Toe</title>
     <link rel='stylesheet' href='{% static "/css/main.css" %}'>
</head>
<body>
{% block content %}
{% endblock content %}
<script src = "{% static 'js/game.js' %}"></script>
{% block javascript %}
{% endblock javascript %}
</body>
</html>

Создайте шаблон представления для индексного представления в game/templates/index.html:

{% comment %} index.html {% endcomment %}

{% extends 'base.html' %}
{% block content %}
<div class="wrapper">
  <h1>Welcome to Tic Tac Toe Game</h1>
  <form method="POST">
    {% csrf_token %}
    <div class='form-control'>
      <label for="room">Room id</label>
      <input id="room" type="text" name="room_code" required />
    </div>
    <div class='form-control'>
      <label for="character_choice">Your character</label>
      <select for="character_choice" name = "character_choice">
      <option value="X">X</option>
      <option value="O">O</option>
      </select>
    </div>
    <input type="submit" class="button" value="Start Game" />
</div>
</form>
{% endblock content %}

Запустите сервер разработки Django и перейдите по адресу http://127.0.0.1:8000, чтобы проверить, работает ли индексная страница:

Django Development Server Index Page Visual

Разработка страницы игры

Теперь, когда индексная страница готова, давайте построим игровую страницу.

Начните с создания game/views.py:

## game/views.py
from django.shortcuts import render, redirect
from django.http import Http404

def game(request, room_code):
    choice = request.GET.get("choice")
    if choice not in ['X', 'O']:
        raise Http404("Choice does not exists")
    context = {
        "char_choice": choice, 
        "room_code": room_code
    }
    return render(request, "game.html", context)

Добавьте URL-маршрут вышеуказанного представления:

## urls.py
from django.urls import path
from game.views import game

urlpatterns = [
    ## other url routes
    path('play/<room_code>', game),
]

Теперь, когда бэкенд готов, давайте создадим фронтенд игрового поля. Ниже приведен шаблон Django:game/templates/game.html

{% extends 'base.html' %}
{% comment %} game.html {% endcomment %}
{% load static %}

{% block content %}
<div class="wrapper">
    <div class="head">
        <h1>TIC TAC TOE</h1>
        <h3>Welcome to room_{{room_code}}</h3>
    </div>
    <div id = "game_board" room_code = {{room_code}} char_choice = {{char_choice}}>
        <div class="square" data-index = '0'></div>
        <div class="square" data-index = '1'></div>
        <div class="square" data-index = '2'></div>
        <div class="square" data-index = '3'></div>
        <div class="square" data-index = '4'></div>
        <div class="square" data-index = '5'></div>
        <div class="square" data-index = '6'></div>
        <div class="square" data-index = '7'></div>
        <div class="square" data-index = '8'></div>
    </div>
    <div id = "alert_move">Your turn. Place your move <strong>{{char_choice}}</strong></div>
</div>
{% endblock content %}

Чтобы сетка и индексная страница выглядели хорошо, добавьте CSS, как показано ниже:

/* static/css/main.css */
body {
    /* width: 100%; */
    height: 90vh;
    background: #f1f1f1;
    display: flex;
    justify-content: center;
    align-items: center;
}
#game_board {
    display: grid;
    grid-gap: 0.5em;
    grid-template-columns: repeat(3, 1fr);
    width: 16em;
    height: auto;
    margin: 0.5em 0;
}
.square{
    background: #2f76c7;
    width: 5em;
    height: 5em;
    display: flex;
    justify-content: center;
    align-items: center;
    border-radius: 0.5em;
    font-weight: 500;
    color: white;
    box-shadow: 0.025em 0.125em 0.25em rgba(0, 0, 0, 0.25);
}
.head{
    width: 16em;
    text-align: center; 
}
.wrapper h1, h3 {
    color: #0a2c1a;
}
label {
    font-size: 20px;
    color: #0a2c1a;
}
input, select{
    margin-bottom: 10px;
    width: 100%;
    padding: 15px;
    border: 1px solid #125a33;
    font-size: 14px;
    background-color: #71d19e;
    color: white;
}
.button{
    color: white;
    white-space: nowrap;
    background-color: #31d47d;
    padding: 10px 20px;
    border: 0;
    border-radius: 2px;
    transition: all 150ms ease-out;
}

Когда вы запустите сервер разработки, вы увидите игровую панель, как показано ниже:

Development Server Game Board Display

Добавление WebSockets в ваше приложение Django

Теперь, когда страницы созданы, давайте добавим к ним WebSockets.

Введите следующий код в game/consumers.py:

## game/consumers.py
import json
from channels.generic.websocket import AsyncJsonWebsocketConsumer

class TicTacToeConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope\['url_route'\]['kwargs']['room_code']
        self.room_group_name = 'room_%s' % self.room_name

        # 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):
        print("Disconnected")
        # Leave room group
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )

    async def receive(self, text_data):
        """
        Receive message from WebSocket.
        Get the event and send the appropriate event
        """
        response = json.loads(text_data)
        event = response.get("event", None)
        message = response.get("message", None)
        if event == 'MOVE':
            # Send message to room group
            await self.channel_layer.group_send(self.room_group_name, {
                'type': 'send_message',
                'message': message,
                "event": "MOVE"
            })

        if event == 'START':
            # Send message to room group
            await self.channel_layer.group_send(self.room_group_name, {
                'type': 'send_message',
                'message': message,
                'event': "START"
            })

        if event == 'END':
            # Send message to room group
            await self.channel_layer.group_send(self.room_group_name, {
                'type': 'send_message',
                'message': message,
                'event': "END"
            })

    async def send_message(self, res):
        """ Receive message from room group """
        # Send message to WebSocket
        await self.send(text_data=json.dumps({
            "payload": res,
        }))

Создайте конфигурацию маршрутизации для игрового приложения, в которой есть маршрут к потребителю. Создайте новый файл game/routing.py и вставьте в него следующий код:

## game/routing.py
from django.conf.urls import url
from game.consumers import TicTacToeConsumer

websocket_urlpatterns = [
    url(r'^ws/play/(?P<room_code>\w+)/$', TicTacToeConsumer.as_asgi()),
]

Следующий шаг - направить корневую конфигурацию маршрутизации на модуль game.routing. Обновите tic_tac_toe/asgi.py следующим образом:

## tic_tac_toe/asgi.py
import os

from django.core.asgi import get_asgi_application
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import game.routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tic_tac_toe.settings')

# application = get_asgi_application()
application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(
            game.routing.websocket_urlpatterns
        )
    ),
})

Давайте построим финальную часть кода, создав JavaScript - клиентскую часть, которая асинхронно общается с сервером. Поместите следующий код в static/js/game.js:

// static/js/game.js

var roomCode = document.getElementById("game_board").getAttribute("room_code");
var char_choice = document.getElementById("game_board").getAttribute("char_choice");

var connectionString = 'ws://' + window.location.host + '/ws/play/' + roomCode + '/';
var gameSocket = new WebSocket(connectionString);
// Game board for maintaing the state of the game
var gameBoard = [
    -1, -1, -1,
    -1, -1, -1,
    -1, -1, -1,
];
// Winning indexes.
winIndices = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
]
let moveCount = 0; //Number of moves done
let myturn = true; // Boolean variable to get the turn of the player.

// Add the click event listener on every block.
let elementArray = document.getElementsByClassName('square');
for (var i = 0; i < elementArray.length; i++){
    elementArray[i].addEventListener("click", event=>{
        const index = event.path[0].getAttribute('data-index');
        if(gameBoard[index] == -1){
            if(!myturn){
                alert("Wait for other to place the move")
            }
            else{
                myturn = false;
                document.getElementById("alert_move").style.display = 'none'; // Hide
                make_move(index, char_choice);
            }
        }
    })
}

// Make a move
function make_move(index, player){
    index = parseInt(index);
    let data = {
        "event": "MOVE",
        "message": {
            "index": index,
            "player": player
        }
    }

    if(gameBoard[index] == -1){
        // if the valid move, update the gameboard
        // state and send the move to the server.
        moveCount++;
        if(player == 'X')
            gameBoard[index] = 1;
        else if(player == 'O')
            gameBoard[index] = 0;
        else{
            alert("Invalid character choice");
            return false;
        }
        gameSocket.send(JSON.stringify(data))
    } 
    // place the move in the game box.
    elementArray[index].innerHTML = player;
    // check for the winner
    const win = checkWinner();
    if(myturn){
        // if player winner, send the END event.
        if(win){
            data = {
                "event": "END",
                "message": `${player} is a winner. Play again?`
            }
            gameSocket.send(JSON.stringify(data))
        }
        else if(!win && moveCount == 9){
            data = {
                "event": "END",
                "message": "It's a draw. Play again?"
            }
            gameSocket.send(JSON.stringify(data))
        }
    }
}

// function to reset the game.
function reset(){
    gameBoard = [
        -1, -1, -1,
        -1, -1, -1,
        -1, -1, -1,
    ]; 
    moveCount = 0;
    myturn = true;
    document.getElementById("alert_move").style.display = 'inline';        
    for (var i = 0; i < elementArray.length; i++){
        elementArray[i].innerHTML = "";
    }
}

// check if their is winning move
const check = (winIndex) => {
    if (
      gameBoard[winIndex[0]] !== -1 &&
      gameBoard[winIndex[0]] === gameBoard[winIndex[1]] &&
      gameBoard[winIndex[0]] === gameBoard[winIndex[2]]
    )   return true;
    return false;
};

// function to check if player is winner.
function checkWinner(){
    let win = false;
    if (moveCount >= 5) {
      winIndices.forEach((w) => {
        if (check(w)) {
          win = true;
          windex = w;
        }
      });
    }
    return win;
}

// Main function which handles the connection
// of websocket.
function connect() {
    gameSocket.onopen = function open() {
        console.log('WebSockets connection created.');
        // on websocket open, send the START event.
        gameSocket.send(JSON.stringify({
            "event": "START",
            "message": ""
        }));
    };

    gameSocket.onclose = function (e) {
        console.log('Socket is closed. Reconnect will be attempted in 1 second.', e.reason);
        setTimeout(function () {
            connect();
        }, 1000);
    };
    // Sending the info about the room
    gameSocket.onmessage = function (e) {
        // On getting the message from the server
        // Do the appropriate steps on each event.
        let data = JSON.parse(e.data);
        data = data["payload"];
        let message = data['message'];
        let event = data["event"];
        switch (event) {
            case "START":
                reset();
                break;
            case "END":
                alert(message);
                reset();
                break;
            case "MOVE":
                if(message["player"] != char_choice){
                    make_move(message["index"], message["player"])
                    myturn = true;
                    document.getElementById("alert_move").style.display = 'inline';       
                }
                break;
            default:
                console.log("No event")
        }
    };

    if (gameSocket.readyState == WebSocket.OPEN) {
        gameSocket.onopen();
    }
}

//call the connect function at the start.
connect();

Теперь мы наконец закончили кодирование и готовы играть в нашу игру "Крестики-нолики"!

Заключение

В этом учебнике мы рассмотрели много тем: Django Channels, WebSockets, и немного фронтенда. Пока что наша игра имеет лишь минимальную, базовую функциональность. Вы можете использовать свои новые базовые знания, чтобы поиграть и добавить больше функциональности. Некоторые дополнительные упражнения, которые вы можете выполнить, включают:

  • Добавление аутентификации пользователей
  • Показ онлайн пользователей
  • Добавление игровых данных в базу данных
  • Сделать приложение масштабируемым, добавив Redis вместо слоя каналов in-memory
  • Добавление искусственного интеллекта
Вернуться на верх