Pull to refresh

How to send messages over sockets and create your own messanging protocols in C++

Level of difficultyMedium
Reading time28 min
Views3.8K

Introduction

The goal of this article is to provide you, my dear reader, with information on how to actually send messages over sockets to other clients in a real-world chat application written in C++. We will go over some of the most common messanging protocols and ways to implement them. Surely, I am no guru in programming or networking, but I am learning and improving in this field substantially; as you all know, the best way to learn is to teach. So I am excited to share with you what I have learned over the course of my programming career.

To follow along this article and compile the code included here, you would need C++17 or higher.

Table of Contents:

  1. Prerequisite knowledge for the article

  2. Obstacles in learning networking in C++

  3. A little bit about sockets

  4. How to create a server and a client

    1. Creating a server

    2. Creating a client

  5. Messanging protocols

    1. Custom Command-Signals

    2. JSON

    3. Protobuf

  6. Summary


Prerequisite knowledge for the article.

In order to feel comfortable while grasping my knowledge of C++ network coding (I am myself far from PRO), ideally, you should at least know or slightly be sensible in the following concepts:

  1. C/C++

  2. Basic network concepts (IP/TCP, Client-Server infrastructure, etc.)

  3. Linux/Windows

  4. Asynchronous programming

  5. Multithreading

Obstacles in learning networking in C++.

When I first started learning networking in C++, the biggest challenge for me was to find any information on how to ACTUALLY create reliable networking applications, where connected clients exchange messages. Almost every article talks only about the technical part of sockets but not about the implementation in real applications. Additionally, I was quite surprised that so few articles describe network sockets in terms of C++, not C. This bewildered and challenged me to make something with C++, and not old-school C (sending love, C programmers. Don't get angry).

Admittedly, this code cannot be pure C++, since POSIX and Windows Sockets originate in C. Nonetheless, I will try to arm you with as much C++ network coding as possible.

There is a book called Hands-On Network Programming with C: Learn socket programming in C and write secure and optimized network code and the legendary Beej's Guide to Network Programming, both of which have given me the fundamental knowledge in network C programming. I would advise you to read them first and then come back here.

A little bit about sockets.

Before I get into the depths of implementing messanging logic, let me briefly recap on how sockets work and how to create a client and a server (yeah, here I will talk about the tech part as well, ugh). If you have experience in socket programming, feel free to skip to the next chapter.

If you are new to the concept, a socket is basically a “tunnelling” object that connects the connecting party (client) and accepting this connection party (server). Sockets are also used for connecting files or other parts of the operating system inside itself; a socket is a broad concept, remember. UNIX-like and Windows operating systems provide almost ubiquitous sockets API, so the cross-platform differences we will have to take into account aren’t many.

This is an illustration of typical communication over the network:

Client connecting to a server.
Client connecting to a server.

A server is nothing more than a device accepting connections from other devices and sharing data with them.

You can switch texts between those two devices on the image and you would stil be left with a correct picture of the network communication. But a more familiar to you representation of network communication is when multiple clients are connected to one server, just like when you visit Instagram and you see your friends online or when you play a video game with other people:

A server (red antenna) handling multiple connection.
A server (red antenna) handling multiple connection.

Here, the server, a.k.a listenning socket, holds numerous sockets pointing to different devices.

An interesting thing about it all is that a client is only aware of its own connection. For example, when you connect to a website, your device thinks that it is the only one connected to a server (the "tunnelling" object), unless the server sends extra information about current online to the client. We will need this information later when we will be prototyping our client and server.

When it comes to programming in C, C++, Python, and other languages, socket interface is almost uniform and has quite the same functions. For instance, to create a socket in C/C++, we need to include required headers.

  • For windows: #include <winsock2.h>

    • More on winsock2.h header-file

  • For UNIX-like systems: #include <sys/socket.h>

    • More on sys/socket.h header-file

After this step, most common functions for using sockets that we are about to use include: socket(), send(), recv(), getaddrinfo(), connect(), accept(), bind(), close()/closesocket(), getpeername(), inet_ntop().

No need to read about each of the system call at the moment, since you will discover them later in the article with a more detailed explanation.

How to Create a Server and a Client.

Now the kids' part is over and it's time we roll the sleeves up and get the big guns. We are about to hard-code the server and the client and discuss each of the steps. The code presented later in the article is suited both for Windows and UNIX systems, cross-platform development.

Our first step, as in every program, is to include and define all imperative components. For the sake of this article, I am going to put them in common.h, since both the client and the server are in the same directory.

Definitions and includes
// common.h

#pragma once

#ifdef _WIN32 // If host's OS is Windows
    #ifndef _WIN32_WINNT // Necessary for properly initializing WindowsAPI
        #define _WIN32_WINNT 0x0600 // minimum - Windows Vista (0x0600)
    #endif

    #include <winsock2.h>
    #include <ws2tcpip.h>
    #pragma comment(lib, "ws2_32.lib") // For VS compiler
#else // Other UNIX-like OSes
    #include <sys/socket.h> 
    #include <sys/types.h>
    #include <arpa/inet.h>
    #include <netinet/in.h>
    #include <netdb.h>
    #include <unistd.h>
    #include <errno.h> // For getting error codes
    #include <sys/select.h>
#endif

#include <string>
#include <iostream>
#include <cstring>
#include <ctime>

// Cross-Platform macros
#ifdef _WIN32
    #define CLOSE_SOCKET(s) closesocket(s)
    #define GET_SOCKET_ERRNO() (WSAGetLastError())
    #define IS_VALID_SOCKET(s) ((s) != INVALID_SOCKET)
#else
    #define SOCKET int
    #define CLOSE_SOCKET(s) close(s)
    #define GET_SOCKET_ERRNO() (errno)
    #define IS_VALID_SOCKET(s) ((s) >= 0)
#endif

#define MAX_DATA_BUFFER_SIZE 2048

// Adds 4-digit length to the beginning of the message string: message.size() == 4 -> "0004<message_string>"; message.size() == 1002 -> "1002<message_string>"
void PrependMessageLength(std::string& message){
    std::string message_size_str = std::to_string(message.size());
    while (message_size_str.size() < 4){
        message_size_str = "0" + message_size_str;
    }
    message = message_size_str + message;
}

In this piece of code, we have included and defined necessary macros. Feel free to explore each include header in more detail but you don’t need to. Just remember that they are needed to be linked for your networking application. Now to the macros. You can read more about what macros are, but basically, they are small helper-functions that are generated at the compile time; the reason I used them is for convenience and platform-specifics handling.

As you can see, I have defined macros with the same names for different  operating systems. Surprisingly, this is the crux of the cross-platform programming: we encapsulate platform specifics in objects with same names and later we use them as usual functions. For instance, to close a socket on Windows, we need to call closesocket(), whereas on Linux or Mac we would call just close(). Also, on Windows, the socket is represented with SOCKET type but on Linux/Mac, this is just a regular int. Also, I have defined some helper structure and functions we will need later on.

There is also PrependMessageLength() function, which prepends packet length to the actual packet. This is going to be the backbone of all our Client-Server communication.

Creating a server.

Helper structures and functions source code
// server.h
#include "common.h"

#include <unordered_map>

#define BACKLOG 10

struct ConnectionInfo{
    bool success;
    std::string address;
    std::string port;

    std::string ToString() const noexcept{
        using namespace std::string_literals; // String optimization

        return address + ":"s + port;
    }
};

inline static ConnectionInfo GetConnectionInfo(sockaddr_storage* addr){
    // Create required structures for getpeername() call
    sockaddr_in* conn_addr = reinterpret_cast<sockaddr_in*>(addr); // informational structure for IPv4 connections

    ConnectionInfo ret_conn;
    ret_conn.success = false;

    char ip_addr[INET_ADDRSTRLEN];
    // Retrieve the IP address from the connection and write it to ip_addr string buffer.
    inet_ntop(AF_INET, &conn_addr->sin_addr, ip_addr, INET_ADDRSTRLEN);

    ret_conn.address = std::string(ip_addr);
    ret_conn.port = std::to_string(conn_addr->sin_port);
    ret_conn.success = true;

    return ret_conn;    
}

  • ConnectionInfo : structure that holds information about a connection.

  • GetConnectionInfo(sockaddr_storage&) : obtains ConnectionInfo from sockaddr_storage structure. Here I used inet_ntop() function to get the string representation of the address, which has this simplified signature: inet_ntop( IP_family , address_structure_with_connection_details , pointer_to_buffer_to_write_address_string_to , length_of_the_IP_address )

Let's also create a class for our server because we all love OOP, don't we?:)

Server class signature
// server.h
#include "common.h"

#include <unordered_map>
  
class Server{
public:
    Server(const char* hostname, const char* port) : hostname_(hostname), port_(port) {}

    // Prohibit any copying.
    Server(const Server& other) = delete;
    Server& operator=(const Server& other) = delete;

    ~Server();
public:
    
    int Start() noexcept;

    void Shutdown() noexcept;

private:
    /* Creates a new configured server socket from `bind_address`.
     * @return valid socket on success, invalid one on error
    */
    SOCKET CreateServerSocket(addrinfo* bind_address) noexcept;

    /* Resolves server local address based on the `hostname_` and `port_` variables.
     * @return `nullptr` on error, `addrinfo` pointer on success.
    */
    addrinfo* GetServerLocalAddress() noexcept;

    /* Sets necessary options for `serv_socket`.
     * @returns `-1` on error, `0` on success.
    */
    int ConfigureServerSocket(SOCKET serv_socket) noexcept;

    /* Send `message` to `receipient_socketfd`.
     * @return `-1` on error, sent bytes on success.
    */
    int SendMessage(SOCKET receipient_socketfd, const std::string& message) noexcept;
    
    /* Send `message` from `conn_from` to all connected clients.
     * @return `-1` on error, `0` on success.
    */
    int BroadcastMessage(const std::string& message, const ConnectionInfo& conn_from) noexcept;
    
    /* Receive data from `sender_socketfd` and write it to `writable_buffer`.
     * @return `-1` on error, `0` on client disconnect, received bytes on success.
    */
    int ReceiveMessage(SOCKET sender_socketfd, char* writable_buffer) noexcept;

    /* Tries to accept a new incoming connection to `server_socket_`.
     * @return `-1` on error, `0` on success.
    */
    int AcceptConnection() noexcept;

    void DisconnectClient(SOCKET sockfd) noexcept;

    /* Server's main loop. Handles incoming data and accepts new connections.
     * @return `-1` on error, `0` on successful shutdown.
    */
    int HandleConnections() noexcept;

private:
    const std::string hostname_, port_;

    SOCKET server_socket_;

    // Variables for select() system call.
    SOCKET max_socket_;
    fd_set sock_polling_set_;

    std::unordered_map<SOCKET, ConnectionInfo> connected_clients_;
};

Server class does not allow copying, since using the same sockets in different parts of the application is a slightly more advanced and dangerous path to follow. I also used here something you could call the Facade pattern , covering all logic from the class user in the private section.

Facade
Facade

fd_set type is a part of <sys/select.h> header file, which provides convienient interface for handling multiple connections. (More about select()). Note, however, select() is becoming obsolete for modern network applications and C++20 coroutines are growing more popular. But in order to use new sugar features, one must possess solid basis in underlying concepts, so don't worry for now. Further reading: select().

Now that we have the signature for the server class, let's define each of the method one by one:

Start() and Shutdown() source code
// server.cpp
#include "server.h"

using namespace std::string_literals; // String operations optimization

int Server::Start() noexcept{
    addrinfo* server_addr_struct = GetServerLocalAddress();
    if (!server_addr_struct){
        return -1;
    }

    server_socket_ = CreateServerSocket(server_addr_struct);
    if (!IS_VALID_SOCKET(server_socket_)){
        return -1;
    }
    freeaddrinfo(server_addr_struct); // Free the resources we won't need anymore.

    // Add the server socket to the socket polling list.
    FD_ZERO(&sock_polling_set_);
    FD_SET(server_socket_, &sock_polling_set_);
    max_socket_ = server_socket_;

    return HandleConnections();
}

void Server::Shutdown() noexcept{
    std::cerr << "[Info] Shutting down the server...\n"s;
    CLOSE_SOCKET(server_socket_);
    std::cerr << "[Info] Server is shut down." << std::endl;
}

Methods Start() and Shutdown() are pretty self-explanatory. They simply provide the facade for our server class, like a switch in your room. After the successful creation of the server socket, we should free the memory taken by the addrinfo variable and clear our polling set (where we will store connected client sockets) with FD_ZERO and set our server socket, with FD_SET, as the first connected socket in order to receive data on it.

CreateServerSocket(addrinfo*) and ConfigureServerSocket(SOCKET) source code
// server.cpp
...
  
SOCKET CreateServerSocket(addrinfo* bind_address) noexcept{
    if (!bind_address){
        std::cerr << "[Error] CreateServerSocket(): bind_address is NULL."s << std::endl;
        return -1;
    }

    // Create a new socket from the resolved address
    std::cerr << "[Debug] Creating server socket object.\n"s;
    SOCKET server_socket = socket(bind_address->ai_family, bind_address->ai_socktype, bind_address->ai_protocol);
    if (!IS_VALID_SOCKET(server_socket)){
        std::cerr << "[Error] Failed to create server socket: socket(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return -1;
    }

    std::cerr << "[Debug] Binding socket to the resolved address."s << std::endl;
    if (bind(server_socket, bind_address->ai_addr, bind_address->ai_addrlen) == -1){
        std::cerr << "[Error] Failed to bind server socket to address "s << hostname_ << ":"s << port_ << " : bind(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return -1;
    }
    // Setting all necessary options for the server socket
    if (ConfigureServerSocket(server_socket) == -1){
        return -1;
    }
    return server_socket;
}

...

    
int ConfigureServerSocket(SOCKET serv_socket) noexcept{
#ifdef _WIN32
    char yes = 1; // Setting variable
#else
    int yes = 1;
#endif
    std::cerr << "[Debug] Setting SO_REUSEADDR socket option to the server socket.\n"s;
    if (setsockopt(serv_socket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1){
        std::cerr << "[Error] Failed to set socket options: setsockopt(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return -1;
    }

    std::cerr << "[Debug] Activating server listenning mode.\n"s;
    if (listen(serv_socket, BACKLOG) == -1){
        std::cerr << "[Error] Failed to activate socket listenner: listen(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return -1;
    }
    std::cout << "[Info] Server is listenning for incoming connections at "s << hostname_ << ":"s << port_ << '\n';
    return 0;
}

CreateServerSocket() coupled with ConfigureServerSocket() are two fundamental methods for our server, since they create and configure its listennig socket. Here I, finally, used the socket() syscall, which has this simplified signature: socket( IP_address_family, socket_type , protocol ) . A socket type is either SOCK_STREAM or SOCK_DGRAM; in our case, we just pass data from sockaddr_in structure we got earlier. Firstly, we bind() the socket to a particular resolved address (otherwise, what's the point of it all, if the server doesn't have an address clients can connect to?).

Later, I used setsockopt() and listen() to set necessary settings for the server socket. The later one is the last step for configuring the server socket, since by calling listen(), we activate this listenning mode I talked previously about. BACKLOG is the number of clients that are allowed to be queded up in a "connection line" to the server. Further reading: socket(), setsockopt(), listen() and backlog.

GetServerLocalAddress() source code
// server.cpp
...

addrinfo* GetServerLocalAddress() noexcept{
    // Create needed data structures
    addrinfo hints, *bind_address;
    // Create a configuration structure for getting server's address structure
    memset(&hints, 0x00, sizeof(hints));
    hints.ai_family = AF_INET; // Use IPv4
    hints.ai_socktype = SOCK_STREAM; // Use TCP

    // Try to resolve server's local address and write it to bind_address variable
    std::cerr << "[Debug] Resolving server hostname.\n"s;
    if (getaddrinfo(hostname_.data(), port_.data(), &hints, &bind_address) != 0){
        std::cerr << "[Error] Failed to resolve server's local address: getaddrinfo(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return nullptr;
    }

    return bind_address;
}

Method GetServerLocalAddress() creates a new addrinfo structure object that will subsequently used for creating server's socket off of. We set preferences for getaddrinfo() function to hints variable in order to not have to worry about other nitty-griddy stuff that can be taken care of for us. Further reading: getaddrinfo(), memset().

AcceptConnection() and DisconnectClient(SOCKET) source code
// server..cpp

...
int AcceptConnection() noexcept{
    // Create new structures for storing data about connecting client.
    sockaddr_storage conn_addr;
    socklen_t conn_len = sizeof(conn_addr);

    SOCKET new_conn = accept(server_socket_, reinterpret_cast<sockaddr*>(&conn_addr), &conn_len);
    ConnectionInfo new_conn_info = GetConnectionInfo(&conn_addr);
    if (!IS_VALID_SOCKET(new_conn)){
        std::cerr << "[Error] Failed to accept new connection from "s << new_conn_info.ToString() << " : accept(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return -1;
    }

    std::cout << "[Info] New connection from "s << new_conn_info.ToString() << '\n';

    // Add the newly connected client to our server data structures.
    if (new_conn > max_socket_){
        max_socket_ = new_conn;
    }
    FD_SET(new_conn, &sock_polling_set_);
    connected_clients_[new_conn] = new_conn_info;
    return 0;
}

void DisconnectClient(SOCKET sockfd) noexcept{
    const ConnectionInfo& conn_info = connected_clients_.at(sockfd);
    std::cerr << "[Info] Client "s << conn_info.ToString() << " has been disconnected.\n"s;
    
    CLOSE_SOCKET(sockfd);
    connected_clients_.erase(sockfd);
    FD_CLR(sockfd, &sock_polling_set_);
}

I suppose that the names of these two methods are quite obvious, so I won't go in talking about why we need them and how they work. But just note that accept() will either return a connected socket number, or an invalid socket and set the error (errno on Linux and WSAGetLastError() on Windows). By the time accept() has returned a valid socket, TCP or UDP connection, based on your socket, is established with a client. Further reading: errno, WSAGetLastError(), accept().

DisconnectClient(SOCKET) and HandleConnections() source code
// server.cpp
...
  
void DisconnectClient(SOCKET sockfd) noexcept{
    const ConnectionInfo& conn_info = connected_clients_.at(sockfd);
    std::cerr << "[Info] Client "s << conn_info.ToString() << " has been disconnected.\n"s;
    
    CLOSE_SOCKET(sockfd);
    connected_clients_.erase(sockfd);
    FD_CLR(sockfd, &sock_polling_set_);
}

int HandleConnections() noexcept{
    while (true){
        // Create a copy of the main polling set, since select() modifies fd_set structure.
        fd_set polling_set_copy = sock_polling_set_;
        
        // See if there is any data available for reading.
        if (select(max_socket_ + 1, &polling_set_copy, NULL, NULL, NULL) < 0){
            std::cerr << "[Error] Failed to fetch data on the server socket: select(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
            return -1;
        }
        
        // Find the socket who sent the data.
        for (SOCKET sock = 1; sock <= max_socket_ + 1; ++sock){
            if (FD_ISSET(sock, &polling_set_copy)){ // the socket should be in the polling set.
                if (sock == server_socket_){ // Received data on the listenning socket == new connection
                    if (AcceptConnection() == -1){ // Almost always, if a client fails to connect, the problem is related to the server.
                        return -1;
                    }
                }
                else{ // Regular client is sending us some data
                    char msg_buffer[MAX_DATA_BUFFER_SIZE];
                    memset(msg_buffer, 0x00, MAX_DATA_BUFFER_SIZE);

                    int recv_bytes = ReceiveMessage(sock, msg_buffer);

                    if (recv_bytes <= 0){ // An error has occurred
                        DisconnectClient(sock);
                        continue;
                    }
                    std::string msg_str(msg_buffer);
                    BroadcastMessage(msg_str, connected_clients_.at(sock));
                    
                }
            } // if (FD_ISSET)
        } // for (SOCKET sock)
    } // while (true)
    return 0;
}

DisconnectClient() is a straightforward method, which just deletes a client from active connections.

HandleConnections() needs a little break-down, though. First, we create a copy of our original fd_set, since select() modifies it in ways we do not want. Then we wait for any data to come to any of our connected sockets in sock_polling_set_ with select(). If anything surfaces, including our server socket, select() is going to return a number of the triggered sockets. In our next step, we iterate over our connected sockets to see who has available data on them. If it's the server socket, then we received data about a new connection. In other cases, it is data coming from regular clients.

Now we are getting to the main part of our article: communication methods. I suggest you paying bigger attention to them, as they are kind of tricky.

ReceiveMessage(SOCKET, char*), SendMessage(SOCKET, std::string&), and BroadcastMessage(std::string&, ConnectionInfo&) source code
// server.cpp
...

int SendMessage(SOCKET receipient_socketfd, const std::string& message) noexcept{
    std::string assembled_msg = message;
    PrependMessageLength(assembled_msg);

    int total_bytes = assembled_msg.size();
    int sent_bytes = 0;
    int sent_n; // temporary variable
    const ConnectionInfo& conn_info = connected_clients_.at(receipient_socketfd);

    // Try to sent all bytes.
    while (sent_bytes < total_bytes){
        std::cerr << "[Info] Sending "s << total_bytes - sent_bytes << " bytes to "s << conn_info.ToString() << std::endl;
        sent_n = send(receipient_socketfd, assembled_msg.data() + sent_bytes, total_bytes - sent_bytes, 0);
        if (sent_n == -1){
            std::cerr << "[Error] Failed to send data to "s << conn_info.ToString() << " : send(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
            return sent_n;
        }
        sent_bytes += sent_n;
    }
    return sent_bytes;
}
int BroadcastMessage(const std::string& message, const ConnectionInfo& conn_from) noexcept{
    // Prepend the message with the address of the client who's sending it.
    std::string packet = conn_from.ToString() + " to ALL: "s + message;
    for (const auto& [sock, client_info] : connected_clients_){
        if (SendMessage(sock, packet) == -1){
            DisconnectClient(sock);
            return -1;
        }
    }
    return 0;
}

int ReceiveMessage(SOCKET sender_socketfd, char* writable_buffer) noexcept{
    if (!writable_buffer){
        std::cerr << "[Error] ReceiveMessage(): writable_buffer is NULL."s << std::endl;
        return -1;
    }
    const ConnectionInfo& conn_info = connected_clients_.at(sender_socketfd);
    
    // first we need to get the length of the preceeding data chunk.
    char message_size_str[5]; // 4 + 1 null-character

    memset(message_size_str, 0x00, sizeof(message_size_str));
    message_size_str[4] = '\0';

    int recv_bytes = recv(sender_socketfd, message_size_str, sizeof(message_size_str) - 1, 0);
    if (recv_bytes <= 0){
        if (recv_bytes < 0){
            std::cerr << "[Error] Failed to receive message length from "s << conn_info.ToString() << " : recv(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        }
        return recv_bytes;
    }
    std::cerr << "[Debug] "s << conn_info.ToString() << " (4 bytes): '"s << message_size_str << "'\n"s;
    // check if the received string is an actual number
    for (const char c : std::string(message_size_str)){
        if (!std::isdigit(c)){
            std::cerr << "[Warning] Client "s << conn_info.ToString() << " : Communication protocol violaition. (char '"s << c << "')\n"s;
            return -1;
        }
    }
    int packet_length = std::atoi(message_size_str);
    recv_bytes = recv(sender_socketfd, writable_buffer, packet_length, 0);
    if (recv_bytes <= 0){
        if (recv_bytes < 0){
            std::cerr << "[Error] Failed to receive message from "s << conn_info.ToString() << " : recv(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        }
    }


    std::cerr << "[Debug] "s << conn_info.ToString() << " ("s << packet_length << " bytes): '"s << writable_buffer << "'\n"s; 
    return recv_bytes;
}
  ...

There are two basic ways how sockets can receive data: in chunks and with continious stream. In this article I am going to cover only "in chunks" way, since it's the simples one to explain and comprehend, but I am about to tell you briefly how both of them work in the next section. For now, just bear with me. In those 3 functions I heavily rely on send() and recv(), as they are the ones responsiple for the actual communication between sockets. Further reading: send() , recv().

For a curious reader, asking why the code does not encompass exceptions, the reason appears to be uncomplicated — runtime and compilation speed. Use of exceptions, though debated to impose almost zero-overhead is, is redundant and decelerating for the application. Returning status codes in functions is a common practice for developing latency-tied applications.

It also simplifies compilation as well as development of a program.

main() source code
// server.cpp
...

int main(int argc, char* argv[]){
    if (argc != 3){
        std::cerr << "[Usage] server <address> <port>" << std::endl;
        return 1;
    }
#ifdef _WIN32
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)){
        std::cerr << "Failed to initialize WinSockAPI: " << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return 1;
    }
#endif

    Server server(argv[1], argv[2]);
    if (server.Start() == -1){
        return 1;
    }
}

In the main() function of our program, we try to initialize WinSockAPI, if we are using Windows, and then launch the server.

You can congratulate yourself for creating your first (or probably not) server! Now it's time to talk about a client.

Creating a client.

When it comes to coding a client, there isn't much difference from the server code. They both send and receive data, except for the client not having to accept or handle multiple connections. Also, on the client side, we need to somehow accept user input; this can be accomplished using std::thread, which was introduced in C++11.

Data exchange flow for the client.
Data exchange flow for the client.

The reason why we use another thread is for logical and convinience-wise reasons. Imagine that you have two workers: one is skilled in receiving and displaying data and the other is skilled in getting and shipping data off. In a nutshell, a thread is just like a worker in real life. As a manager, you wouldn't delegate responsibilities of a scientist to a regular soldier and vice versa.

Here is the signature of the Client class:

Client class signature
// client.h
#include "common.h"

#include <thread>

class Client{
public:
    Client(const char* hostname, const char* port) : remote_hostname_(hostname), remote_port_(port) {}

    Client(const Client& other) = delete;
    Client& operator=(const Client& other) = delete;

    ~Client(){
        Disconnect();
    }
public:
    int Start() noexcept;

    void Disconnect() noexcept;
private:
    /* Creates new `addrinfo` object and fills it with connection information.
     * @return `nullptr` on error, pointer to `addrinfo` object on success.
    */
    addrinfo* ResolveConnectionAddress() noexcept;

    // @return `-1` on error, valid socket on success.
    SOCKET CreateConnectionSocket(addrinfo* conn_addr) noexcept;

    int SendMessage(const std::string& message) noexcept;

    int ReceiveMessage(char* writable_buff) noexcept;

    // Prints the input prompt and flushes stdout afterwards. 
    void PrintInputPrompt() const noexcept;

    /* Handles user input and message-sending process.
     * @return `-1` on error, `0` on successful exit.
    */ 
    int InputHandler();

    /* Main client application loop. Handles incoming data and accepts user input.
     * @return `-1` on error, `0` on successful exit.
    */
    int HandleConnection() noexcept;

private:
    const std::string remote_hostname_, remote_port_;

    SOCKET connection_socket_;
};

In the Client, you can see a new method InputHandler(), which will be responsible for handling user input in conjunction with PrintInputPrompt(). Overall, by now, you should be familiar with the class design I am using here, so I won't spam the meme with the men on the bus, if you allow me. Further reading: multithreading, SOLID.

I won't go in depth for most of the methods, because I did it in the section about the server, except for the new ones.

Start() and Disconnect() source code
// client.cpp
#include "client.h"

Client::Client(const char* hostname, const char* port) : remote_hostname_(hostname), remote_port_(port) {}

int Client::Start() noexcept{
    std::cerr << "[Info] Starting the client.\n"s;
    
    // Get server address
    addrinfo* conn_addr = ResolveConnectionAddress();
    if (!conn_addr){
        return -1;
    }

    // Create connection socket for the client
    connection_socket_ = CreateConnectionSocket(conn_addr);
    if (!IS_VALID_SOCKET(connection_socket_)){
        return -1;
    }

    std::cerr << "[Info] Connecting to remote host ("s << remote_hostname_ << ":"s << remote_port_ << ")\n"s;
    if (connect(connection_socket_, conn_addr->ai_addr, conn_addr->ai_addrlen) == -1){
        std::cerr << "[Error] Failed to connect to the remote host: connect(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return -1;
    }
    std::cerr << "[Info] Successfully connected to "s << remote_hostname_ << ":"s << remote_port_ << std::endl;

    freeaddrinfo(conn_addr); // clean up ununsed resources
    
    return HandleConnection();
}

void Client::Disconnect() noexcept{
    std::cerr << "[Info] Disconnecting from the remote server.\n"s;
    CLOSE_SOCKET(connection_socket_);
    std::cerr << "[Info] Disconnected."s << std::endl;
}

ResolveConnectionAddress() and CreateConnectionSocket(addrinfo*) source code
// client.cpp
...
  
addrinfo* Client::ResolveConnectionAddress() noexcept{
    std::cerr << "[Debug] Resolving remote server address.\n"s;
    // Create helper and connection data structures for getaddrinfo() call
    addrinfo hints, *connection_address;
    memset(&hints, 0x00, sizeof(hints));
    hints.ai_family = AF_INET; // use IPv4 for connection
    hints.ai_socktype = SOCK_STREAM; // use TCP

    if (getaddrinfo(remote_hostname_.data(), remote_port_.data(), &hints, &connection_address) == -1){
        std::cerr << "[Error] Failed to resolve remote host address: getaddrinfo(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return nullptr;
    }

    return connection_address;
}

// @return `-1` on error, valid socket on success.
SOCKET Client::CreateConnectionSocket(addrinfo* conn_addr) noexcept{
    if (!conn_addr){
        std::cerr << "[Error] CreateConnectionSocket(): conn_addr is NULL."s << std::endl;
        return -1;
    }

    std::cerr << "[Debug] Creating new connection socket.\n"s;
    SOCKET new_conn_socket = socket(conn_addr->ai_family, conn_addr->ai_socktype, conn_addr->ai_protocol);
    if (!IS_VALID_SOCKET(new_conn_socket)){
        std::cerr << "[Error] Failed to create a new connection socket: socket(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return -1;
    }
    return new_conn_socket;
}

SendMessage(std::string&) and ReceiveMessage(char*) source code
// client.cpp
...
  
int Client::SendMessage(const std::string& message) noexcept{
    std::string assembled_msg(message);
    PrependMessageLength(assembled_msg);

    int total_bytes = assembled_msg.size();
    int sent_bytes = 0;
    int sent_n;
    std::cerr << "[Debug] Sending message: '"s << assembled_msg << "'\n"s;
    while (total_bytes > sent_bytes){
        sent_n = send(connection_socket_, assembled_msg.data() + sent_bytes, total_bytes - sent_bytes, 0);
        if (sent_n == -1){
            std::cerr << "[Error] Failed to send data to the remote host: send(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
            return sent_n;
        }
        sent_bytes += sent_n;
        std::cerr << "[Debug] Sending "s << sent_n << " bytes to the remote host\n"s;
    }


    return sent_bytes;
}

int Client::ReceiveMessage(char* writable_buff) noexcept{

    // Receive packet length (first 4 bytes)
    char msg_len_str[5]; // 4 bytes + 1 byte for the null-terminating character
    memset(msg_len_str, 0x00, sizeof(msg_len_str));
    msg_len_str[4] = '\0';

    int recv_bytes = recv(connection_socket_, msg_len_str, sizeof(msg_len_str) - 1, 0);
    if (recv_bytes <= 0){ // either client disconnect or an error
        return recv_bytes;
    }
    std::cerr << "[Debug] Received "s << recv_bytes << " bytes (packet length): '"s << msg_len_str <<"\n"s;
    // Check if the message conforms to the protocol
    for (const char c : std::string(msg_len_str)){
        if (!std::isdigit(c)){
            std::cerr << "[Error] Failed to read data from the remote host: invalid protocol format.\n"s;
            return -1;
        }
    }

    int packet_length = std::atoi(msg_len_str);
    recv_bytes = recv(connection_socket_, writable_buff, packet_length, 0);
    if (recv_bytes <= 0){ // Check for errors
        return recv_bytes;
    }
    std::cerr << "[Debug] Received "s << recv_bytes << " bytes (actual packet)\n"s;

    return recv_bytes;
}

The next three functions need some glossing-over.

PrintInputPrompt(), InputHandler(), and HandleConnection() source code
// client.cpp
...
  
void Client::PrintInputPrompt() const noexcept{
    std::cin.clear();
    std::cout << " >>> "s;
    std::cout.flush();
}

int Client::InputHandler(){
    while (true){
        char msg_buff[MAX_DATA_BUFFER_SIZE];
        PrintInputPrompt();

        std::fgets(msg_buff, MAX_DATA_BUFFER_SIZE, stdin);
        std::string message_str(msg_buff);
        message_str.pop_back(); // fgets() adds \n char to the end of the string
        if (SendMessage(message_str) == -1){
            std::exit(1);
        }
        memset(msg_buff, 0x00, MAX_DATA_BUFFER_SIZE);
    }
}

int Client::HandleConnection() noexcept{
    std::thread input_worker_thread(&Client::InputHandler, this); // Create a new thread for reading user input
    input_worker_thread.detach();
    while (true){
        char msg_buff[MAX_DATA_BUFFER_SIZE];
        memset(msg_buff, 0x00, sizeof(msg_buff));

        int recv_bytes = ReceiveMessage(msg_buff);
        if (recv_bytes <= 0){
            if (recv_bytes == 0){
                std::cerr << "[Info] Remote host has closed the connection."s << std::endl;
                std::exit(1);
            }
            else{
                std::cerr << "[Error] Failed to receive data from the remote host: recv(): "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
                std::exit(1);
            }
        }
        std::cout << msg_buff << '\n';

        PrintInputPrompt();
    }
}

PrintInputPrompt() is responsible for, wow, printing input prompt (">>> ") to the console.

HandleConnection() is the main function responsible for managing data. It launches InputHandler worker and later transorms into data-receiving function, since InputHandler is detached.

InputHandler() is a function that we have passed to std::thread to handle accepting user input and sending it to the remote host. Also note that I strayed from the common code-return style and used std::exit for an easier way to handle threads.

When it comes to main() function, there is literally no difference except for some naming ones:

main() source code
int main(int argc, char* argv[]){
    if (argc != 3){
        std::cerr << "[Usage] client <remote_address> <remote_port>"s << std::endl;
        return 1;
    }
#ifdef _WIN32
    WSADATA d;
    if (WSAStartup(MAKEWORD(2, 2), &d)){
        std::cerr << "[Error] Failed to initialize WinSockAPI: "s << std::system_category().message(GET_SOCKET_ERRNO()) << std::endl;
        return 1;
    }
#endif

    Client client(argv[1], argv[2]);
    if (client.Start() == -1){
        return 1;
    }
}

This concludes our discussion of the client and the server, hopefully leaving you with a baggage of knowledge. We are about to chew over messaging protocols, which play a vital role in network-linked applications.

$ ./server_app 127.0.0.1 8889

If you compile and launch the the server and clients, you would get something like this in your terminal.

FIrst $ ./client_app 127.0.0.1 8889

Second $ ./client_app 127.0.0.1 8889

Messaging protocols

Earlier in the article, I touched on the backbone of the protocols: in-chunks and continious stream data receiving. Here is the demonstration for both of the methods:

"In-Chunks" method
"In-Chunks" method
Contionios stream method
Contionios stream method

On the first image, network packet consits of length and data, whereas on the second, data is received in a stream with separation characters, which split up data chunks. In the later method, problems may arise during information storing and parsing: a developer has to account for the size of the writeable buffer as well as handling malicious messages (injections) that may split up packets in a treacherous way by a potential attacker. Incidentally, for applications that use the stream method, there are more complex sequences of characters used to divide the data.

As you can see, the working principle of those two methods is almost the same, but it is more practical and secure to use the in-chunks method. So we are going to hold on to it. Now that we are done with learning about sending packets, let's explore different ways we can craft our messages for some practical applications.

Custom Command-Signals

When developing any application, we want to especially think through how and in which ways the application will be able to interact with the user. The same logic applies to the Client-Server applications.

Imagine that we want to add more functionality to our program: users will be able to create private chat rooms, connect to them, rename, etc. For this task, it is wise to use something some kind of commands. As I began learning about the network and creating my programs, I used to prepend messages with commands in this format: </command><arg1><arg2>.... . Later on, though, I found a more elegant solution: use enums and convert their value to message codes. Let's see how this can work on practice.

enum MessageCodes{
    RegularMessage = 0,
    Disconnect,
    CreateRoom,
    JoinRoom,
    LeaveRoom,
    RenameRoom
};

int SendMessage(const std::string& message, MessageCodes command){
    unsigned char cmd_char = static_cast<unsigned char>(command);
    
    // Pseudo code
    std::string new_message = (message_length + 1) + cmd_char + message;

    // Send the message
    ...
}
Command-Signal packets
Command-Signal packets

As you all know, characters can be represented using hex-values, and the computer interprets them exacly like this. In the pictures, I used human-readable characters to better provide you with how it all works, but this is exclusively for readability, don't be confused.

In our pseudo code, we first converted the MessageCode value to a corresponding unsigned char and then appended this letter to the beginning of the data part of the packet.

To read such a packet, we shall use something like if-clauses or switch keyword. I used switch just because it is faster and qutie more tasteful, so to speak. Here is the pseudo code for reading this kind of a packet:

...
  
int ReceiveMessage(char* writable_buffer){

    std::string recved_msg;
    // ... Receive the messaging code + data

    MessageCodes msg_code = static_cast<MessageCodes>(recved_msg[0]);
    switch (msg_code){
        case MessageCodes::RegularMessage:
            // Handle the client sending a message
            break;

        case MessageCodes::Disconnect:
            // Handle the client wanting to disconnect from the server
            break;

        case MessageCodes::CreateRoom:
            // Handle the client wanting to create a new room.
            break;
        
        ...

        default:
            // Handle incorrect message code
    }

    ...
}

An intuitive and best thought you might have had is to create separate methods for each of the message code, and you would be completely right for implementing this thought.

JSON

If you don't know what JSON is, shortly, it is a one of the data serialization techniques, which is used extensively in network as well as web applications due to its ease-of-use and readability. We can implement JSON to pack the data from the previos example with a timestamp and anything else we would want a message from a client to contain. A great JSON library I'd adivise you to use is JSON for Modern C++ by nlohmann. JSON data is the same data that was in the previos section with the exception of being transformed into an object by the JSON library. Think of this as a build-up on the Custom Command-Signals part. To get my point across, here is the pseudo code:

In this case, json.h identifies the nlohmann's library I brought up earlier, which contains namespace nlohmann used in the code.

#include "json.h"

enum MessageCodes{
    RegularMessage = 0,
    Disconnect,
    CreateRoom,
    JoinRoom,
    LeaveRoom,
    RenameRoom
};

void MessageTextToJSONtext(std::string& message_to_convert, MessageCodes msg_code){
    
    nlohmann::json agent_json_msg = {
        {"Type", static_cast<unsigned char>(msg_code)},
        {"Message", message_to_convert},
        {"Timestamp", GetTimestamp()}
    };

    message_to_convert = agent_json_msg.dump();
}

std::string Convert_JSON_data_to_string(const std::string& received_JSON_data){
    nlohmann::json received_JSON_data = nlohmann::json::parse(agent_message);
    std::string agent_code = received_JSON_data.at("AgentCode"s).dump();
    std::string timestamp = received_JSON_data.at("Timestamp"s).dump();
    std::string message = received_JSON_data.at("Message"s).dump();

    
}

int SendMessage(const std::string& message, MessageCodes command){
    unsigned char cmd_char = static_cast<unsigned char>(command);
    
    // Pseudo code
    std::string new_message = (message_length + 1) + cmd_char + message;

    // Send the message
    ...
}

int ReceiveMessage(char* writable_buffer){

    std::string recved_msg;
    // ... Receive the messaging code + data

    nlohmann::json received_JSON_data = nlohmann::json::parse(agent_message);

    MessageCodes msg_code = static_cast<MessageCodes>(received_JSON_data.at("Type"s).dump());

    std::string timestamp = received_JSON_data.at("Timestamp"s).dump();
    std::string message = received_JSON_data.at("Message"s).dump();


    switch (msg_code){
        case MessageCodes::RegularMessage:
            // Handle the client sending a message
            break;

        case MessageCodes::Disconnect:
            // Handle the client wanting to disconnect from the server
            break;

        case MessageCodes::CreateRoom:
            // Handle the client wanting to create a new room.
            break;
        
        ...

        default:
            // Handle incorrect message code
    }

    ...
}

Seemingly, there is nothing extraordinary or really different from sending plain text. There is a simply addition of a new library and new functions. We create a nlohmann::json object and fill it with relevant data. To retrieve the data from the object, we use it just like a regular std::map from the STL.

Overall, JSON is a human-readable format that is easy to implement and debug. However, the down-side of this communication protocol is the speed—converting to and from JSON is quite slow, especially, if your communication relies on rapidly sending small data packets. A great alternative to JSON is Protobuf.

Protobuf.

Protobuf (Protocol Buffers) is a data-serializing library, developed by Google and widely used in the world. It is a faster, more compact way to pack your data, since Protobuf packs your message down to bytes and converts it back. Compared to JSON, this is a less readable yet faster method to compress your data into network packets. Our first step would be to create a .proto file (signature for data packing):

syntax = "proto3";

message Message {
  string timestamp = 1;
  string message = 2;
  string type = 3;
}

Then, we generate a .pb.h file (Protobuf format) using protobuf message_signature.proto command in the directory we are currently in. This is going to yield us a new file message_signature.pb.h, which we subsequently include in our pseudo code with #include "message_signature.pb.h":

#include "message_signature.pb.h"

enum MessageCodes{
    RegularMessage = 0,
    Disconnect,
    CreateRoom,
    JoinRoom,
    LeaveRoom,
    RenameRoom
};

int SerializeMessage(std::string& sending_message, MessageCodes msg_code){
    
    Message packed_msg;
  
    packed_msg.set_type(std::to_string(static_cast<unsigned char>(msg_code)));
    packed_msg.set_timestamp(GetTimeStamp());
    packed_msg.set_message(sending_message);

    std::string packed_str = packed_msg.SerializeToString();

    return 0;
}

int DeserializeMessage(const std::string& recved_msg, Message& msg_struct){
    Message deser_msg;
    if (deser_msg.ParseFromString(recved_msg)){
        return 0;
    }
  
    return -1; // failed to deserialize the message
}

int SendMessage(const std::string& message, MessageCodes command){
  
    // Pseudo code
    std::string new_message;
    SerializeMessage(new_message, command);

    // Send the message
    ...
}

int ReceiveMessage(char* writable_buffer){

    std::string recved_msg;
  
    // ... Receive the messaging code + data

    Message deser_msg;
    if (DeserializeMessage(recved_msg, deser_msg) == -1){
        std::cerr << "[Error] ReceiveMessage(): failed to deserialize a received message." << std::endl;
        return -1;
    }

    std::string timestamp = deser_msg.timestamp();
    std::string message = deser_msg.message();
    MessageCodes msg_code;
    try{
        msg_code = static_cast<MessageCodes>(deser_msg.type().at(0));      
    } catch(const std::exception& exc){ // deser_msg.type field is empty
        std::cerr << "[Error] ReceiveMessage(): Failed to parse the message code of the packet." << std::endl;
        return -1;
    }

    switch (msg_code){
        case MessageCodes::RegularMessage:
            // Handle the client sending a message
            break;

        case MessageCodes::Disconnect:
            // Handle the client wanting to disconnect from the server
            break;

        case MessageCodes::CreateRoom:
            // Handle the client wanting to create a new room.
            break;
        
        ...

        default:
            // Handle incorrect message code
    }

    ...
}

Once again, we convert a string to a Protobuf object, defined in message_signature.proto, and populate it with necessary data, and convert it back to a packet string using SerializeToString() method.

To deserialize a string received from the remote host, we need to do the opposite: receive a serialized string, call ParseFromString() method, check for any errors and then populate desired strings.

Conclusion

Over the course of this article, we have gone over:

  • What sockets are and how they work

  • How to program a client and a server

  • How to implement different packaging techniques for exchanging data

If you feel overwheled, do not worry. I felt that too for a long time. The best thing you can do right now is to try to code something and apply the material from the article. I hope that my examples have been useful to you and I managed to stock you up with something new.

The repository with all the source code can be found here.

See you in the next article!

Tags:
Hubs:
Total votes 2: ↑2 and ↓0+2
Comments3

Articles