4

Realtime Django con Channels (for dummies)

Es una tarde de domingo, te acabas de certificar en el curso de Django y tu subconsciente piensa: “vaya que hoy es un buen dia para desarrollar mi primera aplicación en tiempo real”. Tomas tu ordenador, ves tus apuntes y ¡OH POR DIOS! No sabes como hacer una aplicación en realtime. Tras trasnochar y buscar en 50 foros distintos, 80 preguntas en Stackoverflow, y 6 horas en el Facebook, encuentras que mejor hubieras aprendido Node.js porque… you know… JavaScript.

No te preocupes, existe una herramienta buenísima para solucionar esto, está diseñada para entregarte acciones en vivo y es facil de implementar. Te presento a Channels.

Channels es una herramienta diseñada especificamente para Django, con el objetivo de tener aplicaciones en tiempo real, pero dejemonos de palabrería y pongamonos manos a la obra.

Comenzaremos creando nuestra carpeta en el escritorio conocida como platzi_snake, ingresamos con cd platzi_snake y generamos nuestro entorno virtual. Luego ingresamos al mismo e instalamos Django (pip install django).

~mkdir platzi_realtime
~cd platzi_realtime
~virtualenv .venv
~source .venv/bin/activate
~(.venv)

Creamos nuestra aplicacion (django-admin.py startproject platzi_snake) e ingresamos a la misma. Luego creamos nuestra aplicación snake_protocol.

~(.venv) django-admin.pystartprojectplatzi_snake
~(.venv) cdplatzi_snake
~(.venv) django-admin.pystartappsnake_protocol

¡Y listo! Ok no. Quizas nos falta instalar ahmm… ¡CHANNELS! Y puede que pip install pathlib tambien sea necesario.

~(.venv) pip install -U channels==1.1.8
~(.venv)pip install pathlib

Nota: la version mas reciente de channels(channels2) funciona unicamente con Python3, para usar Python2 debemos colocar pip install channels==1.1.8

Y por supuesto, nuestro broker (o la aplicacion que se va a encargar de realizar nuestra transaccion) como en nuestro caso asgi_redis.

~(.venv) pip install asgi_redis

Una vez instalado, podemos escribir redis-server para que este se active. Para saber si esta activado escribimos redis-cli ping, y nos debe responder con un PONG

~(.venv) redis_cli ping
~PONG

Primero lo primero: Django no es un brujo, no sabe que vamos a utilizar Channels para que funcione. Por eso nos dirigimos a nuestro settings->INSTALLED_APPS y agregamos Channels (y nuestra nueva app snake protocol por supuesto), tambien nuestro BASE DIR lo editamos de la siguiente manera:

BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(file))))

BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
.
.
.
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # nuestra app sobre la que trabajaremos
    'snake_protocol',
    # nuestra hermosa herramienta channels
    'channels',

]

Primero recordemos como funciona Django (modelo vista controlador)

Lo que haremos es agregar una capa intermedia que reciba la petición, pero que la procese de manera asíncrona y que nos avise cuando la termine.

Ok y te podrías preguntar, ¿esto qué quiere decir? Pues es bastante sencillo: para que sobrecargar nginx, si puedo procesar acciones extra en segundo plano.

Bien, ahora vamos a nuestro proyecto al cual vamos a agregar una nueva URL el nuestro archivo de urls.py

from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^', include("snake_protocol.urls",namespace="index")),
]

Pero seguramente estaras pensando: “el urls no existe en snake protocol”. Ok, tienes razón, es hora de crearlo.

from django.conf.urls import include, url

from .views  import juego

urlpatterns = [
	url(r'^$',juego.as_view(),name='juego'),
]

Una vez tenemos el archivo, declaramos la URL de nuestra vista:

from django.shortcuts import render

# Create your views here.from django.views.generic import TemplateView

classjuego(TemplateView):
	game_template="juego.html"
	defget(self,request,*args,**kwargs):
		return render(request,self.game_template)

Ahora tenemos 2 problemas:

  1. Aún no hemos declarado nuestra carpeta dónde se guarden los archivos estáticos.

  2. Tampoco dónde se guarden las plantillas.

  3. Tampoco tenemos nuetra plantilla creada (eran 3 problemas, no 2).

Primero resolvamos el más sencillo: nuestra carpeta de plantillas. Agregamos nuestro folder al nivel del proyecto con el nombre plantillas.

Ahora en nuestros settings buscamos nuestra definición de TEMPLATES y agregamos la siguiente instruccion en DIRS: os.path.join(BASE_DIR,’plantillas’)

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR,'plantillas')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

