Introduction
Organization
Practical Rust is divided into macro topics, listed below. If you don't know what you're looking for, try reading through the descriptions of the various sections and see if one of them sounds similar to the problem you're trying to solve. If it does, click though to that section, where you'll find a more detailed summary.
- Serialization: reading or writing native program data in an interoperable format (config files, sending data over a network, etc.)
- Observability: enabling the measurement and observation of program state over time.
Not Covered
Practical Rust is not meant to be a beginners' guide to the language itself, but rather a guide to the ecosystem and common ways we solve common problems. If you're looking for information on learning the language itself, consider one of the following excellent resources:
- The Rust Book: the de-facto beginners' guide to Rust.
- Rust by Example: A collection of examples demonstrating the application of beginners' skills.
- Rust API Guidelines: follow this to construct idiomatic Rust libraries
Serialization
- Configuration: supplying input to your program
Configuration
Observability
Observability refers to understanding a system through observation…
(Brendan Gregg, BPF Performance Tools)
One of the most important properties of well-designed applications is their observability. We have to be able to inspect the relevant state of our application in detail, and we have to do it both in the past and present.
Let's say our program is a basic web server hosting some CRUD app. It's running normally, and then our continuous monitoring tool reports abnormally slow response times. We see this alert an hour after it comes in. By then, response times are back to normal.
Ideally we would be able to answer the following questions.
- Why were the response times slow?
- It was due to a spike in traffic that led to high CPU utilization.
- When did this spike in traffic occur?
- Between 10–11 AM.
- What IP addresses were making the queries causing the spike?
- It was two IP addresses
X
andY
. - Investigation shows they're both hosted on Amazon EC2.
- It was two IP addresses
- What endpoints were queried by those IPs during the spike?
- Both
/login
and/signin
were queried in approximately equal volume.
- Both
- Which endpoint ate up the CPU?
- The
/login
endpoint caused the high CPU utilization.
- The
- For the same number of requests, why does one endpoint use more CPU?
- The
/login
endpoint was performingargon2i
password hashes - The
/signin
endpoint was instantly returning404 Not Found
.
- The
- Were any of those requests successful?
- No, they all returned
401 Unauthorized
.
- No, they all returned
From this information, we would be able to understand that the first-order cause of our slow responses was high CPU usage. The second-order cause was that an attacker was attempting to gain access to an account (probably) by trying a password list. The third-order cause was either that our rate limit on /login
attempts is too high, or our settings on argon2i
hashing are too computationally expensive.
We don't usually know all of the exact questions we'll be asking about our application in the future. An observable application is one that supports asking novel questions without needing to be re-programmed.
This can lead to a balancing act. We want to be able to answer critical questions about an application's past and present state, but observability comes with a small, but non-zero runtime cost. If we capture every packet sent to and from a web server, we'll have incredible visibility into exactly what requests and responses we're sending out, the rate they're being sent, to whom and when, and so on. But we'll also have a massive amount of data to continously transmit to a central logging repository, and to store once the data get there.
Always profile your application so that you're aware of the runtime cost (or lack thereof) you pay for using various observability methods.
Observability in Rust
- tracing: emit and collect event-based data
Tracing
Tracing is both a specific kind of observability and a Rust crate that enables it.
Quick Links
- tracing: the core emitter library. Use this to output events for subscribers to collect.
- tracing-subscriber: the core collector library. Use this to actually serialize the events and spans you generate.
tracing-subscriber
has a couple default subscribers to check out. It also defines the necessary traits needed to implement your own. - tokio's tracing intro. (
tracing
is a part of the broadertokio
project.)
Tracing: the Practice
Tracing is all about the observability of per-event data (stolen borrowed from Brendan Gregg). It's not about measuring the overall memory usage of your process over time. It's not about whether your disk is full. Both of those examples are about high-level system resource monitoring.
Tracing is all about events, and sometimes aggregations over events. How many bytes were in the payload for this request? What was the distribution of payload sizes last month? Tracing helps to answer these questions.
Most applications can be understood as a series of discrete events. Often these events are logically independent. Any request-reply application is trivially event-based. The events are the request-reply pairs.
But even something less obviously event-based can still benefit from drawing certain boundaries to encapsulate "events'. Let's say our application is training a neural network. I would anticipate eventually wanting insight into the training process. What sort of "event data" would be helpful here? I might choose individual trainig epochs as the beginnings of new events. The events would then last until the next epoch, and they would contain summary statistics about the performance of that mini training run and the parameters used.
Of course, not all useful information is found in event data. High-level overviews of system state are still critically important. (Ever run out of space because you forgot to turn on log-rotation? Me neither.) But when it's time to dive into the nitty gritty of what's been happening on your system, event traces will prove invaluable.
Tracing: the Crate
tracing is a framework for instrumenting Rust programs to collect structured, event-based diagnostic information.
That about sums it up. Let's unpack some of the key terms.
- framework:
tracing
is more than just a crate. It's a set of interfaces for plugging into shared tracing infrastructure. With justtracing
you can't even see the logs you're producing. The main goal fortracing
is to give library authors a small dependency that allows them to:- Choose what information is relevant in an event.
- Conform to a standard interface that "subscribers" (event collectors) use to collect data.
- instrumenting: refers to modifying code to enable the emission of relevant information
- structured: machine-parseable, i.e. queryable. It's easy for basic logging to only be amenable to human analysis. For tracing to be effective, the collected information should be consumable by a machine and ready for analytical processing (often by submitting the logs to an OLAP database).
tracing
itself is pretty bare-bones. It's inteded for use by both library and executable authors, so it's good that it only requires users to bring in a small dependency. tracing
generally expects that users will either depend on or write other crates to provide the exact suite of functionality they want.
Events and Spans
tracing
differentiates between "events" and "spans". "Events" are likely a familiar concept; they generally map well to "logs" that we've all seen before. Events generally have a timestamp (they occur at a single instant), some structured data, and a message meant for human consumption.
However, events often fail to capture the relationship that they have with each other. During the course of a request-reply session, there can be hundreds or thousands of events. Let's make some up:
{"ts": 1655342020100, "method": "GET", "path": "/"}
{"ts": 1655342020200, "found_user_header": true, "user": "steve3"}
{"ts": 1655342020300, "read": "public/index.html"}
{"ts": 1655342020400, "status_code": 200}
These are all individual events, but there's no structure that relates them. Sure, we could throw a request_id
in each of them so that it's possible to correlate them, but why force ourselves into that extra step of analysis? We know at the time of the request that all of these pieces of information are deeply related. Let's encode that into the structure of our events.
Spans represent periods of time in which a program was executing in a particular context. (tracing docs)
A set of related events occurring over time is a "span". Conceptually, a span with all the information from the above example might look like this:
{
"request_method": "GET",
"request_path": "/",
"begin_ts": 1655342020100,
"end_ts": 1655342020400,
"events": [
{"ts": 1655342020200, "found_user_header": true, "user": "steve3"},
{"ts": 1655342020300, "read": "public/index.html"},
{"ts": 1655342020400, "status_code": 200}
]
}
Alomst any analysis of this applications' emitted logs will benefit from the added structure.
Tips and Tricks
In a sense, the most common problem Rust programmers have to tackle is… actually writing Rust. To that end, this section goes into some general guidelines, tips, and tricks that may be helpful to Rustaceans.
Designing Data Structures
Rule 5. Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming.
(Rob Pike, Notes on Programming in C)
Data structures lie at the heart of all computing, and they're much too great a topic to cover here in any meaningful way. However, there are tips and tricks most experienced Rust programmers encounter at some point. Below is a loosely organized, ever-growing list of patterns at least tangentially related to data-structure design.
Structuring complex enums
Occasionally we may need to have a somewhat complex enum type. There are some subtleties around structuring complex enums that can help with the ergonomics of actually using those types down the line.
As a motivating example, let's say that we're writing a backend API for a microblogging platform. Specifically, we're designing an endpoint called /post
that will allow a user to submit a POST request carrying the content of their post, and our endpoint will write the content to our platform.
The catch is that there are two types of posts: text posts and picture posts. So it makes sense to come up with a type that can represent either one of them. This is exactly the purpose of Rust's enums.
Pattern: bag-of-struct-variants
So we build the following type:
#![allow(unused)] fn main() { enum PostContent { Text { username: String, time: u64, location: (f32, f32), text: String, }, Image { username: String, time: u64, location: (f32, f32), image_data: Vec<u8>, caption: String, } } }
This is the natural starting place for when we're thinking about just coming up with something that will work. Basically, we make a struct variant for each possible case and then fill that variant with every field we'll need in the corresponding case.
There are some downsides to this approach. First, there's often duplication of the fields that the different variants have in common. Every variant of PostContent
has the fields username
, time
, and location
, but this fact isn't expressed in the type system. This leads to the creation of methods like the following:
#![allow(unused)] fn main() { enum PostContent { Text { username: String, time: u64, location: (f32, f32), text: String, }, Image { username: String, time: u64, location: (f32, f32), image_data: Vec<u8>, caption: String, } } impl PostContent { fn username(&self) -> &str { match self { PostContent::Text { username, .. } | PostContent::Image { username, .. } => username } } } }
The username
method expresses the fact that PostContent
will always have a username field. This works. There's nothing terribly wrong with it. But… it's a little unsatisfying. And verbose. Surely there's a way we can express the guaranteed existence of username
, time
, and location
without manually constructing a method for each of them.
Another issue is that the match ergonomics can be a little verbose. Let's say we want to write out a log for the request with all the relevant information. Here's how we might do that:
#![allow(unused)] fn main() { enum PostContent { Text { username: String, time: u64, location: (f32, f32), text: String, }, Image { username: String, time: u64, location: (f32, f32), image_data: Vec<u8>, caption: String, } } fn get_post() -> PostContent { PostContent::Text { username: String::new(), time: 0, location: (0.0, 0.0), text: String::new(), } } let post_content = get_post(); match post_content { PostContent::Text { username, time, location, .. } => println!( "username={username} \ time={time} \ location={location:?}" ), PostContent::Image { username, time, location, caption, .. } => println!( "username={username} \ time={time} \ location={location:?} \ caption={caption}" ) } }
As we add more logged information to these variants, the match patterns become longer and longer. I personally wouldn't want to see something like this in my codebase:
match post_content {
PostContent::Image {
username,
full_name,
time,
location,
caption,
authentication_token,
in_reply_to,
} => {
println!(todo!())
}
}
I mean — it's fine. Nothing horrific. But it does get worse as the types get more complicated, and we can do better.
Pattern: structs-in-tuple-variants
Instead of using struct variants, we can optionally create dedicated types for each of our cases, and then stick them in tuple variants. This will help solve the verbosity problem.
#![allow(unused)] fn main() { enum PostContent { Text(TextContent), Image(ImageContent), } struct TextContent { username: String, time: u64, location: (f32, f32), text: String, } struct ImageContent { username: String, time: u64, location: (f32, f32), image_data: Vec<u8>, caption: String, } fn get_post() -> PostContent { PostContent::Text(TextContent { username: String::new(), time: 0, location: (0.0, 0.0), text: String::new(), }) } let post_content = get_post(); match post_content { PostContent::Text(text)=> println!( "username={} time={} location={:?}", text.username, text.time, text.location, ), PostContent::Image(image) => println!( "username={} time={} location={:?} caption={}", image.username, image.time, image.location, image.caption, ) } }
We broke out the data from each variant into its own struct and then switched to tuple variants in the enum to hold our structs. This is primarily beneficial when you're frequently using lots of different fields from the variants. If you're only using one or two fields, the pattern matching won't get too crazy.
One thing we lose is the ability to use the handy identifiers-in-format-strings approach to string interpolation. C'est la vie.
Also, we still haven't solved one of our two problems: the fields username
, time
, and location
aren't guaranteed by the type system to be present. Boooooo.
Pattern: struct-in-tuple-variant-of-enum-in-struct
For our final trick, we create one last type and stick in it all the fields that are guaranteed to be present. Those fields are removed from the enum variants, and the enum itself has been renamed rather uncreatively to InnerPostContent
.
#![allow(unused)] fn main() { struct PostContent { username: String, time: u64, location: (f32, f32), inner: InnerPostContent, } enum InnerPostContent { Text(TextContent), Image(ImageContent), } struct TextContent { text: String, } struct ImageContent { image_data: Vec<u8>, caption: String, } fn get_post() -> PostContent { PostContent { username: String::new(), time: 0, location: (0.0, 0.0), inner: InnerPostContent::Text(TextContent { text: String::new(), }) } } let post_content = get_post(); match post_content.inner { InnerPostContent::Text(_)=> println!( "username={} time={} location={:?}", post_content.username, post_content.time, post_content.location, ), InnerPostContent::Image(image) => println!( "username={} time={} location={:?} caption={}", post_content.username, post_content.time, post_content.location, image.caption, ) } }
Now the type systems knows for sure that any instance of PostContent
will have a username
, time
, and location
. Accessor methods can be defined more compactly, or omitted entirely if the fields are public. This pattern does create a bit more boilerplate, but in the end it manages to encode more of our business logic and invariants in the type system than the other two patterns. That's a win.