spdr is a small and efficient networking library.
The best way to get spdr is through vcpkg. (Soon, this will be done right after the release of 0.2.0.)
You can build spdr through Visual Studio 2022 or CMake, but you need a working vpckg to pull dependencies.
In the example folder, you will find the chat example. This wil show you the way to implement a simple network protocol.
The first thing to do is you need to define some basics:
constexpr auto CHAT_PROTOCOL_ID = 0xC5A7;
Every network protocol should have a unique id. This id helps to ensure when a connection is established that each network node (client or sever) are actually talking the same "language". If you change anything to your protocol, you should also change this magic id.
The next thing is we need to define some messages:
enum class ChatMessageId : spdr::MessageId
{
JOIN,
SERVER,
CHAT
};
using JoinMessage = spdr::Message<ChatMessageId::JOIN, std::string>;
using ServerMessage = spdr::Message<ChatMessageId::SERVER, std::string, std::string>;
using ChatMessage = spdr::Message<ChatMessageId::CHAT, std::string>;
We first define an enum that contains all message ids. These are unique ids
per message type and are of type MessageId
.
Further we define the actual messages types. This is done by aliasing a Message
with the message id and all payload data types using the using
clause.
To implement the chat server we need to create a network Node
with the given
protocol id.
auto server = spdr::Node{CHAT_PROTOCOL_ID};
Since we need to keep track of the connected users, we will create a small structure that maps the peer id (id of the other node) to a user name:
auto users = std::map<spdr::PeerID, std::string>{};
For a start, we expect the client to first send a join message:
server.on_message<JoinMessage>([&] (auto peer, auto name)
{
users[peer] = name;
server.broadcast<ServerMessage>("SERVER", std::format("{} joined", name));
}
With some error handling omitted, we save the username with the peer and tell all other clients that a new user joined the chat.
Once a client is joined, we expect to receive chat messages.
server.on_message<ChatMessage>([&] (auto peer, auto text)
{
std::string name = std::get<std::string>(users[peer]);
server.broadcast<ServerMessage>(name, text);
});
Assuming that the client properly joined, we first get the user name of the client and then send the text to all clients (including the client that sent the message).
Of course we need to make sure we register clients that disconnect:
server.on_disconnect([&] (auto peer)
{
auto i = users.find(peer);
if (i != users.end())
{
server.broadcast<ServerMessage>("SERVER", std::format("{} disconnected", name));
users.erase(i);
}
});
Now that we setup the server, we need to tell it to actually do something:
server.listen(2001);
server.run();
This will let the server to listen on port 2001 and start processing messages.
If you want ever stop the server you will need to call:
server.stop();
The client is implemented in a similar fashion:
auto client = spdr::Node{CHAT_PROTOCOL_ID};
The client only needs to handle server messages:
client.on_message<ServerMessage>([this] (auto peer, auto user, auto text)
{
if (user != username)
{
std::cout << std::format("{:<10}: {}\n", user, text);
}
});
In the chat example, input is taken directly from cin
. In this vain
we need to figure out when the server happens to disconnect, so we can
stop processing input:
auto running = std::atomic<bool>(true);
client.on_disconnect([this] (auto)
{
running = false;
});
The boolean is atomic, because we are using the feature that the node will process messages on a separate thread. You probably should read about threading below, so you don't break your fancy applications.
In the chat example we get the server's IP address and a user name from the command line arguments.
auto address = std::string{argv[1]};
auto username = std::string{argv[2]};
Now that we have all the bits together, we need to connect the client to the server and send a join message:
auto srv = node.connect({address, 2001});
client.send<JoinMessage>(srv, username);
Finally we will read input as it is typed and send it to the server:
while (running)
{
std::string line;
std::getline(std::cin, line);
if (!line.empty())
{
client.send<ChatMessage>(srv, line);
}
}
You may want to look into the chat example, as it implements all the above with some useful logging and error handling.
Each Node
has it's own thread to process network messages. By default this
thread is used to also process message handlers. There is the option
to move the message handlers onto a different thread and reduce potential
synchronization overhead.
spdr uses c9y's sync infrastructure to
safely delegate work between threads. If you want to move processing into a
different thread, such as for example the thread processing business logic,
you need to specify this thread id on construction of the Node
and
periodically call c9y::sync_point()
.
For example:
auto client = spdr::Node{MY_PROTOCOL_ID, std::this_thread::get_id()};
// some setup on client
client.connect({...});
while (running)
{
do_logic();
do_rendering();
c9y::sync_point();
}
This allows you to not have any additional thread synchronization in your code, since the message handlers will run in your main thread.
A similar thing happens when using Node::run
. The run function will
also call sync_point
and use the calling thread to split work over
two threads and make sure any logic does not slow down the network code.
Although in practice this never happens, but there is a race condition between
calling listen
or connect
and run
. The first message handler could
end up on the wrong thread when network input comes in before run
properly
starts.
If you intend to use run
you should consider setting the handling thread
on the construction of the node:
auto client = spdr::Node{MY_PROTOCOL_ID, std::this_thread::get_id()};
// some setup on client
client.connect({...});
client.run();
The spdr library is distributed under the MIT license. See License.txt for details.