Ahora resolvemos el problema de los estáticos de manera similar: creamos la carpeta al nivel del proyecto y agregamos las siguentes líneas (pueden ser al final del settings.py, no hay problema).


STATIC_URL = '/estatico/'
STATIC_ROOT =os.path.join(os.path.dirname(BASE_DIR),"contenido_estatico")

STATICFILES_DIRS=[
    os.path.join(BASE_DIR,"estatico"),
]

Y es hora de resolver nuestro último problema: crear el bendito HTML dentro de la carpeta de plantillas con una interfaz súper básica.

<html><head><title>platzi goty (okno)title>
head>

<body><imgsrc="https://static.platzi.com/static/images/logos/platzi.3cae3cffd5ef.png"srcset="https://static.platzi.com/static/images/logos/platzi.3cae3cffd5ef.png 1x, https://static.platzi.com/static/images/logos/[email protected] 2x"style="position: absolute; left: 500px; top: 150px;"heigth=""id="platzito">body>
html>

Toma en cuenta el style en la imagen, luego lo vamos a alterar. Lo correcto es crear una nueva propiedad, y hacerlo en hojas de estilo, pero la pereza debe muertes, pero no tutoriales.

Ok, el sitio es tan básico que lo único que hace es mostrarnos el logo de platzi en el navegador.
Ahora, si intentamos correr nuestro proyecto sabes qué pasa…

¡Así es! Porque como dije hace un momento, Django no es brujo, hay que configurar Channels en nuestro settings:

# configuracion de CHANNELS
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "asgi_redis.RedisChannelLayer",  #redis como backend"CONFIG": {
            "hosts": [os.environ.get('REDIS_URL', 'redis://localhost:6379')],  # busca a redis en la direccion
        },
        "ROUTING": "platzi_snake.routing.channel_routing",  # buscar el routing
    },
}

Ahora nos podemos dar cuenta de otra peculiaridad que posee nuestro Django Channels. Nos dice que nos falta un archivo llamado routing: aquí es donde sucede la magia del SEGUNDO PLANO.

Al mismo nivel que nuestro settings, declaramos el archivo routing.py con la siguiente información:

from channels import include

channel_routing = [
	include("snake_protocol.routing.websocket_routing",path=r'^/ws_platzi'),
]

Como nos podemos dar cuenta, sigue siendo Django. Es más, es bastante sencillo confundirlo.

En el mismo nivel que este archivo, debemos crear algo que le diga a Django: ¡Hey! Manda esto a segundo plano, y lo llamaremos asgi.py(asyncronous server gateway) y le decimos que vamos a utilizar Django Channels (¿recuerdas que redis tambien era asgi?).

import os

from channels.asgi import get_channel_layer

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "platzi_snake.settings")
channel_layer = get_channel_layer()

Ok, le acabamos de decir a Django; manda lo que entre por Channels al segundo plano, pero debemos completar lo que nos indexa de nuestra URL…. ay perdón, nuestro routing…
eso por supuesto en nuestro archivo routing.py.

from channels import route
from .consumers import *


websocket_routing = [
	route("websocket.connect", ws_add),
	route("websocket.receive", ws_message),
	route("websocket.disconnect", ws_disconnect),
]

Acá declaramos 3 compuertas: la de apertura, la del mensaje y la de cerrar. Estas pueden variar.
Ahora veamos, falta algo… ¡EL CONSUMER! La vista de nuestro websocket.

Entonces pues… lo creamos dentro de nuestra aplicacion (ojo que aplicacion esta en negrita, no se crea en el proyecto. Bueno, sí se puede. Pero por orden vamos a crearlo en nuestra app). Comencemos agregando las tres definiciones que corresponden, el ws_add, ws_message, ws_disconnect.

import json
import logging
from channels import Channel, Group
from channels.sessions import channel_session


defws_add(message):
    message.reply_channel.send({"accept": True})
    Group("platzi_piton").add(message.reply_channel)


defws_message(message):try:
        data = json.loads(message['text'])
    except ValueError:
        log.debug("el formato no parece json=%s", message['text'])
        returnif data:
        reply_channel = message.reply_channel.name
    returnFalsedefws_disconnect(message):
    Group("platzi_piton").discard(message.reply_channel)

Con una URL, y como todas, es necesario colocar la casilla donde se indexa (o sea, en nuestra app snake_protocol), allí creamos el archivo websocket_routing.py.

En el ws_add creamos un grupo llamado platzi_piton que es el que escucha las peticiones del websocket.

