1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294
|
Integrate with Django
=====================
If you're looking at adding real-time capabilities to a Django project with
WebSocket, you have two main options.
1. Using Django Channels_, a project adding WebSocket to Django, among other
features. This approach is fully supported by Django. However, it requires
switching to a new deployment architecture.
2. Deploying a separate WebSocket server next to your Django project. This
technique is well suited when you need to add a small set of real-time
features — maybe a notification service — to an HTTP application.
.. _Channels: https://channels.readthedocs.io/
This guide shows how to implement the second technique with websockets. It
assumes familiarity with Django.
Authenticate connections
------------------------
Since the websockets server runs outside of Django, we need to integrate it
with ``django.contrib.auth``.
We will generate authentication tokens in the Django project. Then we will
send them to the websockets server, where they will authenticate the user.
Generating a token for the current user and making it available in the browser
is up to you. You could render the token in a template or fetch it with an API
call.
Refer to the topic guide on :doc:`authentication <../topics/authentication>`
for details on this design.
Generate tokens
...............
We want secure, short-lived tokens containing the user ID. We'll rely on
`django-sesame`_, a small library designed exactly for this purpose.
.. _django-sesame: https://github.com/aaugustin/django-sesame
Add django-sesame to the dependencies of your Django project, install it, and
configure it in the settings of the project:
.. code-block:: python
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"sesame.backends.ModelBackend",
]
(If your project already uses another authentication backend than the default
``"django.contrib.auth.backends.ModelBackend"``, adjust accordingly.)
You don't need ``"sesame.middleware.AuthenticationMiddleware"``. It is for
authenticating users in the Django server, while we're authenticating them in
the websockets server.
We'd like our tokens to be valid for 30 seconds. We expect web pages to load
and to establish the WebSocket connection within this delay. Configure
django-sesame accordingly in the settings of your Django project:
.. code-block:: python
SESAME_MAX_AGE = 30
If you expect your web site to load faster for all clients, a shorter lifespan
is possible. However, in the context of this document, it would make manual
testing more difficult.
You could also enable single-use tokens. However, this would update the last
login date of the user every time a WebSocket connection is established. This
doesn't seem like a good idea, both in terms of behavior and in terms of
performance.
Now you can generate tokens in a ``django-admin shell`` as follows:
.. code-block:: pycon
>>> from django.contrib.auth import get_user_model
>>> User = get_user_model()
>>> user = User.objects.get(username="<your username>")
>>> from sesame.utils import get_token
>>> get_token(user)
'<your token>'
Keep this console open: since tokens expire after 30 seconds, you'll have to
generate a new token every time you want to test connecting to the server.
Validate tokens
...............
Let's move on to the websockets server.
Add websockets to the dependencies of your Django project and install it.
Indeed, we're going to reuse the environment of the Django project, so we can
call its APIs in the websockets server.
Now here's how to implement authentication.
.. literalinclude:: ../../example/django/authentication.py
:caption: authentication.py
Let's unpack this code.
We're calling ``django.setup()`` before doing anything with Django because
we're using Django in a `standalone script`_. This assumes that the
``DJANGO_SETTINGS_MODULE`` environment variable is set to the Python path to
your settings module.
.. _standalone script: https://docs.djangoproject.com/en/stable/topics/settings/#calling-django-setup-is-required-for-standalone-django-usage
The connection handler reads the first message received from the client, which
is expected to contain a django-sesame token. Then it authenticates the user
with :func:`~sesame.utils.get_user`, the API provided by django-sesame for
`authentication outside a view`_.
.. _authentication outside a view: https://django-sesame.readthedocs.io/en/stable/howto.html#outside-a-view
If authentication fails, it closes the connection and exits.
When we call an API that makes a database query such as
:func:`~sesame.utils.get_user`, we wrap the call in :func:`~asyncio.to_thread`.
Indeed, the Django ORM doesn't support asynchronous I/O. It would block the
event loop if it didn't run in a separate thread.
Finally, we start a server with :func:`~websockets.asyncio.server.serve`.
We're ready to test!
Download :download:`authentication.py <../../example/django/authentication.py>`,
make sure the ``DJANGO_SETTINGS_MODULE`` environment variable is set properly,
and start the websockets server:
.. code-block:: console
$ python authentication.py
Generate a new token — remember, they're only valid for 30 seconds — and use
it to connect to your server. Paste your token and press Enter when you get a
prompt:
.. code-block:: console
$ websockets ws://localhost:8888/
Connected to ws://localhost:8888/
> <your token>
< Hello <your username>!
Connection closed: 1000 (OK).
It works!
If you enter an expired or invalid token, authentication fails and the server
closes the connection:
.. code-block:: console
$ websockets ws://localhost:8888/
Connected to ws://localhost:8888.
> not a token
Connection closed: 1011 (internal error) authentication failed.
You can also test from a browser by generating a new token and running the
following code in the JavaScript console of the browser:
.. code-block:: javascript
websocket = new WebSocket("ws://localhost:8888/");
websocket.onopen = (event) => websocket.send("<your token>");
websocket.onmessage = (event) => console.log(event.data);
If you don't want to import your entire Django project into the websockets
server, you can create a simpler Django project with ``django.contrib.auth``,
``django-sesame``, a suitable ``User`` model, and a subset of the settings of
the main project.
Stream events
-------------
We can connect and authenticate but our server doesn't do anything useful yet!
Let's send a message every time a user makes an action in the admin. This
message will be broadcast to all users who can access the model on which the
action was made. This may be used for showing notifications to other users.
Many use cases for WebSocket with Django follow a similar pattern.
Set up event stream
...................
We need an event stream to enable communications between Django and websockets.
Both sides connect permanently to the stream. Then Django writes events and
websockets reads them. For the sake of simplicity, we'll rely on `Redis
Pub/Sub`_.
.. _Redis Pub/Sub: https://redis.io/topics/pubsub
The easiest way to add Redis to a Django project is by configuring a cache
backend with `django-redis`_. This library manages connections to Redis
efficiently, persisting them between requests, and provides an API to access
the Redis connection directly.
.. _django-redis: https://github.com/jazzband/django-redis
Install Redis, add django-redis to the dependencies of your Django project,
install it, and configure it in the settings of the project:
.. code-block:: python
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
},
}
If you already have a default cache, add a new one with a different name and
change ``get_redis_connection("default")`` in the code below to the same name.
Publish events
..............
Now let's write events to the stream.
Add the following code to a module that is imported when your Django project
starts. Typically, you would put it in a :download:`signals.py
<../../example/django/signals.py>` module, which you would import in the
``AppConfig.ready()`` method of one of your apps:
.. literalinclude:: ../../example/django/signals.py
:caption: signals.py
This code runs every time the admin saves a ``LogEntry`` object to keep track
of a change. It extracts interesting data, serializes it to JSON, and writes
an event to Redis.
Let's check that it works:
.. code-block:: console
$ redis-cli
127.0.0.1:6379> SELECT 1
OK
127.0.0.1:6379[1]> SUBSCRIBE events
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "events"
3) (integer) 1
Leave this command running, start the Django development server and make
changes in the admin: add, modify, or delete objects. You should see
corresponding events published to the ``"events"`` stream.
Broadcast events
................
Now let's turn to reading events and broadcasting them to connected clients.
We need to add several features:
* Keep track of connected clients so we can broadcast messages.
* Tell which content types the user has permission to view or to change.
* Connect to the message stream and read events.
* Broadcast these events to users who have corresponding permissions.
Here's a complete implementation.
.. literalinclude:: ../../example/django/notifications.py
:caption: notifications.py
Since the ``get_content_types()`` function makes a database query, it is
wrapped inside :func:`asyncio.to_thread()`. It runs once when each WebSocket
connection is open; then its result is cached for the lifetime of the
connection. Indeed, running it for each message would trigger database queries
for all connected users at the same time, which would hurt the database.
The connection handler merely registers the connection in a global variable,
associated to the list of content types for which events should be sent to
that connection, and waits until the client disconnects.
The ``process_events()`` function reads events from Redis and broadcasts them to
all connections that should receive them. We don't care much if a sending a
notification fails. This happens when a connection drops between the moment we
iterate on connections and the moment the corresponding message is sent.
Since Redis can publish a message to multiple subscribers, multiple instances
of this server can safely run in parallel.
Does it scale?
--------------
In theory, given enough servers, this design can scale to a hundred million
clients, since Redis can handle ten thousand servers and each server can
handle ten thousand clients. In practice, you would need a more scalable
message stream before reaching that scale, due to the volume of messages.
|