Module spirit::guide::tutorial [−][src]
Expand description
A tutorial
The task to tackle
Let’s go, step by step, over how an application that uses spirit
might be built and what
everything the library can offer.
Imagine you work for a company and it is business critical to have a Hello World service. It would listen on an HTTP endpoint and greet anyone who comes. One couldn’t ask for an easier task, as that’s basically the hyper’s example.
But then, you show it to your boss. The boss is happy with the functionality, overall, but starts asking pointed questions about how to configure the port it listens on, or if it can listen on multiple at once. And where the logs go, similarly about metrics, etc, etc. Overall, the actual functionality is fine, but there’s a lot of absolutely boring boilerplate to write.
The initial conversion, introducing spirit
That’s what spirit
is about, to cut down on it a little bit. So, let’s start with first
converting the application to spirit
, without actually using anything from it.
use std::convert::Infallible;
use std::net::SocketAddr;
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
use serde::Deserialize;
use spirit::prelude::*;
use spirit::Spirit;
use structopt::StructOpt;
// This'll hold our configuration. It's empty for now.
#[derive(Clone, Debug, Default, Deserialize)]
struct Cfg {
}
// Here we will have command line options. We will also add them later on. The doc string is used
// as part of the help text of the application.
/// The hello world service.
///
/// Greets people over HTTP.
#[derive(Clone, Debug, StructOpt)]
// We can use all the structopts tricks here.
#[structopt(
version = "1.0.0-example",
author,
)]
struct Opts {
}
async fn handle(_: Request<Body>) -> Result<Response<Body>, Infallible> {
Ok(Response::new("Hello, World!".into()))
}
#[tokio::main]
async fn main() {
// This'll load our configuration and options. But we didn't add any yet, so we'll just leave
// it this way.
let _spirit = Spirit::<Opts, Cfg>::new()
// The false asks not to have a background thread, we'll change that later.
.build(false)
// Don't worry, we'll deal with this unwrap later too.
.unwrap();
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
This doesn’t do much. What we have added is configuration line parsing. It’ll be able to accept paths to config files (yes, possibly multiple, or directories, that are scanned for the configuration files). The configuration is then loaded ‒ from the files, and from overrides from the command line options. We could instruct it take some environment variables or to embed a default configuration „file“ into the program directly. And then we could access the command line options and the configuration ‒ though the structures for them are empty for now.
We can, of course, add whatever configuration options we like, as long as they can be deserialized
using serde
. We could then either read the configuration from the spirit
object, or hook into the changes with for example
on_config
. This is what can be used for options
specific for our application. But we are actually worried about the parts that belong to every
service, not just our own, so we are going to use few more bits of spirit
for that.
Configuring logging
Let’s add logging into the application first. There are two parts. One is instrumenting our code
with all the logging macros from the log
crate, link warn
and debug
. There’s nothing
unusual here.
The other part is the logger ‒ something that formats and sends the log somewhere. The
spirit-log
crate can create the logger from configuration and command line options. So we go to
the crate’s documentation and get inspired by the example there. We add the configuration options
(fragment
) to our own configuration structure and install the
pipeline
that installs the logger and updates it on
configuration reloading (it’ll even reopen log files on SIGHUP
).
use std::convert::Infallible;
use std::net::SocketAddr;
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
use log::debug;
use serde::Deserialize;
use spirit::prelude::*;
use spirit::{Pipeline, Spirit};
use spirit_log::{Cfg as Logging, CfgAndOpts as LogBoth, Opts as LogOpts};
use structopt::StructOpt;
#[derive(Clone, Debug, Default, Deserialize)]
struct Cfg {
/// The logging.
///
/// This allows multiple logging destinations in parallel, configuring the format, timestamp
/// format, destination.
#[serde(default, skip_serializing_if = "Logging::is_empty")]
logging: Logging,
}
/// The hello world service.
///
/// Greets people over HTTP.
#[derive(Clone, Debug, StructOpt)]
#[structopt(
version = "1.0.0-example",
author,
)]
struct Opts {
// Adds the `--log` and `--log-module` options.
#[structopt(flatten)]
logging: LogOpts,
}
async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
debug!("Handling request {:?}", req);
Ok(Response::new("Hello, World!".into()))
}
#[tokio::main]
async fn main() {
let _spirit = Spirit::<Opts, Cfg>::new()
// This is the new, interesting part. It takes both the logging configuration and command
// line options, puts them together and tells system put it in place.
//
// Note that it would allow us to augment the logger on the way through the pipeline (for
// example adding a hardcoded logger to the set provided by the configuration ‒ it can be
// used for example to integrate with sentry, if you have that need).
.with(
Pipeline::new("logging").extract(|opts: &Opts, cfg: &Cfg| LogBoth {
cfg: cfg.logging.clone(),
opts: opts.logging.clone(),
}),
)
.build(false)
.unwrap();
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
So we can now configure the log destinations in a config file (and we can set multiple ones, with different logging levels, at once, filtering of log levels based on the log target, and some more). Once we turn on the background thread, we’ll gain ability to change logging at runtime. It comes handy, but having to write it every time into each new application is tedious ‒ now we can reuse it every time.
Similarly, we can pull in support for configuring metrics, a HTTP client, proper daemonization (though it is no longer as fashionable for services to go into background on their own and some kind of init system thing usually handles that), etc. Hopefully, as time goes, there’ll be even more.
All these crates provide their own bits of configuration and an example that can be used as the basic „canonical“ way to use them and tweak it to get exactly the needed support.
Improving the UX around configuration
So we have pulled in a whole bunch of configuration fragments and we are not stopping here, we are going to configure our own things too in a moment. And we can compose the final configuration from multiple files, environment variables, etc. That’s great, but it also means we can easily get lost in the sheer amount of things that can be configured and knowing what configuration we run with is also a bit of a challenge.
This is where spirit-cfg-helpers
come into play. This allows us, with a bit of work, to add few
new command line switches. The --help-config
flag is similar to help ‒ it prints the whole tree
of what can be configured, each option with a little desciption (yes, we’ll talk about where that
comes from). The --dump-config
does the start up of the application up to the point when the
configuration is composed from all the parts, parsed and then it just prints the whole
configuration as it would have been used and exits.
Apart from having to throw the right field into our configuration option structure and plugging it in, like with the other crates, we need make sure our configuration structure implements two additional traits.
The Serialize
allows us to take the parsed configuration structure and dump
it. Usually, it can be simply derived (and it pairs with the Deserialize
use to parse the configuration in the first place).
If you’re feeling perfectionist, you can optimize the behaviour of the parsing and dumping for
better UX. For example, annotating vectors of things with #[serde(default, skip_serializing_if = "Vec::is_empty")]
makes the vector disappear if it has no elements. Similar with Option
. There
are few utilities around, like deserialize_duration
(so you
can specify durations in form of 3days 5hours
). There’s also the Hidden
to hide passwords from the dump and logs ‒ if you have a field password: Hidden<Stryng>
, it’ll be
printed to both as ***
instead the actual password.
The StructDoc
provides the help and the structure of the configuration
file. It is derived in a similar way to the serde
derives. The help is taken from the doc
comments. If you use a type that is not known to it, you can annotate it with #[structdoc(leaf = "name-of-type")]
and the type in the help will be taken from there.
The support for structdoc
is optional in the spirit extension crates, under the cfg-help
feature, but turned on by default. If you disable default features, you may need to turn the
structdoc
support on explicitly in Cargo.toml
.
...
spirit-log = { version = "0.4", default-features = false, features = ["cfg-help"] }
So let’s extend our example:
use std::convert::Infallible;
use std::net::SocketAddr;
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
use log::debug;
use serde::{Deserialize, Serialize};
use spirit::prelude::*;
use spirit::{Pipeline, Spirit};
use spirit_log::{Cfg as Logging, CfgAndOpts as LogBoth, Opts as LogOpts};
use structdoc::StructDoc;
use structopt::StructOpt;
// We have just added the two new derives in here
#[derive(Clone, Debug, Default, Deserialize, Serialize, StructDoc)]
struct Cfg {
/// The logging.
///
/// This allows multiple logging destinations in parallel, configuring the format, timestamp
/// format, destination.
#[serde(default, skip_serializing_if = "Logging::is_empty")]
logging: Logging,
}
/// The hello world service.
///
/// Greets people over HTTP.
#[derive(Clone, Debug, StructOpt)]
// We can use all the structopts tricks here.
#[structopt(
version = "1.0.0-example",
author,
)]
struct Opts {
// Adds the `--log` and `--log-module` options.
#[structopt(flatten)]
logging: LogOpts,
}
async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
debug!("Handling request {:?}", req);
Ok(Response::new("Hello, World!".into()))
}
#[tokio::main]
async fn main() {
let _spirit = Spirit::<Opts, Cfg>::new()
// This is the new, interesting part. It takes both the logging configuration and command
// line options, puts them together and tells system put it in place.
//
// Note that it would allow us to augment the logger on the way through the pipeline (for
// example adding a hardcoded logger to the set provided by the configuration ‒ it can be
// used for example to integrate with sentry, if you have that need).
.with(
Pipeline::new("logging").extract(|opts: &Opts, cfg: &Cfg| LogBoth {
cfg: cfg.logging.clone(),
opts: opts.logging.clone(),
}),
)
.build(false)
.unwrap();
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
Configuring the web server and managing the whole lifetime
Until now, we just created the spirit
object at the beginning, let it initialize stuff, but then
forgot about it. That’s not taking the full advantage of it. What the thing can do for us is also
to reload configuration in the background, terminate when asked for, run shutdown tasks, and handle
errors during startup.
But for that we need to integrate the HTTP server into spirit
, so it can be manipulated by it.
Spirit provides configuration fragments both for the tokio
runtime (configuring number of
threads, for example ‒ up until now we have let it decide for us) and for HTTP servers. Let’s pull
them in.
Then we move from using build
to using run
to execute the actual „body“ of the application.
use hyper::{Body, Request, Response};
use log::debug;
use serde::{Deserialize, Serialize};
use spirit::prelude::*;
use spirit::{Pipeline, Spirit};
use spirit_log::{Cfg as Logging, CfgAndOpts as LogBoth, Opts as LogOpts};
use spirit_hyper::{server_from_handler, BuildServer, HttpServer};
use spirit_tokio::runtime::Config as TokioCfg;
use spirit_tokio::Tokio;
use structdoc::StructDoc;
use structopt::StructOpt;
// We have just added the two new derives in here
#[derive(Clone, Debug, Default, Deserialize, Serialize, StructDoc)]
struct Cfg {
/// The logging.
///
/// This allows multiple logging destinations in parallel, configuring the format, timestamp
/// format, destination.
#[serde(default, skip_serializing_if = "Logging::is_empty")]
logging: Logging,
// Some more configuration things go in here.
/// The work threadpool.
///
/// This is for performance tuning.
threadpool: TokioCfg,
/// Where to listen on for incoming requests.
// Makes the array "disappear" if empty. Omit to always force at least an explicit empty array.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
listen: Vec<HttpServer>,
}
impl Cfg {
fn listen(&self) -> &Vec<HttpServer> {
&self.listen
}
}
/// The hello world service.
///
/// Greets people over HTTP.
#[derive(Clone, Debug, StructOpt)]
// We can use all the structopts tricks here.
#[structopt(
version = "1.0.0-example",
author,
)]
struct Opts {
#[structopt(flatten)]
logging: LogOpts,
}
async fn handle(req: Request<Body>) -> Response<Body> {
debug!("Handling request {:?}", req);
Response::new("Hello, World!".into())
}
// Note: The main is no longer async tokio::main thing, it is the ordinary blocking main.
fn main() {
Spirit::<Opts, Cfg>::new()
.with(
Pipeline::new("logging").extract(|opts: &Opts, cfg: &Cfg| LogBoth {
cfg: cfg.logging.clone(),
opts: opts.logging.clone(),
}),
)
// Right, this is not a pipeline. This serves a double purpose. First, it integrates the
// tokio runtime into the spirit, which must be started *before* run (it sometimes can be
// started implicitly as part of a pipeline that uses tokio, but the only one we have here
// is plugged in inside the run).
//
// The other purpose is to actually configure how many threads run, etc.
//
// Rust gets confused if you don't provide at least `cfg: &_` as the type.
.with(Tokio::from_cfg(|cfg: &Cfg| cfg.threadpool.clone()))
// The run will first construct the spirit object, then run the body. If there's any error
// both during the setup (before run) or inside run, the error is logged (to the configured
// logging location, if that is already set up, or to stderr if it is very early).
.run(|spirit| {
// Here we could do some more loading, starting up, etc.
// We also can access the spirit and read configuration from it.
//
// But actually, we just plug another pipeline in, one that sets up the http server(s).
// We could do that before run too, but this way all the theoretical loading happens
// before we start listening and we can also use that and pass it to the created
// server.
spirit.with(
Pipeline::new("listen")
// Yes, we are passing the whole vector to spirit, but provide instruction how
// to create one server. Spirit figures on its own how to start multiple from
// that, which ones to add or remove on a change and all that.
// FIXME: Does anyone know why we need an actual function in here and inline
// closure is just not enough? If we just inline it here, rustc is not able to
// build the whole pipeline and one of the million trains don't align right,
// though it is confused enough not to even hint at which one. A rustc bug?
.extract_cfg(Cfg::listen)
.transform(BuildServer(server_from_handler(handle)))
)?;
Ok(())
// Now, the `run` will terminate. But the spirit will wait with shutdown until the
// futures ‒ in case the servers we started ‒ finish running.
});
}
This adds a bit of copy-paste style code from docs, but we gained the ability to configure multiple HTTP servers, including fine details like how many concurrent connections are we willing to have open at each time, or if we want to support HTTP2. As a bonus, we get the ability to actually reconfigure what ports (and addresses) we listen on at runtime.
There are few more tricks. It is possible to also listen on unix domain sockets, or even create a
hybrid configuration where some instances listen on IPv4, some on IPv6 and some on unix domain
sockets. Furthermore, the server configuration fragment can be parametrized by additional type
parameter. The library doesn’t touch it, but it is possible ‒ if you don’t use the
server_from_handler
, but build the server manually ‒ to access that field and customize each
separate instance of the listening server.
The complete thing
The repository contains an example that is an extended version of the above exercise. You’ll further find:
- Some more logging, including of the content of newly loaded configuration.
- Embedding of base configuration inside the program.
- Looking for configuration inside the environment.
- Parametrizing the behaviour of the server by both global (whole application) and local (one server instance) configuration.
- Support for the unix domain sockets.
- Daemonization.
- Manual building of the server, which allows for returning errors, for passing context and other a bit more advanced things.
Hopefully, this shows some of the possibilities of what spirit is capable of. Further chapters do into more details about specific topics.