16

Building a Chat Server

In the previous chapter, you created a RESTful API for your project. In this chapter, you will build a chat server for students using Django Channels. Students will be able to access a different chat room for each course they are enrolled on. To create the chat server, you will learn how to serve your Django project through Asynchronous Server Gateway Interface (ASGI), and you will implement asynchronous communication.

In this chapter, you will:

  • Add Channels to your project
  • Build a WebSocket consumer and appropriate routing
  • Implement a WebSocket client
  • Enable a channel layer with Redis
  • Make your consumer fully asynchronous

The source code for this chapter can be found at https://github.com/PacktPublishing/Django-4-by-example/tree/main/Chapter16.

All Python modules used in this chapter are included in the requirements.txt file in the source code that comes along with this chapter. You can follow the instructions to install each Python module below or you can install all requirements at once with the command pip install -r requirements.txt.

Creating a chat application

You are going to implement a chat server to provide students with a chat room for each course. Students enrolled on a course will be able to access the course chat room and exchange messages in real time. You will use Channels to build this functionality. Channels is a Django application that extends Django to handle protocols that require long-running connections, such as WebSockets, chatbots, or MQTT (a lightweight publish/subscribe message transport commonly used in Internet of Things (IoT) projects).

Using Channels, you can easily implement real-time or asynchronous functionalities into your project in addition to your standard HTTP synchronous views. You will start by adding a new application to your project. The new application will contain the logic for the chat server.

You can the documentation for Django Channels at https://channels.readthedocs.io/.

Let’s start implementing the chat server. Run the following command from the project educa directory to create the new application file structure:

django-admin startapp chat

Edit the settings.py file of the educa project and activate the chat application in your project by editing the INSTALLED_APPS setting, as follows:

INSTALLED_APPS = [
    # ...
    'chat',
]

The new chat application is now active in your project.

Implementing the chat room view

You will provide students with a different chat room for each course. You need to create a view for students to join the chat room of a given course. Only students who are enrolled on a course will be able to access the course chat room.

Edit the views.py file of the new chat application and add the following code to it:

from django.shortcuts import render, get_object_or_404
from django.http import HttpResponseForbidden
from django.contrib.auth.decorators import login_required
@login_required
def course_chat_room(request, course_id):
    try:
        # retrieve course with given id joined by the current user
        course = request.user.courses_joined.get(id=course_id)
    except:
        # user is not a student of the course or course does not exist
        return HttpResponseForbidden()
    return render(request, 'chat/room.html', {'course': course})

This is the course_chat_room view. In this view, you use the @login_required decorator to prevent any non-authenticated user from accessing the view. The view receives a required course_id parameter that is used to retrieve the course with the given id.

You access the courses that the user is enrolled on through the relationship courses_joined and you retrieve the course with the given id from that subset of courses. If the course with the given id does not exist or the user is not enrolled on it, you return an HttpResponseForbidden response, which translates to an HTTP response with status 403.

If the course with the given id exists and the user is enrolled on it, you render the chat/room.html template, passing the course object to the template context.

You need to add a URL pattern for this view. Create a new file inside the chat application directory and name it urls.py. Add the following code to it:

from django.urls import path
from . import views
app_name = 'chat'
urlpatterns = [
    path('room/<int:course_id>/', views.course_chat_room,
         name='course_chat_room'),
]

This is the initial URL patterns file for the chat application. You define the course_chat_room URL pattern, including the course_id parameter with the int prefix, as you only expect an integer value here.

Include the new URL patterns of the chat application in the main URL patterns of the project. Edit the main urls.py file of the educa project and add the following line to it:

urlpatterns = [
    # ...
    path('chat/', include('chat.urls', namespace='chat')),
]

URL patterns for the chat application are added to the project under the chat/ path.

You need to create a template for the course_chat_room view. This template will contain an area to visualize the messages that are exchanged in the chat, and a text input with a submit button to send text messages to the chat.

Create the following file structure within the chat application directory:

templates/
    chat/
        room.html

Edit the chat/room.html template and add the following code to it:

{% extends "base.html" %}
{% block title %}Chat room for "{{ course.title }}"{% endblock %}
{% block content %}
  <div id="chat">
  </div>
  <div id="chat-input">
    <input id="chat-message-input" type="text">
    <input id="chat-message-submit" type="submit" value="Send">
  </div>
{% endblock %}
{% block include_js %}
{% endblock %}
{% block domready %}
{% endblock %}

This is the template for the course chat room. In this template, you extend the base.html template of your project and fill its content block. In the template, you define a <div> HTML element with the chat ID that you will use to display the chat messages sent by the user and by other students. You also define a second <div> element with a text input and a submit button that will allow the user to send messages. You add the include_js and domready blocks defined in the base.html template, which you are going to implement later, to establish a connection with a WebSocket and send or receive messages.

