Macros and teams
jcbellido February 14, 2024 [Code] #Rust #Macros #shuttle.rsOne afternoon I decided that I wanted to learn a bit about the details of HTTP based APIs and user authentication. It's something I've never done from scratch. It looked like a cool little side research. While wiring the persistence I discovered that I wanted to simplify my testing armoring.
Which led to my first non-trivial proc macro.
What in turn made look back and reflect on how teams I've work with have interacted with this particular feature of rust. What I perceive as their real costs and how, at the end, "hoomans human".
DIY Authentication
This story begins with an article I found in shuttle.rs's documentation about authentication. I've written quite some backend lately but I never had to care about authing agents. It was a good opportunity to learn about it by implementing something from scratch.
This is probably a bad idea |
---|
I do not recommend to write your own authentication layer1. Sounds like the kind of idea that, if reaches production, might get you in trouble. In my defense, I was curious, and I'm not releasing any of this onto the wild.2 |
Shuttle's article covers a lot of ground. I believe it's a reasonable starting point. I wasn't particularly interested in hosting this app inside shuttle.rs so I dissected the sample and started writing the glue myself. Their proposed implementation looks a bit like:
-
Postgres on persistence
-
sqlx glueing rust to the DB
-
Axum serving the calls
-
Templated server-side rendering for the pages
It's a perfectly reasonable stack if you're an edge-cloudy provider and part of your goal is to sell services to the reader. So one of my first calls was to yeet Postgres and use the borderline almighty SQLite.
Notes on SQLx
sqlx is an async library that let's you connect and query SQL DB's, among others SQLite and PostgreSQL. It plays well with axum. It includes some tooling, like the SQLx CLI, with operations such as:
- create a new DB
- runs migrations
- enables static analysis of your queries
This is a good project that I've used a couple times and it always delivers. I had very few surprises with it.
TDD-ing + SQLite: the problem
After a bit of reading and github spelunking I was ready to write some tests for my totally tutorial based code. Very soon I found myself writing the following block again and again:
use User;
use persistence;
const PATH_DIR_MIGRATION: &str = "./migrations";
async
I hope the code is clear but in summary, what I'm doing is:
- create a SQLite and run the current migration
- my test
- close the pool
In case you want to get properly bored you can read about the details of
test_sqlite::sqlite_create
below, in a handy appendix. Or in a github gist.
There's a lot of scaffolding.
Maybe it's possible to reduce all this noise?
Is it perhaps a good candidate to use macros and reduce the cruft?
Rust Macros
When approaching rust it's somewhat tempting to read the word "macro", shortcut into #DEFINE
, and skip the whole thing3. At least this is what I did in my first approach. Perhaps it's a naming problem. Buried in the manual itself we find a way better word: metaprogramming.
Fundamentally, macros are a way of writing code that writes other code, which is known as metaprogramming.
Then, the manual adds:
Metaprogramming is useful for reducing the amount of code you have to write and maintain, which is also one of the roles of functions.
Around me, the first real contact with macros any new rust developer would experience happens when working with serialization and deserialization, usually through serde. The always informative @jonhoo has a wonderful piece on this crate:
Through serde
we get an intuition on how powerful this feature can be.
TDD-ing + SQLite: take 2
In previous section I was complaining about the cruft I was copy-paste-ing over and over. But, what if the test could look like this instead?
async
Where all the scaffolding is hidden away inside the macro? Let's explore a potential implementation.
#[test_sqlite] macro
It's important to note that currently (March 2024) you need to follow a strict method if you want to develop a procedural macro. You'll need to add a new crate, name things appropriately and import a couple of low level crates to your sources. It's a bit of setup.
Let's keep in mind that the following macro code is a sample and that I'm not exactly a hardcore crustacean. I know it works, and I know it's keeping my code cleaner, I know it has a bunch of redundant clone
s in it. It's also a reasonable "non trivial" sample.
extern crate proc_macro;
use ;
use ;
// Notice the "proc_macro2" TokenStream
If you prefer, there's a version hosted in gist.
A note from danvazrom |
---|
Very kindly, Mr. danvazrom proof read this article and pointed out that my fixture isn't cleaning after it. That's completely true and I must confess that's also on purpose. From time to time I like to "peek" into the DBs to be extra-duper super that I'm doing what I think I'm doing. Go follow the guy, he's a smart fella. |
Through the wonderful rust-analyzer we can recursively expand and check what's reaching the compiler:
If you look carefully you'll see how macros are operating recursively. In particular how test_sqlite
embeds a call to tokio::test
. The compiler will see the full expanded code and if something fishy happens it'll complain as expected.
Assuming you're using a "plebs editor"4 (ie. VSCode) this effectively means that you'll see a squiggly reporting an issue on code that you can't see.5
The team cost of macros
In the previous sections I described what me-myself wanted to solve: reduce the cruft in these tests
and the way I decided to go ahead with it: add a procedural macro
. Working alone this is a simple step for the codebase: I know the implementation in-n-out, I wrote it, hardened it, tested it. And more importantly, I don't need to convince anyone to adopt this solution.
But let's imagine a team that's starting with rust or perhaps, for reasons, hasn't had the need to develop their own macros before. What is this team committing to?
-
A new crate for the macro. Usually a trivial matter of fixing a bunch of
Cargo.toml
entries. But let's not forget our beloved build engineers and their fancy robots. They'll have to be sure to wire everything. -
Introduced the dependencies: syn, quote and proc-macro2. Probably a minor issue since these dependencies are constrained to the crate containing the macros.
-
Rust as TokenStream. When writing macros our goal is to transform the input into valid rust code. This requires a bit of mental gymnastics on which a developer needs to shift its focus from the business domain into a way more formal language domain.
-
The
quote!
blocks are effectively a DSL. The sample above is not exactly trivial but it's on the very simple side.quote!
blocks can get complicated and difficult to parse if something bad happens in the macro. Or if you need to go back and extend its features. -
Behind the scene code. The code the team writes looks different than the code reaching the compiler.
-
Using new features of the tooling: rust-analyzer expand macro operation, or perhaps the cargo-expand. I haven't been able to naturally integrate macros with my day to day IDE experience6. A problem that makes the code feel distant, obscure and perhaps even scary.
Those being the "hard aspects" of macro maintenance in a team. I believe there's also a policy aspect. Let's be cheeky and call it ...
Macro "sanity spectrum"
As with many programming language features (where C++ templates come to mind) there seems to be a spectrum of adoption for macros. A gradient that starts in: "this feature is never used" and goes all the way up to "arcane black magic".
It's quite difficult to "not use macros at all" when using rust. Many are borderline mandatory. An example from a simple async CLI:
async
Where the first lines of that async CLI is using 2 macros already. You could expand all that "by hand" and have pure rust in your code but it feels quite silly. They're a tremendous feature.
The potential problem happens "at the other side of the spectrum" where macros are doing a hell of a lot. Perhaps you're building UI and targeting web. You might be interested in using rust-as-react through yew (it's a magnificent library, btw) And, since you're a modern fella, you don't want to use filthy structs, you want to use the chaddest function components! You're in luck, the #function_component macro has you covered. And then your code will look a lot like javascript.
The human side
We can say that teams are a bunch of guys working together towards a shared goal. They'll bring their own preferences and skill sets to the collective. And in the case of software developers this includes strong opinions on abstraction and tolerance for consuming piles of documentation and scaffolding.
In relation with macro adoption I had the chance to meet both extremes. Those that consider the costs as being too prohibitive. The others that see the opportunity to extend rust through macros effectively making it look as other languages.
Wrapping up
I perceive a difference between macros and other features of rust. They seem to come with a steep price tag for the adopting teams. At the same time they open a fantastic array of possibilities that go well beyond the very simple example contained in this article.
Where I see the real issue, in the context of teams, is in the hoomans. Unless you're careful navigating the "sanity spectrum" there's a real chance of leaving some of your devs out and behind. And it's easy to reach narratives where some of your team colleagues will describe your carefully tailored macros as "over complex" or "unnecessarily flashy" while others embrace their usefulness and use them for absolutely everything.
Good hunt out there!
Appendix: sqlite_create
For my own future reference and in case you're curious about the details of sqlite_create it looks like this:
use Command;
use Result;
use ;
async
pub async
Where I'm using SQLx CLI (through cargo).
The core concept here is: create this SQLite DB and run this migration, if something fishy happens go BANANAS.
Unless you know very well what the heck you're doing, obvs.
And even if you claim to know what you're doing, be careful
Particularly if you're an old fart like me.
Or perhaps there's a configuration or setting or plugin I know nothing about that makes working with macros in VSCode slightly less mysterious.
VSCode is a magnificent beast. I have no issues with it, even though I'm trying to get into hellix editor lately.
During the last year a couple of very interesting new IDEs have appeared in my radar. JetBrains RustRover or the quite intriguing Zed Editor. I haven't tested those in depth and perhaps they have a more integrated experience when working with macros and other rust features.