Tutorial en Django para hacer un Chat asincrono y salas

6 minutos

Serpientes dialogando

A diferencia que AJAX, el protocolo WebSocket proporciona una comunicación bidireccional entre el servidor y el cliente. Podemos intercambiar información asíncrona sin la obligación de hacer más peticiones u obligar a un refresco del navegador; es posible enviar información al servidor en cualquier momento o recibirla. Idóneo para montar un Chat, objetivo de este tutorial. Aunque los usos son ilimitados: editar información en tiempo real, enviar notificaciones, etc. Tu creatividad es la única limitación técnica.

Cuando trabajamos con Django es posible utilizar WebSockets gracias a los Channels, una función nacida en el agosto del 2016 para gestionar este tipo de tareas en paralelo, y lanzaremos un servidor web bautizado como Daphne adapto para esta labor.

Resultado final

Conversación en chat

Características del chat

  • Integración completa con Django y Websockets, por medio de Channels.
  • Respuestas asincronas con el objetivo de mejorar el rendimiento.
  • Salas individuales para mantener conversaciones privadas.
  • Front-End minimalista con Javascript para su fácil implementación en otros sistemas.

Tutorial

1) Levantar una base de datos y Redis

Antes de empezar a trabajar con Django necesitaremos 2 piezas fundamentales.

  • Base de datos relacional: en el tutorial se incorporará PostgreSQL al ser la más recomendada dentro del ecosistema. Su objetivo es almacenar los datos de los usuarios, como su perfil e historial.
  • Base de datos clave-valor: En este caso usaremos Redis. Con ella gestionaremos los mensajes para separar los usuarios en salas y puedan mantener conversaciones privadas.

Para simplificar la tarea de instalación y configuración lanzaremos estos servicios con Docker.

Creamos un documento con el nombre docker-compose.yaml con el siguiente contenido.

version: '3.1'

services:

  db:
    image: postgres
    restart: always
    volumes:
      - ./postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432:5432

  redis:
    image: redis:alpine
    restart: always
    ports:
      - 6379:6379

Ejecutamos.

docker-compose up -d

2) Crear entorno virtual

Doy por sentado que dispones en el equipo el paquete python-virtualenv. En caso contrario no dudes de buscarlo para tu sistema.

Después creamos un entorno virtual.

python3 -m venv .venv

Iniciamos el entorno.

source .venv/bin/activate

3) Instalar dependencias

Antes de continuar deberemos instalar las dependencias de Python que nos dejarán acceder a elementos no nativos de Django como un servidor asíncrono o Websockets.

Creamos un archivo llamado requirements.txt con el contenido.

# Django
django
# Servidor para Django
daphne==2.4.1
# Conector para PostgreSQL
psycopg2-binary
# Channels
channels==2.4.0
# Conector de Redis para Channels
channels_redis

Instalamos con pip.

pip3 install -r requirements.txt

4) Crear proyecto Django

Creamos el esqueleto del proyecto.

django-admin startproject mi_web
cd mi_web

Ahora, dentro del proyecto Django crearemos 2 aplicaciones.

  • front: Mostrará el HTML necesario para que los usuarios puedan navegar.
  • chat: Gestionará los Websockets y salas.

Creamos los directorios que usaremos.

mkdir -p app/front app/chat

Generaremos las aplicaciones.

django-admin startapp front app/front
django-admin startapp chat app/chat

En mi_web/settings.py activamos ambas.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'app.chat',
    'app.front',
]

Es el momento de configurar la base de datos. Modificamos la siguiente sección.

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

… por la siguiente.

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'postgres',
        'USER': 'postgres',
        'PASSWORD': 'postgres',
        'HOST': '127.0.0.1',
        'PORT': '5432',
    }
}

Actualizamos la base de datos.

python3 manage.py makemigrations
python3 manage.py migrate

Django esta preparado.

5) Incluir channels

Incluimos channels al listado de aplicaciones dentro de mi_web/settings.py. Debe estar antes de las anteriores.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'channels',
    'app.chat',
    'app.front',
]

Continuamos configurando settings.py, de indicamos a channels donde estará su base de datos para gestionar las salas.

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

6) Enviar/Recibir por Websockets

Es el momento de montar una puerta de entrada para que el cliente de Websocket pueda conectarse y esperar nuevo contenido, para ser alimentado con información asíncrona hasta que se sacie o se desconecte. A este concepto se le denomina Consumidor (Consumer).

Para aplicarlo dentro de Django creamos un archivo llamado consumers.py en app/chat/ con el siguiente contenido.

# app/chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async

class ChatConsumer(AsyncWebsocketConsumer):

    async def connect(self):
        ''' Cliente se conecta '''

        # Recoge el nombre de la sala
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.room_group_name = "chat_%s" % self.room_name

        # Se une a la sala
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)

        # Informa al cliente del éxito
        await self.accept()

    async def disconnect(self, close_code):
        ''' Cliente se desconecta '''
        # Leave room group
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)

    async def receive(self, text_data):
        ''' Cliente envía información y nosotros la recibimos '''
        text_data_json = json.loads(text_data)
        name = text_data_json["name"]
        text = text_data_json["text"]

        # Enviamos el mensaje a la sala
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                "type": "chat_message",
                "name": name,
                "text": text,
            },
        )

    async def chat_message(self, event):
        ''' Recibimos información de la sala '''
        name = event["name"]
        text = event["text"]

        # Send message to WebSocket
        await self.send(
            text_data=json.dumps(
                {
                    "type": "chat_message",
                    "name": name,
                    "text": text,
                }
            )
        )

Lo siguiente es definir sus rutas.

Creamos routing.py en la aplicación de chat.

# app/chat/routing.py
from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer),
]

7) Cambiar a un servidor asincrono.

Lamentablemente Django no nos permite trabajar con aplicaciones asincronía con su servidor WSGI. Deberemos dar el salto a ASGI (Asynchronous Server Gateway Interface) su sucesor espiritual, creado con la finalidad de otorgar una interfaz estándar entre servidores web, marcos y aplicaciones Python con capacidad asíncrona.

Creamos en la raíz el archivo asgi.py con el siguiente contenido.

import os

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
import django

django.setup()

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from app.chat.routing import websocket_urlpatterns


application = ProtocolTypeRouter(
    {
        "websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
    }
)

Al final de mi_web/settings.py añadimos la configuración para que Django cambie a ASGI.

ASGI_APPLICATION = "asgi.application"

Lanzamos el servidor.

python3 manage.py runserver

Nos encontraremos un mensaje informativo que nos confirmará su conexión.

Starting ASGI/Channels 

Opcionalmente puedes borrar en mi_web/settings.py.

WSGI_APPLICATION = 'mi_web.wsgi.application'

8) Montar Front-End para chat

El Back-End esta hambriendo de contenido y los WebSockets están prácticamente sin estrenar. Vamos a construir un minimalista interfaz web para poder enviar y recibir todos los mensajes que necesitemos sin limitación de usuarios. Usaremos como base el sistema de plantillas que incorpora Django.

Dentro de app/front/ creamos la carpeta templates y dentro el archivo chat.html. Quedando con la siguiente ruta: app/front/templates/chat.html. Dentro incluiremos todo el HTML y Javascript necesario. El stack tecnológico lo puedes customizar al gusto, en el ejemplo he dejado lo esencial.

  • HTML5
  • Spectre.css para el aspecto visual.
  • Javascript Vanilla.