Run the development server and open http://127.0.0.1:8000/chat/room/1/ in your browser, replacing 1 with the id of an existing course in the database. Access the chat room with a logged-in user who is enrolled on the course. You will see the following screen:

Figure 16.1: The course chat room page

This is the course chat room screen that students will use to discuss topics within a course.

Real-time Django with Channels

You are building a chat server to provide students with a chat room for each course. Students enrolled on a course will be able to access the course chat room and exchange messages. This functionality requires real-time communication between the server and the client. The client should be able to connect to the chat and send or receive data at any time. There are several ways you could implement this feature, using AJAX polling or long polling in combination with storing the messages in your database or Redis. However, there is no efficient way to implement a chat server using a standard synchronous web application. You are going to build a chat server using asynchronous communication through ASGI.

Asynchronous applications using ASGI

Django is usually deployed using Web Server Gateway Interface (WSGI), which is the standard interface for Python applications to handle HTTP requests. However, to work with asynchronous applications, you need to use another interface called ASGI, which can handle WebSocket requests as well. ASGI is the emerging Python standard for asynchronous web servers and applications.

You can find an introduction to ASGI at https://asgi.readthedocs.io/en/latest/introduction.html.

Django comes with support for running asynchronous Python through ASGI. Writing asynchronous views is supported since Django 3.1 and Django 4.1 introduces asynchronous handlers for class-based views. Channels builds upon the native ASGI support available in Django and provides additional functionalities to handle protocols that require long-running connections, such as WebSockets, IoT protocols, and chat protocols.

WebSockets provide full-duplex communication by establishing a persistent, open, bidirectional Transmission Control Protocol (TCP) connection between servers and clients. You are going to use WebSockets to implement your chat server.

You can find more information about deploying Django with ASGI at https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/.

You can find more information about Django’s support for writing asynchronous views at https://docs.djangoproject.com/en/4.1/topics/async/ and Django’s support for asynchronous class-based views at https://docs.djangoproject.com/en/4.1/topics/class-based-views/#async-class-based-views.

The request/response cycle using Channels

It’s important to understand the differences in a request cycle between a standard synchronous request cycle and a Channels implementation. The following schema shows the request cycle of a synchronous Django setup:

Figure 16.2: The Django request/response cycle

When an HTTP request is sent by the browser to the web server, Django handles the request and passes the HttpRequest object to the corresponding view. The view processes the request and returns an HttpResponse object that is sent back to the browser as an HTTP response. There is no mechanism to maintain an open connection or send data to the browser without an associated HTTP request.

The following schema shows the request cycle of a Django project using Channels with WebSockets:

Figure 16.3: The Django Channels request/response cycle

Channels replaces Django’s request/response cycle with messages that are sent across channels. HTTP requests are still routed to view functions using Django, but they get routed over channels. This allows for WebSockets message handling as well, where you have producers and consumers that exchange messages across a channel layer. Channels preserves Django’s synchronous architecture, allowing you to choose between writing synchronous code and asynchronous code, or a combination of both.

Installing Channels

You are going to add Channels to your project and set up the required basic ASGI application routing for it to manage HTTP requests.

Install Channels in your virtual environment with the following command:

pip install channels==3.0.5

Edit the settings.py file of the educa project and add channels to the INSTALLED_APPS setting as follows:

INSTALLED_APPS = [
    # ...
    'channels',
]

The channels application is now activated in your project.

Channels expects you to define a single root application that will be executed for all requests. You can define the root application by adding the ASGI_APPLICATION setting to your project. This is similar to the ROOT_URLCONF setting that points to the base URL patterns of your project. You can place the root application anywhere in your project, but it is recommended to put it in a project-level file. You can add your root routing configuration to the asgi.py file directly, where the ASGI application will be defined.

Edit the asgi.py file in the educa project directory and add the following code highlighted in bold:

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educa.settings')
django_asgi_app = get_asgi_application()
application = ProtocolTypeRouter({
    'http': django_asgi_app,
})

In the previous code, you define the main ASGI application that will be executed when serving the Django project through ASGI. You use the ProtocolTypeRouter class provided by Channels as the main entry point of your routing system. ProtocolTypeRouter takes a dictionary that maps communication types like http or websocket to ASGI applications. You instantiate this class with the default application for the HTTP protocol. Later, you will add a protocol for the WebSocket.

Add the following line to the settings.py file of your project:

ASGI_APPLICATION = 'educa.routing.application'

The ASGI_APPLICATION setting is used by Channels to locate the root routing configuration.

