Websockets en Django empleando Django Channels, Servidores wsgi y asgi

- Andrés Cruz

In english
Websockets en Django empleando Django Channels, Servidores wsgi y asgi

Hoy en día, existen múltiples aplicaciones que requieren de la funcionalidad de comunicación en tiempo real, real-time; aplicaciones de mensajería como chats es imprescindible este tipo de funcionalidad para tener a los clientes conectados y estos puedan enviar y recibir mensajes.

Claro está que esta funcionalidad de real-time la puedes implementar en un servidor común y corriente con tecnologías como ajax, pero obviamente estar cada rato enviando peticiones a nuestro servidor para saber el estado es extremadamente ineficiente; para estos casos existe un mecanismo en base a websocket que nos permite implementar esta característica, el real-time de manera eficiente y en Django para esto, tenemos lo que se conoce como Django channels que a través de una capa de canales podemos implementar nuestros websocket en nuestra aplicación.

Django channels y los websockets

Para esto tenemos que cambiar el servidor que tenemos por defecto basado en WSGI a un servidor de tipo ASGI que es el que nos provee nuestro Django channels.

Aplicaciones síncronos empleando WSGI Web Server Gateway Interface

El servidor que tenemos por defecto y el que nosotros empleamos al momento de hacer el deploy o publicación de nuestra aplicación, generalmente es de tipo WSGI que es la definición que toman los servidores web cuando estamos desarrollando en Django, en otras palabras los WSGI vienen siendo para Python lo que es Apache para PHP.

WSGI viene de las siglas de Web Server Gateway Interface que son servidores de tipo síncronos, por lo tanto NO podemos implementar funcionalidades de tipo real-time.

Aplicaciones asíncronas usando ASGI Asynchronous Server Gateway Interface

Los servidores WSGI en Python al igual que ocurre con Apache, son empleados para manejar las peticiones HTTP y trabajan de manera síncronos con nuestra petición y los mismos NO pueden trabajar de manera asíncrona y para estos casos necesitamos otro tipo de servidor llamados ASGI.

Como hablábamos anteriormente, los WSGI son los servidores estándar para desarrollar en Python, sin embargo, para desarrollar nuestras aplicaciones asíncronas en Django necesitamos de un servidor ASGI que nos permite manejar el esquema tradicional de peticiones HTTP y también WebSockets mediante una capa de channels o canales.

Django Channles nos permite emplear un servidor de tipo ASGI y con esto podemos manejar no solamente las peticiones de tipo HTTP, si no también protocolos que requieren de una comunicación de lado a lado, es decir, de tipo full-duplex de manera persistente abriendo un canal bidireccional para transmitir los mensajes empleando el protocolo llamado TCP Transmission Control Protocol de tal manera que esta comunicación full-duplex nos permite conectarnos entre nuestro cliente y servidor de manera persistente sin necesidad de estar preguntando estado mediante ajax.

WSGI y ASGI

WSGI: conexión clásica

Cuando una petición HTTP es enviada por un cliente mediante el navegador para nuestro servidor web, Django maneja la petición pasando la misma a un objeto de tipo HttpRequest y lo manda de acuerdo a la vista según la ruta configurada, la vista procesa la petición y retorna un objeto de tipo HttpResponse y lo manda de vuelta a nuestro cliente, entiéndase nuestro navegador web mediante el siguiente esquema:

Servidor de tipo WSGi en Django
Servidor de tipo WSGi en Django

ASGI: conexión bidireccional asíncrona

Los canales en Django, reemplazan el esquema anteriormente mencionado, mediante mensajes que son pasados de lado a lado de manera asíncrona en nuestra aplicación; las peticiones de tipo HTTP son aún mapeadas mediante el mismo esquema de rutas que tenemos para un proyecto en Django, pero para manejar las rutas de los canales, es decir, los webSockets necesitamos otra capa, otro juego de rutas para manejar este tipo de peticiones full-duplex; por lo tanto, podemos emplear simplemente una arquitectura de tipo sincrónica o asíncrona e inclusive las dos empleando los servidores de tipo ASGI mediante los canales en Django:

Servidor de tipo ASGI en Django
Servidor de tipo ASGI en Django

ya que solamente va a ver cambios en el estado del servidor cuando se envíen y/o reciban mensajes y no sería necesario que empleemos el esquema ajax, en los servidores asíncronos de tipo WSGI.

Consumer

Una vez instalado y configurado channels en nuestro proyecto en Django, para poder emplear el esquema de canales en nuestra aplicación, tenemos que crear lo que se conoce como consumers, estos consumers son los que van a manejar las peticiones de tipo asíncronos mediante nuestro servidor ASGI específicamente la conección mediante websockets.

Los consumers son piezas de código individuales que nos permiten leer y escribir mensajes según la comunicación full-duplex hablada anteriormente; estos consumers, tienen 3 métodos importantes:

  • El de tipo connect, que se ejecuta cuando se crea una colección con el cliente.
  • El de tipo receive, que cuya función se ejecuta al momento de recibir un mensaje desde el cliente.
  • El de tipo disconnect, que nos permite tener un mecanismo cuando nos desconectamos del cliente.

Como puedes ver, si has trabajado un poco con los websocket, tienen una estructura similar, ya que en la práctica los costumers son simplemente websocket en el servidor.

Luego de crear el consumers, tenemos que crear el ruteo correspondiente al mismo:

from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path('ws/alert/room/<room_id>', consumers.AlertConsumer.as_asgi()),
]

En nuestro curso de Django desde cero tenemos varias secciones en las cuales trabajamos con esto y con los aspectos básicos y no tan básicos de Django.
 Configurar proyecto en Django con Django Channels

En este apartado, vamos a crear una aplicación mínima con Django Channels para enviar mensajes entre clientes que estarán inscritos en una habitación en donde podrán comunicarse, por lo tanto será un chat grupal como si se tratase de un grupo en Whatsapp, Telegram o Discord; para ello, crearemos un nuevo proyecto como lo comentado en el capítulo 3 que llamaremos mychannels.

Instalaciones y configuraciones básicas

El siguiente apartado será instalar algunas dependencias necesarias.

Instalamos Django channels mediante:

$ python -m pip install -U 'channels[daphne]'

Daphne es un servidor ASGI puramente creado en Python para ambientes UNIX, mantenido por miembros del proyecto Django y es un servidor de referencia para ASGI y por estos motivos es el servidor empleado por el equipo de Django Channels.

Creamos una aplicación para manejar el chat y las alertas (mensajes):

$ python manage.py startapp chat
$ python manage.py startapp alert

El modelo para los mensajes (alertas) consta del contenido, el usuario creador del mensaje y la fecha de creación, lo típico en un mensaje de texto:

alert/models.py

from django.contrib.auth.models import User

from django.db import models

# Create your models here.

class Alert(models.Model):
    content = models.CharField(max_length=200)
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    create_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.content

Y un modelo para manejar las habitaciones, que es donde los usuarios podrán comunicarse, importante notar la relación inversa de las habitaciones para los usuarios 'rooms_joined' que emplearemos más adelante al momento de asignar un usuario a una habitación:

chat/models.py

class Room(models.Model):
    name = models.CharField(max_length=60, unique=True)
    users = models.ManyToManyField(User, related_name='rooms_joined', blank=True)

    def __str__(self):
        return self.name

Registramos las aplicaciones:

INSTALLED_APPS = [
    ***
    'daphne',
    'chat',
    'alert'
]

En el ejemplo anterior, instalamos el servidor de 'daphne', que es la aplicación que vamos a usar.

Aplicamos las migraciones:

$ python manage.py migrate

Creamos un usuario superadministrador para poder emplear Django Admin:

$ python manage.py createsuperuser

Registramos la gestión de las habitaciones desde Django Admin:

mychannels\chat\admin.py

from django.contrib import admin

from .models import Room

@admin.register(Room)
class RoomAdmin(admin.ModelAdmin):
    pass

Creamos algunos usuarios desde la interfaz de Django Admin:

Users test
Users test

Y habitaciones:

room test
room test

Configurar servidor ASGI

Como comentamos anteriormente, emplearemos Django Channels junto con un servidor de tipo ASGI que debemos de configurar; en la documentación oficial, especifican los pasos que ejemplificamos en este apartado:

https://channels.readthedocs.io/en/latest/installation.html

Preparamos el proyecto para emplear el servidor de tipo ASGI y conservamos el canal de HTTP:

mychannels/routing.py

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

import chat.routing


from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    'websocket' : AuthMiddlewareStack(
        URLRouter(chat.routing.websocket_urlpatterns)
    )
})

AuthMiddlewareStack es una clase implementada en Django Channels que permite proteger las rutas con autenticación requerida.

Con:

"http": django_asgi_app,

Preservamos el canal HTTP, por lo tanto, podemos emplear el mismo proyecto en Django para comunicar aplicaciones mediante los websockets empleando las rutas definidas en:

chat\routing.py

Y las rutas tradicionales definidas en el proyecto en Django de tipo síncrona que definimos en urls.py a nivel de las aplicaciones de Django.

Es importante mencionar que aún no se a configurado el archivos de rutas para los consumers/wetsockets asi que, pudieras comentar la siguiente línea:

mychannels/routing.py

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

# import chat.routing
from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    # 'websocket' : AuthMiddlewareStack(
    #     URLRouter(chat.routing.websocket_urlpatterns)
    # )
})

Indicamos que emplee el archivo de configuración generado anteriormente y con esto, el servidor de tipo ASGI:

mychannels/settings.py

ASGI_APPLICATION = "mychannels.routing.application"

Con esto, si ejecutamos el servidor, lo cual lo podemos hacer normalmente empleando el mismo comando de siempre:

$ python manage.py runserver

Tendremos un servidor de tipo ASGI en lugar del servidor WSGI, este servidor también puede procesar las peticiones de tipo WSGI que es como lo hemos venido empleando hasta este momento:

Django version 5.X, using settings 'mychannels.settings'
Starting ASGI/Daphne version 4.1.0 development server at http://127.0.0.1:8000/

En lugar del de tipo WSGI:

Django version 5.X, using settings 'mychannels.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Esto es imprescindible para poder seguir con el resto del capítulo y poder conectarse a los websockets; si ves una salida como la siguiente:

django.core.exceptions.ImproperlyConfigured: Cannot find 'X' in ASGI_APPLICATION module mychannels.routing

Significa que tienes un problema al referenciar el archivo de routing:

ASGI_APPLICATION = "mychannels.routing.application"

Crear websocket (Consumer) y cliente (Vista y Template)

En este apartado, vamos a crear el websocket mediante los consumers que son aquellas que permiten la comunicación fullduplex y la página cliente, la cual no es más que la vista y el template que consume el consumer mencionado mediante JavaScript.

Consumer

Una vez instalado y configurado channels en nuestro proyecto en Django, para poder emplear el esquema de canales en nuestra aplicación, tenemos que crear lo que se conoce como consumers, estos consumers son los que van a manejar las peticiones de tipo asíncronos mediante nuestro servidor ASGI específicamente la conección mediante websockets.

Los consumers son piezas de código individuales que nos permiten leer y escribir mensajes según la comunicación full-duplex hablada anteriormente; estos consumers, tienen 3 métodos importantes:

  • El de tipo connect(), que se ejecuta cuando se crea una colección con el cliente.
  • El de tipo receive(), que cuya función se ejecuta al momento de recibir un mensaje desde el cliente.
  • El de tipo disconnect(), que nos permite tener un mecanismo cuando nos desconectamos del cliente.