<!doctype html>
<html lang="es">
    <head>
        <meta charset="UTF-8"/>
        <title>Chat</title>
        <!-- Spectre CSS -->
        <link rel="stylesheet" href="https://unpkg.com/spectre.css/dist/spectre.min.css">
    </head>
    <body>
        <div class="container grid-md">
            <h1 class="mt-2 text-center">Chat con Django</h1>
            <div class="columns">
                <aside class="column col-4">

                    <!-- Configurar alias -->
                    <div class="form-group">
                        <label class="form-label">
                            Nombre
                            <input id="nombre" class="form-input" type="text" placeholder="Oveja programadora...">
                        </label>
                    </div>
                    <!-- Fin Configurar alias -->

                </aside>
                <main class="column">

                    <!-- Mensajes -->
                    <section id="mensajes"></section>
                    <!-- Fin Mensajes -->

                    <!-- Enviar mensajes -->
                    <section class="form-group">
                        <input id="texto" class="form-input" type="text" placeholder="Nuevo mensaje...">
                        <button id="enviar" class="btn btn-block">Enviar</button>
                    </section>
                    <!-- Fin Enviar mensajes -->
                </main>
            </div>
        </div>
        <script>
         /*
          * VARIABLES
          */
         // Define el nombre de la sala
         const SALA_CHAT = 'python';
         // Conecta con WebSockets
         const CHAT_SOCKET = new WebSocket('ws://localhost:8000/ws/chat/' + SALA_CHAT + '/');
         // Captura el campo con el nombre del usuario
         const CAMPO_NOMBRE = document.querySelector('#nombre');
         // Captura el contenedor que posee todos los mensajes
         const MENSAJES = document.querySelector('#mensajes');
         // Captura el campo con el nuevo texto
         const CAMPO_TEXTO = document.querySelector('#texto');
         // Boton para enviar mensaje
         const BOTON_ENVIAR = document.querySelector('#enviar');

         /*
          * FUNCIONES
          */

         /**
          * Método que añade un nuevo mensaje en el HTML (#mensajes)
          */
         function anyadirNuevoMensajeAlHTML(nombre, texto, propio = false) {
             // Contenedor
             const MI_CONTENEDOR = document.createElement('div');
             MI_CONTENEDOR.classList.add(propio ? 'bg-primary' : 'bg-secondary', 'p-2');
             // Nombre
             const MI_NOMBRE = document.createElement('h2');
             MI_NOMBRE.classList.add('text-tiny', 'text-bold', 'mt-2');
             MI_NOMBRE.textContent = nombre;
             MI_CONTENEDOR.appendChild(MI_NOMBRE);
             // Texto
             const MI_TEXTO = document.createElement('p');
             MI_TEXTO.classList.add('my-2');
             MI_TEXTO.textContent = texto;
             MI_CONTENEDOR.appendChild(MI_TEXTO);
             // Anyade todo a MENSAJES
             MENSAJES.appendChild(MI_CONTENEDOR);
         }

         /**
          * Método que envia el mensaje al consumer por medio de WebSockets
          */
         function enviarNuevoMensaje() {
             // Envia al WebSocket un nuevo mensaje
             CHAT_SOCKET.send(JSON.stringify({
                 name: CAMPO_NOMBRE.value,
                 text: CAMPO_TEXTO.value
             }));
             // Limpiamos el campo donde hemos escrito
             CAMPO_TEXTO.value = '';
             // Le volvemos a dar el foco para escribir otro mensaje
             CAMPO_TEXTO.focus();
         }

         /*
          * EVENTOS
          */

         // Conectado
         CHAT_SOCKET.addEventListener('open', () => {
             console.log('Conectado');
         });
         // Desconectado
         CHAT_SOCKET.addEventListener('close', () => {
              console.log('Desconectado');
          });

         // Recibir mensaje
         CHAT_SOCKET.addEventListener('message', (event) => {
             console.log('Recibido nuevo mensaje');
             const MI_NUEVA_DATA = JSON.parse(event.data);
             anyadirNuevoMensajeAlHTML(MI_NUEVA_DATA.name, MI_NUEVA_DATA.text, MI_NUEVA_DATA.name === CAMPO_NOMBRE.value);
          });

         // Enviar mensaje cuando se pulsa en el botón Enviar
         BOTON_ENVIAR.addEventListener('click', enviarNuevoMensaje);

         // Enviar mensaje cuando se pulsa en el teclado Enter
         CAMPO_TEXTO.addEventListener('keyup', (e) => e.keyCode === 13 ? enviarNuevoMensaje() : false);
        </script>
    </body>
</html>

Dentro de app/front/views.py indicamos en el vista donde está la plantilla que acabamos de definir.

# app/front/views.py
from django.shortcuts import render

def chat(request):
    return render(request, 'chat.html')

Definimos la ruta donde será mostrado la vista, iremos a mi_web/urls.py.

# mi_web/urls.py
from django.contrib import admin
from django.urls import path
from app.front import views

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

9) Probarlo

Abrimos en 2 navegadores/ventajas/pestañas la siguiente ruta http://127.0.0.1:8000/.

En cada uno indicamos un nombre diferente y escribimos en la caja de mensaje.

Te felicito, acabas de crear un chat en Django.

Apuntes finales

Si algún paso no ha funcionado como esperabas dispones del código completo en Github unificando todo lo explicado en el tutorial.

Para terminar quiero transmitirte que realizar un chat moderno es laborioso, aún queda muchas características que puedes incluir:

  • Capacidad para borrar mensajes individuales.
  • Integrar los usuarios.
  • Marcas quienes están conectados.
  • Enviar archivos.
  • Y muchas otras…

Es una excelente base para continuar el trabajo. Te deseo mucha suerte en el camino, estoy seguro que poco a poco llegarás donde te lo propongas.

¿Te ha gustado? Comprame un café

Comentarios

{{ comments.length }} comentarios

Nuevo comentario

Nueva replica  {{ formatEllipsisAuthor(replyComment.author) }}

Acepto la política de Protección de Datos.

Escribe el primer comentario

Tal vez también te interese...