When Channels is added to the INSTALLED_APPS setting, it takes control over the runserver command, replacing the standard Django development server. Besides handling URL routing to Django views for synchronous requests, the Channels development server also manages routes to WebSocket consumers.

Start the development server using the following command:

python manage.py runserver

You will see output similar to the following:

Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
May 30, 2022 - 08:02:57
Django version 4.0.4, using settings 'educa.settings'
Starting ASGI/Channels version 3.0.4 development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Check that the output contains the line Starting ASGI/Channels version 3.0.4 development server. This line confirms that you are using the Channels development server, which is capable of managing synchronous and asynchronous requests, instead of the standard Django development server. HTTP requests continue to behave the same as before, but they get routed over Channels.

Now that Channels is installed in your project, you can build the chat server for courses. To implement the chat server for your project, you will need to take the following steps:

  1. Set up a consumer: Consumers are individual pieces of code that can handle WebSockets in a very similar way to traditional HTTP views. You will build a consumer to read and write messages to a communication channel.
  2. Configure routing: Channels provides routing classes that allow you to combine and stack your consumers. You will configure URL routing for your chat consumer.
  3. Implement a WebSocket client: When the student accesses the chat room, you will connect to the WebSocket from the browser and send or receive messages using JavaScript.
  4. Enable a channel layer: Channel layers allow you to talk between different instances of an application. They’re a useful part of making a distributed real-time application. You will set up a channel layer using Redis.

Let’s start by writing your own consumer to handle connecting to a WebSocket, receiving and sending messages, and disconnecting.

Writing a consumer

Consumers are the equivalent of Django views for asynchronous applications. As mentioned, they handle WebSockets in a very similar way to how traditional views handle HTTP requests. Consumers are ASGI applications that can handle messages, notifications, and other things. Unlike Django views, consumers are built for long-running communication. URLs are mapped to consumers through routing classes that allow you to combine and stack consumers.

Let’s implement a basic consumer that can accept WebSocket connections and echoes every message it receives from the WebSocket back to it. This initial functionality will allow the student to send messages to the consumer and receive back the messages it sends.

Create a new file inside the chat application directory and name it consumers.py. Add the following code to it:

import json
from channels.generic.websocket import WebsocketConsumer
class ChatConsumer(WebsocketConsumer):
    def connect(self):
        # accept connection
        self.accept()
    def disconnect(self, close_code):
        pass
    # receive message from WebSocket
    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        # send message to WebSocket
        self.send(text_data=json.dumps({'message': message}))

This is the ChatConsumer consumer. This class inherits from the Channels WebsocketConsumer class to implement a basic WebSocket consumer. In this consumer, you implement the following methods:

  • connnect(): Called when a new connection is received. You accept any connection with self.accept(). You can also reject a connection by calling self.close().
  • disconnect(): Called when the socket closes. You use pass because you don’t need to implement any action when a client closes the connection.
  • receive(): Called whenever data is received. You expect text to be received as text_data (this could also be binary_data for binary data). You treat the text data received as JSON. Therefore, you use json.loads() to load the received JSON data into a Python dictionary. You access the message key, which you expect to be present in the JSON structure received. To echo the message, you send the message back to the WebSocket with self.send(), transforming it into JSON format again through json.dumps().

The initial version of your ChatConsumer consumer accepts any WebSocket connection and echoes to the WebSocket client every message it receives. Note that the consumer does not broadcast messages to other clients yet. You will build this functionality by implementing a channel layer later.

Routing

You need to define a URL to route connections to the ChatConsumer consumer you have implemented. Channels provides routing classes that allow you to combine and stack consumers to dispatch based on what the connection is. You can think of them as the URL routing system of Django for asynchronous applications.

Create a new file inside the chat application directory and name it routing.py. Add the following code to it:

from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
    re_path(r'ws/chat/room/(?P<course_id>d+)/$', 
            consumers.ChatConsumer.as_asgi()),
]

In this code, you map a URL pattern with the ChatConsumer class that you defined in the chat/consumers.py file. You use Django’s re_path to define the path with regular expressions. You use the re_path function instead of the common path function because of the limitations of Channels’ URL routing. The URL includes an integer parameter called course_id. This parameter will be available in the scope of the consumer and will allow you to identify the course chat room that the user is connecting to. You call the as_asgi() method of the consumer class in order to get an ASGI application that will instantiate an instance of the consumer for each user connection. This behavior is similar to Django’s as_view() method for class-based views.

It is a good practice to prepend WebSocket URLs with /ws/ to differentiate them from URLs used for standard synchronous HTTP requests. This also simplifies the production setup when an HTTP server routes requests based on the path.

Edit the global asgi.py file located next to the settings.py file so that it looks like this:

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
import chat.routing
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educa.settings')
django_asgi_app = get_asgi_application()
application = ProtocolTypeRouter({
    'http': django_asgi_app,
    'websocket': AuthMiddlewareStack(
        URLRouter(chat.routing.websocket_urlpatterns)
    ),
})