En es_message es cuando ya recibió algo (lo modificaremos mas adelante) y el ws_disconnect es para cerrar esa conexión y que ya no siga escuchando.

Podemos utilizar nuestra vieja y confiable ./manage makemigrations, ./manage migrate

./manage runserver

Lo primero que notamos en nuestra terminal será que nuestra terminal se llena de información (más de lo normal).

platzi_snake git:(master) ./manage.py runserver
Performing system checks...

System checkidentifiedno issues (0 silenced).
March 10, 2018 - 00:02:13
Django version1.11.7, usingsettings'platzi_snake.settings'Starting Channels development serverathttp://127.0.0.1:8000/
Channel layer default (asgi_redis.core.RedisChannelLayer)
Quit the serverwith CONTROL-C.
2018-03-1000:02:13,603 - INFO - worker - Listening on channels http.request, websocket.connect, websocket.disconnect, websocket.receive
2018-03-1000:02:13,603 - INFO - worker - Listening on channels http.request, websocket.connect, websocket.disconnect, websocket.receive
2018-03-1000:02:13,605 - INFO - worker - Listening on channels http.request, websocket.connect, websocket.disconnect, websocket.receive
2018-03-1000:02:13,606 - INFO - worker - Listening on channels http.request, websocket.connect, websocket.disconnect, websocket.receive
2018-03-1000:02:13,608 - INFO - server - HTTP/2 support not enabled (install the http2 and tls Twisted extras)
2018-03-1000:02:13,609 - INFO - server - Using busy-loopsynchronousmodeon channel layer
2018-03-1000:02:13,609 - INFO - server - Listening on endpoint tcp:port=8000:interface=127.0.0.1

Es hora de la verdad: entramos a nuestro navegador y….

Ok, ok, ya casi vamos empezando. Por lo menos Channels ya esta corriendo, es un inicio, ¿no?

Es hora de jugar un poco con nuestro websocket. Así que comencemos a jugar con JavaScript.

Cuando el proyecto es grande, hay que colocar los estáticos en otra carpeta, bla bla bla bla… pero como este es pequeño, lo haremos en el mismo HTML.

Debajo del HTML en el área de script agregamos lo siguiente:

<html><head><title>platzi goty (okno)title>
head>

<body><imgsrc="https://static.platzi.com/static/images/logos/platzi.3cae3cffd5ef.png"srcset="https://static.platzi.com/static/images/logos/platzi.3cae3cffd5ef.png 1x, https://static.platzi.com/static/images/logos/[email protected] 2x"style="position: absolute; left: 500px; top: 150px;"heigth=""id="platzito"><script>var ws_scheme_dispatch = window.location.protocol == "https:" ? "wss" : "ws";
var ws_path_dispatch = ws_scheme_dispatch + '://' + window.location.host + '/ws_platzi';
console.log("Conectando a " + ws_path_dispatch)
dispatch_socket = new WebSocket(ws_path_dispatch);

if (dispatch_socket.readyState == WebSocket.OPEN) dispatch_socket.onopen();



document.onkeypress =  mueve_el_platzi;
functionmueve_el_platzi(e){
	var x = event.which || event.keyCode;
	if (x==119||x==87){muevelo_baby("W")}
	elseif(x==83||x==115){muevelo_baby("S")}
	elseif(x==65||x==97){muevelo_baby("A")}
	elseif(x==68||x==100){muevelo_baby("D")}
}



functionmuevelo_baby(letra){
	var message = {
        action: "muevelo",
        direccion: letra,
    };
    dispatch_socket.send(JSON.stringify(message));
}


script>

body>
html>

Lo que estamos haciendo a continuación es una conexión con el websocket creado en Channels, lo imprimimos con el console.log, y le decimos que escuche cuando este abierto. Para comprobar que todo bien, podemos refrescar nuestro navegador y en las herramientas de desarrollador podemos ver que esta sucediendo.

Ahora sí, ya es momento de hacer magia. Primero lo primero, creas una funcion que le hable al websocket(que le envie una informacion de cualquier tipo), y que tal si esta es un keypress en cualquer parte del teclado?, en fin, es un juego, no? pues es hora de recurrir a nuestro amigo el codigo ascii… wiiii!!!…


document.onkeypress =  mueve_el_platzi;
function mueve_el_platzi(e){
	var x = event.which || event.keyCode;if (x==119||x==87){muevelo_baby("W")}
	elseif(x==83||x==115){muevelo_baby("S")}
	elseif(x==65||x==97){muevelo_baby("A")}
	elseif(x==68||x==100){muevelo_baby("D")}
}

primero lo primero, nuestra funcion esta declarada para que en el momento de presionar una tecla, esta se active, ¿como va a identificar la tecla?, gracias a nuestro a migo el codigo ascii, lo convertimos a numero, y si esta esta en mayuscula o minuscula la lee, luego la envia a una funcion llamada muevelo baby que lo convierte en un paquete y lo manda por nuestro socket dispatch_socket, pero… hace algo? PORSUPUESTO QUE NO!,

pero es hora de interactuar con channels

lo primero que hacemos, es ir a nuestro ws_message y agregamos las siguientes lineas:

import json
import logging
from channels import Channel, Group
from channels.sessions import channel_session


defws_add(message):
    message.reply_channel.send({"accept": True})
    Group("platzi_piton").add(message.reply_channel)


defws_message(message):try:
        data = json.loads(message['text'])
    except ValueError:
        log.debug("el formato no parece json=%s", message['text'])
        returnif data:
        reply_channel = message.reply_channel.name
        if data['direccion'] == "W": 
        	direccion(0,-1,reply_channel)
        if data['direccion'] == "S":
        	direccion(0,1,reply_channel)  	
        if data['direccion'] == "A":
        	direccion(-1,0,reply_channel)  	
        if data['direccion'] == "D":
        	direccion(1,0,reply_channel)  	
    returnFalsedefws_disconnect(message):
    Group("platzi_piton").discard(message.reply_channel)


le estamos preguntando a channels

si lo que recibio fue una letra de las validas, dependiendo de la letra que envie una instrucción (+1, -1 o 0) a una funcion llamada direccion, que contiene lo siguiente(lo colocamos en una nueva funcion que le llamaremos direccion, debajo de ws_disconnect):

defdireccion(x,y,reply_channel):if reply_channel isnotNone:
        # Channel(reply_channel).send({
        Group("platzi_piton").send({
            "text": json.dumps ({
              "EJE_X": x,
              "EJE_Y": y,
            })
        })

aca channels nos esta devolviendo por el canal donde lo mandamos (reply channel) el resultado que necesitamos

y con eso ya teneoms para regresar a nuestro javascript!

de manera super primitiva, declaramos variables globales

var EJE_X=500;
var EJE_Y=150;

dispatch_socket.onmessage = function(e) {
	var data =JSON.parse(e.data);
	EJE_X = EJE_X+parseInt(data.EJE_X)*5
	EJE_Y = EJE_Y+parseInt(data.EJE_Y)*5document.getElementById('platzito').style.left=EJE_X+"px";
	document.getElementById('platzito').style.top=EJE_Y+"px";
} 

y ahora viene un dato importante!!!… para correr de manera correcta channels necesitamos 2 terminales(al menos en maquina local), y ambas con el entorno virtual activo, en cada una corremos distinstas instrucciones:

en una corremos el worker ( rutas de channels) y en otra corremos el servidor SIN EL WORKER, nos mostrara algo similar a esto:

./manage.py runserver --noworker

y en la otra

./manage.py runworker

y por supuesto!, ya podemos visitar a nuestro amigo el logo de platzi para verlo como se mueve!(utilizando las teclas W, A , S , D

haz de estar pensando: QUEE! tanto para esto, LO PUDE HABER HECHO EN JAVASCRIPT!!!.. pues… si, la veradd si 😦 pero hey, que te parece si subimos la apuesta?

por ahora no hemos hecho nada que no se pueda hacer con simplemente javascript y en front-end. pero que te parece si hacemos que se pueda manipular desde otro navegador LA MISMA IMAGEN

puedes imaginar un multiplayer game online, realtime chat, o algo mas que te de tu imaginacion, los cambios son pocos, pero esenciales.

primero lo primero: nos dirigimos a nuestro archivo de channels, comentamos la linea Channel(reply_channel).send({ y agregamos el grupo que habiamos creado previamente)

defdireccion(x,y,reply_channel):if reply_channel isnotNone:
        # Channel(reply_channel).send({
        Group("platzi_piton").send({
            "text": json.dumps ({
              "EJE_X": x,
              "EJE_Y": y,
            })
        })

ahora, si abrimos 2 navegadores, ( o uno en incognito y el otro en navegación normal) podemos ver como este puede ser controlado desde distintos puntos

claro, ya podras tu agregar paredes, iconos peronalizados, y otras cosas, pero esto es solo un vistazo al mundo del realtime!…

si quieres saber de manera detallada como hacer realtime con channels, te dejo la documentacion


y el codigo por si quieres hecharle un vistazo 😉

pd: este es mi primer post, asi que si algo no sale bien, lo siento

Escribe tu comentario
+ 2