Best Network Technologies
By Noam YadgarNetwork 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.