Menu

Best Network Technologies

By Noam Yadgar
#design-patterns #network #tcp #grpc #rest #websocket #message broker #system-design #software-engineering

Network communication in general is a very wide topic. It can probably fit in a large section of the New York Public Library (if not already). Fitting even a brief overview of this subject from bottom to top in a single article, might be a bit pretentious. So I won’t do it. Instead, I’ll be focusing mainly on software design patterns and technologies for communicating over a network.

Let’s unravel the mystery

We will start our journey with TCP/IP . Unless your program directly communicates using electromagnetic waves, there’s a good chance that your program will communicate with the world, using TCP/IP. I know you’ve heard this term before, but what is it?

TCP/IP, or more accurately the Internet Protocol Suite, is pretty much the internet as we know it. This suite consists a set of communication protocols organized into four layers:

  • Datalink: The Datalink layer handles the physical infrastructure for communicating over a network. Things like hardware drivers, NICs (Network Interface Card), wireless networks, Ethernet cables, modems, and more.
  • Internet: The Internet layer is controlling data delivery, as well as routing. It takes files, chopped into packets of data, and delivers these packets through different routes. When they reach their destination, it’s also responsible for reassembling the packets. The internet layer is essentially hoping through networks to find optimal routes for packets to be delivered. It hops through a network of networks => an inter-network => internet :)
  • Transport: The Transport layer forms a connection between two devices. It’s also responsible for chopping the data into packets and attaching appropriate headers for the internet layer. Two of the most popular transport protocols are:
    • TCP (Transport Control Protocol): Guarantees no data loss, receiver needs to send acknowledgments. (more common)
    • UDP (User Datagram Protocol): Loss-tolerant and faster connection, receiver is not sending acknowledgments.
  • Application: The Application layer is the one we’re going to focus on in this article. Since this is the layer we (developers/users) interact with the most. This layer uses Ports to communicate with the transport layer. Some of the most popular application protocols are: HTTP, POP3, FTP, SMTP, SSH, IMAP, and many more.

Let’s make our simple TCP server

To unravel the mystery, let’s create a simple application on top of a TCP server. We’ll be using Go because setting up a TCP server with Go is incredibly easy.

package main

import (
	"fmt"
	"net"
)

func main() {
	srv, err := net.Listen("tcp", "localhost:7776")
	if err != nil {
		panic(err)
	}
	defer srv.Close()

	fmt.Println("starting server")
	for {
		c, err := srv.Accept()
		if err != nil {
			panic(err)
		}
		go handleReq(c)
	}
}

func handleReq(c net.Conn) {
	defer c.Close()
	buf := make([]byte, 512)
	c.Write([]byte("Enter your name:\n"))
	if _, err := c.Read(buf); err != nil {
		c.Write([]byte(err.Error()))
		return
	}
	c.Write([]byte(fmt.Sprintf("Hello %s", string(buf))))
}

If we’ll go ahead and run this main.go file:

go run main.go
starting server

In another terminal, We can connect directly to localhost:7776 using a tool like netcat:

nc localhost 7776
Enter your name:
Jane
Hello Jane

Cool! We just launched our very own TCP server. Theoretically, we can write our application protocol and implement this protocol in our different programs, so they would be able to communicate with each other, using this protocol.

Of course, writing your own protocol is not an easy task. Lucky for us, there are some brilliant applications already written and ready to use whenever we need to plug our program into a network. All of them cover a variety of use cases. Here are some of the most useful software-network design patterns and technologies:


REST API

REST API (Representational State Transfer), is probably the most popular and successful design pattern. It was proposed by Roy Fielding in 2000. The REST API leverages the HTTP (Hypertext Transfer Protocol) that has been around since 1989.

Use Case

To create a server-client relationship, mostly for websites, APIs and occasionally microservices.

General Architecture:
REST APIs are based on Resources and a set of four operations (known as CRUD) that clients can communicate with a server:

  • Create: Using the HTTP POST method, lets clients create resources.
  • Read: Using the HTTP GET method, lets clients read resources.
  • Update: Using the HTTP PUT/PATCH method, lets clients update resources.
  • Delete: Using the HTTP DELETE method, lets clients delete resources.
PROS CONS
Reliable and stateless Fixed endpoints
Easy to scale Less suitable for triggering live events
Widely supported Clients must adapt to the API
Automatic caching Uniform interface

Example

Searching for a book in an imaginary bookstore might look like this:

GET https://api.bookstore.net/books/a-great-book

The server response will be typically in a JSON format:

[
  {
    "id": "17a1ee01-9a6b-4954-8a53-8fb2291e8fff",
    "title": "a great book",
    "author": "jacob jacobson",
    "genre": "education",
    "price": 14.99,
    "currency": "USD",
    "description": "a very good book",
    "stock": 48
  }
]

The REST API doesn’t need that much of an advertisement, since it’s still probably the most popular design choice for software communication, especially websites and web communication in general.

GraphQL

The GraphQL is not a design pattern/convention, but a technology. It was originally written by Meta in 2012 and was open-sourced in 2015. Similarly to a REST API, the GraphQL API is using HTTP. But, unlike REST API, GraphQL has a layer of software written on top of HTTP. All requests are made with an HTTP GET request, using a single endpoint.

Use Case

GraphQL was made as a more flexible and bandwidth-light alternative to REST APIs. As smartphones gained popularity, the number of API consumers and the need for different client apps rose significantly. This caused two main issues:

  • All clients had to adapt the API’s interface. It mostly means that it’s harder to deploy schema updates for the served resources.
  • REST APIs have fixed responses, which means that when a client needs to query for custom data or joined resources, it needs to make multiple API calls. On large scales, this translates to an explosion of expensive API calls.

The Graph Query Language solves this by letting the client query data that fits the client’s needs exactly. Even if it means interacting with relational resources in a single call.

PROS CONS
Flexible and Predictable Requires extra software from both ends
Easily scalable Doesn’t use HTTP native status codes
Stateless No automatic caching
Economic More complicated relative to a REST API

Example

Let’s query only for book IDs, titles, and stock amounts

{
  books(filter: { title: { eq: "a great book" } }) {
    items {
      id
      title
      stock
    }
  }
}

The server response might be:

{
  "data": {
    "items": [
      {
        "id": "17a1ee01-9a6b-4954-8a53-8fb2291e8fff",
        "name": "a great book",
        "stock": 48
      }
    ]
  }
}

Since GraphQL is considered an alternative to REST API, the two are commonly compared. And since REST API is simpler to implement, I think that the use of GraphQL should be considered heavily before choosing to implement it.

WebSocket

Unlike the last two, WebSocket is an application-level protocol. While HTTP requests are prefixed with http:// or https:// for secured requests, WebSocket will use ws:// or wss:// for secured connection.

A WebSocket is essentially a Full-Duplex (Bi-Directional) TCP session between two sides. A client is making an HTTP GET request to a WebSocket server. The server is responding with a 101 “switching protocol” status, and a single, opened TCP connection is being made by the two sides, allowing them to exchange data in real-time.

Use Case

Let’s imagine you’re writing a beautiful application that lets remote users edit documents collaboratively. To make for the best user experience, you want your app to reflect changes in realtime. So if you and I are working on the same document, we will see each other inputs in real-time.

We can make this effect using a REST API. By making the client constantly request updates from the server (maybe every n units of time). This pattern is known as “short polling” and it’s very expensive and inefficient. We can do a lot better, by implementing a “long polling” mechanism. The client asks for updates, the server keeps the connection open until an update occurs (or it passed the timeout). Once the response is ready, the server answers the call, closes the connection, and, the client can immediately ask for updates again.

But using a REST API for this can be a bit slow, and besides, we don’t want to make requests while the users are not active (when the server constantly reaches timeouts). A WebSocket will keep a Full-Duplex TCP “highway” between each client and the server, allowing for event-driven data exchange, instead of time-based data exchange.

PROS CONS
Realtime data exchange Stateful
Fast and efficient Challenging to scale out
Widely supported Hardware requirements are higher (relative to others)
Easy to implement Required to keep connections alive for a long time

Example

Here’s a simple NodeJs chat. This is the server:

const ws = require("ws");

const wss = new ws.WebSocketServer({ port: 9999 });

console.log("starting chat");
wss.on("connection", (ws) => {
  ws.on("message", (data) => {
    wss.clients.forEach((client) => {
      client.send(data);
    });
  });
});

And the client:

const w = require("ws");
const readline = require("readline");
const user = process.argv[2];

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});
const ws = new w.WebSocket("ws://localhost:9999");

ws.on("open", () => {
  rl.on("line", (msg) => {
    ws.send(user + " > " + msg);
    process.stdout.moveCursor(0, -1);
    process.stdout.clearLine(1);
  });
});

ws.on("message", (data) => console.log("%s", data));

If we run the server and two other separated terminals of clients, we can chat:

cat > hi, I am a cat  cat > hi, I am a cat
dog > hi cat!         dog > hi cat!
I am a dog...       

gRPC

