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
|
.. _chat_tutorial:
Tutorial: Building a basic chat server
======================================
In this tutorial we will build a very basic chat server. It will allow
anyone to send messages to everyone else currently connected to the
server.
This tutorial is meant to serve as an introduction to WebSockets in
Quart. If you want to skip to the end the code is on `Github
<https://github.com/pallets/quart/tree/main/examples/chat>`_.
1: Creating the project
-----------------------
We need to create a project for our chat server, I like to use `Poetry
<https://python-poetry.org>`_ to do this. Poetry is installed via pip
(or via `Brew <https://brew.sh/>`_):
.. code-block:: console
pip install poetry
We can then use Poetry to create a new chat project:
.. code-block:: console
poetry new --src chat
Our project can now be developed in the *chat* directory, and all
subsequent commands should be in run the *chat* directory.
2: Adding the dependencies
--------------------------
We only need Quart to build this simple chat server, which we can
install as a dependency of the project by running the following:
.. code-block:: console
poetry add quart
Poetry will ensure that this dependency is present and the paths are
correct by running:
.. code-block:: console
poetry install
3: Creating the app
-------------------
We need a Quart app to be our web server, which is created by the
following addition to *src/chat/__init__.py*:
.. code-block:: python
:caption: src/chat/__init__.py
from quart import Quart
app = Quart(__name__)
def run() -> None:
app.run()
To make the app easy to run we can call the run method from a poetry
script, by adding the following to *pyproject.toml*:
.. code-block:: toml
:caption: pyproject.toml
[tool.poetry.scripts]
start = "chat:run"
Which allows the following command to start the app:
.. code-block:: console
poetry run start
4: Serving the UI
-----------------
When users visit our chat website we will need to show them a UI which
they can use to enter and receive messages. The following HTML
template should be added to *src/chat/templates/index.html*:
.. code-block:: html
:caption: src/chat/templates/index.html
<script type="text/javascript">
const ws = new WebSocket(`ws://${location.host}/ws`);
ws.addEventListener('message', function (event) {
const li = document.createElement("li");
li.appendChild(document.createTextNode(event.data));
document.getElementById("messages").appendChild(li);
});
function send(event) {
const message = (new FormData(event.target)).get("message");
if (message) {
ws.send(message);
}
event.target.reset();
return false;
}
</script>
<div style="display: flex; height: 100%; flex-direction: column">
<ul id="messages" style="flex-grow: 1; list-style-type: none"></ul>
<form onsubmit="return send(event)">
<input type="text" name="message" minlength="1" />
<button type="submit">Send</button>
</form>
</div>
This is a very basic UI both in terms of the styling, but also as
there is no error handling for the WebSocket.
We can now serve this template for the root path i.e. ``/`` by adding
the following to *src/chat/__init__.py*:
.. code-block:: python
from quart import render_template
@app.get("/")
async def index():
return await render_template("index.html")
5: Building a broker
--------------------
Before we can add the websocket route we need to be able to pass
messages from one connected client to another. For this we will need a
message-broker. To start we'll build our own in memory broker by
adding the following to *src/chat/broker.py*:
.. code-block:: python
:caption: src/chat/broker.py
import asyncio
from typing import AsyncGenerator
from quart import Quart
class Broker:
def __init__(self) -> None:
self.connections = set()
async def publish(self, message: str) -> None:
for connection in self.connections:
await connection.put(message)
async def subscribe(self) -> AsyncGenerator[str, None]:
connection = asyncio.Queue()
self.connections.add(connection)
try:
while True:
yield await connection.get()
finally:
self.connections.remove(connection)
This ``Broker`` has a publish-subscibe pattern based interface, with
clients expected to publish messages to other clients whilst
subscribing to any messages sent.
6: Implementing the websocket
-----------------------------
We can now implement the websocket route, by adding the following to
*src/chat/__init__.py*:
.. code-block:: python
:caption: src/chat/__init__.py
import asyncio
from quart import websocket
from chat.broker import Broker
broker = Broker()
async def _receive() -> None:
while True:
message = await websocket.receive()
await broker.publish(message)
@app.websocket("/ws")
async def ws() -> None:
try:
task = asyncio.ensure_future(_receive())
async for message in broker.subscribe():
await websocket.send(message)
finally:
task.cancel()
await task
The ``_receive`` coroutine must run as a separate task to ensure that
sending and receiving run concurrently. In addition this task must be
properly cancelled and cleaned up.
When the user disconnects a CancelledError will be raised breaking the
while loops and triggering the finally blocks.
7: Testing
----------
To test our app we need to check that messages sent via the websocket
route are echoed back. This is done by adding the following to
*tests/test_chat.py*:
.. code-block:: python
:caption: tests/test_chat.py
import asyncio
from quart.testing.connections import TestWebsocketConnection as _TestWebsocketConnection
from chat import app
async def _receive(test_websocket: _TestWebsocketConnection) -> str:
return await test_websocket.receive()
async def test_websocket() -> None:
test_client = app.test_client()
async with test_client.websocket("/ws") as test_websocket:
task = asyncio.ensure_future(_receive(test_websocket))
await test_websocket.send("message")
result = await task
assert result == "message"
As the test is an async function we need to install `pytest-asyncio
<https://github.com/pytest-dev/pytest-asyncio>`_ by running the
following:
.. code-block:: console
poetry add --dev pytest-asyncio
Once installed it needs to be configured by adding the following to
*pyproject.toml*:
.. code-block:: toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
Finally we can run the tests via this command:
.. code-block:: console
poetry run pytest tests/
If you are running this in the Quart example folder you'll need to add
a ``-c pyproject.toml`` option to prevent pytest from using the Quart
pytest configuration.
8: Summary
----------
The message-broker we've built so far only works in memory, which
means that messages are only shared with users connected to the same
server instance. To share messages across server instances we need to
use a third party broker, such as redis via the `aioredis
<https://aioredis.readthedocs.io>`_ library which supports a pub/sub
interface.
|