This project was created in order to learn more about aiohttp and asyncio, aimed to tackle the following tasks:
aiohttp
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:
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:
{'action': 'set_nick', 'nick': '<my nickname>'}
{'action': 'set_nick', 'success': False, 'message': 'Nickname is already in use'}
{'action': 'set_nick', 'success': True, 'message': ''}
Join a room:
{'action': 'join_room', 'room': '<room name>'}
{'action': 'join_room', 'success': False, 'message': 'Name already in use in this room.'}
{'action': 'join_room', 'success': True, 'message': ''}
Send a message:
{'action': 'chat_message', 'message': '<my message>'}
{'action': 'chat_message', 'success': True, 'message': '<chat_message>'}
Room user list:
{'action': 'user_list', 'room': '<room_name>'}
{'action': 'user_list', 'success': True, 'room': '<room_name>', 'users': ['<user1>', '<user2>']}
Bodies this server may broadcast to your client at any time:
{'action': 'connecting', 'room': room, 'user': user}
{'action': 'joined', 'room': room, 'user': user}
{'action': 'left', 'room': room, 'user': user, 'shame': False}
.close()
(e.g. using CTRL+C to stop their client):
{'action': 'left', 'room': room, 'user': user, 'shame': True}
{'action': 'nick_changed', 'room': room, 'from_user': user, 'to_user': user}
{'action': 'chat_message', 'message': message, 'user': user}
Create a client that:
math
.math
and print's it in nicely.1 + 1
or 1 * 10
), answer that user with a message.1 + 1
@MathStudent: 2
Bonus tasks:
aioconsole
to have a terminal chat. This can be a bit buggy, but ask me if you need help.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
ws://0.0.0.0:8080/chat
. In aiohttp
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:
{'action': 'set_nick', 'nick': '<my nickname>'}
{'action': 'set_nick', 'success': False, 'message': 'Nickname is already in use'}
{'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'})
Join a chat room. By default you join the default
chat room.
This can be called multiple times to change room.
Usage:
{'action': 'join_room', 'room': '<room name>'}
{'action': 'join_room', 'success': True, 'message': ''}
Example code:
await ws.send_json({'action': 'join_room', 'room': 'test'})
Chat! NB: The body returned on a sent message is not the same as a message received from another person. Usage:
{'action': 'chat_message', 'message': '<my message>'}
{'action': 'chat_message', 'success': True, 'message': '<chat_message>'}
Example code:
await websocket.send_json({'action': 'chat_message', 'message': 'Hello world!'})
Ask for user list of a room Usage:
{'action': 'user_list', 'room': '<room_name>'}
{'action': 'user_list', 'success': True, 'room': '<room_name>', 'users': ['<user1>', '<user2>']}
Example code:
await ws.send_json({'action': 'user_list', 'room': 'test'})
Disconnect
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