Something I wanted to do when I was a little kid launching model rockets was to build a module that could fit inside a rocket and send back video and telemetry. I loved the idea of being able to do a countdown and remote launch, and get a view of what the rocket “saw” as it ascended and floated back down to earth. Sadly, the tech for doing that to the degree I imagined did not exist back then, and what tech did exist was prohibitively expensive for a 10 year old.
Recently, I’ve rediscovered my interest in model rockets and have reflected on that old pipe dream. I realized: “Hey, the tech does exist today, and it’s really cheap! It just needs to be assembled and programmed!” (famous last words). I’m not really a network guy, so figuring out how to efficiently stream mixed content between two networked devices has been a learning experience. I’ve been working on it in my spare time for a while and have made some PoC programs in Java that that stream video, data, and even commands over an ad hoc network.
With some working concepts in hand, I’m ready to start writing the suite of programs that will eventually run within the module. To do that, I will need some major clean up and refactoring of my concept code.
This post is going to focus on a small part of that, a pattern that I learned and implemented that I think is really cool and not really documented anywhere. I spent a fair amount of time reading documentation, Stack Overflow, and lots of tutorials, only to find that no one has really written about this particular pattern or how to implement it. So I figured I might as well be that guy.
The Problem
The telemetry module and control system are going to be connected over a wireless signal. For simplicity’s sake, let’s assume the telemetry module should be able to stream information back to the control system in the form of video and data, and the control system should be able to receive and parse the incoming stream. In reality the system should do more, but let’s keep things basic here. In other words, we’re going to consider this a Client-Server implementation.
Now, model rockets travel pretty fast, and don’t have long flights. What that means is that our information streams will need to be blazing fast in order to capture meaningful data. Additionally, we will want to see a real time video stream, and it can’t be laggy. At the same time, we (kind of) don’t care about lost packets. If part of a video frame is lost, we’ll probably never notice. Or if it arrives out of order, we’ll simply put it where it needs to go. We can also write data out to an SD card or something, so that if and when the feed is lost, we can still replay it once the rocket has been recovered.
Next, we will be dealing with limited bandwidth. Every sent and received packet will have overhead, and we want to send as much data in as few packets as possible. Because the rocket will be traveling quickly and may go out of range, we also want to minimize packets generated to get better granularity of data before that happens.
Finally, we will need to design a protocol. The protocol will be the shared language between the module and protocol. After all, what good is fast transmission if neither end understands the other?
In Sum
We need a Client-Server implementation that uses a shared, possibly complex communication protocol, and which packs lots of data into a small space that can be transmitted rapidly (and without concern for dropped data).
The Pattern
Client-Server is a breeze to implement, no worries there. We can also model a communication protocol as a separate object that gets serialized prior to transmission. As far as transmission protocol, we will probably want to use UDP instead of the more common TCP. Why? It’s fast and doesn’t care if or when your packets arrive.
Interlude: Some Network Humor
TCP Tells a Joke (source) | UDP Tells a Joke (source) |
---|---|
Client: "Hi, I'd like to hear a TCP joke." Server: "Hello, would you like to hear a TCP joke?" Client: "Yes, I'd like to hear a TCP joke." Server: "OK, I'll tell you a TCP joke." Client: "Ok, I will hear a TCP joke." Server: "Are you ready to hear a TCP joke?" Client: "Yes, I am ready to hear a TCP joke." Server: "Ok, I am about to send the TCP joke. It will last 10 seconds, it has two characters, it does not have a setting, it ends with a punchline." Client: "Ok, I am ready to get your TCP joke that will last 10 seconds, has two characters, does not have an explicit setting, and ends with a punchline." Server: "I'm sorry, your connection has timed out. Hello, would you like to hear a TCP joke?" |
Client: "Knock knock" Client: "Banana" Client: "Banana" Client: "Banana" Client: "Banana" Client: "Banana" Client: "Banana" Client: "Orange" Client: "Orange you glad I didn't say banana?" Server: "Who's there?" |
Back to the problem(s)
Using UDP is all well and good, but there is a constraint, namely that it limits us to a maximum packet size of about 64Kb. Because we want to make better use of that, we should look into compression. The gzip
format is pretty efficient for a lot of purposes. We expect to be sending text and binary data, so we could get a fair amount of extra data across the network and increase our effective bandwidth (as long as we don’t mind paying a small penalty of compressing and decompressing at each end).
And that should do it!
Pattern Summary:
We will create a simple Message
class with a few fields, which will be transmitted over the network. It will also be shared between the client and server. The client will accept a Message object, serialize it, compress it, then send it over UDP to the server, which will decompress the message, deserialize it, and then use the result to build a new Message object and process it accordingly.
Sample Code
We’re going to use Jackson for serializing and deserializing objects, which means there will be some external dependencies. As a result, we should really be using a build tool. I am going to hammer this out using Gradle because I hate configuring things with XML, so Maven and Ant are not happening.
Our Message class is going to be really basic. Nothing even needs to be imported. We’re going to add some simple attributes and constructors, and a toString
override so we can print out the message easily. It looks like this:
public class Message {
// These all need to be public for Jackson to work with this class
public String to;
public String from;
public String body;
// This is the signature used to create the initial message
Message(String to, String from, String body) {
this.to = to;
this.from = from;
this.body = body;
}
// This is needed so that Jackson can create a Message
// instance from deserialized JSON
public Message() {
super();
}
@Override
public String toString() {
return String.format("To: %s\nFrom: %s\nBody: %s\n", this.to, this.from, this.body);
}
}
The no-argument constructor is needed by Jackson when deserializing. When we get to that point, you’ll see why.
Next we’re going to define a Client class. This will need to instantiate a Message object with some data and perform all the serialization and compression before sending off into the ether. It looks like this:
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.util.zip.GZIPOutputStream;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Client implements Runnable {
private InetAddress destinationHost;
private Integer destinationPort;
private Thread t;
private Message message;
Client(String destinationHost, Integer destinationPort, Message message) throws IOException {
this.destinationPort = destinationPort;
this.destinationHost = InetAddress.getByName(destinationHost);
this.message = message;
System.out.printf("Client: Preparing to broadcast to %s:%d.\n", destinationHost.toString(), destinationPort);
start();
}
public void start() {
if (t == null) {
t = new Thread (this, "ClientThread");
t.start();
}
}
@Override
public void run() {
try {
// Convert message to JSON object using Jackson
ObjectMapper mapper = new ObjectMapper();
String message = mapper.writeValueAsString(this.message);
// Convert to a byte array stream. Using an output stream will allow
// us to manipulate the bytes
byte[] messageBytes = message.getBytes();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(messageBytes.length);
// Compress the stream using GZIPOutputStream
System.out.printf("Client: Compressing test message.\n");
GZIPOutputStream compressed = new GZIPOutputStream(outputStream);
// Write the compressed message out to messageBytes, then close stream
compressed.write(messageBytes, 0, messageBytes.length);
compressed.flush();
compressed.close();
// Set up the socket to the server. Datagram is UDP.
DatagramSocket clientSocket = new DatagramSocket();
// Construct a packet using the outputStream
DatagramPacket packet = new DatagramPacket(
outputStream.toByteArray(),
outputStream.toByteArray().length,
destinationHost,
destinationPort
);
// Send away!
System.out.printf(
"Client: Sending compressed message. %d compressed bytes, %d uncompressed.\n",
outputStream.toByteArray().length,
messageBytes.length
);
clientSocket.send(packet);
// The client is now finished.
} catch (IOException e) {
// Catch any exception that might occur above and print a stack trace
e.printStackTrace();
}
}
}
There are a few things to unpack here. For one, you’ll notice that the Client runs in a thread. For demonstration purposes, I am going to wrap both the Client and Server classes in threads that are called by the main
method. This will allow the demonstration to occur locally, with your computer sending itself a message and is a bit of a simplification. It’s not a requirement for this pattern to work.
When the client is initialized, it is given a handful of parameters. Nothing shocking, just a destination address and port, and the message to send. With this, the thread executes.
The first step is to serialize the message using ObjectMapper
. ObjectMapper
is a part of Jackson’s Databind project, and is responsible for translating an object graph to and from JSON. In order to be transmitted over the network as a Datagram (UDP), the JSON then needs to be converted into a byte array.
This is where things get interesting. Once we have converted the String into a byte array, we also instantiate a ByteArrayOutputStream
of the same length. In Java, output streams are an abstract class that represent a “flow” of data through a pipeline and to a sink. This allows us to chain functions and modify data before it is used.
In this case, our ByteArrayOutputStream
is the sink (called outputStream
), our pipeline is GZIPOutputStream
, and our source is the JSON byte array, messageBytes
. These lines
compressed.write(messageBytes, 0, messageBytes.length);
compressed.flush();
compressed.close();
write out the entirety of our JSON byte array into this pipeline. The data passes through gzip
, where it is compressed, and the compressed data ends up in the outputStream
variable. Afterwards, we flush
and close
the GZIP pipeline, to ensure that all data has been written out and the stream is closed.
Finally, we open a DatagramSocket
, which is a Java socket for transmitting DatagramPacket
s and is an implementation of UDP. The contents of outputStream
, now the compressed serialization of the message, is added to a DatagramPacket, along with its length and a destination. The packet is fired off, and the thread closes.
Next, we have the Server class. This will be a mirror image of the Client class:
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.util.zip.GZIPInputStream;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Server implements Runnable {
private Integer listenPort;
private Thread t;
Server(Integer listenPort) throws IOException {
this.listenPort = listenPort;
System.out.printf("Server: Listening on port %d.\n", listenPort);
start();
}
public void start() {
if (t == null) {
t = new Thread (this, "ServerThread");
t.start();
}
}
@Override
public void run() {
try {
// Set up the socket to receive data
DatagramSocket serverSocket = null;
serverSocket = new DatagramSocket(listenPort);
// We don't know what the size of the incoming packet will be, so we allocate a bigger chunk
// A Datagram can be 65,507 bytes, max. The lorem ipsum text in main() should be about 795
// uncompressed bytes, so we'll set it a little higher for that, and truncate the null chars
// at the end
byte[] receiveData = new byte[800];
ByteArrayInputStream inputStream = new ByteArrayInputStream(receiveData);
// Receive a packet
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
serverSocket.receive(receivePacket);
// The packet is compressed, so it will need to be decompressed
System.out.printf("Server: Decompressing message.\n");
GZIPInputStream decompressed = new GZIPInputStream(inputStream);
decompressed.read(receiveData, 0, receiveData.length);
decompressed.close();
// Get the uncompressed data, and deserialize it
String data = new String( receivePacket.getData() );
ObjectMapper mapper = new ObjectMapper();
Message message = mapper.readValue(data.trim(), Message.class);
// Print the received thing to console
System.out.printf("Message received:\n\n%s", message.toString());
System.out.printf("Received %d uncompressed bytes.\n", data.trim().getBytes().length);
} catch (IOException e) {
e.printStackTrace();
}
}
}
Like the Client class, a Server is run in a thread, and it starts by simply listening to a given port. We tell it that it will be receiving a byte array of size 800. We won’t know the true size of the incoming byte array, so we’ll need to overestimate a bit, then trim
the null characters later. The listening process blocks until a packet has been received.
We also prepare a ByteArrayInputStream
. Java input streams are part of the same concept of output streams, but in reverse. Instead of writing to the sink, we’re reading from it. In this case, we instantiate the ByteArrayInputStream
with a buffer array that will hold the contents of the received packet.
Once the packet has been received, it needs to be decompressed. We set up GZIPInputStream
using inputStream
as the source. This will read the input stream, decompress it, and store it in the receiveData
byte array. This decompressed data is now the JSON representation of the original message.
The decompressed data is instantiated as a String, and ObjectMapper
is called again, this time to deserialize the JSON into a Message
object. Note that trim()
is called on the String representation; this is because the byte array the packet was read into was (hopefully) larger than the decompressed data. As a result, there will be lots of null characters at the end. trim()
eliminates those and facilitates deserialization.
Finally, a Main
class to orchestrate everything:
import java.io.IOException;
public class Main {
public static void main(String[] args) throws IOException {
// Default to a port that's easy to remember
Integer commsPort = 1234;
// This is the object we're going to serialize, compress and send over UDP
Message message = new Message(
"Alice",
"Bob",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vestibulum dolor odio, sed sollicitudin sapien efficitur ut. Vestibulum gravida risus eu tellus aliquet commodo. Pellentesque sollicitudin orci et risus lobortis, quis elementum est bibendum. Suspendisse molestie cursus facilisis. Integer nec tellus pulvinar velit feugiat fermentum. Sed non risus sem. Pellentesque enim dui, interdum in pharetra id, bibendum eu ex. Praesent lacinia ante et dolor faucibus, quis dignissim eros pellentesque. Curabitur hendrerit imperdiet condimentum. Donec mi quam, elementum vel consectetur ac, posuere blandit ex. Sed vitae velit eu lectus rutrum condimentum. Donec vitae odio et mi vehicula euismod aliquam quis sem. Nulla aliquam convallis felis at tristique.");
// Instantiate a new Server. This will spin up a new thread that listens
// on the default port for incoming data
Server server = new Server(commsPort);
// Instantiate a new Client. This will spin up a new thread that sends
// the Message object over the network
Client client = new Client("127.0.0.1", commsPort, message);
}
}
This super-simple class starts up a Server and Client, each communicating on the same port, and at the localhost (127.0.0.1 for most users). It also constructs a Message for the Client to send.
Demonstration
I set up Gradle to pull in the Jackson libraries and package this code into a “fat” jar file. That process is a bit outside the scope of this post, but it isn’t really the important piece anyway. Running the fat jar in a terminal gives the following:
Server: Listening on port 1234.
Client: Preparing to broadcast to 127.0.0.1:1234.
Client: Compressing test message.
Client: Sending compressed message. 443 compressed bytes, 795 uncompressed.
Server: Decompressing message.
Message received:
To: Alice
From: Bob
Body: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vestibulum dolor odio, sed sollicitudin sapien efficitur ut. Vestibulum gravida risus eu tellus aliquet commodo. Pellentesque sollicitudin orci et risus lobortis, quis elementum est bibendum. Suspendisse molestie cursus facilisis. Integer nec tellus pulvinar velit feugiat fermentum. Sed non risus sem. Pellentesque enim dui, interdum in pharetra id, bibendum eu ex. Praesent lacinia ante et dolor faucibus, quis dignissim eros pellentesque. Curabitur hendrerit imperdiet condimentum. Donec mi quam, elementum vel consectetur ac, posuere blandit ex. Sed vitae velit eu lectus rutrum condimentum. Donec vitae odio et mi vehicula euismod aliquam quis sem. Nulla aliquam convallis felis at tristique.
Received 795 uncompressed bytes.
Success!
If you’d like to mess around with the code and try it out for yourself, I’ve posted it in a public GitHub repo, along with a gradle build file that will let you compile and run a fat jar. Instructions are in the README and should be easy to follow.
Summary
It’s a pretty neat pattern, and one that I haven’t really seen documented anywhere. Some variation of this will play a big role in the rocketry project, and I’m looking forward to fleshing it out a bit more.
Thoughts? Comments, corrections, suggestions? Hate mail? Shoot me a message and let me know!