6.031
6.031 — Software Construction
Spring 2021

Reading 24: Sockets & Networking

Software in 6.031

Safe from bugsEasy to understandReady for change
Correct today and correct in the unknown future. Communicating clearly with future programmers, including future you. Designed to accommodate change without rewriting.

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):

  • 18.9.22.69 is the IP address of a MIT web server.

  • 173.194.193.99 is the address of a Google web server.

  • 104.47.42.36 is the address of a Microsoft Outlook email handler.

  • 127.0.0.1 is the loopback or localhost address: it always refers to the local machine. Technically, any address whose first octet is 127 is a loopback address, but 127.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 using dig, host, or nslookup on the command line, e.g.:

    $ dig +short web.mit.edu
    18.9.22.69
    
  • google.com is exactly what you think it is. Try using one of the commands above to find google.com’s IP address. What do you see?

  • mit-edu.mail.protection.outlook.com is the name for MIT’s incoming email handler, a spam filtering system hosted by Microsoft.

  • localhost is a name for 127.0.0.1. When you want to talk to a server running on your own machine, talk to localhost.

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. A port can have only one listener at a time, so if some other server process tries to listen to the same port, it will fail.

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 to 18.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.

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 its accept method to listen to it. Remember that a port can only have one listener, so this will fail if another thread or process is currently listening at the same port.

  • A connected socket can send and receive messages to and from the process on the other end of the connection.

    In Java, clients use a Socket constructor to establish a socket connection to a server. Servers obtain a connected socket as a Socket object returned from ServerSocket.accept.

The word “socket” arises by analogy to a hole for a physical plug, like a USB cable. A listening socket is similar to a USB socket waiting for a cable to be plugged into it. A connected socket is like a USB socket with a cable plugged into it, and like a cable with two ends, the connection involves two sockets – one at the client end, and one at the server end.

Be careful with this physical-socket analogy, though, because it’s not quite right. In the real world, a USB socket allows only one cable to be connected to it, so a “listening” USB socket becomes a “connected” USB socket when a cable is physically plugged into it, and stops being available for new connections. But that’s not how network sockets work. Instead, when a new client arrives at a listening socket, a fresh connected socket is created on the server to manage the new connection. The listening socket continues to exist, bound to the same port number and ready to accept another arriving client when the server calls accept on it again. This allows a server to have multiple active client connections that originally targeted the same port number.

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

Client server socket buffer*

You’re developing a new web server program on your own laptop. You start the server running on port 8080.

Fill in the blanks for the URL you should visit in your web browser to talk to your server:

__A__://__B__:__C__

(missing explanation)

* 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:

OutputStream objects represent data sinks, places we can write data to. For example:

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:

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 see the full code for EchoClient and EchoServer. 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
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
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
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
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
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
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
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
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
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
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
readFromClient.close();
writeToClient.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
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
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
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

Network sockets 1

Alice has a connected socket with Bob. How does she send a message to Bob?

(missing explanation)

Network sockets 2

Which of these are necessary for a client to know in order to connect to and communicate with a server?

* defines the messages sent and received, more details in the next section

(missing explanation)

Echo echo echo echo

In the EchoClient example, which of these might block?

(missing explanation)

And in EchoServer, which of these might block?

(missing explanation)

Block block block block

Since BufferedReader.readLine() is a blocking method, which of these is true:

(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.

Let’s look at some examples of wire protocols.

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.25.4.17...
Connected to www.eecs.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 www.eecs.mit.edu 80
Trying 18.25.4.17...
Connected to www.eecs.mit.edu.
Escape character is '^]'.
GET / HTTP/1.1↵
Host: www.eecs.mit.edu↵
↵
HTTP/1.1 200 OK
Date: Mon, 25 Jan 2021 17:36:21 GMT
... more headers ...

a105
<!DOCTYPE html>
... more HTML ...
<title>Homepage | MIT EECS</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 MIT’s incoming email handler is mit-edu.mail.protection.outlook.com.

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. So this example starts by logging into Athena so that you are on MITnet:

$ ssh your-username-here@athena.dialup.mit.edu
... authenticate yourself ...
$ curl -w '\n' whatismyip.akamai.com
athena-IP-address-here
$ telnet mit-edu.mail.protection.outlook.com 25
Trying 104.47.40.36...
Connected to mit-edu.mail.protection.outlook.com.
Escape character is '^]'.
220 ABC123000.mail.protection.outlook.com Microsoft ESMTP MAIL Service
HELO athena-IP-address-here
250 ABC123000.mail.protection.outlook.com Hello [your-ip-address]
MAIL FROM: <your-username-here@mit.edu>↵
250 2.1.0 Sender OK
RCPT TO: <your-username-here@mit.edu>↵
250 2.1.5 Recipient OK
DATA↵
354 Start mail input; end with <CRLF>.<CRLF>
From: <your-username-here@mit.edu>↵
To: <your-username-here@mit.edu>↵
Subject: testing↵
↵
This is a hand-crafted artisanal email.↵
.↵
250 2.6.0 <111111-22-33-44-55555555@ABC.eop-123.prod.protection.outlook.com>
QUIT↵
221 2.0.0 Service closing transmission channel
Connection closed by foreign host.

SMTP is quite chatty in comparison to HTTP, even including human-readable instructions to tell the client how to submit their message.

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:

  • Safe from bugs

    • 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 / HTTP/1.1
Host: www.eecs.mit.edu
  • GET is the method: we’re asking the server to get a page for us.
  • / is the request-uri: the description of what we want to get.
  • HTTP/1.1 is the http-version.
  • Host: www.eecs.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 the request.
  • 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

Wire protocols 1

Which of the following tools could you use to speak HTTP with a web server?

(missing explanation)

Wire protocols 2

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 ByteArray­InputStream and ByteArray­OutputStream. Suppose we want to test this method:

void upperCaseLine(BufferedReader input, PrintWriter output) throws IOException
requires:
input and output are open
effects:
attempts to read a line from input and attempts to write that line, in upper case, to output

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.

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.

More practice

If you would like to get more practice with the concepts covered in this reading, you can visit the question bank. The questions in this bank were written in previous semesters by students and staff, and are provided for review purposes only – doing them will not affect your classwork grades.