CSE 102 – Tutorial 10 – A Chat Server
Sections tagged with a green marginal line are mandatory.
Sections tagged with an orange marginal line are either more advanced or supplementary, and are therefore considered optional. They will help you deepen your understanding of the material in the course. You may skip these sections on a first pass through the tutorial, and come back to them after finishing the mandatory exercises.
Discussion of the tutorials with friends and classmates is allowed, with acknowledgment, but please read the Moodle page on “Collaboration, plagiarism, and AI”.
There are no automated tests for this tutorial, although you should nevertheless upload your files to the submission server (as usual, you can upload as many times as you like).
Introducing async networking
In this tutorial, we are going to work our way towards implementing a
simple chat server based on Transports
and Protocols from Python’s asyncio library.
Transports are classes provided by asyncio in order to abstract various kinds of communication channels. Once the communication channel is established, a transport is always paired with a protocol instance. The protocol can then call the transport’s methods for various purposes. In this tutorial, we are going to use the TCP transport that allows to create reliable, streamable, point-to-point connections.
Protocols are classes that implement a network protocol. These classes are used in conjunction with transports: the protocol parses incoming data and asks for the writing of outgoing data, while the transport is responsible for the actual I/O and buffering. When subclassing a protocol class, you have to override certain methods, called callbacks, which will be called by the transport on certain events (for example when some data is received).
An echo server (and client)
We expect you to write your solutions to all of the problems in this
section in a file named echo.py and to upload it using the
form below.
Let’s start with a very simple echo server, which is a simple program that accepts connections from clients, waits for data from them, and writes it back to the clients. The initial code for our echo server is given below. (We have included type hints to make the code easier to understand and modify.)
# --------------------------------------------------------------------
import asyncio
import sys
from typing import cast
# --------------------------------------------------------------------
class EchoServerClientProtocol(asyncio.Protocol):
def connection_made(self, transport : asyncio.BaseTransport) -> None:
peername = transport.get_extra_info('peername')
print('Connection from {}'.format(peername))
self.transport = cast(asyncio.Transport, transport)
def data_received(self, data : bytes) -> None:
# convert the binary data received into a string
message : str = data.decode()
print('Data received: {!r}'.format(message))
# send the same data back
print('Send: {!r}'.format(message))
self.transport.write(data)
print('Close the client socket')
self.transport.close()
# --------------------------------------------------------------------
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
coro = loop.create_server(EchoServerClientProtocol, '127.0.0.1', 8888)
server : asyncio.base_events.Server = loop.run_until_complete(coro)
# Serve requests until Ctrl+C is pressed
print('Serving on {}'.format(server.sockets[0].getsockname()))
try:
loop.run_forever()
except KeyboardInterrupt:
passYou should copy this code and save it to a file named
echo.py.
Now for some explanations.
The asyncio.Protocol relies on the asyncio
event loop to dispatch events from the network. To create a TCP server,
we recover the asyncio event loop via the
asyncio.get_event_loop() function, and then create the
server by calling the .create_server() method on the
asyncio event loop. This method takes three arguments:
a subclass of the
asyncio.Protocolclass. This class will be responsible for handling the communication with the clients, with one instance of the class created per client.the second argument gives the IP address/interface on which your server is going to listen for new connections. By convention,
127.0.0.1refers to a local address that is not accessible from outside of the computer, while0.0.0.0will make your server accept connections from anywhere.the third argument is the port on which your server is going to listen. Here we picked 8888 as the default, but you can choose any number between 1024 and 32767. Be aware that two servers cannot listen on the same port for a given IP address/interface, so you may get an error message if you pick a port number that is already being used by another application. (In particular, if you are running Jupyter notebooks you should pick a different port number, since it uses port 8888 by default.)
Once the server has been created, we just tell asyncio
to run the server, and wait for the completion of its creation. From
there, the server is going to accept new connections from clients and
serve them.
Let’s go back to the EchoServerClientProtocol class. As
we said, this class is reponsible for the communication with clients,
and one instance of that class is created per client. The
asyncio.Protocol uses a callback-oriented API. This means
that asyncio tells your class about (network) events by
calling specific methods of this class.
Here, we use two methods: .connection_made() and
.data_received().
The first one,
.connection_made()is called when a new client connects, and is called on a freshly created instance of theProtocolsubclass. It takes as argument aasyncio.Transportinstance representing the connection to the client. Thisasyncio.Transportallows us to get information about the client, to write to the client or to close the connection with the client. Here, we simply use the.get_extra_info()method to get the remote client address. We also store the transport in a data attribute of ourEchoServerClientProtocolclass.The second method,
.data_received()is called when some data has been received from the client. The data received so far is given as an argument to the method. Note that the data is in binary format, so we convert it to a string by calling thedecode()method. (In this simple echo server, we don’t do anything with the decoded string other than log the fact that we received it.)By the very nature of networking, whether the data is buffered, chunked o reassembled depends on the transport. We here make the assumption that we received all the data from the client, and use the stored transport to write it back to the client and close the connection. The data is not directly written to the client but is instead buffered and will be sent later when possible.
If you haven’t already done so, at this point you should launch the
server! This is simply a matter of opening a terminal and running the
command python echo.py, or alternatively following the
instructions below in Spyder.
If you are using Spyder, you cannot launch the server using the default “Run file” command. This will result in an exception like so:
RuntimeError: This event loop is already runningInstead you should run the file by navigating to “Run $ Run in external terminal” from the menu.
To test the echo server you need to run a separate program, called a
“client”. If you are familiar with the telnet command and have it
installed on your system, then you can use it as a client by running
telnet localhost 8888. Otherwise, we have provided the code
for a very simple telnet-like client (client.py), which you can download and
execute directly in the same way as above. You should get this
message:
Connected to 127.0.0.1 at port 8888.
To quit, type "/quit".You can enter a single line of text, and should see it repeated (“echoed”) back to you. At that point the client will quit after realizing that the server dropped the connection (see point 3 below); to connect to the server again, simply restart another client.
Now we are going to start with a simple modification of the server to modify this behavior:
- Modify the echo server so that it transforms the received string to
uppercase letters before sending it back to the client. (For instance,
if the client sends the line “hello” then the server should respond with
the line “HELLO”.) Keep in mind that the data is sent back to the user
in binary format. So, after you transform the underlying string message,
you will have to encode it again by calling the
encode()method.
As previously mentioned, the code for the echo server above makes the
assumption that all the data from the client is received on the first
call to .data_received(). However, in practice the data may
actually only arrive in chunks — for example because the client is
sending a lot of data, or because of some networking latency issues. In
that case, the .data_received() method is going to be
called several times. As such, when developing a client/server protocol,
one must design the communication formats so that it is possible for the
peers to know when they received all the data. In this tutorial, we are
only going to work with text-oriented protocols: when a peer wants to
send some data, it sends it using some ASCII format that must fit in one
line of text. Hence, a peer knows that it fully received the data when
it sees a newline.
Further modify the echo server so that it buffers the data sent by the client until it receives a full line marked by a trailing
'\r\n'sequence, before sending the line (converted to uppercase) back to the client. Hint: You can add new data attributes to theEchoServerClientProtocolclass for storing the partially received data.To test the server, try connecting as before, but now type the escape character Ctrl-D after entering some text (you may have to hit it twice), then entering some more text and typing Ctrl-D again, and so on, eventually ending the line by pressing Enter. For example, a client might have an interaction like the transcript below (where “^D” stands for Ctrl-D, and “^J” stands for Enter):
Connected to 127.0.0.1 at port 8888. To quit, type "/quit". Hi,^DI um^D have to tell you something!^J HI,I UM HAVE TO TELL YOU SOMETHING! Connection closed by server.On the side of the server, the log of its interaction with the client may look something like this:
Connection from ('127.0.0.1', 43632) Data received: 'Hi,' Current buffer: HI, Data received: 'I um' Current buffer: HI,I UM Data received: ' have to tell you something!\r\n' Current buffer: HI,I UM HAVE TO TELL YOU SOMETHING! Send: 'HI,I UM HAVE TO TELL YOU SOMETHING!\r\n' Close the client socket
Another flaw of our server is that it closes the connection as soon as it receives a line of data. Instead, we’d like the server to continue waiting for new data, echoing lines one-by-one as soon as they are received, until the client closes the connection.
Further modify the echo server to keep echoing the clients lines one-by-one until the client closes the connection. Although you do not need to do anything on the side of the echo server when the client leaves, you could for example print a message that the connection has been closed. To that end, you can add an override for the
connection_lostmethod (see also below).The client should now be able to have a longer conversation with the server:
Connected to 127.0.0.1 at port 8888. To quit, type "/quit". hey HEY how are you doing? HOW ARE YOU DOING? I asked first I ASKED FIRST stop copying me STOP COPYING ME i said stop! I SAID STOP! /quit
A simple chat server
We expect you to write your solutions to all of the problems in this
section in a file named chat.py and to upload it using the
form below.
We are now moving to our chat server. In its simplest form, a chat server is just a super echo server: it receives data from clients and echos it back to them. The only difference is that, instead of sending the data back to the sending client, it echos the data to all the other clients.
For that purpose, we are going to modify the echo server so that all
the EchoServerClientProtocol instances share a global state
that can be used to store some common data shared by all the clients.
Since the intention is to implement a chat server, we’ll also take the
opportunity to rename it ChatServerClientProtocol:
# --------------------------------------------------------------------
import asyncio
import sys
# --------------------------------------------------------------------
class ChatServerState:
pass
# --------------------------------------------------------------------
class ChatServerClientProtocol(asyncio.Protocol):
def __init__(self, state : ChatServerState):
self.state = state
def connection_made(self, transport : asyncio.BaseTransport) -> None:
# register the client in self.state
# note that `transport` can be used as a key in a dictionary
pass
def connection_lost(self, exc : Exception | None) -> None:
# unregister the client from self.state
pass
def data_received(self, data : bytes) -> None:
# buffer the received data until a full line is received
# then, forward that full line to all clients
pass
# --------------------------------------------------------------------
if sys.version_info < (3, 10):
loop = asyncio.get_event_loop()
else:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
state = ChatServerState()
def create_protocol() -> ChatServerClientProtocol:
global state
return ChatServerClientProtocol(state)
coro = loop.create_server(create_protocol, '127.0.0.1', 8888)
server = loop.run_until_complete(coro)
# Serve requests until Ctrl+C is pressed
print('Serving on {}'.format(server.sockets[0].getsockname()))
try:
loop.run_forever()
except KeyboardInterrupt:
passIn the new program, a ChatServerClientProtocol instance
has a new data attribute state (of type
ChatServerState) that is shared among all the clients. When
a client connects, we simply register the new transport
into that global state, so that it is then possible to reach all
clients’ transport from the methods. Relative to the original version of
the echo server, we also added a method .connection_lost()
that is called when a client disconnects. We are going to use that
callback to unregister clients when they leave the chat server.
- Implement the three callbacks of
ChatServerClientProtocolto implement a super-echo server that forwards the data it receives to all clients. Some hints are given in comments. For testing your server, you can run several instances of the client program.
From this point, you can start chatting with people that connect to your server. However, there is nothing in the protocol that lets you distinguish messages coming from different clients. We would like to associate each client with a nickname, and to that end we need to make the protocol a bit more sophisticated. When a client connects, it should send first a line containing its nickname, which must be an alphanumerical string. During this period, the client does not receive any message, and if the client tries to use a nickname that is already in use, the server simply closes the connection. Once a valid nickname has been registered, the client starts receiving chat messages, which moreover are always prefixed by the sender’s nickname.
- Modify the chat server to implement the nickname protocol.
Since now all clients have a unique identity, it is possible to tag messages for delivery to a specific target user. For that, we are going to extend our protocol again: when the server receives a line of the form:
#nick message...then it searches for a client whose identity is nick and
sends the message privately to that client. If the nickname does not
exist, the server simply sends some error message to the sender.
Additionally, we would like for clients to be able to see who is online with the command
!userswhich tells the server to send a space-separated list of all the client nicknames.
- Implement the private message & clients listing protocol.
We may like to add chat rooms to our server. A chat room is uniquely defined by a name, and users can freely enter/leave chatrooms. When in a chatroom, a client can send a message to it and all the clients in that chatroom will receive that message (prefixed with the chatroom name). It is also possible to get a listing of all the chatrooms and of all the users present in a given chatroom.
We add the following commands to the protocol and ask you to (optionally) implement them:
@roomname message: send the messagemessageto the chatroomroomname. If the client is not in the chatroom or if the chatroom does not exist, an error message is returned.!enter roomname: enter the chatroomroomname- the chatroom is created if it does not exist. Does nothing if the client is already part of that chatroom.!leave roomname: leave the chatroomroomname- the chatroom is destroyed if no clients remain in that chatroom. Does nothing if the client is not part of that chatroom.!rooms: send a space-separated list of all the chatrooms.!members roomname: send a space-separated list of all the users of a given chatroom. Send an error message if the chatroom does not exists.
You are also free to extend the protocol with other commands, of course! Please just document them.