In this code, you add a new route for the websocket protocol. You use URLRouter to map websocket connections to the URL patterns defined in the websocket_urlpatterns list of the chat application routing.py file. You also use AuthMiddlewareStack. The AuthMiddlewareStack class provided by Channels supports standard Django authentication, where the user details are stored in the session. Later, you will access the user instance in the scope of the consumer to identify the user who sends a message.

Implementing the WebSocket client

So far, you have created the course_chat_room view and its corresponding template for students to access the course chat room. You have implemented a WebSocket consumer for the chat server and tied it with URL routing. Now, you need to build a WebSocket client to establish a connection with the WebSocket in the course chat room template and be able to send/receive messages.

You are going to implement the WebSocket client with JavaScript to open and maintain a connection in the browser. You will interact with the Document Object Model (DOM) using JavaScript.

Edit the chat/room.html template of the chat application and modify the include_js and domready blocks, as follows:

{% block include_js %}
  {{ course.id|json_script:"course-id" }}
{% endblock %}
{% block domready %}
  const courseId = JSON.parse(
    document.getElementById('course-id').textContent
  );
  const url = 'ws://' + window.location.host +
              '/ws/chat/room/' + courseId + '/';
  const chatSocket = new WebSocket(url);
{% endblock %}

In the include_js block, you use the json_script template filter to securely use the value of course.id with JavaScript. The json_script template filter provided by Django outputs a Python object as JSON, wrapped in a <script> tag, so that you can safely use it with JavaScript. The code {{ course.id|json_script:"course-id" }} is rendered as <script id="course-id" type="application/json">6</script>. This value is then retrieved in the domready block by parsing the content of the element with id="course-id" using JSON.parse(). This is the safe way to use Python objects in JavaScript.

You can find more information about the json_script template filter at https://docs.djangoproject.com/en/4.1/ref/templates/builtins/#json-script.

In the domready block, you define an URL with the WebSocket protocol, which looks like ws:// (or wss:// for secure WebSockets, just like https://). You build the URL using the current location of the browser, which you obtain from window.location.host. The rest of the URL is built with the path for the chat room URL pattern that you defined in the routing.py file of the chat application.

You write the URL instead of building it with a resolver because Channels does not provide a way to reverse URLs. You use the current course ID to generate the URL for the current course and store the URL in a new constant named url.

You then open a WebSocket connection to the stored URL using new WebSocket(url). You assign the instantiated WebSocket client object to the new constant chatSocket.

You have created a WebSocket consumer, you have included routing for it, and you have implemented a basic WebSocket client. Let’s try the initial version of your chat.

Start the development server using the following command:

python manage.py runserver

Open the URL http://127.0.0.1:8000/chat/room/1/ in your browser, replacing 1 with the id of an existing course in the database. Take a look at the console output. Besides the HTTP GET requests for the page and its static files, you should see two lines including WebSocket HANDSHAKING and WebSocket CONNECT, like the following output:

HTTP GET /chat/room/1/ 200 [0.02, 127.0.0.1:57141]
HTTP GET /static/css/base.css 200 [0.01, 127.0.0.1:57141]
WebSocket HANDSHAKING /ws/chat/room/1/ [127.0.0.1:57144]
WebSocket CONNECT /ws/chat/room/1/ [127.0.0.1:57144]

The Channels development server listens for incoming socket connections using a standard TCP socket. The handshake is the bridge from HTTP to WebSockets. In the handshake, details of the connection are negotiated and either party can close the connection before completion. Remember that you are using self.accept() to accept any connection in the connect() method of the ChatConsumer class, implemented in the consumers.py file of the chat application. The connection is accepted, and therefore, you see the WebSocket CONNECT message in the console.

If you use the browser developer tools to track network connections, you can also see information for the WebSocket connection that has been established.

It should look like Figure 16.4:

Figure 16.4: The browser developer tools showing that the WebSocket connection has been established

Now that you can connect to the WebSocket, it’s time to interact with it. You will implement the methods to handle common events, such as receiving a message and closing the connection. Edit the chat/room.html template of the chat application and modify the domready block, as follows:

{% block domready %}
  const courseId = JSON.parse(
    document.getElementById('course-id').textContent
  );
  const url = 'ws://' + window.location.host +
              '/ws/chat/room/' + courseId + '/';
  const chatSocket = new WebSocket(url);
  chatSocket.onmessage = function(event) {
    const data = JSON.parse(event.data);
    const chat = document.getElementById('chat');
    chat.innerHTML += '<div class="message">' +
                      data.message + '</div>';
    chat.scrollTop = chat.scrollHeight;
  };
  chatSocket.onclose = function(event) {
    console.error('Chat socket closed unexpectedly');
  };
{% endblock %}

