While preparing another blog post, I realized that the part of the
code was quickly becoming larger than the part of the code that was meant to
illustrate the topic of the post. So I decided to write one blog post
entirely about how to send data from process A to process B over a plain
TCP/IP connection.

Who needs sending things at TCP/IP level?

Granted, many, if not most, scenarios, undoubtedly do better with a higher-level
network protocol that hides all the technical details beneath a fancy API.
And there are already plenty to choose from, depending on the needs:
Message queue protocols, gRPC, protobuf, FlatBuffers, RESTful Web API’s,
WebSockets, and so on.

However, in some situations–especially with small projects–, any approach
you choose may look like completely oversized, not to mention the additional
package dependencies that you’d have to introduce.

Luckily, creating simple network communication with
the standard net package
is not as difficult as it may seem.

Simplification #1: connections are io streams

The net.Conn interface implements the io.Reader, io.Writer, and io.Closer
interfaces
. Hence you can use a TCP connection
like any io stream.

I know what you think – “Ok, so I can send strings or byte slices over a
TCP connection. That’s nice but what about complex data types? Structs and such?”

Simplification #2: Go knows how to encode complex types efficiently

(Did you also just read “God knows…”? I think it happens to me almost every
other time I read this text.)

When it comes to encoding structured data for sending over the net, JSON comes
readily to mind. But wait – Go’s standard encoding/gob package provides
a way of serializing and deserializing Go data types without the need for
adding string tags to structs, dealing with JSON/Go incompatibilities, or waiting
for json.Unmarshal to laboriously parse text into binary data.

Gob encoders and decoders work directly on io streams – and this fits just
nicely into our simplification #1 – connections are io streams.

Let’s put this all together in a small sample app.

The sample app’s goal

The app shall do two things:

  1. Send and receive a simple message as a string
  2. Send and receive a struct via GOB

The first part–sending simple strings–shall demonstrate how easy it is
to send data over a TCP/IP network without any higher-level protocols.

The second part goes a step further and sends a complete struct over the
network, with strings, slices, maps, and even a recursive pointer to the
struct itself.

Thanks to the gob package, this requires no efforts. The following
animation shows how gob data gets from a client to a server, and when
this looks quite unspectacular, it’s because using gob is unspectacular.

It’s not much more than that!

Basic ingredients for sending string data over TCP

On the sending side

Sending strings requires three simple steps.

  1. Open a connection to the receiving process
  2. Write the string
  3. Close the connection

The net package provides a couple of methods for this.

ResolveTCPAddr() takes a string representing a TCP address (like, for example,
localhost:80, 127.0.0.1:80, or [::1]:80, which all represent port #80 on
the local machine) and returns a net.TCPAddr (or an error if the string
cannot be resolved to a valid TCP address).

DialTCP() takes a net.TCPAddr and connects to this address. It returns
the open connection as a net.TCPConn object (or an error if the connection
attempt fails).

If we don’t need much fine-grained control over the Dial settings, we can use
net.Dial() instead. This function takes an address string directly and
returns a general net.Conn object. This is sufficient for our test case;
however, if you need functionality that is only available on TCP connections,
you have to use the “TCP” variants (DialTCP, TCPConn, TCPAddr, etc).

After successful dialing, we can treat the new connection like any other
input/output stream, as mentioned above. We can even wrap the connection into
a bufio.ReadWriter and benefit from the various ReadWriter methods like
ReadString(), ReadBytes, WriteString, etc.

** Remember that buffered Writers need to call Flush() after writing,
so that all data is forwarded to the underlying network connection.**

Finally, each connection object has a Close() method to conclude the
communication.

Fine tuning

A couple of tuning options are also available. Some examples:

The Dialer interface provides these options (among others):

  • DeadLine and Timeout options for timing out an unsuccessful dial;
  • a KeepAlive option for managing the life span of the connection

The Conn interface also has deadline settings; either for the connection as
a whole (SetDeadLine()) or specific to read or write calls (SetReadDeadLine()
and SetWriteDeadLine()).

Note that the deadlines are fixed points in (wallclock) time. Unlike timeouts,
they don’t reset after a new activity. Each activity on the connection must
therefore set a new deadline.

The sample code below uses no deadlines, as it is simple enough so that we can
easily see when things get stuck. Ctrl-C is our manual “deadline trigger
tool”.

On the receiving side

The receiver has to follow these steps.

  1. Start listening on a local port.
  2. When a request comes in, spawn a goroutine to handle the request.
  3. In the goroutine, read the data. Optionally, send a response.
  4. Close the connection.

Listening requires a local port to listen to. Typically, the listening
application (a.k.a. “server”) announces the port it listens to, or if it
provides a standard service, it uses the port associated with that service.
For example, Web servers usually listen on port 80 for HTTP requests and
on port 443 for HTTPS requests. SSH daemons listen on port 22 by default,
and a WHOIS server uses port 43.

The core parts of the net package for implementing the server side are:

net.Listen() creates a new listener on a given local network address. If
only a port ist passed, as in “:61000”, then the listener listens on
all available network interfaces. This is quite handy, as a computer usually
has at least two active interfaces, the loopback interface and at least one
real network card.

A listener’s Accept() method waits until a connection request comes in.
Then it accepts the request and returns the new connection to the caller.
Accept() is typically called within a loop to be able to serve multiple
connections simultaneously. Each connection can be handled by a goroutine,
as we will see in the code.

The code

Instead of just pushing a few bytes around, I wanted the code to demonstrate
something more useful. I want to be able to send different commands with
different data payload to the server. The server shall identify each
command and decode the command’s data.

So the client in the code below sends two test commands: “STRING” and “GOB”.
Each are terminated by a newline.

The STRING command includes one line of string
data, which can be handled by simple read and write methods from bufio.

The GOB command comes with a struct that contains a couple of fields,
including a slice, a map, and a even a pointer to itself. As you can see when
running the code, the gob package moves all this through our network
connection without any fuss.

What we basically have here is some sort of ad-hoc protocol, where the client
and the server agree that a command is a string followed by a newline followed
by some data. For each command, the server must know the exact data format
and how to process the data.

To achieve this, the server code takes a two-step approach.

Step 1: When the Listen() function accepts a new connection, it spawns
a new goroutine that calls function handleMessage(). This function reads
the command name from the connection, looks up the appropriate handler
function from a map, and calls this function.

Step 2: The selected handler function reads and processes the command’s data.

Here is a visual summary of this process.

Keep these pictures in mind, they help reading the actual code.

The Code



Source link

LEAVE A REPLY

Please enter your comment!
Please enter your name here