Describing all aspects of Nisse in a single article would be complex and overwhelming. I want to start with a beginner’s perspective and address a real-world problem that many beginners attempt. I will identify some of the issues and introduce solutions one at a time, ultimately leading to the final library, which is Nisse.
The simplest web application I can build is a web server. A web server maintains no state between calls, and the interface is straightforward. The client sends an HTTP message and, in return, receives an HTTP response.
Even better, we already have applications (browsers) that handle all the difficult aspects of interacting with a web server, so we don’t need to write a client-side application.
NisseV1
All the code for this article is in the directory V1. It uses only the standard libraries, so it should be easy for anybody to build. A “Makefile” is provided as an example.
Build & Run
> git clone https://github.com/Loki-Astari/NisseBlogCode.git
> cd NisseBlogCode/V1
> make
> ./NisseV1 8080 /Directory/You/Want/To/Server/On/Port/8080
I will go over a couple of things in the file that I believe are worth explicitly pointing out:
The code in this file is just enough to parse an HTTP Get request and provide a file in the HTTP Response object. It is provided here solely so that all examples can be compiled. Note: this is not part of Nisse, I will come back and address the code in a subsequent article on HTTP and expand this to be more comprehensive.
This is the interface between the server code and the HTTPStuff. For these articles, I wanted to keep the interface as simple as possible, and again, this is not part of Nissa.
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cerr << "Usage: NisseV1 <port> <documentPath>" << "\n";
return 1;
}
try
{
static const int port = std::stoi(argv[1]);
static const std::filesystem::path contentDir = std::filesystem::canonical(argv[2]);
std::cout << "Nisse Proto 1\n";
WebServer server(port, contentDir);
server.run();
}
catch(std::exception const& e)
{
std::cerr << "Exception: " << e.what() << "\n";
throw;
}
catch(...)
{
std::cerr << "Exception: UNKNOWN\n";
throw;
}
}
The main()
function obtains and validates user input to initialize the server. If everything is in order, it creates a WebServer
object and starts the application dispatch loop by calling run()
.
Here are two main points to note:
-
Unlike most beginner tutorials, web applications are event-driven. They operate with a "dispatch loop" that executes user code when events occur rather than following a sequential list of commands. In this example, the run()
function represents the dispatch loop and manages all the underlying details. Typically, frameworks allow you to register user code for specific events, but since this is a simple application, it simply handles an HTTP request.
-
I use exceptions to handle critical errors. Many engineers believe exceptions are problematic because they obscure control flow (I partially agree). However, I prefer using exceptions, judiciously, as they reduce the amount of explicit error-handling code required for serious issues that necessitate application shutdown. When shutting down a C++ application, it is essential to unwind the stack correctly and ensure all relevant destructors are called to release resources; therefore, abort()
, terminate()
, and exit()
are usually inappropriate (unless there is corruption) in C++ applications (unlike in C). You MUST catch exceptions in main(),
as it is implementation-defined whether the stack unwinds if an exception escapes the main()
function. By catching the exception in main()
, you ensure the stack unwinds correctly, and all destructors are called. Then, you can generate appropriate messages and logs before re-throwing the exceptions. Re-throwing allows the OS to take necessary actions when the application exits abnormally.
Socket Code
A minor call out. The socket code is all C code (and thus in the global namespace). You will see in my code that all C code is prefixed by ::
to make sure I explicitly call the C version of these functions. For example, when creating a server-end socket, I call ::socket(),
::bind(),
::listen()
, and ::accept()
. This ensures that I do not accidentally call similarly named methods.
A lot of the code in this example is simply creating and handling sockets and doing a rudimentary job of checking and handling basic errors that these functions could generate; class Server is 50 lines and class Socket is another 200 lines and represents more than half the code.
Errors
The C socket code has a simple interface and returns -1
when there is an error. An error when creating sockets usually means programming issues, but in all these cases, the error is unrecoverable, so we simply throw an exception to exit the application (and clean up any connections).
Server::Server(int port)
{
fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
throw std::runtime_error{Message{} << "Failed to create socket: " << errno << " " << strerror(errno)};
}
struct ::sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(port);
serverAddr.sin_addr.s_addr = INADDR_ANY;
int bindStatus = ::bind(fd, reinterpret_cast<struct ::sockaddr*>(&serverAddr), sizeof(serverAddr));
if (bindStatus == -1) {
throw std::runtime_error{Message{} << "Failed to bind socket: " << errno << " " << strerror(errno)};
}
int listenStatus = ::listen(fd, backlog);
if (listenStatus == -1) {
throw std::runtime_error{Message{} << "Failed to listen socket: " << errno << " " << strerror(errno)};
}
}
During read/write operations, the return value is either -1 on failure or the number of bytes received/sent. We need to address two issues: an error is not always fatal, and success does not always indicate that the operation has been completed as requested. Therefore, code must be explicitly added to check for all scenarios. This typically means that read/write operations are performed in a loop until either a critical failure occurs or you decide that enough data has been retrieved/sent.
void Socket::readMoreData(std::size_t maxSize, bool required)
{
std::size_t currentSize = std::size(buffer);
std::size_t amountRead = 0;
buffer.resize(currentSize + maxSize);
while (readAvail && amountRead != maxSize)
{
int nextChunk = ::read(fd, &buffer[0] + currentSize + amountRead, maxSize - amountRead);
if (nextChunk == -1 && errno == EINTR) {
continue;
}
if (nextChunk == -1 && errno == ECONNRESET) {
readAvail = false;
break;
}
if (nextChunk == -1) {
buffer.resize(currentSize + amountRead);
throw std::runtime_error(Message{} << "Catastrophic read failure: " << errno << " " << strerror(errno));
}
if (nextChunk == 0) {
readAvail = false;
}
amountRead += nextChunk;
if (!required) {
break;
}
}
buffer.resize(currentSize + amountRead);
}
I want to emphasize that I have done the bare minimum in error checking here. This is adequate for a simple demo program but insufficient for production code. The problem is that error codes are not entirely consistent across ‘*nix’ variants and differ significantly from Windows systems. I will discuss this in the next article.
Next Step
Address inconsistencies between ‘*nix’ and Windows
platforms.