Como puedes ver, si has trabajado un poco con los websocket, tienen una estructura similar, ya que en la práctica los consumers son simplemente websockets en el servidor.

Creamos un consumer como el siguiente:

chat/consumers.py

import json
from channels.generic.websocket import WebsocketConsumer

class ChatConsumer(WebsocketConsumer):

    def connect(self):
        print('Conectado')
        self.accept()
        # return super().connect()

    def disconnect(self, code):
        print("Desconectado")
        # return super().disconnect(code)

    def receive(self, text_data):
        print("Recibido")

        text_data_json = json.load(text_data)
        message = text_data_json['message']

        print(message)

        self.send(text_data=json.dumps({ 'message':message }))

        # return super().receive(text_data, bytes_data)

En el método de conexión inicializamos el WebSocket y aceptamos la conexión:

self.accept()

El método más interesante en el de receive(), qué es el que se ejecuta cuando se recibe un mensaje, aquí, recibimos en mensaje enviado por otro cliente:

text_data_json = json.loads(text_data)
message = text_data_json['message']

Y enviamos nuevamente a todos los clientes conectados:

self.send(text_data=json.dumps({'message': message}))

Pudiéramos realizar operaciones adicionales como guardar el mensaje en la base de datos, pero de esto nos ocupamos luego.

Luego de crear el consumers, tenemos que crear el ruteo correspondiente al mismo:

chat/routing.py

from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path('ws/chat/room/<room_id>', consumers.ChatConsumer.as_asgi()),
]

Recuerda descomentar la importación de la ruta definida anteriormente desde el proyecto:

mychannels/routing.py

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

import chat.routing

from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    'websocket' : AuthMiddlewareStack(
        URLRouter(chat.routing.websocket_urlpatterns)
    )
})

Cliente: Consumir Websocket 

Creamos la vista, la cual, solamente devuelve un template y busca la habitación con la relación inversa:

channels/chat/views.py

@login_required
def room(request, room_id):
    try:
        room = request.user.rooms_joined.get(id=room_id)
    except:
        return HttpResponseForbidden()

    return render(request, 'chat/room.html', {'room':room})

Y su ruta:

chat/urls.py

from django.urls import path
from . import views

app_name = 'chat'
urlpatterns = [
    path('room/<int:room_id>/', views.room, name='room')
]

Registramos la ruta a nivel del proyecto:

mychannels/urls.py

urlpatterns = [
    path('admin/', admin.site.urls),
    path('chat/', include('chat.urls')),
]

En cuanto al template, queda de la siguiente manera:

chat/index.html

{% extends 'base.html' %}

{% block content %}

<style>

    body, html, .container{
        height: 100%;
        overflow: hidden;
    }

    #boxMsj{
        height: calc(100% - 100px);
        overflow-y: auto;
    }
</style>

<div id="chat" class="container">

    <div id="boxMsj">

    </div>

    <input type="text" name="" id="message" class="form-control mt-2">
    <input type="submit" value="Enviar" id="bMessage" class="mt-1 btn btn-success">
</div>

<script>
    window.onload = function () {

        document.querySelector("#bMessage").addEventListener("click", sendMsj)
        document.querySelector("#message").addEventListener("keypress", function (e) {
            if (e.keyCode == 13) {
                sendMsj()
            }
        })

        function sendMsj() {
            message = document.querySelector("#message")

            if (message.value.trim() !== "") {
                chatSocket.send(JSON.stringify({ 'message': message.value.trim() }))
                message.value = ""
            }
        }

        var url = 'ws://' + window.location.host + '/ws/chat/room/{{ room.id }}'
        var chatSocket = new WebSocket(url)
        chatSocket.onopen = function (e) {
            //chatSocket.send(JSON.stringify({'message' : "Hola Mundo desde el cliente"}))
            console.log("WS abierto")
        }

        chatSocket.onmessage = function (data) {

            const msj = JSON.parse(data.data)
            document.querySelector("#boxMsj").innerHTML += "<div class='alert alert-success'>" + msj.message + "</div>"

            console.log(msj)
        }

        chatSocket.onclose = function (e) {
            console.log("Cerrada la conexión")
        }


    }
