Indeed, there is no unified, simple answer to “how do I write a Rust program instead of a [other language]-in-Rust program”. There could be an entire book (or books, one per other language) on the subject; I don't know if anyone’s written such things.
Given your desire to manage multiple services and starting and stopping them together, here's one way to solve it well with common traits, sketched out (this code runs but doesn't do anything useful):
use std::net::SocketAddr;
use std::net::TcpListener;
/// abstracts over different services *before* they start
trait ServiceConfig {
fn start(self: Box<Self>) -> Result<Box<dyn Service>, Error>;
}
/// abstracts over different services *after* they start
trait Service {
fn stop(self: Box<Self>) -> Result<(), Error>;
}
type Error = Box<dyn std::error::Error + Send + Sync>;
// --------------------------------------------------------------
struct HttpBuilder {
addr: SocketAddr,
}
struct HttpServer {
listener: TcpListener,
}
impl HttpBuilder {
fn serve(self) -> Result<HttpServer, Error> {
// In a real server, the TcpListener would be handed over to a thread
// or async task which accepts connections, not returned here.
Ok(HttpServer {
listener: TcpListener::bind(self.addr)?,
})
}
}
impl ServiceConfig for HttpBuilder {
fn start(self: Box<Self>) -> Result<Box<dyn Service>, Error> {
// This bit of code just adapts the concrete types in
// `HttpBuilder::serve()` to the generic boxed form.
Ok(Box::new(self.serve()?))
}
}
impl Service for HttpServer {
fn stop(self: Box<Self>) -> Result<(), Error> {
Ok(())
}
}
// --------------------------------------------------------------
fn main() -> Result<(), Error> {
// Pretend these are two different types -- it works the same.
let not_started: Vec<Box<dyn ServiceConfig>> = vec![
Box::new(HttpBuilder {
addr: "127.0.0.1:8000".parse().unwrap(),
}),
Box::new(HttpBuilder {
addr: "127.0.0.1:8001".parse().unwrap(),
}),
];
// Start all services
let started: Result<Vec<Box<dyn Service>>, Error> = not_started
.into_iter()
.map(|service| service.start())
.collect();
eprintln!("Services started.");
// Check for errors (crudely, without identifying which service,
// but we could fix that above in the map()).
let started = started?;
// wait ...
// Stop all services
let errors: Vec<Error> = started
.into_iter()
.filter_map(|service| service.stop().err())
.collect();
if errors.is_empty() {
eprintln!("Services stopped.");
} else {
// Report shutdown errors non-fatally
eprintln!("{} services had errors while stopping.", errors.len());
}
Ok(())
}
Some will object to this code. As @quinedot put it, “ ServerFactoryFactory
s aren't a common Rust design either”, and that's true. You should not write this code in order to write good code. You should write this code to achieve your goal of common startup and shutdown.
Or, if you have only two or three services, then throw out the traits and just call each service’s unique startup and shutdown functions in your main()
(or other top-level function). The important part isn't the traits, it's the typestate: a HttpServer
or a Box<Service>
doesn't exist until the service starts, and it doesn't exist after the service stops. That's how you avoid Option
s. I wrote the traits just to demonstrate that using typestate doesn't stop you from abstracting with traits, if you use Box
in the right places.