Rust is a statically compiled language. As such it excels, and is geared towards, static program structures. This means that some restrictions apply:
-
Every type needs to have a known size or, if its a trait, it needs to be behind a
dyn T
reference -
Traits need to be object safe to qualify being allowed in a
dyn T
reference
During my work the following requirements came up:
- Have a known list of possible components
- Be able to instantiate any number and combination of these components
- Establish typed connections between the instances of the components
In this case, the idea is be to have a precompiled binary that is ‘batteries-included’ in which the user can then activate a number of instances of its components and wire-up a data flow between them. After several months of trying out different systems and continously refining our ideas we’ve found a nice pattern that I want to document here.
Note: This is not a tutorial, and more of a guided explanation. Rust knowledge is required!
Laying the groundwork
Let’s assume the components we care about are called ’Plugin’s. So we will need a trait for that:
trait Plugin {
async fn starting(&mut self) -> Result<()> {
Ok(())
}
async fn run(&self) -> Result<()>;
async fn stopping(&mut self) -> Result<()> {
Ok(())
}
}
(I’m using async fns in traits, if that’s not yet available feel free to use async-trait
to work around it.)
The Plugin
trait will be the heart piece of this whole endeavour. It can be used as such for example:
struct WatchFolder {
path: PathBuf,
}
impl Plugin for WatchFolder {
async fn run(&self) -> Result<()> {
while let Ok(event) = watch_folder(self.path).await {
info!(?event, "Received folder event");
}
}
}
What is that
watch_folder
function and theinfo!
macro? And why are you not specifiying the Error in thoseResults
?
Good that you mention those Neikos, they are just placeholders, so that anyone reading this doesn’t have to think in ‘foos’ or ‘bars’ and instead can just focus on the heart of the issue. Whereas for the error type, its not important and readers are expected to fill that in. It’s not meant to be copy-pasted!
Won’t it just confuse them even more?
Maybe? But who knows, I prefer writing semi-realistic code. Moving on…
Now that we’ve seen how the Plugin
object is meant to be seen, let’s use it!
async fn main() -> Result<()> {
let mut watcher = WatchFolder { path: PathBuf::from("./src") };
watcher.starting().await?;
watcher.run().await?;
watcher.stopping().await?;
}
Great! That’s easy and fairly straightforward. Now comes the first hurdle. We want to spawn several of them. How could we do this?
Well, we’re programmers, and if we have several, we’ll just loop!
async fn main() -> Result<()> {
let paths = ["./src", "/etc/hosts"];
let watchers = paths.iter()
.map(|p| WatchFolder { path: p.into() })
.collect::<Vec<_>>();
for watcher in watchers {
watcher.starting().await?;
}
let done_watchers = watchers
.into_iter()
.map(|watcher| async move {
watcher.run().await;
watcher
})
.collect::<FuturesUnordered>()
.collect::<Result<Vec<_>>>().await?;
for watcher in watchers {
watcher.stopping().await?;
}
}
Whew that got more complicated!
Yeah, well we’re doing async stuff! But don’t worry, it’s all fairly straightforward:
- First we get a list of paths (Could just as well be from user input, or a config file, etc…)
-
We then
.iter
ate over that list and construct someWatchFolder
instances, and put everything into aVec
- We then start each of them one after the other
-
Then, I create a future, but don’t await it yet, which I then collect into a
FuturesUnordered
through theExtend
trait. -
Since
FuturesUnordered
is aStream
I can then collect from that and make it aResult<Vec<_>>
, pass on any errors and get back the watchers!-
They had to be moved into the future, since we don’t want to have borrows in our
Future
s (its generally a headache)
-
They had to be moved into the future, since we don’t want to have borrows in our
Stepping up the difficulty
What if we now have multiple kinds of plugins? Let’s define a new one!
struct SimpleWebserver {
port: u16,
directory: PathBuf,
server_handle: Option<ServerHandle>,
}
impl SimpleWebserver {
pub fn new(port: u16, directory: PathBuf) -> Self {
SimpleWebserver {
port,
directory,
server_handle: None,
}
}
}
impl Plugin for SimpleWebserver {
async fn starting(&mut self) -> Result<()> {
self.server_handle = Some(webserver::start(self.port, &self.directory).await?);
Ok(())
}
}
What does the webserver do?
That’s just a detail, it could do whatever you want! It’s just Rust code after all.
Now, let’s try to instantiate both a WatchFolder
and a SimpleWebserver
!
async fn main() -> Result<()> {
let plugins = vec![WatchFolder { path: PathBuf::from("./src") }, SimpleWebserver::new(3456, PathBuf::from(".")];
// .. call starting, run, and stopping
}
Oh-oh! That doesn’t compile! They are not of the same type.
Yup, and now we’re leaving the static world and making our first steps into the dynamic world.
Trait Objects to the rescue
All the way at the top, we’ve defined our Plugin
trait. It’s a fairly simple trait, and thus allows us to pack it behind a fat pointer and ‘erase’ the concrete type, keeping only the information conveyed by the Plugin
trait. This is then called a ‘Trait object’. If you want to read more, check the rust reference on it!
What’s a fat pointer?
A fat pointer is a pointer with some associated metadata. In this case it would be a table of methods that correspond to the methods in the Plugin
trait.
The concept of the so-called ‘call table’ is represented through dyn Plugin
. Everytime you see a dyn
, you should think “Ah! It could be anything, so I can only do generic stuff to it”
Now, the issue is that dyn Plugin
is just ‘something’ represented as a table of methods, so how big is it? We can’t know! So Rust prevents us to use it ‘as-is’ and requires us to put it behind some form of pointer. There’s a few we can pick from:
-
&dyn Plugin
-
Box<dyn Plugin>
-
Rc<dyn Plugin>
-
Arc<dyn Plugin>
And surely more that I’ve missed, they all have different properties, but since we want to ‘own’ the resulting Plugin
s we will use Box<dyn Plugin>
.
Stepping up the difficulty (contd.)
TODO:
-
Explain how to use
Vec<Box<dyn Plugin>>
- Explain how a second trait is needed to construct it based on a given config
- Explain how communication can be done
-
Explain how this communication can be typed by using
Box<dyn Any>