Framing Messages
One thing that we haven’t talked about yet is how to format the messages that are exchanged between server and client.
One problem we had with CloudHash
was that the client had to open a new connection for every command it wanted to send to the server. The main reason for this is that the client/server had no agreed-upon way to frame the beginning and end of messages, so they had to fall back to using EOF to signify the end of a message.
While this technically ‘got the job done’, it wasn’t ideal. Opening a new connection for each command adds unnecessary overhead. It’s certainly possible to send multiple messages over the same TCP connection, but if you’re going to leave the connection open, you need some way of signaling that one message is ending and another is beginning.
This idea of reusing connections across multiple messages is the same concept behind the familiar keep-alive feature of HTTP. By leaving the connection open for multiple requests (and having an agreed-upon method of framing messages) resources can be saved by not opening new connections.
There are, literally, an infinite number of choices for framing your messages. Some are very complicated; some are simple. It all depends on how you want to format your messages.
I keep talking about messages, which I see as distinct from protocols. For example, the HTTP protocol defines both the message boundaries (a series of newlines) as well as a protocol for the content of the message involving a request line, headers, etc.
A protocol defines how your messages should be formatted, whereas this chapter is concerned with how to separate your messages from one another on the TCP stream.
Using newlines
Using newlines is a really simple way to frame your messages. If you know, for certain, that your application client and server will be running on the same operating system, you can even fall back to using IO#gets
and IO#puts
on your sockets to send messages with newlines.
Let’s rewrite the relevant part of the CloudHash
server to frame messages with newlines instead of EOFs:
def handle(connection)
loop do
request = connection.gets
break if request == 'exit'
connection.puts process(request)
end
end
The relevant changes to the server are just the addition of the loop
and the change from read
to gets
. In this way the server will process as many requests as the client wishes to send until it sends the ‘exit’ request.
A more robust approach would be to use IO.select
and wait for events on the connection. Currently the server would come crashing down if the client socket disconnected without first sending the ‘exit’ request.
The client would then send its requests with something like:
def initialize(host, port)
@connection = TCPSocket.new(host, port)
end
def get
request "GET #{key}"
end
def set
request "SET #{key} #{value}"
end
def request(string)
@connection.puts(string)
# Read until receiving a newline to get the response.
@connection.gets
end
Note that the client no longer uses class methods. Now that our connection can persist across requests, we can encapsulate a single connection in an instance of an object and just call methods on that object.
Remember that I said it was permissible to use gets
and puts
if you’re certain that the client/server will run on the same operating system? Let me explain why.
If you look at the documentation for gets
and puts
it says that it uses $/
as the default line delimiter. This variable holds the value \n
on Unix systems but holds the value of \r\n
on Windows systems. Hence my warning, one system using puts
may not be compatible with another using gets
. If you use this method then ensure that you pass an argument to those methods with an explicit line delimiter so that they’re compatible.
One real-world protocol that uses newlines to frame messages is HTTP. Here’s an example of a short HTTP request:
GET /index.html HTTP/1.1\r\n
Host: www.example.com\r\n
\r\n
In this example the newlines are made explicit with the escape sequence \r\n
. This sequence of newlines must be respected by any HTTP client/server, regardless of operating system.
This method certainly works, but it’s not the only way.
Using A Content Length
Another method of framing messages is to specify a content length.
With this method the sender of the message first denotes the size of their message, packs that into a fixed-width integer representation and sends that over the connection, immediately followed by the message itself. The receiver of the message will read the fixed-width integer to begin with. This gets them the message size. Then the receiver can read the exact number of bytes specified in the message size to get the whole message.
Here’s how we might change the relevant part of the CloudHash
server to use this method:
# This gets us the size of a random fixed-width integer.
SIZE_OF_INT = [11].pack('i').size
def handle(connection)
# The message size is packed into a fixed-width. We
# read it and unpack it.
packed_msg_length = connection.read(SIZE_OF_INT)
msg_length = packed_msg_length.unpack('i').first
# Fetch the full message given its length.
request = connection.read(msg_length)
connection.write process(request)
end
The client would send a request using something like:
payload = 'SET prez obama'
# Pack the message length into a fixed-width integer.
msg_length = payload.size
packed_msg_length = [msg_length].pack('i')
# Write the length of the message, followed immediately
# by the message itself.
connection.write(packed_msg_length)
connection.write(payload)
The client packs the message length as a native endian integer. This is important because it guarantees that any given integer will be packed into the same number of bytes. Without this guarantee the server wouldn’t know whether to read a 2, 3, 4, or even higher digit number to represent the message size. Using this method the client/server always communicate using the same number of bytes for the message size.
As you can see it’s a bit more code, but this method doesn’t use any wrapper methods like gets
or puts
, just the basic IO operations like read
and write
.