Moving outside of the browser, gRPC (Remote Procedure Call), is an open-sourced, modern implementation of RPC created by Google in 2015. Similarly to REST API, gRPC uses the HTTP protocol. To be more accurate, it’s built on top of HTTP/2.0, which is faster and supports streams. But this is where the technical similarities end, as the gRPC framework takes a very different approach.

To quote the official documentation:

In gRPC, a client application can directly call a method on a server application on a different machine as if it were a local object, making it easier for you to create distributed applications and services.

gRPC uses something called Protocol Buffers (.proto files). A protobuf file describes structured data (such as interfaces and datatypes) in a language-neutral syntax. With a Protocol Buffer compiler (protoc), you can generate data access classes in all popular programming languages. Those classes will have methods to send, receive and serialize protobuf data, which RPC is sending as pure binaries. For each language, you can use the gRPC package to spin up a server and hook it to the compiled protobufs services, creating a bidirectional communication between clients and a server, using native code and fast data serialization (around 5 times faster than JSON)

Use Case

The gRPC framework is great for microservices communication. Using the .proto files, It’s easy to create API contracts that have well-defined data types and methods. The protobuf compiler (protoc) automatically creates data access classes in different programming languages of choice, for both a gRPC Server and a gRPC Stub (client).

PROS CONS
Supports streams in bidirectional communication Requires extra software
High performance and well-defined APIs Less compatible with functional languages
Great for scale Doesn’t have full browser support
Auto-generated boilerplates No consistent error and caching infrastructure

Example

syntax = "proto3";

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string greet = 1;
}

This simple .proto file defines a service with a single method and two datatypes. Let’s compile this file to Golang (of course we can choose a different language)

protoc --proto_path=./proto \
       --go_out=. --go_opt=Mgreeter.proto=/greeter \
       --go-grpc_out=. --go-grpc_opt=Mgreeter.proto=/greeter \
       proto/greeter.proto

This will automatically generate a Go package, consisting of two files:

greeter.pb.go
greeter_grpc.pb.go

We can use this package and implement a gRPC server:

package main

import (
	"context"
	"log"
	"net"

	"google.golang.org/grpc"
	pb "my_grpc_service/greeter"
)

type server struct {
	pb.UnimplementedGreeterServer
}

func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	return &pb.HelloReply{Greet: "Hello " + in.GetName()}, nil
}

func main() {
	srv, err := net.Listen("tcp", ":5555")
	if err != nil {
		panic(err)
	}
	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})
	log.Printf("server srvtening at %v", srv.Addr())
	if err := s.Serve(srv); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

And a client:

package main

import (
	"context"
	"flag"
	"fmt"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	pb "my_grpc_service/greeter"
)

var name = flag.String("name", "all", "Name to greet")

func main() {
	flag.Parse()
	conn, err := grpc.Dial("localhost:5555", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
    panic(err)
	}
	defer conn.Close()
	c := pb.NewGreeterClient(conn)

	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	if res, err := c.SayHello(ctx, &pb.HelloRequest{Name: *name}); err != nil {
		fmt.Println(err)
	} else {
	  fmt.Println(res.GetGreet())
  }
}

We can run our server:

go run greeter_server/main.go
2023/01/13 14:21:58 server listening at [::]:5555

And in another terminal, run our client project with a name flag:

go run greeter_client/main.go --name Jane
Hello Jane

Notice how our client directly calls SayHello as if it was a local procedure.

Message Broker

In essence, a message broker sits in the middle of endpoints, manages their communication, and, decouples them. Instead of directly connecting microservices, we spin up a dedicated service that acts as a mediator between the microservices.

This form of architecture makes a lot of sense in the topological aspect of microservices networking. Two of the most popular design patterns that are based on a message broker are:

  • Message Queue : A service can send a message to another service, but instead of directly sending the message, this message will be stored by the broker in a FIFO (1st in 1st out) message queue. The broker can store this queue in some form of persistent memory, so whenever the target service is ready, it can pull messages from the queue. This pattern decouples the sender and the receiver and creates an asynchronous behavior. The target service doesn’t necessarily need to be alive for the message to be sent. We can set up multiple consumers for a single queue, and they will “compete” for the messages, essentially pulling out messages from the same queue. This is great when you need to scale out your service.
  • Publish/Subscription This pattern is a bit more flexible than a “simple” message queue. Instead of setting an end to end queues by the broker, we will use topics/channels. Publishers can produce messages to specific topics and, consumers can subscribe to any topic, they want to consume messages. Consumers don’t necessarily have to “compete” for messages from the same topics, but receive the same messages. Some “pub/sub” brokers like Apache Kafka go as far as using streams instead of queues. So consumers can read \(n\) messages, and commit to a stream offset.