In this code, you define the following events for the WebSocket client:

  • onmessage: Fired when data is received through the WebSocket. You parse the message, which you expect in JSON format, and access its message attribute. You then append a new <div> element with the message received to the HTML element with the chat ID. This will add new messages to the chat log, while keeping all previous messages that have been added to the log. You scroll the chat log <div> to the bottom to ensure that the new message gets visibility. You achieve this by scrolling to the total scrollable height of the chat log, which can be obtained by accessing its scrollHeight attribute.
  • onclose: Fired when the connection with the WebSocket is closed. You don’t expect to close the connection, and therefore, you write the error Chat socket closed unexpectedly to the console log if this happens.

You have implemented the action to display the message when a new message is received. You need to implement the functionality to send messages to the socket as well.

Edit the chat/room.html template of the chat application and add the following JavaScript code to the bottom of the domready block:

const input = document.getElementById('chat-message-input');
const submitButton = document.getElementById('chat-message-submit');
submitButton.addEventListener('click', function(event) {
  const message = input.value;
  if(message) {
    // send message in JSON format
    chatSocket.send(JSON.stringify({'message': message}));
    // clear input
    input.innerHTML = '';
    input.focus();
  }
});

In this code, you define an event listener for the click event of the submit button, which you select by its ID chat-message-submit. When the button is clicked, you perform the following actions:

  1. You read the message entered by the user from the value of the text input element with the ID chat-message-input.
  2. You check whether the message has any content with if(message) .
  3. If the user has entered a message, you form JSON content such as {'message': 'string entered by the user'} by using JSON.stringify().
  4. You send the JSON content through the WebSocket, calling the send() method of chatSocket client.
  5. You clear the contents of the text input by setting its value to an empty string with input.innerHTML = ''.
  6. You return the focus to the text input with input.focus() so that the user can write a new message straightaway.

The user is now able to send messages using the text input and by clicking the submit button.

To improve the user experience, you will give focus to the text input as soon as the page loads so that the user can type directly in it. You will also capture keyboard keypress events to identify the Enter key and fire the click event on the submit button. The user will be able to either click the button or press the Enter key to send a message.

Edit the chat/room.html template of the chat application and add the following JavaScript code to the bottom of the domready block:

input.addEventListener('keypress', function(event) {
    if (event.key === 'Enter') {
      // cancel the default action, if needed
      event.preventDefault();
      // trigger click event on button
      submitButton.click();
    }
  });
  
input.focus();

In this code, you also define a function for the keypress event of the input element. For any key that the user presses, you check whether its key is Enter. You prevent the default behavior for this key with event.preventDefault(). If the Enter key is pressed, you fire the click event on the submit button to send the message to the WebSocket.

Outside of the event handler, in the main JavaScript code for the domready block, you give the focus to the text input with input.focus(). By doing so, when the DOM is loaded, the focus will be set on the input element for the user to type a message.

The domready block of the chat/room.html template should now look as follows:

{% block domready %}
  const courseId = JSON.parse(
    document.getElementById('course-id').textContent
  );
  const url = 'ws://' + window.location.host +
              '/ws/chat/room/' + courseId + '/';
  const chatSocket = new WebSocket(url);
  chatSocket.onmessage = function(event) {
    const data = JSON.parse(event.data);
    const chat = document.getElementById('chat');
    chat.innerHTML += '<div class="message">' +
                      data.message + '</div>';
    chat.scrollTop = chat.scrollHeight;
  };
  chatSocket.onclose = function(event) {
    console.error('Chat socket closed unexpectedly');
  };
  const input = document.getElementById('chat-message-input');
  const submitButton = document.getElementById('chat-message-submit');
  submitButton.addEventListener('click', function(event) {
    const message = input.value;
    if(message) {
      // send message in JSON format
      chatSocket.send(JSON.stringify({'message': message}));
      // clear input
      input.value = '';
      input.focus();
    }
  });
  input.addEventListener('keypress', function(event) {
    if (event.key === 'Enter') {
      // cancel the default action, if needed
      event.preventDefault();
      // trigger click event on button
      submitButton.click();
    }
  });
  
  input.focus();
{% endblock %}

Open the URL http://127.0.0.1:8000/chat/room/1/ in your browser, replacing 1 with the id of an existing course in the database. With a logged-in user who is enrolled on the course, write some text in the input field and click the SEND button or press the Enter key.

You will see that your message appears in the chat log:

Figure 16.5: The chat room page, including messages sent through the WebSocket