</script>

{% endblock %}
El template base:
chat/base.html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>

    {% load static %}

    <link rel="stylesheet" href="{% static 'bootstrap/dist/css/bootstrap.min.css' %}">
    <link rel="stylesheet" href="{% static 'css/custom.css' %}">
    <link rel="stylesheet" href="{% static 'fontawesome/css/all.min.css' %}">

</head>

<body>

    <div class="container my-3">
        {% block content %}

        {% endblock %}
    </div>

    <script src="{% static 'bootstrap/dist/js/bootstrap.min.js' %}"></script>

</body>

</html>

En el template anterior, lo importante es el JavaScript que es el que consume al consumer; se crea una conexión al consumer/websocket creado anteriormente:

var url = 'ws://' + window.location.host + '/ws/chat/room/{{ room.id }}'
var chatSocket = new WebSocket(url)

El siguiente método se ejecuta cuando se abre la conexión con el websocket:

chatSocket.onopen = function (e) {
  console.log("WS abierto")
}

Este script lo puedes emplear para avisar al usuario de que la conexión está disponible, luego, colocamos un escucha que, al recibir un mensaje, crea un elemento HTML mostrando el contenido de dicho mensaje:

chatSocket.onmessage = function (data) {

            
            var data = JSON.parse(data.data)
            console.log(data)

            document.querySelector("#boxMsj").innerHTML = 
            `
                <div class="card mt-2">
                    <div class="card-body">
                        <h5 class="my-0">${data.message}</h5>

                        <p class="text-muted float-end my-0">${data.username}</p>
                        <p class="text-muted my-0">${data.datetime}</p>
                        
                    </div>
                </div>
            ` + document.querySelector("#boxMsj").innerHTML
     

        }
    }

Finalmente tenemos un script para cuando se cierra la conexión, que puede ser cuando se interrumpe la conexión a Internet, se detiene el servidor u otro:

chatSocket.onclose = function (e) {
  console.log("Cerrada la conexión")
}

Si ingresamos a una ruta estando autenticado mediante la sesión (para esto puede iniciar sesión en Django Admin) seleccionando una habitación creada en la base de datos, por ejemplo, la primera:

http://127.0.0.1:8000/admin/chat/room/1

Y envías un mensaje, verás una respuesta.

Puedes abrir otro navegador diferente y autenticarte con otro usuario y verás que los mensajes son sincronizados entre ambos usuarios.

Comunicación por las capas de canales (Multiple channels)

Actualmente, tenemos una aplicación asíncrona full duplex con la limitante en la cual solamente puede ser consumida por un usuario, es decir, en nuestra aplicación tipo chat, solamente un usuario puede hablar con el mismo al no ser posible que se pueda unir otros clientes a dicha habitación, lo cual, convierte nuestra aplicación en una bastante inutil...

 

Con las capas de canales de Django Channels permiten crear canales para poder unir múltiples usuarios a una misma habitación; es decir, ahora podremos adaptar nuestra aplicación para que puedan chatear en tiempo real en comunicación fullduplex múltiples usuarios.

 

Para emplear estas capas, debemos de realizar una configuración a nivel el proyecto que indica el backend que vamos a emplear para habilitar estas capas, las capas pueden ser en memoria o el óptimo, empleando Redis, que es la opción que vamos a emplear en el libro; puedes obtener más información en:

 

https://channels.readthedocs.io/en/stable/topics/channel_layers.html

Redis

Redis es una base de datos NoSQL extremadamente rápida, y es por eso que vemos que es empleada el servicios que requieren de velocidad, como para configurar una caché, redis almacena los datos en una pareja de clave-valor.

Instalar Redis en MacOS

Para instalar Redis si empleas MacOS, podemos instalarlo mediante Homebrew que no es más que un gestor de paquetes para MacOS; comencemos instalando Homebrew:

 

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

También podemos obtener redis si empleas Laravel Herd:

 

https://herd.laravel.com/

 

Que provee un ambiente para desarrollar en PHP, aunque, nos interesa es el apartado para la gestión de base de datos, que sería el que debemos de instalar:

 

https://dbngin.com/

 

Y desde aquí, podemos emplear Redis:

 

Redis en DBngin

 

 

En el libro, usaremos DBngin por su facilidad.

Instalar Redis en Windows

En Windows, puedes usar la opción de Laragon, que no es más que el Laravel Herd pero para Windows, es decir, un ambiente para desarrollar en PHP:

 

https://laragon.org/

 

En su versión "Full"

Channels Redis

El siguiente paquete permite emplear redis junto con Django Channels y con esto, poder crear la capa de comunicación en el backend:

 

$ pip install channels-redis

 

https://pypi.org/project/channels-redis/

 

Y configuramos el proyecto:

 

mychannels/settings.py

 

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("localhost", 6379)],
        },
    },
}

 

En caso de que no quieras o puedas emplear Redis, puedes seguir el resto del capítulo empleando en su lugar la "In-Memory Channel Layer":

 

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    }
}

 

Que es recomendada para ambientes de desarrollo o testing.

 

Finalmente, con la capa de canal configurada anteriormente, es posible realizar la comunicación entre varios clientes que estén consumiendo el mismo consumer y poder escuchar y enviar mensajes en dicho canal.

Conceptos claves

Por defecto, el uso de las funciones send()group_send()group_add() entre otras, son asíncronas, lo que significa que debes esperarlas. Si necesita llamarlos desde código síncrono, necesitará usar un contenedor como el siguiente asgiref.sync.async_to_sync:

 

Mediante el siguiente objeto:

 

self.channel_layer

 

Podemos realizar diversas operaciones como:

  • self.channel_layer.group_add Unir un cliente a un grupo.
  • self.channel_layer.group_discard Quitar un cliente de un grupo.
  • self.channel_layer.group_send Envíar un mensaje, esta es la función más interesante ya que permite fragmentar el proceso para enviar un mensaje; por ejemplo, si tenemos el siguiente mensaje:

 

await self.channel_layer.group_send(
    room.group_name,
    {
        "type": "chat_message",
        "room_id": room_id,
        "username": self.scope["user"].username,
        "message": message,
    }
)

 

El parámetro para el tipo, corresponde al nombre de la función que va a enviar el mensaje, el resto de los parámetros, corresponde a los datos enviados a esta función mediante el evento:

 

async def chat_message(self, event):
    """
    Called when someone has messaged our chat.
    """
    # Send a message down to the client
    await self.send_json(
        {
            "msg_type": settings.MSG_TYPE_MESSAGE,
            "room_id": event["room_id"],
            "username": event["username"],
            "message": event["message"],
        },
    )

 

A diferencia del primer esquema que presentamos (single channels) en el apartado anterior, estas funciones permiten emplear el multi canal, lo que significa que podemos conectar más de un cliente a un solo canal, por lo demás, tienen una lógica similar a la empleada anteriormente.

 

El objeto:

 

self.channel_name

 

Contiene una referencia a la capa (CHANNEL_LAYERS) configurada anteriormente.

 

El resto del material forma parte del curso y libro

Andrés Cruz

Desarrollo con Laravel, Django, Flask, CodeIgniter, HTML5, CSS3, MySQL, JavaScript, Vue, Android, iOS, Flutter

Andrés Cruz en Udemy

Acepto recibir anuncios de interes sobre este Blog.