Use Case

Here is some graph theory for you: Imagine a cluster of microservices, all working together to serve a product. Let’s suppose we want to connect all microservices to each other. Using any form of duplex communication can potentially yield \(\sum\_{i=0}^{n-1}i\) connections (duplex) to manage as \(n=services\).

So, a cluster of just 10 services, directly communicating can form 45 distinct connections. Besides the large number of connections we have to manage (which can also be managed quite elegantly using a service discovary ), there are a few other big issues, a message broker will overcome:

  • Clients don’t have to be alive, waiting for responses.
  • Services don’t have to be alive to receive “requests”.
  • Services can be completely oblivious of each other.

Our 10 services all connected together will form 10 connection to a message broker (Known as a Star network topology). It’s important to point out the message brokers are typically not optimized for large data transfer (as opposed to technologies like gRPC).

PROS CONS
Services are decoupled Requires a running service
Asynchronous behavior Requires a form of persistent memory
Great for scale If the brokers are down, we lose all communication
Easy to connect Might be overkill for small projects

Example

Let’s spin up the popular message broker RabbitMQ , using Docker

docker run -d --hostname codepilot --name rabbitmq -p 5672:5672 rabbitmq:3

Now we can write a program that connects and sends a message to a quque:

This code is taken from the official RabbitMQ tutorial

package main

import (
	"context"
	"log"
	"time"

	amqp "github.com/rabbitmq/amqp091-go"
)

func failOnError(err error, msg string) {
	if err != nil {
		log.Panicf("%s: %s", msg, err)
	}
}

func main() {
	conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
	failOnError(err, "Failed to connect to RabbitMQ")
	defer conn.Close()

	ch, err := conn.Channel()
	failOnError(err, "Failed to open a channel")
	defer ch.Close()

	q, err := ch.QueueDeclare(
		"greeter", // name
		false,     // durable
		false,     // delete when unused
		false,     // exclusive
		false,     // no-wait
		nil,       // arguments
	)
	failOnError(err, "Failed to declare a queue")
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	body := "Hello World!"
	err = ch.PublishWithContext(ctx,
		"",     // exchange
		q.Name, // routing key
		false,  // mandatory
		false,  // immediate
		amqp.Publishing{
			ContentType: "text/plain",
			Body:        []byte(body),
		})
	failOnError(err, "Failed to publish a message")
	log.Printf(" [x] Sent %s\n", body)
}

And let’s write a program that receives messages from this queue:

package main

import (
	"log"

	amqp "github.com/rabbitmq/amqp091-go"
)

func failOnError(err error, msg string) {
	if err != nil {
		log.Panicf("%s: %s", msg, err)
	}
}

func main() {
	conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
	failOnError(err, "Failed to connect to RabbitMQ")
	defer conn.Close()

	ch, err := conn.Channel()
	failOnError(err, "Failed to open a channel")
	defer ch.Close()

	q, err := ch.QueueDeclare(
		"greeter", // name
		false,     // durable
		false,     // delete when unused
		false,     // exclusive
		false,     // no-wait
		nil,       // arguments
	)
	failOnError(err, "Failed to declare a queue")

	msgs, err := ch.Consume(
		q.Name, // queue
		"",     // consumer
		true,   // auto-ack
		false,  // exclusive
		false,  // no-local
		false,  // no-wait
		nil,    // args
	)
	failOnError(err, "Failed to register a consumer")

	var forever chan struct{}

	go func() {
		for d := range msgs {
			log.Printf("Received a message: %s", d.Body)
		}
	}()

	log.Printf(" [*] Waiting for messages. To exit press CTRL+C")
	<-forever
}

If we run the sender.go

go run sender.go
2023/02/02 16:00:11  [x] Sent Hello World!

Let’s stop the sender and run the receiver.go

go run receiver.go
2023/02/02 16:01:38  [*] Waiting for messages. To exit press CTRL+C
2023/02/02 16:01:38 Received a message: Hello World!

Notice how we didn’t have to keep the two endpoints alive at the same time. The sender is simply sending messages to the greeter queue and, the receiver is simply consuming messages from this queue.

Wrap up

This is where I’m going to end, as this article is already getting quite long. Of course, there are many more protocols and technologies to talk about, such as SMTP for email transfer, FTP for file transfer, SSH for shell connection and many more. Maybe I’ll cover some of them in the future. I hope you’ve found this article interesting and enriching. If you have any suggestions, please don’t hesitate to contact me.

Thank you for reading.