zero2prod (Rust)

So many years ago, if you believe the rumors of that time, Python was not very popular, Flask was somewhere in narrow circles, and Django sellers had to put in a good word. Of course, everyone understood that Django was the future, and not only because Java was boring everyone, but because it was convenient for both business and coding. What can I say, when reading the book zero2prod, you involuntarily remember the pleasure of studying Django, the surprise – “well, it was possible”, and perhaps the depth of elaboration of details that an ordinary developer could have mastered on his own, but was usually too lazy.

Rust, despite its modest speed, is very convenient for day-to-day development, and the book (which is in the title) reveals the details of this paradoxical feature.

It would be nice to have some logic in building an application, but unfortunately or fortunately, in our time, with a wide range of tools, any component of the future structure looks the most suitable for the place of the first to be implemented. The convergence of the project, in turn, will be determined by everything, and not the least role in it will be played by the imagination of the authors and the perseverance of colleagues.

Let's start with configuration files. Stepanov once said that the only use of inheritance is in field inheritance. We have a basic configuration file base.yaml and many, many configuration files for different environments. In code, it looks something like this:

Config::builder()
    .add_source(File::new("configuration/base.yaml", FileFormat::Yaml))
    .add_source(File::new(&env_config, FileFormat::Yaml))
    .build()

Just in case, yaml format is a convenient KV with indents.

Migrations for the database, why not. Well, we don't have a database yet, but we know that a persistent state is needed, and somehow we'll need to create tables or change columns. For migrations, the order of execution is important (timestamp is sometimes added to file names) and the tool (command line tool). The author of zero2prod uses migrations in a bash script. Something like this:

...
export DATABASE_URL=...
sqlx database create
sqlx migrate run

Lyrical digression: it's funny that rust cli was considered as a killer feature along with async/await. That is, as if people were thinking in 2018 where to apply resources and four directions were considered: Embedded systems, WebAssembly, Command-line interfaces, Network services. And here the author of a book about rust uses bash with a straight face.

Docker. Probably not a very common, but very convenient solution – two stage build. In the first step we build the code, in the second we copy and run the binary. The author of zero2prod attaches great importance to the image size – it's a fascinating read. Then the author deploys the application to the cloud, simultaneously describing the configuration file for deployment – where the base lives, where the application code is, whether a load balancer is needed, etc. In general, this is a separate story, but it would be nice to know how your code will be executed.

A few words about sqlx, which according to the docs “..is not an ORM, but compile-time checked queries”. That is, during compilation the guys connect to the database and check the structure of your SQL queries. It seems that at some point everyone got fed up with this, and the guys released a command line utility for generating queries offline. Further in the book there is not very popular, but very interesting argumentation about the query engine. If my memory serves me right, the essence of ORM is in a fairly easy replacement of the database without changing the application code (see, for example, django). So, the author zero2prod suggests using pure SQL, arguing that the application language can be changed, and SQL queries will remain escalated. Let me remind you, the book is about the Rust programming language.

Lyrical digression: a popular question at interviews is – what libs did you use? Author zero2prod compares sqlx with two orms, and it seems that the most demanding reader will find something interesting for himself.

So, we want to write code, but how? The author of the book suggests using tests, and splitting the project into client code and a library. The client code is just for poking around with a curl, or through a browser, and the library needs to be covered with tests a little more than completely – red green development, and all that. Here, as they say, there is a nuance. Fixing your tests is always ok. Fixing someone else's tests, if the test case is no more than ten lines, is also ok, but fixing an abstraction in tests (because there are a lot of tests and abstractions are needed) is more of a skip test than a fix. Arguing about tests is as useless as arguing about programming languages ​​(although everyone already knows that rust is the best 🙂

All web applications eventually use the “extremely fast” web framework, including zero2prod. The author of the book uses actix-web with a very interesting feature powerful request routing. It's simpler here with code:

App::new()
    .route("/health_check", web::get().to(routes::health_check))
    .route("/subscriptions", web::post().to(routes::subscribe))    

The routes::subscribe handle can have almost any signature. According to the author of actix-web, this is due to the Rust type system, not macro magic. For example,

pub async fn health_check() ...
pub async fn subscribe(form: web::Form<Email>, pool: web::Data<SqlitePool>) ...

// in the same time we can swap args if we want in subscribe(...)
pub async fn subscribe(pool: web::Data<SqlitePool>, form: web::Form<Email>) ...

It seems that in dynamic languages ​​this is not very trivial to do.

The author zero2prod has an interesting code structure – in one file both the route implementation and the database query. It seems like everything is thrown together, on the other hand the code is very compact, so I don't want to split it into files, for example:

pub async fn subscribe(...) -> Result<HttpResponse, SubscribeError> {
    let new_subscriber = form.0.try_into()?;
    let mut transaction = pool.begin().await?;
    insert_subscriber(&new_subscriber, &mut transaction).await?;
    transaction.commit().await?;
    Ok(HttpResponse::Ok().finish())
}

These question marks in the code are about the convenience (or ergonomics) of Rasta. Each such mark actually expands into something like this:

if insert_subscriber(&new_subscriber, &mut transaction).await.is_err() {
    return HttpResponse::InternalServerError().finish();
}

An attentive reader will immediately wonder how SubscribeError is related to the http response (ResponseError) – here the native feature of Rust – Trait (they are also protocols) works, something like this:

impl From<sqlx::Error> for SubscribeError {
    fn from(e: sqlx::Error) -> Self {
        Self::DatabaseError(e)
    }
}

impl From<String> for SubscribeError {
    fn from(e: String) -> Self {
        Self::ValidationError(e)
    }
}

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
            SubscribeError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

A bit verbose, can be made shorter using error handling libraries: anyhow and thiserror. The idea here, as it seems to me, is that errors are somewhere in one place and do not interfere with reading the code, the main logic of the program (only ok path – cool!).

Input validations. The author of the book suggests considering String, they are subscribers, as “dirty” data, and the structure SubscriberName(String) as clean data, respectively, the transition between states is strictly in one place:

impl SubscriberName {
    pub fn parse(s: String) -> Result<SubscriberName, String> { ... }
}

In an ideal world, it would probably work, but it seems unlikely that any String inputs will be wrapped in structs. Again, there is the AsRef trait – maybe it will work:

impl AsRef<str> for SubscriberName {
    fn as_ref(&self) -> &str {
        &self.0
    }
}
// e.g. we can use our SubscriberName here 
fn somewhere_inside_codebase(x: impl AsRef<str>) { ... }

A few words about telemetry. Probably, it is not worth giving up everything and replacing logs with telemetry today. The idea seems to be sensible, and it seems like there are already many services for collecting events from applications. But I don’t know. I liked this wrapper (see below) which seems to force you to use short functions and simultaneously adds an event at the function input and an event at the output:

#[tracing::instrument(
    name = "Adding a new subscriber",
    skip(form, pool),
    fields(
        subscriber_email = %form.email,
        subscriber_name= %form.name
    )
)]
pub async fn subscribe(...) {}

There is still a lot of interesting things in the book, so enjoy reading.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *