Django channels — using WebSockets

Jan. 6, 2021

  • server1.jpg Back-End

Django channels — using WebSockets

-two-way communication between web-server and client-page-

Http protocol follow the strict procedural schema: client request- server response. The server cannot send any real time update information to client page, unless the client page have predesigned ajax procedures for requesting information from time to time which is not very handy and not quite “real time”. WebSockets could do that: the server could send information to client page and so, we have a bi-directional communication between server and client.

Let’s begin by setting up a Django project using WebSockets.

python -m venv env
source env/bin/activate
pip install django
pip install channels       #asyncronous view support
mkdir channels
cd channels
django-admin startproject config .

This is our starting project (based on Django 3.1.4):

channels/
├── config
│   ├── asgi.py
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

Before installing channels, be sure you have installed python3-devel package, otherwise installation will fail with the error “Python.h: No such file or directory”.

After adding to ‘/config/settings.py’

INSTALLED_APPS = [
    ...
    channels’,
]...
ASGI_APPLICATION = 'config.asgi.application'

and running

python manage.py runserver

we will see that we already have a Django ASGI enabled application:

...
Django version 3.1.4, using settings 'config.settings'
Starting ASGI/Channels version 3.0.3 development server at http://127.0.0.1:8000/
...

Channels needs to use Redis (a Remote Dictionary Service, an in-memory data structure store, used as a database, cache). First, add in python environment

pip install channels-redis #channels interface with redis

Redis could be available as a docker service — after installing docker, start redis with:

sudo systemctl enable docker
sudo docker run -p 6379:6379 -d redis:5

or as an individual service, with some methods of installation :

1)Redis server

yum install redis              # on Fedora
sudo systemctl enable redis

2) Python method redis-server install

pip install redis-serverIn a python terminal:
>>> import redis_server
>>> redis_server.REDIS_SERVER_PATH 
/home/mihai/all/data/work2020/env/lib64/python3.9/site-packages/redis_server/bin/redis-server

In a shell terminal start 'redis-server' as a background process:
/home/mihai/all/data/work2020/env/lib64/python3.9/site-packages/redis_server/bin/redis-server &

3) or you could install from source.

To verify that redis is active and listening on port 6379 , we can use:

ss -an | grep 6379

tcp   LISTEN     0      511                                                                                    0.0.0.0:6379               0.0.0.0:*            
tcp   LISTEN     0      511                                                                                       [::]:6379                  [::]:*

or test redis by running a python script (see docs): verif_channels.py

import os, sys, django

project_path = "."
project_settings = "config"if project_path == "":
    sys.path.append(os.getcwd())
else:
    sys.path.append(project_path)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", project_settings + ".settings")
django.setup()

import channels.layers

channel_layer = channels.layers.get_channel_layer()
from asgiref.sync import async_to_sync
async_to_sync(channel_layer.send)('test_channel', {'type': 'hello'})
ret = async_to_sync(channel_layer.receive)('test_channel')
print(ret)

if(ret == {'type': 'hello'}):
    print("Ok: redis is working")
else:
    print("Error: redis not working")

At this point, we need to inform django project to use redis by adding in ‘/config/settings.py’:

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

Let’s also ad an app as a playground space.

python manage.py startapp app

Make ‘/config/urls.py’ as:

from django.contrib import admin
from django.urls import path, includeurlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('app.urls')),
]

And let to be ‘/app/urls.py’

from django.urls import path
from . import viewsurlpatterns = [
    path('', views.home, name = 'home'),
]

add to ‘/app/views.py’

from django.http import HttpResponsedef home(request):
    return render(request, 'app/index.html', {})

and app to ‘/config/settings.py’

INSTALLED_APPS = [
    ...
    'app',
]

create /app/templates/app/index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>WebSockets!</title>
</head>
<body>
    <h1>Hello, WebSockets!</h1>
</body></html>

To suppress warning messages, run also

python manage.py migrate

Now, we have a minimal Django project frame to dive in WebSockets “adventure” communication. (localhost:8000)

Consumers.py is the WebSockets equivalent of views.py procedures.

A simple syncron consumer will be like that:

class EchoConsumer(WebsocketConsumer):    def connect(self):
        self.accept()
        self.send(text_data='connected')    def receive(self, *, text_data):
        print(text_data)
        self.send(text_data="echo: "+text_data)    def disconnect(self, message):
        print('diconnect')
The same consumer, defined as asyncron, has the structure:
class AsyncEchoConsumer(AsyncWebsocketConsumer):    async def connect(self):
        self.n  = 0
        await self.accept()
        await self.send(text_data='connected')    async def receive(self, *, text_data):
        self.n += 1
        print(text_data)
        await self.send(text_data="echo: "+ str(self.n) + " " + text_data)    async def disconnect(self, message):
        print('diconnect')

