Simple terminal server

272

Let's take a break from the nerdy echo server stuff and, before diving deeper into it, implement something rather easy which you may find fun to play with.

The subject of today's lesson is a simple terminal server. Terminal server is a server which you can connect to with telnet, send and execute some commands on the server's side, and get some response.

Here how it works. The server's algorithm is easy:

  1. Accept incoming connection on a given port;
  2. Create a session object for the connection accepted;
  3. Go to step 1.

Session's algorithm should look like that:

  1. Read a string until \n character is found;
  2. Parse command name and arguments from the string received;
  3. Invoke some code responsible for this command and get a response string from it;
  4. Send this response back to the client;
  5. Go to step 1.

In fact, there is nothing new from Boost.Asio in this lesson. If you remember everything we've learned so far, you should be able to write such server on your own. This lesson is rather about how do you organize things.

Most of the code we've already seen before, so no need to repeat it again and again. Let's review here key features only.

class server
{
public:

    server(io::io_context& io_context, std::uint16_t port)
    {
        // ...
    }

    template <typename F>
    void attach(std::string const& pattern, std::string const& description, F&& f)
    {
        // ...
    }

private:

    void accept()
    {
        ...
    }

    io::io_context& io_context;
    tcp::acceptor acceptor;
    std::optional<tcp::socket> socket;
    dispatcher_type dispatcher;
};

Everything is familiar except server::attach and dispatcher. Dispatcher is a map of terminal command handlers:

using string_group = std::vector<std::string>;

struct dispatcher_entry
{
    std::string const description; // Command description
    std::size_t const args; // Command arguments count
    std::function<std::string (string_group const&)> const handler; // Command handler
};

// Name-value map of command handlers
using dispatcher_type = std::map<std::string, dispatcher_entry>;

server::attach is a function where you pass command pattern, command description, and its handler. Command pattern consists of a name and optional parameter placeholders:

template <typename F>
void attach(std::string const& pattern, std::string const& description, F&& f)
{
    // Pattern examples:
    // dir
    // cd %
    // copy % %
    auto cmd = split(pattern);

    // cmd[0] is command name
    // cmd.size() - 1 is parameters count
    dispatcher.emplace(cmd[0], dispatcher_entry
    {
        description,
        cmd.size() - 1,
        std::move(f)
    });
}

split is a simple helper function which splits a given string into vector of strings:

string_group split(std::string const& string)
{
    using separator = boost::char_separator<char>;
    using tokenizer = boost::tokenizer<separator>;

    string_group group;

    for(auto const& str : tokenizer(string, separator(" ")))
    {
        group.emplace_back(str);
    }

    return group;
}

We keep the dispatcher inside the server object and pass a reference to it into the session:

class session : public std::enable_shared_from_this<session>
{
public:

    session(tcp::socket&& socket, dispatcher_type const& dispatcher)
    : socket    (std::move(socket))
    , dispatcher(dispatcher)
    {
    }

    // ...

private:

    tcp::socket socket;
    dispatcher_type const& dispatcher; // Here we go
    io::streambuf incoming;
    std::queue<std::string> outgoing;
};

So, a session reads incoming data with async_read_until until \n character is found, then parses the line received. Here is how we parse the line received:

static std::pair<std::string, string_group> parse(std::string const& string)
{
    string_group args = split(string);
    auto name = std::move(args[0]); // First part is command name
    args.erase(args.begin()); // The rest is optional command arguments
    return {name, args};
}

After that all we need is to lookup for a corresponding command handler, execute it if one is found, and send the response back. session::dispatch function at your service:

void dispatch(std::string const& line)
{
    auto command = parse(line); // Parse incoming command
    std::stringstream response;

    // Check if we have a corresponding handler
    if(auto it = dispatcher.find(command.first); it != dispatcher.cend())
    {
        auto const& entry = it->second;

        // Check if given arguments count is correct
        if(entry.args == command.second.size())
        {
            try
            {
                // Handler invocation result is a response string
                response << entry.handler(command.second);
            }
            catch(std::exception const& e)
            {
                // Or the error message if one has occurred
                response << "An error has occurred: " << e.what();
            }
        }
        else
        {
            response << "Wrong arguments count. Expected: " << entry.args << ", provided: " << command.second.size();
        }
    }
    else // If not - show the list of the commands available
    {
        response << "Command not found. Available commands:\n\r";

        for(auto const& [cmd, entry] : dispatcher)
        {
            response << "- " << cmd << ": " << entry.description << "\n\r";
        }
    }

    // Push the response into the outgoing queue
    write(response.str());

    // Check if the queue was empty before that, in which case we should initiate message delivery back to the client
    if(outgoing.size() == 1)
    {
        write();
    }
}

The function is big and cover all cases. In real life you probably should split it into several functions.

And finally, let's look into the main function:

int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        std::cout << "Usage: server [Port]\n";
        return 0;
    }

    io::io_context io_context;
    server srv(io_context, boost::lexical_cast<std::uint16_t>(argv[1]));

    srv.attach("date", "Print current date and time", [] (string_group const& args)
    {
        auto now = boost::posix_time::second_clock::local_time();
        return to_simple_string(now);
    });

    srv.attach("pwd", "Print working directory", [] (string_group const& args)
    {
        return boost::filesystem::current_path().string();
    });

    srv.attach("dir", "Print working directory's content", [] (string_group const& args)
    {
        std::stringstream result;

        std::copy
        (
            boost::filesystem::directory_iterator(boost::filesystem::current_path()),
            boost::filesystem::directory_iterator(),
            std::ostream_iterator<boost::filesystem::directory_entry>(result, "\n\r")
        );

        return result.str();
    });

    srv.attach("cd %", "Change current working directory", [] (string_group const& args)
    {
        boost::filesystem::current_path(args[0]);
        return "Working directory: " + boost::filesystem::current_path().string();
    });

    srv.attach("mul % %", "Multiply two numbers", [] (string_group const& args)
    {
        double a = boost::lexical_cast<double>(args[0]);
        double b = boost::lexical_cast<double>(args[1]);
        return std::to_string(a * b);
    });

    io_context.run();

    return 0;
}

See how we attach command handlers. We provide command pattern, its description, and function object to handle it. And look how simple is this — just a couple lines of code and yet another command is supported by the server.

Note that we don't have to touch anything related to I/O to extend our server with new commands. The idea of this lesson is that you shouldn't mix I/O code with higher-level application logic. Keep them isolated and provide controllable narrow way of communication between them.

And finally, let's run the server, connect to it with telnet, and execute some commands:

./server 8888
> telnet localhost 8888
Connecting To localhost...

Welcome to my very own terminal server!
> test
Command not found. Available commands:
- cd: Change current working directory
- date: Print current date and time
- dir: Print working directory's content
- mul: Multiply two numbers
- pwd: Print working directory

> date
2020-Oct-02 10:57:02
> dir
/projects/dens.website/tutorials/cpp-asio/simple-terminal-server/server.cpp
/projects/dens.website/tutorials/cpp-asio/simple-terminal-server/session.hpp
/projects/dens.website/tutorials/cpp-asio/simple-terminal-server/types.hpp

> cd ..
Working directory: /projects/dens.website/tutorials/cpp-asio
> cd ..
Working directory: /projects/dens.website/tutorials
> cd ???
An error has occurred: boost::filesystem::current_path: The filename, directory name, or volume label syntax is incorrect: "???"

So, looks like everything works fine and errors are properly handled. Compile the server and see how it works. Add your own commands to the server and make sure they work too.

Full source code for this lesson: source.zip

Rate this post:
Share this page:

Learning plan

First approach on improvement of TCP echo server implementation: making read and write work in parallel
Second approach on improvement of TCP echo server implementation: eliminating gaps and memory copying
Third approach on improvement of TCP echo server implementation: multithreading
35. Simple terminal server
An implementation of a simple terminal server which you connect to with telnet and execute commands