Great! The message has been sent through the WebSocket and the ChatConsumer consumer has received the message and has sent it back through the WebSocket. The chatSocket client has received a message event and the onmessage function has been fired, adding the message to the chat log.

You have implemented the functionality with a WebSocket consumer and a WebSocket client to establish client/server communication and can send or receive events. However, the chat server is not able to broadcast messages to other clients. If you open a second browser tab and enter a message, the message will not appear on the first tab. In order to build communication between consumers, you have to enable a channel layer.

Enabling a channel layer

Channel layers allow you to communicate between different instances of an application. A channel layer is the transport mechanism that allows multiple consumer instances to communicate with each other and with other parts of Django.

In your chat server, you plan to have multiple instances of the ChatConsumer consumer for the same course chat room. Each student who joins the chat room will instantiate the WebSocket client in their browser, and that will open a connection with an instance of the WebSocket consumer. You need a common channel layer to distribute messages between consumers.

Channels and groups

Channel layers provide two abstractions to manage communications: channels and groups:

  • Channel: You can think of a channel as an inbox where messages can be sent to or as a task queue. Each channel has a name. Messages are sent to a channel by anyone who knows the channel name and then given to consumers listening on that channel.
  • Group: Multiple channels can be grouped into a group. Each group has a name. A channel can be added or removed from a group by anyone who knows the group name. Using the group name, you can also send a message to all channels in the group.

You will work with channel groups to implement the chat server. By creating a channel group for each course chat room, the ChatConsumer instances will be able to communicate with each other.

Setting up a channel layer with Redis

Redis is the preferred option for a channel layer, though Channels has support for other types of channel layers. Redis works as the communication store for the channel layer. Remember that you already used Redis in Chapter 7, Tracking User Actions, Chapter 10, Extending Your Shop, and Chapter 14, Rendering and Caching Content.

If you haven’t installed Redis yet, you can find installation instructions in Chapter 7, Tracking User Actions.

To use Redis as a channel layer, you have to install the channels-redis package. Install channels-redis in your virtual environment with the following command:

pip install channels-redis==3.4.1

Edit the settings.py file of the educa project and add the following code to it:

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

The CHANNEL_LAYERS setting defines the configuration for the channel layers available to the project. You define a default channel layer using the RedisChannelLayer backend provided by channels-redis and specify the host 127.0.0.1 and the port 6379, on which Redis is running.

Let’s try the channel layer. Initialize the Redis Docker container using the following command:

docker run -it --rm --name redis -p 6379:6379 redis

If you want to run the command in the background (in detached mode) you can use the -d option.

Open the Django shell using the following command from the project directory:

python manage.py shell

To verify that the channel layer can communicate with Redis, write the following code to send a message to a test channel named test_channel and receive it back:

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

You should get the following output:

{'message': 'hello'}

In the previous code, you send a message to a test channel through the channel layer, and then you retrieve it from the channel layer. The channel layer is communicating successfully with Redis.

Updating the consumer to broadcast messages

Let’s edit the ChatConsumer consumer to use the channel layer. You will use a channel group for each course chat room. Therefore, you will use the course id to build the group name. ChatConsumer instances will know the group name and will be able to communicate with each other.

Edit the consumers.py file of the chat application, import the async_to_sync() function, and modify the connect() method of the ChatConsumer class, as follows:

import json
from channels.generic.websocket import WebsocketConsumer
from asgiref.sync import async_to_sync
class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = f'chat_{self.id}'
        # join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )
        # accept connection
        self.accept()
    # ...

In this code, you import the async_to_sync() helper function to wrap calls to asynchronous channel layer methods. ChatConsumer is a synchronous WebsocketConsumer consumer, but it needs to call asynchronous methods of the channel layer.

In the new connect() method, you perform the following tasks:

  1. You retrieve the course id from the scope to know the course that the chat room is associated with. You access self.scope['url_route']['kwargs ']['course_id'] to retrieve the course_id parameter from the URL. Every consumer has a scope with information about its connection, arguments passed by the URL, and the authenticated user, if any.
  2. You build the group name with the id of the course that the group corresponds to. Remember that you will have a channel group for each course chat room. You store the group name in the room_group_name attribute of the consumer.
  3. You join the group by adding the current channel to the group. You obtain the channel name from the channel_name attribute of the consumer. You use the group_add method of the channel layer to add the channel to the group. You use the async_to_sync() wrapper to use the channel layer asynchronous method.
  4. You keep the self.accept() call to accept the WebSocket connection.

When the ChatConsumer consumer receives a new WebSocket connection, it adds the channel to the group associated with the course in its scope. The consumer is now able to receive any messages sent to the group.

In the same consumers.py file, modify the disconnect() method of the ChatConsumer class, as follows:

 class ChatConsumer(WebsocketConsumer):
    # ...
    def disconnect(self, close_code):
        # leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )
    # ...

