Django Channels и WebSocket`ы
В течение более чем десяти лет после выхода Django в 2005 году страницы были в основном статичными, AJAX использовался только в ограниченных случаях, и все было относительно просто. За последние пять лет веб-приложения, работающие в реальном времени, эволюционировали в сторону более клиент-серверного и однорангового взаимодействия. Такой тип взаимодействия возможен благодаря WebSockets, новому протоколу, который обеспечивает полнодуплексную связь и поддерживает постоянное, открытое соединение между клиентом и сервером.
Django Channels облегчает поддержку WebSockets в Django подобно традиционным HTTP представлениям. В нем реализована встроенная поддержка асинхронных представлений, что позволяет проектам Django работать не только с HTTP, но и с протоколами, требующими длительных соединений, такими как WebSockets, MQTT, чатботы и т.д.
В этом руководстве мы покажем вам, как создать приложение реального времени с помощью Django Channels. Для демонстрации на живом примере мы создадим игру "Крестики-нолики" для двух игроков, как показано ниже. Вы можете получить полный исходный код в моем репозитории GitHub.
Настройка проекта 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, чтобы проверить, работает ли индексная страница:
Разработка страницы игры
Теперь, когда индексная страница готова, давайте построим игровую страницу.
Начните с создания 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; }
Когда вы запустите сервер разработки, вы увидите игровую панель, как показано ниже:
Добавление 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
- Добавление искусственного интеллекта