Reading 23: Sockets & Networking
Software in 6.031
Objectives
In this reading we examine client/server communication over the network using the socket abstraction.
Network communication is inherently concurrent, so building clients and servers will require us to reason about their concurrent behavior and to implement them with thread safety. We must also design the wire protocol that clients and servers use to communicate, just as we design the operations that clients of an ADT use to work with it.
Client/server design pattern
In this reading (and in the problem set) we explore the client/server design pattern for communication with message passing.
In this pattern there are two kinds of processes: clients and servers. A client initiates the communication by connecting to a server. The client sends requests to the server, and the server sends replies back. Finally, the client disconnects. A server might handle connections from many clients concurrently, and clients might also connect to multiple servers.
Many Internet applications work this way: web browsers are clients for web servers, an email program like Outlook is a client for a mail server, etc.
On the Internet, client and server processes are often running on different machines, connected only by the network, but it doesn’t have to be that way — the server can be a process running on the same machine as the client.
Sockets and streams
We begin with some important concepts related to network communication, and to input/output in general. Input/output (I/O) refers to communication into and out of a process – perhaps over a network, or to/from a file, or with the user on the command line or a graphical user interface.
IP addresses
A network interface is identified by an IP address. IP version 4 addresses are 32-bit numbers written in four 8-bit parts. For example (as of this writing):
127.0.0.1
is the loopback or localhost address: it always refers to the local machine. Technically, any address whose first octet is127
is a loopback address, but127.0.0.1
is standard.
You can ask Google for your current IP address. In general, as you carry around your laptop, every time you connect your machine to the network it can be assigned a new IP address.
Hostnames
Hostnames are names that can be translated into IP addresses. A single hostname can map to different IP addresses at different times; and multiple hostnames can map to the same IP address. For example:
web.mit.edu
is the name for MIT’s web server. You can translate this name to an IP address yourself usingdig
,host
, ornslookup
on the command line, e.g.:$ dig +short web.mit.edu 18.9.22.69
dmz-mailsec-scanner-4.mit.edu
is the name for one of MIT’s spam filter machines responsible for handling incoming email.google.com
is exactly what you think it is. Try using one of the commands above to findgoogle.com
’s IP address. What do you see?localhost
is a name for127.0.0.1
. When you want to talk to a server running on your own machine, talk tolocalhost
.
Translation from hostnames to IP addresses is the job of the Domain Name System (DNS). It’s super cool, but not part of our discussion today.
Port numbers
A single machine might have multiple server applications that clients wish to connect to, so we need a way to direct traffic on the same network interface to different processes.
Network interfaces have multiple ports identified by a 16-bit number. Port 0 is reserved, so port numbers effectively run from 1 to 65535.
A server process binds to a particular port — it is now listening on that port. Clients have to know which port number the server is listening on. There are some well-known ports that are reserved for system-level processes and provide standard ports for certain services. For example:
- Port 22 is the standard SSH port.
When you connect to
athena.dialup.mit.edu
using SSH, the software automatically uses port 22. - Port 25 is the standard email server port.
- Port 80 is the standard web server port.
When you connect to the URL
http://web.mit.edu
in your web browser, it connects to18.9.22.69
on port 80.
When the port is not a standard port, it is specified as part of the address.
For example, the URL http://128.2.39.10:9000
refers to port 9000 on the machine at 128.2.39.10
.
When a client connects to a server, that outgoing connection also uses a port number on the client’s network interface, usually chosen at random from the available non-well-known ports.
Network sockets
A socket represents one end of the connection between client and server.
A listening socket is used by a server process to wait for connections from remote clients.
In Java, use
ServerSocket
to make a listening socket, and use itsaccept
method to listen to it.A connected socket can send and receive messages to and from the process on the other end of the connection. It is identified by both the local IP address and port number plus the remote address and port, which allows a server to differentiate between concurrent connections from different IPs, or from the same IP on different remote ports.
In Java, clients use a
Socket
constructor to establish a socket connection to a server. Servers obtain a connected socket as aSocket
object returned fromServerSocket.accept
.
Buffers
The data that clients and servers exchange over the network is sent in chunks. These are rarely just byte-sized chunks, although they might be. The sending side (the client sending a request or the server sending a response) typically writes a large chunk (maybe a whole string like “HELLO, WORLD!” or maybe 20 megabytes of video data). The network chops that chunk up into packets, and each packet is routed separately over the network. At the other end, the receiver reassembles the packets together into a stream of bytes.
The result is a bursty kind of data transmission — the data may already be there when you want to read them, or you may have to wait for them to arrive and be reassembled.
When data arrive, they go into a buffer, an array in memory that holds the data until you read it.
reading exercises
* see What if Dr. Seuss Did Technical Writing?, although the issue described in the first stanza is no longer relevant with the obsolescence of floppy disk drives
Byte streams
The data going into or coming out of a socket is a stream of bytes.
In Java, InputStream
objects represent sources of data flowing into your program.
For example:
- Reading from a file on disk with a
FileInputStream
- User input from
System.in
- Input from a network socket
OutputStream
objects represent data sinks, places we can write data to.
For example:
FileOutputStream
for saving to filesSystem.out
for normal output to the userSystem.err
for error output- Output to a network socket
Character streams
The stream of bytes provided by InputStream
and OutputStream
is often too low-level to be useful.
We may need to interpret the stream of bytes as a stream of Unicode characters, because Unicode can represent a wide variety of human languages (not to mention emoji).
A String
is a sequence of Unicode characters, not a sequence of bytes, so if we want to use strings to manipulate the data inside our program, then we need to convert incoming bytes into Unicode, and convert Unicode back to bytes when we write it out.
In Java, Reader
and Writer
represent incoming and outgoing streams of Unicode characters.
For example:
FileReader
andFileWriter
treat a file as a sequence of characters rather than bytes- the wrappers
InputStreamReader
andOutputStreamWriter
adapt a byte stream into a character stream
One of the pitfalls of I/O is making sure that your program is using the right character encoding, which means the way a sequence of bytes represents a sequence of characters.
The most common character encoding for Unicode characters is UTF-8.
For network communication, UTF-8 is the right choice.
Usually, when you create a Reader
or Writer
, Java will default to UTF-8 encoding.
But problems occur when other programs on your computer use a different character encoding to read and write files, which means your Java program can’t interoperate with them.
To compensate for this file-compatibility problem, Java on your platform may instead default to using a different character encoding, picking it up from a system setting – and then messing up Java code that does network communication, for which the better default is UTF-8.
For example, Microsoft Windows has a non-standard encoding called CP-1252, and for Java programs running on Windows, this may be the default.
Character-encoding bugs can be hard to detect. UTF-8, CP-1252, and most other character encodings happen to be supersets of one of the oldest standardized character encoding, ASCII. ASCII is big enough to represent English, so English text tends to be unaffected by character-encoding bugs. But the bug is lying in wait for accented Latin characters, or scripts other than the Latin alphabet, or emoji, or even just ‘fancy’ “curved” quotes. When there is a character encoding disagreement, these characters turn into garbage.
To avoid character-encoding problems, make sure to explicitly specify the character encoding whenever you construct a Reader
or Writer
object.
The example code in this reading always specifies UTF-8.
Blocking
Input/output streams exhibit blocking behavior. For example, for socket streams:
- When an incoming socket’s buffer is empty, calling
read
blocks until data are available. - When the destination socket’s buffer is full, calling
write
blocks until space is available.
Blocking is very convenient from a programmer’s point of view, because the programmer can write code as if the read
(or write
) call will always work, no matter what the timing of data arrival.
If data (or for write
, space) is already available in the buffer, the call might return very quickly.
But if the read or write can’t succeed, the call blocks.
The operating system takes care of the details of delaying that thread until read
or write
can succeed.
As we’ve seen, blocking happens throughout concurrent programming, not just in I/O. Concurrent modules don’t work in lockstep, like sequential programs do, so they typically have to wait for each other to catch up when coordinated action is required.
Using network sockets in Java
Let’s look at the nuts and bolts of socket programming in Java.
For the sake of introduction, we’ll look at a simple EchoServer
that just echoes everything the client sends it, and an EchoClient
that takes console input from the user and sends it to the EchoServer
.
You can find the full code for EchoClient
and EchoServer
on GitHub.
Some of the code snippets in this reading are simplified for presentation purposes, but the link in the corner of each snippet points to the full version.
Client code
First we’ll look at the client point of view.
The client opens a connection to a hostname and port by constructing a Socket
object:
EchoClient.java
line 20
String hostname = "localhost";
int port = 4589;
Socket socket = new Socket(hostname, port);
If there is a server process running on the given hostname (in this case localhost
, meaning the same machine that the client process is running on) and listening for connections to the designated port, then this constructor will succeed and produce an open Socket
object.
If there is no server process listening on that port, then the connection fails and new Socket()
throws an IOException
.
Assuming the connection is successful, the client can now obtain two byte streams that communicate with the server:
EchoClient.java
line 44
OutputStream outToServer = socket.getOutputStream();
InputStream inFromServer = socket.getInputStream();
With sockets, remember that the output of one process is the input of another process. If a client and a server have a socket connection, then the client has an output stream that flows to the server’s input stream, and vice versa.
A client usually wants more powerful operations than the simple InputStream
and OutputStream
interfaces provide.
For EchoClient
, we want to use streams of characters instead of bytes, using the Reader
and Writer
interfaces described earlier.
We also want to read and write full lines of characters, terminated by newlines, which is functionality provided by BufferedReader
and PrintWriter
.
So we wrap the streams in classes that provide those operations:
EchoClient.java
line 44
PrintWriter writeToServer =
new PrintWriter(new OutputStreamWriter(outToServer, StandardCharsets.UTF_8));
BufferedReader readFromServer =
new BufferedReader(new InputStreamReader(inFromServer, StandardCharsets.UTF_8));
Note the explicit specification of UTF-8 character encoding, the best choice for portable network communication.
The basic loop of EchoClient
prepares a message for the server by letting the user type it at the keyboard, then sends the message to the server, and then waits for a reply:
EchoClient.java
line 52
BufferedReader readFromUser =
new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
while (true) {
String message = readFromUser.readLine();
...
writeToServer.println(message);
...
String reply = readFromServer.readLine();
...
}
Note that all three of these method calls are potentially blocking.
First the client process will block until the user types something at the console and presses Enter.
Then it will send it to the server with println()
, but if the server’s buffer happens to be full, this println()
call will block until it can fit the message into the buffer.
Then the process will block until the server has sent its reply.
It should not surprise us that this code has a very similar flavor to code for implementing message passing with blocking queues.
To send a request to the server, we write the request to the socket output stream, just as we would use BlockingQueue.put()
in the queue paradigm.
To receive a reply, we read it from the input stream, where we would have used BlockingQueue.take()
.
Both sending and receiving use blocking calls.
Two important details are hiding in the ...
parts of the code sketch above.
First, readLine()
returns null
if the stream it is reading from has been closed by the other side.
For a socket stream, this would indicate that the server has closed its side of the connection.
EchoClient
responds to that by exiting the loop:
EchoClient.java
line 66
String reply = readFromServer.readLine();
if (reply == null) break; // server closed the connection
Second, println()
initially puts the message into a buffer inside the PrintWriter
object, on the client’s side of the connection.
The message content isn’t sent to the server until the buffer fills up or the client closes its side of the connection.
So it’s critically important to flush the buffers after writing, forcing all buffer contents to be sent:
EchoClient.java
line 62
writeToServer.println(message);
writeToServer.flush(); // important! otherwise the line may just sit in a buffer, unsent
PrintWriter
has a constructor that enables automatic flushing, but it only works for some operations.
With autoflush turned on, println(message)
automatically flushes the buffer, but the seemingly-equivalent print(message + "\n")
may sit in the buffer, unsent.
We all learned this as children, but it’s worth restating: always remember to flush.
Finally, after exiting the loop, we close the streams and the socket, which both signals to the server that the client is done, and frees buffer memory and other resources associated with the streams:
EchoClient.java
line 78
readFromServer.close();
writeToServer.close();
socket.close();
Server code
The server starts with a listening socket, represented by a ServerSocket
object.
The server creates a ServerSocket
object to listen for incoming client connections on a particular port number:
EchoServer.java
line 25
int port = 4589;
ServerSocket serverSocket = new ServerSocket(port);
If another listening socket is already listening to this port, possibly in a different process, then this constructor will throw a BindException
to tell you that the address is already in use.
Note that no hostname is needed to create this socket, because by default the server socket listens for connections to any network interface of the machine that the server process is running on.
Unlike an ordinary Socket
, the ServerSocket
does not provide byte streams to read or write.
Instead, it produces a sequence of new client connections.
Each time a client opens a connection to the specified port, the ServerSocket
yields up a new Socket
object for the new connection.
The next client connection can be obtained using accept()
:
EchoServer.java
line 33
Socket socket = serverSocket.accept();
The accept()
method is blocking. If no client connection is pending, then accept()
waits until a client arrives before returning a Socket
object for that client connection.
Once the server has a connection to a client, it uses the socket’s input and output streams in much the same way the client did: receiving messages from the client, and preparing and sending replies back.
EchoServer.java
line 54
PrintWriter writeToClient =
new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
BufferedReader readFromClient =
new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
while (true) {
// read a message from the client
String message = readFromClient.readLine();
if (message == null) break; // client closed its side of the connection
if (message.equals("quit")) break; // client sent a quit message
// prepare a reply, in this case just echoing the message
String reply = "echo: " + message;
// write the reply
writeToClient.println(reply);
writeToClient.flush(); // important! otherwise the reply may just sit in a buffer, unsent
}
// close the streams and socket
readFromServer.close();
writeToServer.close();
socket.close();
The server implements two different ways to stop communication between client and server.
One way we have already seen in message-passing with queues: the client sends a poison pill message, in this case "quit"
.
But another way that the client can stop is simply to close its end of the connection.
readLine()
recognizes this event as the end of the socket input stream, and signals it by returning null
.
Multithreaded server code
The server code we’ve written to this point has the limitation that it can handle only one client at a time.
The server loop is dedicated to a single client, blocking on readFromClient.readLine()
and repeatedly reading and replying to messages from that one client, until the client disconnects.
Only then does the server go back to its ServerSocket
and accept
a connection from the next client waiting in line.
If we want to handle multiple clients at once, using blocking I/O, then the server needs a new thread to handle I/O with each new client.
While each client-specific thread is working with its own client, another thread (perhaps the main thread) stands ready to accept
a new connection.
Here is how the multithreaded EchoServer
works.
The connection-accepting loop is run by the main thread:
EchoServer.java
line 31
while (true) {
// get the next client connection
Socket socket = serverSocket.accept();
// handle the client in a new thread, so that the main thread
// can resume waiting for another client
new Thread(new Runnable() {
public void run() {
handleClient(socket);
}
}).start();
}
And then the client-handling loop is run by the new thread created for each new connection:
EchoServer.java
line 53
private static void handleClient(Socket socket) {
// same server loop code as above:
// open readFromClient and writeToClient streams
// while (true) {
// read message from client
// prepare reply
// write reply to client
// }
// close streams and socket
}
Closing streams and sockets with try-with-resources
One new bit of Java syntax is particularly useful for working with streams and sockets: the try-with-resources statement.
This statement automatically calls close()
on variables declared in its parenthesized preamble:
try (
// preamble: declare variables initialized to objects that need closing after use
) {
// body: runs with those variables in scope
} catch(...) {
// catch clauses: optional, handles exceptions thrown by the preamble or body
} finally {
// finally clause: optional, runs after the body and any catch clause
}
// no matter how the try statement exits, it automatically calls
// close() on all variables declared in the preamble
For example, here is how it can be used to ensure that a client socket connection is closed:
EchoClient.java
line 23
try (
Socket socket = new Socket(hostname, port);
) {
// read and write to the socket
} catch (IOException ioe) {
ioe.printStackTrace();
} // socket.close() is automatically called here
The try-with-resources statement is useful for any object that should be closed after use:
- byte streams:
InputStream
,OutputStream
- character streams:
Reader
,Writer
- files:
FileInputStream
,FileOutputStream
,FileReader
,FileWriter
- sockets:
Socket
,ServerSocket
The Python with
statement has similar semantics.
reading exercises
In the EchoClient example, which of these might block?
(missing explanation)
And in EchoServer, which of these might block?
(missing explanation)
Wire protocols
Now that we have our client and server connected up with sockets, what do they pass back and forth over those sockets? Unlike the in-memory objects sent and received using synchronized queues in Message-Passing, here we send and receive streams of bytes. Instead of choosing or designing an abstract data type for our messages, we will choose or design a protocol.
A protocol is a set of messages that can be exchanged by two communicating parties.
A wire protocol in particular is a set of messages represented as byte sequences, like hello world
and bye
(assuming we’ve agreed on a way to encode those characters into bytes).
Many Internet applications use simple ASCII-based wire protocols. You can use a program called Telnet to check them out.
Telnet client
telnet
is a utility that allows you to make a direct network connection to a listening server and communicate with it via a terminal interface.
Windows, Linux, and Mac OS X can all run telnet
, although more recent operating systems no longer have it installed by default.
You should first check if telnet is installed by running the command telnet
on the command line.
If you don’t have it, then look for instructions about how to install it (Linux, Windows, Mac OS).
On Windows, an alternative telnet client is PuTTY, which has a graphical user interface.
HTTP
Hypertext Transfer Protocol (HTTP) is the language of the World Wide Web. We already know that port 80 is the well-known port for speaking HTTP to web servers, so let’s talk to one on the command line.
Try using your telnet client with the commands below. User input is shown in green, and for input to the telnet connection, newlines (pressing enter) are shown with ↵. (If you are using PuTTY on Windows, you will enter the hostname and port in PuTTY's connection dialog, and you should also select Connection type: Raw, and Close window on exit: Never. The last option will prevent the window from disappearing as soon as the server closes its end of the connection.)
$ telnet www.eecs.mit.edu 80 Trying 18.62.0.96... Connected to eecsweb.mit.edu. Escape character is '^]'. GET /↵ <!DOCTYPE html> ... lots of output ... <title>Homepage | MIT EECS</title> ... lots more output ...
The GET
command gets a web page.
The /
is the path of the page you want on the site.
So this command fetches the page at http://www.eecs.mit.edu:80/
.
Since 80 is the default port for HTTP, this is equivalent to visiting http://www.eecs.mit.edu/ in your web browser.
The result is HTML code that your browser renders to display the EECS homepage.
Internet protocols are defined by RFC specifications (RFC stands for “request for comments”, and some RFCs are eventually adopted as standards). RFC 1945 defined HTTP version 1.0, and was superseded by HTTP 1.1 in RFC 2616. So for many web sites, you might need to speak HTTP 1.1 if you want to talk to them. For example:
$ telnet web.mit.edu 80 Trying 18.9.22.69... Connected to web.mit.edu. Escape character is '^]'. GET /about/ HTTP/1.1↵ Host: web.mit.edu↵ ↵ HTTP/1.1 200 OK Date: Tue, 18 Apr 2017 15:25:23 GMT ... more headers ... 9b7 <!DOCTYPE html> ... more HTML ... <title>About MIT | MIT - Massachusetts Institute of Technology</title> ... lots more HTML ... </html> 0
This time, your request must end with a blank line. HTTP version 1.1 requires the client to specify some extra information (called headers) with the request, and the blank line signals the end of the headers.
You will also more than likely find that telnet does not exit after making this request — this time, the server keeps the connection open so you can make another request right away.
To quit Telnet manually, type the escape character (probably Ctrl
-]
) to bring up the telnet>
prompt, and type quit
:
... lots more HTML ... </html> 0 Ctrl-] telnet> quit↵ Connection closed.
SMTP
Simple Mail Transfer Protocol (SMTP) is the protocol for sending email (different protocols are used for client programs that retrieve email from your inbox).
Because the email system was designed in a time before spam, modern email communication is fraught with traps and heuristics designed to prevent abuse.
But we can still try to speak SMTP.
Recall that the well-known SMTP port is 25, and dmz-mailsec-scanner-4.mit.edu
was the name of a MIT email handler.
You’ll need to fill in your-IP-address-here and your-username-here, and the ↵ indicate newlines for clarity. This will only work if you’re on MITnet, and even then your mail might be rejected for looking suspicious:
$ telnet dmz-mailsec-scanner-4.mit.edu 25 Trying 18.9.25.15... Connected to dmz-mailsec-scanner-4.mit.edu. Escape character is '^]'. 220 dmz-mailsec-scanner-4.mit.edu ESMTP Symantec Messaging Gateway HELO your-IP-address-here↵ 250 2.0.0 dmz-mailsec-scanner-4.mit.edu says HELO to your-ip-address:port MAIL FROM: <your-username-here@mit.edu>↵ 250 2.0.0 MAIL FROM accepted RCPT TO: <your-username-here@mit.edu>↵ 250 2.0.0 RCPT TO accepted DATA↵ 354 3.0.0 continue. finished with "\r\n.\r\n" From: <your-username-here@mit.edu>↵ To: <your-username-here@mit.edu>↵ Subject: testing↵ This is a hand-crafted artisanal email.↵ .↵ 250 2.0.0 OK 99/00-11111-22222222 QUIT↵ 221 2.3.0 dmz-mailsec-scanner-4.mit.edu closing connection Connection closed by foreign host.
SMTP is quite chatty in comparison to HTTP, providing some human-readable instructions like continue. finished with "\r\n.\r\n"
to tell us how to terminate our message content.
Designing a wire protocol
When designing a wire protocol, apply the same rules of thumb you use for designing the operations of an abstract data type:
Keep the number of different messages small. It’s better to have a few commands and responses that can be combined rather than many complex messages.
Each message should have a well-defined purpose and coherent behavior.
The set of messages must be adequate for clients to make the requests they need to make and for servers to deliver the results.
Just as we demand representation independence from our types, we should aim for platform-independence in our protocols. HTTP can be spoken by any web server and any web browser on any operating system. The protocol doesn’t say anything about how web pages are stored on disk, how they are prepared or generated by the server, what algorithms the client will use to render them, etc.
We can also apply the three big ideas in this class:
-
The protocol should be easy for clients and servers to generate and parse. Simpler code for reading and writing the protocol (e.g. a parser generated automatically from a grammar, or simple regular expressions with a regular-expression-matching library) will have fewer opportunities for bugs.
Consider the ways a broken or malicious client or server could stuff garbage data into the protocol to break the process on the other end.
Email spam is one example: when we spoke SMTP above, the mail server asked us to say who was sending the email, and there’s nothing in SMTP to prevent us from lying outright. We’ve had to build systems on top of SMTP to try to stop spammers who lie about
From:
addresses.Security vulnerabilities are a more serious example. For example, protocols that allow a client to send requests with arbitrary amounts of data require careful handling on the server to avoid running out of buffer space, or worse.
Easy to understand: for example, choosing a text-based protocol means that we can debug communication errors by reading the text of the client/server exchange. It even allows us to speak the protocol “by hand” as we saw above.
Ready for change: for example, HTTP includes the ability to specify a version number, so clients and servers can agree with one another which version of the protocol they will use. If we need to make changes to the protocol in the future, older clients or servers can continue to work by announcing the version they will use.
Serialization is the process of transforming data structures in memory into a format that can be easily stored or transmitted (not the same as serializability from Thread Safety). Rather than invent a new format for serializing your data between clients and servers, use an existing one. For example, JSON (JavaScript Object Notation) is a simple, widely-used format for serializing basic values, arrays, and maps with string keys.
Specifying a wire protocol
In order to precisely define for clients & servers what messages are allowed by a protocol, use a grammar.
For example, here is a very small part of the HTTP 1.1 request grammar from RFC 2616 section 5:
request ::= request-line
((general-header | request-header | entity-header) CRLF)*
CRLF
message-body?
request-line ::= method SPACE request-uri SPACE http-version CRLF
method ::= "OPTIONS" | "GET" | "HEAD" | "POST" | ...
...
Using the grammar, we can see that in this example request from earlier:
GET /aboutmit/ HTTP/1.1
Host: web.mit.edu
GET
is themethod
: we’re asking the server to get a page for us./aboutmit/
is therequest-uri
: the description of what we want to get.HTTP/1.1
is thehttp-version
.Host: web.mit.edu
is some kind of header — we would have to examine the rules for each of the...-header
options to discover which one.- And we can see why we had to end the request with a blank line: since a single
request
can have multiple headers that end in CRLF (newline), we have another CRLF at the end to finish therequest
. - We don’t have any
message-body
— and since the server didn’t wait to see if we would send one, presumably that only applies for other kinds of requests.
The grammar is not enough: it fills a similar role to method signatures when defining an ADT. We still need the specifications:
What are the preconditions of a message? For example, if a particular field in a message is a string of digits, is any number valid? Or must it be the ID number of a record known to the server?
Under what circumstances can a message be sent? Are certain messages only valid when sent in a certain sequence?
What are the postconditions? What action will the server take based on a message? What server-side data will be mutated? What reply will the server send back to the client?
reading exercises
Consider this example wire protocol, specified using two grammars…
Messages from the client to the server
The client can turn lights, identified by numerical IDs, on and off. The client can also request help.
MESSAGE ::= ( ON | OFF | HELP_REQ ) NEWLINE
ON ::= "on " ID
OFF ::= "off " ID
HELP_REQ ::= "help"
NEWLINE ::= "\r"? "\n"
ID ::= [1-9][0-9]*
Messages from the server to the client
The server can report the status of the lights and provides arbitrary help messages.
MESSAGE ::= ( STATUS | HELP ) NEWLINE
STATUS ::= ONE_STATUS ( NEWLINE "and " ONE_STATUS )*
ONE_STATUS ::= ID " is " ( "on" | "off" )
HELP ::= [^\r\n]+
NEWLINE ::= "\r"? "\n"
ID ::= [1-9][0-9]*
We’ll use ↵ to represent a newline.
(missing explanation)
(missing explanation)
Testing client/server code
Remember that concurrency is hard to test and debug. We can’t reliably reproduce race conditions, and the network adds a source of latency that is entirely beyond our control. You need to design for concurrency and argue carefully for the correctness of your code.
Separate network code from data structures and algorithms
Most of the ADTs in your client/server program don’t need to rely on networking. Make sure you specify, test, and implement them as separate components that are safe from bugs, easy to understand, and ready for change — in part because they don’t involve any networking code.
If those ADTs will need to be used concurrently from multiple threads (for example, threads handling different client connections), use message passing with threadsafe queues if possible, synchronization if necessary, or the thread safety strategies of confinement, immutability, and existing threadsafe data types.
Separate socket code from stream code
A function or module that needs to read from and write to a socket may only need access to the input/output streams, not to the socket itself. This design allows you to test the module by connecting it to streams that don’t come from a socket.
Two useful Java classes for this are ByteArrayInputStream
and ByteArrayOutputStream
.
Suppose we want to test this method:
The method is normally used with a socket:
Socket sock = ...
// read a stream of characters from the socket input stream
BufferedReader in =
new BufferedReader(new InputStreamReader(sock.getInputStream(), StandardCharsets.UTF_8));
// write characters to the socket output stream, with autoflushing set to true
PrintWriter out =
new PrintWriter(new OutputStreamWriter(sock.getOutputStream(), StandardCharsets.UTF_8),
true /* autoflush */);
upperCaseLine(in, out);
If the underlying to-upper-case conversion is a function we implement, it should already be specified, tested, and implemented separately.
But now we can also test the read/write behavior of upperCaseLine
:
// fixed input stream of "dog" (line 1) and "cat" (line 2)
String inString = "dog\ncat\n";
ByteArrayInputStream inBytes = new ByteArrayInputStream(inString.getBytes());
ByteArrayOutputStream outBytes = new ByteArrayOutputStream();
// read a stream of characters from the fixed input string
BufferedReader in = new BufferedReader(new InputStreamReader(inBytes, StandardCharsets.UTF_8));
// write characters to temporary storage, with autoflushing
PrintWriter out = new PrintWriter(new OutputStreamWriter(outBytes, StandardCharsets.UTF_8), true);
upperCaseLine(in, out);
// check that it read the expected amount of input
assertEquals("cat", in.readLine(), "expected input line 2 remaining");
// check that it wrote the expected output
assertEquals("DOG\n", outBytes.toString(), "expected upper case of input line 1");
In this test, inBytes
and outBytes
are test stubs.
To isolate and test just upperCaseLine
, we replace the components it normally depends on (input/output streams from a socket) with components that satisfy the same spec but have canned behavior: an input stream with fixed input, and an output stream that stores the output in memory.
Testing strategies for more complex modules might use a mock object to simulate the behavior of a real client or server by producing entire canned sequences of interaction and asserting the correctness of each message received from the other component.
Summary
In the client/server design pattern, concurrency is inevitable: multiple clients and multiple servers are connected on the network, sending and receiving messages simultaneously, and expecting timely replies. A server that blocks waiting for one slow client when there are other clients waiting to connect to it or to receive replies will not make those clients happy. At the same time, a server that performs incorrect computations or returns bogus results because of concurrent modification to shared mutable data by different clients will not make anyone happy.
All the challenges of making our multi-threaded code safe from bugs, easy to understand, and ready for change apply when we design network clients and servers. These processes run concurrently with one another (often on different machines), and any server that wants to talk to multiple clients concurrently (or a client that wants to talk to multiple servers) must manage that multi-threaded communication.