When the connection is closed, you call the group_discard() method of the channel layer to leave the group. You use the async_to_sync() wrapper to use the channel layer asynchronous method.

In the same consumers.py file, modify the receive() method of the ChatConsumer class, as follows:

class ChatConsumer(WebsocketConsumer):
    # ...
    # receive message from WebSocket
    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        # send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
            }
        )

When you receive a message from the WebSocket connection, instead of sending the message to the associated channel, you send the message to the group. You do this by calling the group_send() method of the channel layer. You use the async_to_sync() wrapper to use the channel layer asynchronous method. You pass the following information in the event sent to the group:

  • type: The event type. This is a special key that corresponds to the name of the method that should be invoked on consumers that receive the event. You can implement a method in the consumer named the same as the message type so that it gets executed every time a message with that specific type is received.
  • message: The actual message you are sending.

In the same consumers.py file, add a new chat_message() method in the ChatConsumer class, as follows:

class ChatConsumer(WebsocketConsumer):
    # ...
    # receive message from room group
    def chat_message(self, event):
        # send message to WebSocket
        self.send(text_data=json.dumps(event))

You name this method chat_message() to match the type key that is sent to the channel group when a message is received from the WebSocket. When a message with type chat_message is sent to the group, all consumers subscribed to the group will receive the message and will execute the chat_message() method. In the chat_message() method, you send the event message received to the WebSocket.

The complete consumers.py file should now look like this:

import json
from channels.generic.websocket import WebsocketConsumer
from asgiref.sync import async_to_sync
class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = f'chat_{self.id}'
        # join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )
        # accept connection
        self.accept()
    def disconnect(self, close_code):
        # leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )
    # receive message from WebSocket
    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        # send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
            }
        )
    # receive message from room group
    def chat_message(self, event):
        # send message to WebSocket
        self.send(text_data=json.dumps(event))

You have implemented a channel layer in ChatConsumer, allowing consumers to broadcast messages and communicate with each other.

Run the development server with the following command:

python manage.py runserver

Open the URL http://127.0.0.1:8000/chat/room/1/ in your browser, replacing 1 with the id of an existing course in the database. Write a message and send it. Then, open a second browser window and access the same URL. Send a message from each browser window.

The result should look like this:

Figure 16.6: The chat room page with messages sent from different browser windows

You will see that the first message is only displayed in the first browser window. When you open a second browser window, messages sent in any of the browser windows are displayed in both of them. When you open a new browser window and access the chat room URL, a new WebSocket connection is established between the JavaScript WebSocket client in the browser and the WebSocket consumer in the server. Each channel gets added to the group associated with the course ID and passed through the URL to the consumer. Messages are sent to the group and received by all consumers.

Adding context to the messages

Now that messages can be exchanged between all users in a chat room, you probably want to display who sent which message and when it was sent. Let’s add some context to the messages.

Edit the consumers.py file of the chat application and implement the following changes:

import json
from channels.generic.websocket import WebsocketConsumer
from asgiref.sync import async_to_sync
from django.utils import timezone
class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.user = self.scope['user']
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = f'chat_{self.id}'
        # join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )
        # accept connection
        self.accept()
    def disconnect(self, close_code):
        # leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )
    # receive message from WebSocket
    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        now = timezone.now()
        # send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
                'user': self.user.username,
                'datetime': now.isoformat(),
            }
        )
    # receive message from room group
    def chat_message(self, event):
        # send message to WebSocket
        self.send(text_data=json.dumps(event))

You now import the timezone module provided by Django. In the connect() method of the consumer, you retrieve the current user from the scope with self.scope['user'] and store them in a new user attribute of the consumer. When the consumer receives a message through the WebSocket, it gets the current time using timezone.now() and passes the current user and datetime in ISO 8601 format along with the message in the event sent to the channel group.

Edit the chat/room.html template of the chat application and add the following line highlighted in bold to the include_js block:

{% block include_js %}
  {{ course.id|json_script:"course-id" }}
  {{ request.user.username|json_script:"request-user" }}
{% endblock %}

Using the json_script template, you safely print the username of the request user to use it with JavaScript.

In the domready block of the chat/room.html template, add the following lines highlighted in bold:

{% block domready %}
  const courseId = JSON.parse(
    document.getElementById('course-id').textContent
  );
  const requestUser = JSON.parse(
    document.getElementById('request-user').textContent
  );
  # ...
{% endblock %}

In the new code, you safely parse the data of the element with the ID request-user and store it in the requestUser constant.

Then, in the domready block, find the following lines:

const data = JSON.parse(e.data);
const chat = document.getElementById('chat');
chat.innerHTML += '<div class="message">' +
                  data.message + '</div>';
