View on GitHub

Vorner's random stuff

Announcing Spirit

Spirit is a crate that cuts down on boilerplate when creating unix daemons, with support for live configuration reloading.

A case study

To explain the motivation and what the crate does, let’s do a small case study. We want to create a Hello World Service ‒ the client connects over TCP and is greeted. Let steal borrow and modify an example from tokio (you know, because we want to scale to ridiculous number of parallel clients and such and because tokio is cool).

extern crate env_logger;
#[macro_use]
extern crate log;
extern crate tokio;

use tokio::prelude::*;
use tokio::net::TcpListener;

fn main() {
    env_logger::init();

    // Bind the server's socket.
    let addr = "127.0.0.1:12345".parse().unwrap();
    let listener = TcpListener::bind(&addr)
        .expect("unable to bind TCP listener");

    // Pull out a stream of sockets for incoming connections
    let server = listener.incoming()
        .map_err(|e| eprintln!("accept failed = {:?}", e))
        .for_each(|sock| {
            let addr = conn
                .peer_addr()
                .map(|addr| addr.to_string())
                .unwrap_or_else(|_| "<unknown>".to_owned());
            debug!("Handling connection {}", addr);
            let written = tokio::io::write(sock, "Hello world\n")
                .map(|_| ())
                .or_else(move |e| {
                    warn!("Failed to write message to {}: {}", addr, e);
                    future::ok(())
                });
            tokio::spawn(written)
        });

    // Start the Tokio runtime
    tokio::run(server);
}

This is all-right as a prototype goes, but there’s a whole bunch of work to be done before this can go to production:

Did I forget about something? Probably. But it is an impressive laundry list anyway. And it has nothing to do with the specific purpose of the service, this is a laundry list we would have no matter what service we would be writing.

And sure, there are crates for most of these things around. But integrating them into the program still requires some work and some plumbing code. We should be spending our time writing the unique code, not some plumbing boilerplate again and again.

The plumbing code is inside the spirit crate. It gets done most of the above list, with only some configuration. You specify a structure where you’d like your configuration to be loaded, a structure where parsed command line arguments should be stored (if you don’t want some of these, use the provided Empty structure). Then, you can either hook come callbacks in or let some helpers (fragments of configuration with code provided by spirit and some companion crates) do all the reconfiguration stuff.

This is how the example looks like with spirit

extern crate failure;
#[macro_use]
extern crate log;
#[macro_use]
extern crate serde_derive;
extern crate spirit;
extern crate spirit_tokio;
extern crate tokio;

use std::collections::HashSet;

use failure::Error;
use spirit::{Empty, Spirit, SpiritInner};
use spirit_tokio::TcpListen;
use tokio::net::TcpStream;
use tokio::prelude::*;

#[derive(Default, Deserialize)]
struct Ui {
    msg: String,
}

#[derive(Default, Deserialize)]
struct Config {
    /// On which ports (and interfaces) to listen.
    listen: HashSet<TcpListen>,
    /// The UI (there's only the message to send).
    ui: Ui,
}

impl Config {
    /// A function to extract the tcp ports configuration.
    fn listen(&self) -> HashSet<TcpListen> {
        self.listen.clone()
    }
}

/// Handle one connection, the tokio way.
fn handle_connection(
    spirit: &SpiritInner<Empty, Config>,
    conn: TcpStream,
    _: &Empty,
) -> impl Future<Item = (), Error = Error> {
    let addr = conn
        .peer_addr()
        .map(|addr| addr.to_string())
        .unwrap_or_else(|_| "<unknown>".to_owned());
    debug!("Handling connection {}", addr);
    let mut msg = spirit.config().ui.msg.clone().into_bytes();
    msg.push(b'\n');
    tokio::io::write_all(conn, msg)
        .map(|_| ()) // Throw away the connection and close it
        .or_else(move |e| {
            warn!("Failed to write message to {}: {}", addr, e);
            future::ok(())
        })
}

pub fn main() {
    Spirit::<_, Empty, _>::new(Config::default())
        .config_ext("toml")
        .config_helper(Config::listen, handle_connection, "listen")
        .run(|_| Ok(()));
}

When you look at it, there are three sections (not counting the imports at the top). The first part describes the structure of configuration, in form of serde-deserializable structures. The spirit will take care of loading it and updating it on SIGHUP. Notice the TcpListen part, which comes from the spirit-tokio helper crate ‒ that one knows how to parse configuration for and create a TCP listener (and change the ports at runtime, to reflect the configuration).

The second part is handling of one TCP connection. It’s almost the same as the closure in the for_each in the above example, with few little differences. First, it gets some more parameters in addition to the connection. An instance of the Spirit singleton, which is used to read the up to date value of the message to send. Then there’s the one unused parameter of Empty type ‒ the TcpListen configuration fragment allows to plug additional application-specific configuration (as a type parameter) of each listening socket and it is passed as this parameter.

And then there’s the bootstrapping section, that creates the spirit object and fires it off. To explain what happens there:

Playing around with the example

The above example is also in the git repository ‒ with few additions, like embedded default configuration. You can try it out.

So, let’s run it without any configuration to start with, but with a configuration directory set to your home directory ‒ so we can add configuration later on:

cd spirit/spirit-tokio
cargo run --example hws -- -l debug -L hws=trace -L spirit=trace "$HOME"

What we did here is turning on debug to error output on debug level, but the two interesting crates (the hws itself ‒ hello world service, and spirit) run on trace.

You can connect to it on port 2345 and you should see the hello world message.

Now, add a configuration file (hws.toml) to your home directory:

[[listen]]
port = 7891

[ui]
message = "Bye bye"

And send SIGHUP to the service:

killall -s SIGHUP hws

You’ll notice several log messages to scroll by. You are no longer able to connect on the port 2345, but on the new one 7891. And you get a new message.

The state of the crate and plans

I follow the „release often, release early“ rule. The crate is in early stages of development ‒ so you’ll find TODO notes scattered through the code and documentation, many pieces of functionality are still missing. But I hope this’ll improve over time.

Also, the only available configuration fragments ‒ the magical helpers ‒ are for TCP and UDP sockets. This should improve. Next versions (or, other crates, actually) should provide more helpers ‒ for TLS sockets, hyper, tokio-web and probably others.

Currently, only unix systems are supported. I don’t know Windows and how the services there work, but hopefully someone will implement it. If not, I’ll at least make sure it can compile and provide limited functionality.

How you can help

First, by trying it out. I want to know if there’s something to improve (well, there likely is). Report problems and ideas how to improve.

If you like to write code, the repository contains some issues that can be worked on (and if you want to help with them, but feel stuck, I’m offering help).

And of course, adding new helpers (either into the repository by a pull request, or as completely independent crates) helps too.