Corresponding WebSocket client, launched in frontend (html page) has the following structure:

...
  <textarea id="display" rows="10" cols="50"></textarea>
  <input id="submit" type="button" value="Send">

<script>

        //echo
        const echoSocket = new WebSocket(
            'ws://'
            + window.location.host
            + '/ws/echo/'
        );

        echoSocket.onmessage = function(e) {
            textarea = document.querySelector('#display');
            textarea.value += (e.data + '\n');
            //scroll to end
            textarea.scrollTop = textarea.scrollHeight;
        };  

        echoSocket.onopen = function(){
            echoSocket.send("open conn");
        }

        echoSocket.onclose = function(){
            echoSocket.send("close conn");
        }

        document.querySelector('#submit').onclick = function(e) {
            echoSocket.send("hello!");      
        }

</script>

/app/routing.py is the WebSocket equivalent of /app/urls.py

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/echo/',consumers.AsyncEchoConsumer.as_asgi()),
]

For much fun is welcomed the addition of a clock refreshed by async WebSockets data refreshes from the server to the page:

client page socket:

<script>

        //clock
        const clockSocket = new WebSocket(
            'ws://'
            + window.location.host
            + '/ws/clock/'
        );

        clockSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            document.querySelector('#clock').innerHTML = (data.message);
        };

</script>

server async consumer which sends time data every 1 sec to the html page:

class clock(AsyncWebsocketConsumer):
    async def connect(self):
        await self.accept()
        await self.start_periodic_task()

    async def start_periodic_task(self):
        while True:
            bucharest = pytz.timezone('Europe/Bucharest')
            now_local = datetime.now(tz=bucharest)
            current_time = now_local.strftime("%H:%M:%S - %d/%m/%Y -") + " Europe/Bucharest"
            #now = datetime.now()
            #current_time = now.strftime("%H:%M:%S")
            await self.send(text_data=json.dumps({
               'message': current_time
            }))
            await asyncio.sleep(1)

But WebSockets would have limited usefullnes without the possibility of broadcasting message to a group of sockets. This is done by including by grouping sockets in the same “channel_layer”.

We will update our AsyncEchoConsumer like that:

class EchoConsumer(WebsocketConsumer):

    def connect(self):
        self.accept()
        self.send(text_data='connected')

    def receive(self, *, text_data):
        print(text_data)
        self.send(text_data="echo: "+text_data)

    def disconnect(self, message):
        print('disconnect')

class AsyncEchoConsumer(AsyncWebsocketConsumer):

    async def connect(self):
        self.n  = 0

        self.group_name = "mygroup"

        # Join group
        await self.channel_layer.group_add(
            self.group_name,
            self.channel_name
        )        

        await self.accept()
        await self.send(text_data='connected')

    async def receive(self, *, text_data):
        self.n += 1
        print(text_data)

        #this below will send message only to the sender socket not to group
        #await self.send(text_data="echo: "+ str(self.n) + " " + text_data)

        # Send message to group
        await self.channel_layer.group_send(
            self.group_name,
            {
                'type': 'group_message',  #this is sending pocedure name
                'message': text_data,
                'n': self.n
            }
        )
  
    # Sending message to group
    async def group_message(self, event):
        message = event['message']
        n = event['n']

        await self.send(text_data="echo: "+ str(n) + " " + message)


    async def disconnect(self, message):

        # Leave group
        await self.channel_layer.group_discard(
            self.group_name,
            self.channel_name
        )

        print('disconnect')

Add new sockets in client page:

<textarea id="display2" rows="10" cols="30"></textarea>
    <textarea id="display3" rows="10" cols="30"></textarea>

        <script>
        ...

        //echo2
        const echoSocket2 = new WebSocket(
            'ws://'
            + window.location.host
            + '/ws/echo2/'
        );

        echoSocket2.onmessage = function(e) {
            textarea = document.querySelector('#display2');
            textarea.value += (e.data + '\n');
            //scroll to end
            textarea.scrollTop = textarea.scrollHeight;
        };  
        
        //echo3
        const echoSocket3 = new WebSocket(
            'ws://'
            + window.location.host
            + '/ws/echo3/'
        );

        echoSocket3.onmessage = function(e) {
            textarea = document.querySelector('#display3');
            textarea.value += (e.data + '\n');
            //scroll to end
            textarea.scrollTop = textarea.scrollHeight;
        };  
        </script>

and new entries in routing.py:

re_path(r'ws/echo2/',consumers.AsyncEchoConsumer.as_asgi()),
re_path(r'ws/echo3/',consumers.AsyncEchoConsumer.as_asgi()),

Resource: git repository

A more real application of grouping sockets by updating (syncronizing) data in multiple different page (one common counter which is access in the same time by all users, just simple app without any authentication system) is below:

counter-channels git repository


Return to home