chat.scrollTop = chat.scrollHeight;

Replace those lines with the following code:

const data = JSON.parse(e.data);
const chat = document.getElementById('chat');
const dateOptions = {hour: 'numeric', minute: 'numeric', hour12: true};
const datetime = new Date(data.datetime).toLocaleString('en', dateOptions);
const isMe = data.user === requestUser;
const source = isMe ? 'me' : 'other';
const name = isMe ? 'Me' : data.user;
chat.innerHTML += '<div class="message ' + source + '">' +
                  '<strong>' + name + '</strong> ' +
                  '<span class="date">' + datetime + '</span><br>' +
                  data.message + '</div>';
chat.scrollTop = chat.scrollHeight;

In this code, you implement the following changes:

  1. You convert the datetime received in the message to a JavaScript Date object and format it with a specific locale.
  2. You compare the username received in the message with two different constants as helpers to identify the user.
  3. The constant source gets the value me if the user sending the message is the current user, or other otherwise.
  4. The constant name gets the value Me if the user sending the message is the current user or the name of the user sending the message otherwise. You use it to display the name of the user sending the message.
  5. You use the source value as a class of the main <div> message element to differentiate messages sent by the current user from messages sent by others. Different CSS styles are applied based on the class attribute. These CSS styles are declared in the css/base.css static file.
  6. You use the username and the datetime in the message that you append to the chat log.

Open the URL http://127.0.0.1:8000/chat/room/1/ in your browser, replacing 1 with the id of an existing course in the database. With a logged-in user who is enrolled on the course, write a message and send it.

Then, open a second browser window in incognito mode to prevent the use of the same session. Log in with a different user, also enrolled on the same course, and send a message.

You will be able to exchange messages using the two different users and see the user and time, with a clear distinction between messages sent by the user and messages sent by others. The conversation between two users should look similar to the following one:

Figure 16.7: The chat room page with messages from two different user sessions

Great! You have built a functional real-time chat application using Channels. Next, you will learn how to improve the chat consumer by making it fully asynchronous.

Modifying the consumer to be fully asynchronous

The ChatConsumer you have implemented inherits from the base WebsocketConsumer class, which is synchronous. Synchronous consumers are convenient for accessing Django models and calling regular synchronous I/O functions. However, asynchronous consumers perform better, since they don’t require additional threads when handling requests. Since you are using the asynchronous channel layer functions, you can easily rewrite the ChatConsumer class to be asynchronous.

Edit the consumers.py file of the chat application and implement the following changes:

import json
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import async_to_sync
from django.utils import timezone
class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.user = self.scope['user']
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = 'chat_%s' % self.id
        # join room group
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )
        # accept connection
        await self.accept()
    async def disconnect(self, close_code):
        # leave room group
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )
    # receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        now = timezone.now()
        # send message to room group
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
                'user': self.user.username,
                'datetime': now.isoformat(),
            }
        )
    # receive message from room group
    async def chat_message(self, event):
        # send message to WebSocket
        await self.send(text_data=json.dumps(event))

You have implemented the following changes:

  1. The ChatConsumer consumer now inherits from the AsyncWebsocketConsumer class to implement asynchronous calls
  2. You have changed the definition of all methods from def to async def
  3. You use await to call asynchronous functions that perform I/O operations
  4. You no longer use the async_to_sync() helper function when calling methods on the channel layer

Open the URL http://127.0.0.1:8000/chat/room/1/ with two different browser windows again and verify that the chat server still works. The chat server is now fully asynchronous!

Integrating the chat application with existing views

The chat server is now fully implemented, and students enrolled on a course can communicate with each other. Let’s add a link for students to join the chat room for each course.

Edit the students/course/detail.html template of the students application and add the following <h3> HTML element code at the bottom of the <div class="contents"> element:

<div class="contents">
  ...
  <h3>
    <a href="{% url "chat:course_chat_room" object.id %}">
      Course chat room
    </a>
  </h3>
</div>

Open the browser and access any course that the student is enrolled on to view the course contents. The sidebar will now contain a Course chat room link that points to the course chat room view. If you click on it, you will enter the chat room:

Figure 16.8: The course detail page, including a link to the course chat room

Congratulations! You successfully built your first asynchronous application using Django Channels.

Additional resources

The following resources provide additional information related to the topics covered in this chapter:

Summary

In this chapter, you learned how to create a chat server using Channels. You implemented a WebSocket consumer and client. You also enabled communication between consumers using a channel layer with Redis and modified the consumer to be fully asynchronous.

The next chapter will teach you how to build a production environment for your Django project using NGINX, uWSGI, and Daphne with Docker Compose. You will also learn how to implement custom middleware and create custom management commands.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset