This project was created in order to learn more about aiohttp and asyncio, aimed to tackle the following tasks:
- Use WebSockets to subscribe to data with
aiohttp
- Create a client that has multiple functions running async to achieve the following tasks:
- Constantly read messages from the WebSocket
- Send messages or have a scheduled refresh task
- Gracefully shut down all tasks when one task fails or a websocket connection is closed
- Create a server that is very simplified and minimalistic, but supports chat rooms with
aiohttp
.
The server is temporarily available through Azure: aiohttp-chat.norwayeast.cloudapp.azure.com:8080
.
Install the environment and requirements:
poetry install
(Alternatively use pip install aiohttp[speedups]
and pip install aioconsole
in your own environment)
client_echo_example.py
is an example of a clean, simple client that:
- Establishes a connection to the websocket server
- Create two tasks:
- Infinite loop that sends messages to the server every 15 second
- Print every incoming message (Messages returned from the echo server)
- Properly shut down if one task raises an exception or is completed
The server is mixed together in server.py
, but the ws_echo
-function is the view that is used.
For more information about the actual chat server and how it works, please have a look at the #server explanation section.
The chat is a bit more complicated than the echo example, and requires one to know the server API in order to completely understand it. For an example on how to write your own client, see #Usage.
You will not receive the broadcast message about your changes, only your confirmation or error.
Change Nick:
- Input:
{'action': 'set_nick', 'nick': '<my nickname>'}
- Fail:
{'action': 'set_nick', 'success': False, 'message': 'Nickname is already in use'}
- OK:
{'action': 'set_nick', 'success': True, 'message': ''}
Join a room:
- Input:
{'action': 'join_room', 'room': '<room name>'}
- Fail:
{'action': 'join_room', 'success': False, 'message': 'Name already in use in this room.'}
- OK:
{'action': 'join_room', 'success': True, 'message': ''}
Send a message:
- Input:
{'action': 'chat_message', 'message': '<my message>'}
- OK:
{'action': 'chat_message', 'success': True, 'message': '<chat_message>'}
Room user list:
- Input:
{'action': 'user_list', 'room': '<room_name>'}
- OK:
{'action': 'user_list', 'success': True, 'room': '<room_name>', 'users': ['<user1>', '<user2>']}
Bodies this server may broadcast to your client at any time:
- When your client is connecting:
{'action': 'connecting', 'room': room, 'user': user}
- When someone joins the room:
{'action': 'joined', 'room': room, 'user': user}
- When someone leaves the room:
{'action': 'left', 'room': room, 'user': user, 'shame': False}
- When someone disconnects without using
.close()
(e.g. using CTRL+C to stop their client):{'action': 'left', 'room': room, 'user': user, 'shame': True}
- When someone changes their nick name:
{'action': 'nick_changed', 'room': room, 'from_user': user, 'to_user': user}
- When someone sends a message:
{'action': 'chat_message', 'message': message, 'user': user}
Create a client that:
- Connects to the public websocket server.
- Joins the room
math
. - Changes it's nick name.
- Fetches the user list in
math
and print's it in nicely. - Reads broadcast messages.
- Whenever a user asks a basic math question (e.g.
1 + 1
or1 * 10
), answer that user with a message. There is already a bot running asking questions every 10 seconds. 6. Example:
MathStudent:1 + 1
Your client:@MathStudent: 2
- Disconnect properly. My bot will laugh at you if you don't.
Bonus tasks:
- Use
aioconsole
to have a terminal chat. This can be a bit buggy, but ask me if you need help. - Write a website with JavaScript to utilize the websocket. Start off by reading messages, then expand.
Please ask any questions if you have them.
This is intended to get you started writing your own client.
For a short list of the API see API Simplified
- Connect via
ws://0.0.0.0:8080/chat
. Inaiohttp
this can be done like this:
from aiohttp import ClientSession
async with ClientSession() as session:
async with session.ws_connect('ws://0.0.0.0:8080/chat', ssl=False) as ws:
# ...
See client_echo_example.py
for an example.
-
Set a nick name. Your nick will be a random nickname by default. (E.g.
User1234
).
This can be called multiple times to change your nick name. Usage:- Change nick json body:
{'action': 'set_nick', 'nick': '<my nickname>'}
- If nickname is rejected, you will get an error message:
{'action': 'set_nick', 'success': False, 'message': 'Nickname is already in use'}
- If nickname is approved, no error will be present:
{'action': 'set_nick', 'success': True, 'message': ''}
Example code:
from aiohttp import ClientSession async with ClientSession() as session: async with session.ws_connect('ws://0.0.0.0:8080/chat', ssl=False) as ws: await ws.send_json({'action': 'set_nick', 'nick': 'Jonas'})
- Change nick json body:
-
Join a chat room. By default you join the
default
chat room.
This can be called multiple times to change room. Usage:- Change room json body:
{'action': 'join_room', 'room': '<room name>'}
- If everything is OK, no error will be present:
{'action': 'join_room', 'success': True, 'message': ''}
Example code:
await ws.send_json({'action': 'join_room', 'room': 'test'})
- Change room json body:
-
Chat!
NB: The body returned on a sent message is not the same as a message received from another person.
Usage:- Done by sending a body like this:
{'action': 'chat_message', 'message': '<my message>'}
- If everything is OK, this message will be returned:
{'action': 'chat_message', 'success': True, 'message': '<chat_message>'}
Example code:
await websocket.send_json({'action': 'chat_message', 'message': 'Hello world!'})
- Done by sending a body like this:
-
Ask for user list of a room
Usage:- Ask for user list body:
{'action': 'user_list', 'room': '<room_name>'}
- Body retrieved:
{'action': 'user_list', 'success': True, 'room': '<room_name>', 'users': ['<user1>', '<user2>']}
Example code:
await ws.send_json({'action': 'user_list', 'room': 'test'})
- Ask for user list body:
-
Disconnect
- With aiohttp close the connection normally: websocket.close()
- OR Send a close code
All messages on your actions will return with a 'success': True/False
.
The client.py
utilizes all these APIs. It has also included a way to interactively chat with other clients
through the terminal using aioconsole
. How ever, it's not very clean to use, as it's cluttered with log messages.
To get rid of the clutter, simply edit the logger to be WARNING
instead.
To spawn a server in aiohttp
one simply defines an application, what should happen on shut down and add routes to it.
In server.py
we have added two routes:
app = web.Application()
# ...
app.add_routes([web.get('/echo', handler=ws_echo)]) # `ws_echo` handles this request.
app.add_routes([web.get('/chat', handler=ws_chat)]) # `ws_chat` handles this request
# ...
This exposes two endpoints:
ws://0.0.0.0:8080/echo
ws://0.0.0.0:8080/chat
We'll focus on the echo
endpoint in this section for simplicity, and then later add some of the concepts
needed in order to add broadcasting to all clients later.
I will refer to the ws_echo(request: Request)
function as the view
. The view should always take one input parameter,
which is the request
. The request
will contain information about the request, as well as the app
that we created
before.
First, we create an empty WebSocketResponse()
, and check that the request that hit the view is an actual websocket
request (Remember, full code with doc strings can be found in server.py
):
async def ws_echo(request: Request) -> web.WebSocketResponse:
websocket = web.WebSocketResponse() # Create a websocket response object
# Check that everything is OK, if it's not, close the connection.
ready = websocket.can_prepare(request=request)
if not ready:
await websocket.close(code=WSCloseCode.PROTOCOL_ERROR)
await websocket.prepare(request) # Load it with the request object
# ...
If can_prepare
returns True, we know that prepare
will not fail. If prepare
returns False
,
we simply close the connection.
In order to echo the message, we write a simple async for
-loop, where we check that the incoming message is a
websocket message (this step can be skipped, but helps PyCharm understand the object type we're dealing with),
and then that the type is of WSMsgType.text
. We can then load it with .json()
and read data as normal.
In order to send a message to the client, we use send_json()
. This must be awaited, as send_json()
is a coroutine.
# ...
await websocket.prepare(request) # Load it with the request object
async for message in websocket: # For each message in the websocket connection
if isinstance(message, WSMessage):
if message.type == web.WSMsgType.text: # If it's a text, process it as a message
message_json = message.json()
logger.info('> Received: %s', message_json)
echo = {'echo': message_json}
await websocket.send_json(echo) # Send back the message
logger.info('< Sent: %s', echo)
# WebSocketResponse handles close, ping, pong etc. by default
return websocket
Please note that async for
is forever living and could also be written like this:
message = websocket.receive()
for msg in message:
# ...
So the server is actually quite simple. The only thing needed in addition to broadcast to other WebSockets is to
add a websocket
dictionary to the app
. I did this like this:
app = web.Application()
app['websockets'] = defaultdict(dict)
I use a defaultdict
to not get a KeyError
when attempting to access an item that does not exist. It will instead
create it. This is handy since I opted for this structure, where the room name is testroom
and the username is Jonas
and Hotfix
:
{
'websockets': {
'testroom': {
'Jonas': '<Websocket connection object>',
'Hotfix': '<Websocket connection object>'
}
}
}
To store a connection to this object, I simply add it like this:
request.app['websockets'][room][user] = websocket
As we can see, I use the .app['websockets']
on the request
object. This means my views are able to see all other
connections that is currently active in the testroom
. In other words, we can do this:
for ws in request.app['websockets']['testroom'].values():
await ws.send_json({'hello': 'world'})
To get less clutter I've created a few helper functions in utils.py