Part 3: Talking and Listening
Introduction⌗
We’ve all been there. It’s ten minutes before the debate competition. In every corner of the waiting room stands a candidate, rehearsing their arguments and making sure that their stance is believable to themselves. The more astute of them however, would go forth and and debate themselves, trying to find those miniscule improvements in semantics that could bolster their defenses against a ruthless interjector.
Now imagine if they were all in the same coordinates (somehow). They would all
be talking over each other and not knowing whether they were listening to
themselves or another guy who was right there. Now imagine you have multiple
applications listening on the same source (port, if you want to be precise).
The issue is, you never know which application should parse which message!
Of course, the underlying infra in your computer will not allow you to set up
two servers (listeners) in the same location, unless you go with
SO_REUSEPORT
,
which is a whole different ball game and lead to errors out of your control.
For now, you’ll get the below error if you try running two instances of the previous program parallely:
Error: Os { code: 98, kind: AddrInUse, message: "Address already in use" }
But you don’t really want to have to compile each component separately, especially if it’s something complex that will need to be dynamically compiled each time you run the component. “You want to have another process that does more listening? Please wait another minute while we compile it again with 4 characters changed and spin it up.”
But wait, what if we could somehow tell the program at start that it should begin listening at a certain port? That way, when we need to start something up, we can just ask it to spin itself up at a certain port! This sort of behavior is possible through invocation arguments, and this allows us to, as per part 1, let the component know in what context it spawns.
Invocation Arguments⌗
Invocation arguments are additional data that you start your program with. For example, in the following code:
$ ls -l
The -l
is a flag that you send to ls
. It’s an option that tells you that the
ls
program must list the files in a long listing format, with all the details
that you need. Consider the following example:
$ tail -f my_file.txt
This implies that tail
should -f
(follow) my_file.txt
. Here, the -f
part
is the optional argument, and my_file.txt
is the value for it. We will write
something similar for our code.
Ideally what we want to do is provide a way for our program to choose a port to listen in on when it comes up. Let’s say something like
$ myprog -p 7777
ensures it listens on port 7777.
Invocation Arguments for Rust⌗
For a rich argument parser for our program, we shall use
getopts
. This works similar to C’s
getopt
function, but, IMHO, is a lot easier to work with. Add it to your
cargo.toml
:
getopts = "0.2"
Now we need to use it in our program. Import it as follows:
use getopts::Options;
So how do we define options? We can define them as follows:
let mut opts = Options::new();
opts.optopt("p", "port", "choose port to run on", "PORT");
This creates an Options
struct that we can parse the options into. But in
order to parse the options, we must first find a way to read all the arguments
that we pass to the program. To do that, we shall use the std::env
namespace.
We can collect the arguments as follows:
use std::env;
And then, within the main function:
let args: Vec<String> = env::args().collect();
Now we can parse our arguments as follows:
let matches = match opts.parse(&args[1..]) {
Ok(m) => m,
Err(f) => {
panic!("{:?}", f.to_string());
}
};
So why are we parsing our arguments from the second index? The args
vector
always contains the program name as the first element. This helps write proper
usage messages, identify source of logs for the program, etc. However, we do
not want to parse the program name with our options, and so we take a slice of
the vector from the second element and try to parse the elements into the match
object, handling errors as necessary.
The below code lets us parse the port from the args:
// Check if there is a string parameter for the 'p' argument
let port = matches.opt_str("p");
// If none, panic and exit
if port.is_none() {
panic!("No port specified!");
}
// Set the listening address as localhost:port.
let addr = format!("127.0.0.1:{}", port.unwrap());
Now all that is left is to replace all occurrences of 127.0.0.1:8080
with
addr
, and we’re done!
Testing it out⌗
Let’s test it out with the following on your shell. Run the following:
$ cargo build
Then run the program explicitly (the program/project name might be different in your machine) as follows:
$ cd target/debug
$ ./simple_socket_comm -p 9999 & ./simple_socket_comm -p 9997
[1] 1207039
Server is listening on 127.0.0.1:9997
Server is listening on 127.0.0.1:9999
New connection from 127.0.0.1:47720
New connection from 127.0.0.1:35852
Wrote data: "Talking to myself"
Wrote data: "Talking to myself"
Received: "Returning: Talking to myself"
Received: "Returning: Talking to myself"
Connection closed by 127.0.0.1:35852
Connection closed by 127.0.0.1:47720
Here, we can see two instances of our simple listener talking to themselves by sending the same data to themselves!
Talking to each other⌗
I originally wanted to write this as its own article, but the change is trivial enough that it can be a simple addendum to this one. Let’s add a “target port” parameter that we use where we expect the other system to be listening in.
opts.optopt("t", "target", "Target of receiver", "TARGET_PORT");
Then parse it as follows:
let target = matches.opt_str("t");
if target.is_none() {
panic!("No target specified!");
}
Now we can connect to the target as follows:
let mut write_stream = match TcpStream::connect(
target_addr.clone()).await {
Ok(val) => val,
Err(e) => {
eprintln!("Could not connect: {:?}", e);
return;
}
};
Let’s try running this again.
$ ./simple_socket_comm -p 5555 -t 5556 & ./simple_socket_comm -p 5556 -t 5555
[1] 94453
Server is listening on 127.0.0.1:5555
Server is listening on 127.0.0.1:5556
New connection from 127.0.0.1:39862
Wrote data: "Talking to myself"
Wrote data: "Talking to myself"
New connection from 127.0.0.1:53044
Received: "Returning: Talking to myself"
Connection closed by 127.0.0.1:39862
Received: "Returning: Talking to myself"
Connection closed by 127.0.0.1:53044
This example still says “talking to myself”. Is there a way we can customize the message we send?
Next steps⌗
Next, let’s explore deploying these with docker.