This commit is contained in:
669
content/posts/2024-02-18-building-business-services-in-rust.md
Normal file
669
content/posts/2024-02-18-building-business-services-in-rust.md
Normal file
@@ -0,0 +1,669 @@
|
||||
---
|
||||
type: blog-post
|
||||
title: Building services in rust
|
||||
description: This post should show that it is not just possible to write production ready services in rust, but that it can be a nice experience without too much boiler plate. I also go over some does and dont's so that you hopefully have a nice experience
|
||||
draft: false
|
||||
date: 2024-02-19
|
||||
updates:
|
||||
- time: 2024-02-19
|
||||
description: first iteration
|
||||
tags:
|
||||
- "#blog"
|
||||
---
|
||||
|
||||
Building business services might sound like a boring topic, and in some
|
||||
instances it can be. But from my point of view, building business services is a
|
||||
test for how ready a language or tool is, to achieve mainstream appeal. With
|
||||
this article I hope to show that Rust is right around that point, if not already
|
||||
there, so lets jump right in.
|
||||
|
||||
# But what is a business service
|
||||
|
||||
First of all we should define some criteria of what a business services is,
|
||||
otherwise we have nothing to measure against, even if we're gonna use half-baked
|
||||
fluffy metrics to decide on anyways.
|
||||
|
||||
A business service, is a long running application, capable of running multiple
|
||||
different endpoints at once, maybe an http stack on a port, grpc on another,
|
||||
sending logs somewhere, calling external services, putting stuff on a queue, you
|
||||
name it. It is a multi facetted application that serves an external need of some
|
||||
sort and is basically a shell around some business logic.
|
||||
|
||||
A business service can be anything from a microservice, which serves one of the
|
||||
above, or monolith, it doesn't really matter as those are orthogonal metrics,
|
||||
i.e. they are about scale not capabilities, how much damned stuff you can cram
|
||||
in a box, and how many engineers to page once it goes down.
|
||||
|
||||
Most importantly of all a business service should be testable, it should be
|
||||
relatively self-serving once not receiving direct maintenance other than patch
|
||||
upgrades, and do its absolute damnest to fulfill its requirements; serve a
|
||||
business need.
|
||||
|
||||
To me the most important is test-ability, can it serve:
|
||||
|
||||
- Unittests
|
||||
- Integrationtests
|
||||
- Acceptests
|
||||
|
||||
If these above are cumbersome to do, then the language isn't ready for
|
||||
mainstream usage. It doesn't matter how capable, how fast, or how secure it is,
|
||||
if normal engineers can't write code in the language without arcane knowledge
|
||||
then it isn't ready.
|
||||
|
||||
So to sum up the criteria:
|
||||
|
||||
- Ergonomics
|
||||
- How easy is it to manage external dependencies
|
||||
- Testability
|
||||
|
||||
As you can probably tell, these are not some of rusts core values, maybe except
|
||||
for ergonomics, but I'll show that it is still possible to do great work in it.
|
||||
|
||||
# Rust as a Service
|
||||
|
||||
Lets start from the top and go through a few architectural patterns that are
|
||||
common in business software, such as handling dependency injection, interfaces
|
||||
and concrete types, strategy pattern, etc. And which tools you need to rely on
|
||||
to achieve them.
|
||||
|
||||
## Dependency injection ~~hell~~
|
||||
|
||||
Dependency management or injection as it is normally called for services, is
|
||||
simply a way for a function to take in some abstraction from outside and use its
|
||||
functionality, without having to deal with the complexities of how it actually
|
||||
implements said functionality. It is also extremely useful for testing a piece
|
||||
by itself.
|
||||
|
||||
I come from an object-oriented background as such that is usually how I go about
|
||||
solving these issues, especially as Rusts functional programming model have some
|
||||
ergonomic downsides that makes it difficult to do dependency injection using it
|
||||
(for reasons I won't go into here).
|
||||
|
||||
Usually you use dependency injection via a constructor
|
||||
|
||||
```rust
|
||||
pub struct MealPlannerAPI {
|
||||
meal_planner: MealPlannerDatabase
|
||||
}
|
||||
|
||||
impl MealPlannerAPI {
|
||||
pub fn new(meal_planner: MealPlannerDatabase) -> Self {
|
||||
Self {
|
||||
meal_planner
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn book_meal(&self) -> Result<()> {
|
||||
self.meal_planner.book_meal(/* some input */).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Quite simply we take in some `struct` or `trait` in the `new` function, which
|
||||
serves as our constructor. And we can now just call the `book_meal` on the
|
||||
`meal_planner` inner type. This has a few benefits. If the input is a trait, we
|
||||
can mock it out, or we can use macro to mock a struct and swap a concrete value
|
||||
with it (even if I don't recommend it, but more on that later).
|
||||
|
||||
Lets say for now that MealPlannerDatabase is a `trait`
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait MealPlannerDatabase {
|
||||
pub async fn book_meal(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
pub struct MealPlannerPsqlDatabase {
|
||||
psql: sqlx::ConnPool<Postgres>
|
||||
}
|
||||
|
||||
impl MealPlannerPsqlDatabase {
|
||||
pub fn new(psql: sqlx::ConnPool<Postgres>) -> Self {
|
||||
Self {
|
||||
psql
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MealPlannerDatabase for MealPlannerPsqlDatabase {
|
||||
pub async fn book_meal(&self) -> Result<()> {
|
||||
self.psql.query!("INSERT ... INTO ...").execute(&mut self.psql).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a small example, which we'll make use off later, but notice that we've
|
||||
split up the implementation up into two parts, the interface (contract) and the
|
||||
concrete type (implementation). This helps us in a few ways, i.e. we can swap
|
||||
out the implementation for either a different database in this case, or a mock
|
||||
if we want to test the `MealPlannerAPI`.
|
||||
|
||||
We can also add the `mockall` trait to our trait to automatically get mocks
|
||||
generated. This is quite convenient, but comes with some downsides in that it
|
||||
can reduce the feature set that you would normally have available. For example
|
||||
you cannot use `impl` in functions.
|
||||
|
||||
The keen eyed among you may notice that the above code wouldn't actually
|
||||
compile. I.e. you cannot take a trait as input to a function without a pointer,
|
||||
this is because we don't know the size of said trait (it may be any of the
|
||||
possible implementations or none), as such we need some abstraction around it.
|
||||
Secondly, the database might have some requirements that it needs to be called
|
||||
exclusively, so it may need an `Arc` or a `Mutex`, which we didn't deal with
|
||||
either.
|
||||
|
||||
For that we'll make use of the facade pattern. I.e. we're gonna create a facade,
|
||||
such that our external code doesn't have to deal at all with us having a trait,
|
||||
a mutex, arc whatever. The only thing that matters is that it can depend on the
|
||||
functionality without too much hassle.
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
pub struct MealPlannerDatabase(Arc<dyn traits::MealPlannerDatabase>);
|
||||
|
||||
impl MealPlannerDatabase {
|
||||
// Options we want to expose
|
||||
pub fn psql(psql: sqlx::ConnPool<Postgres>) -> Self {
|
||||
Self(Arc::new(MealPlannerPsqlDatabase::new(psql)))
|
||||
}
|
||||
|
||||
// Escape hatch
|
||||
pub fn dynamic(concrete: Arc<dyn traits::MealPlannerDatabase>) -> Self {
|
||||
Self(concrete)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for MealPlannerDatabase {
|
||||
Target = Arc<dyn traits::MealPlannerDatabase>
|
||||
|
||||
deref(&self) -> Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now you could technically have an `Arc`, `Mutex`, whatever and the consumer
|
||||
would be none the wiser, it still allows you to use the inner functions as you
|
||||
normally would `self.meal_planner.book_meal().await?`.
|
||||
|
||||
You can even expand on it with an actual inner pattern if you need the Mutex, or
|
||||
something more complicated. The `dynamic` specifies that we can still use it as
|
||||
a test, as we can replace the internals with our mock.
|
||||
|
||||
### Shared dependencies as a Service
|
||||
|
||||
The last pattern I want to show is shared dependency management. For that we'll
|
||||
use a few rust features as well. The corner stone of the pattern is to create a
|
||||
single shared resource, which we can use to new up all the required dependencies
|
||||
we need.
|
||||
|
||||
```rust
|
||||
pub struct App {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SharedApp(Arc<App>)
|
||||
|
||||
impl SharedApp {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(App{}))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for SharedApp {
|
||||
Target = Arc<App>;
|
||||
|
||||
fn deref(&self) -> Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Again we use a custom deref that makes sure we can reach the inner pattern,
|
||||
without having to wrap everything in Arcs, and or mutexes. I forgot to mention
|
||||
why we do so. When you've got 10-100 dependencies, it becomes a little long in
|
||||
the tooth, to have to wrap each an everything in Arcs because the `SharedApp` is
|
||||
a shared object and needs to be `clone`.
|
||||
|
||||
Before we move on to how to actually use this pattern, I'd like to give a
|
||||
recommendation. The App should not contain every single struct you need, it
|
||||
should contain foundational IO resources. Such as a database connection pool,
|
||||
queue manager, grpc connection, logger instance etc. Things that need setup from
|
||||
external configuration.
|
||||
|
||||
```rust
|
||||
pub struct App {
|
||||
psql: sqlx::ConnPool<Postgres>
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
That means that it won't contain `MealPlannerAPI` or `MealPlannerDatabase`.
|
||||
We'll get to those in another way.
|
||||
|
||||
To actually get to the concrete types we'll use something called extension
|
||||
traits
|
||||
|
||||
```rust
|
||||
//file: meal_planner_api.rs
|
||||
|
||||
pub struct MealPlannerAPI {
|
||||
// skipped for brevity ...
|
||||
}
|
||||
|
||||
pub mod extensions {
|
||||
pub trait MealPlannerAPIExt {
|
||||
fn meal_planner_api(&self) -> MealPlannerAPI;
|
||||
}
|
||||
|
||||
impl MealPlannerAPIExt for SharedApp {
|
||||
fn meal_planner_api(&self) -> MealPlannerAPI {
|
||||
MealPlannerAPI::new(self.meal_planner_database())
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This means that we can now from the outside call `app.meal_planner_api()` and
|
||||
we'll get an instance of the concrete type. If you've got a high volume service,
|
||||
you can either choose to move these values down into the shared struct itself,
|
||||
or cache them in the `SharedApp` using an object pool. In most cases the
|
||||
performance cost is negligible. In some cases rust will even inline these
|
||||
functions even if they're traits, to make them faster.
|
||||
|
||||
The database is the same, but uses values on self instead.
|
||||
|
||||
```rust
|
||||
//file: meal_planner_database.rs
|
||||
|
||||
pub struct MealPlannerDatabase {
|
||||
// skipped for brevity ...
|
||||
}
|
||||
|
||||
pub mod extensions {
|
||||
pub trait MealPlannerDatabaseExt {
|
||||
fn meal_planner_database(&self) -> MealPlannerDatabase;
|
||||
}
|
||||
|
||||
impl MealPlannerDatabaseExt for SharedApp {
|
||||
fn meal_planner_database(&self) -> MealPlannerDatabase {
|
||||
MealPlannerDatabase::psql(self.psql.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice that we use the `psql` method instead, and that this acts like a normal
|
||||
struct, even if it fronts for a trait. This is super convenient. This also means
|
||||
that you could technically create multiple `App`s for different purposes and
|
||||
only choose to implement the extensions for those that need said dependencies.
|
||||
|
||||
This should cover all of our needs to handling dependencies. And if you'd like
|
||||
to can see this in action at: https://git.front.kjuulh.io/kjuulh/flux-releaser.
|
||||
Where I heavily use this pattern both for a cli and for a service in the same
|
||||
crate.
|
||||
|
||||
## Dependencies all of them
|
||||
|
||||
We may have to run multiple different hot paths in our code, which are code
|
||||
paths which see high traffic, or where the main traffic comes through. This may
|
||||
be a http runtime, grpc, messaging etc.
|
||||
|
||||
For that right now, tokio is the name of the game. This is also why I didn't
|
||||
touch on the question above of why I marked nearly every function as async. If
|
||||
you develop this kind of software, it is a given that nearly all functions will
|
||||
touch some IO, and as such will be async, if not you will just have to go back
|
||||
afterwards and add async.
|
||||
|
||||
You want a fast, ergonomic, and stable runtime. In most languages these are
|
||||
built in. In rust the defacto standard is tokio. Even if there are multiple
|
||||
other alternatives on the marked, but for now, tokio is what you'd probably
|
||||
choose if you built services. It may change in the future though so don't take
|
||||
my word as gospel, and figure out what fits best for you. The only thing I ask
|
||||
is that you be consistent.
|
||||
|
||||
Tokio has the benefit of being able to spawn many virtual `threads` (tasks), and
|
||||
as such even if we only have a single core, or part of one. We can still run
|
||||
asynchronous work.
|
||||
|
||||
This should most of the time be done by a lifecycle management library,
|
||||
something that can make sure that a bunch of parallel services are running at
|
||||
the same time, and if one fails they all shut down. But we can just start by
|
||||
hacking our own together to illustrate how it works.
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let app = SharedApp::new();
|
||||
|
||||
tokio::select! {
|
||||
res = app.meal_planner_api().serve() => {
|
||||
res
|
||||
},
|
||||
res = app.meal_planner_grpc().serve() => {
|
||||
res
|
||||
},
|
||||
// .. As many as you'd like
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
This is a bit of a naive example, but should illustrate that you can run
|
||||
multiple tasks at the same time serving requests. Do note that if one exits all
|
||||
of them will terminate. But we can now share `app` between all the different
|
||||
runtimes and execution flows, like you'd normally do in any service.
|
||||
|
||||
I will go into how to actually make a nice development environment in another
|
||||
article, such that you should know which packages to provide as a standard
|
||||
development offering. But for now we'll just let our little service setup
|
||||
everything for itself. So keep in mind that the database setup, apis, runtimes
|
||||
etc. could be provided by a dedicated team.
|
||||
|
||||
## Testability
|
||||
|
||||
One of the most important criteria for myself is being able to test a service. I
|
||||
usually defer on writing fewer more end-2-end tests rather than a lot of small
|
||||
unit tests. This is convenient, because rust doesn't make it easy to write unit
|
||||
tests.
|
||||
|
||||
Lets start with integration tests and then afterwards move on to unittests,
|
||||
because in rust they're quite different.
|
||||
|
||||
### Integration tests
|
||||
|
||||
Integration tests I categorize a a test that span an entire service, including
|
||||
its io dependencies, but not other services. Such that you'd include a database,
|
||||
messaging broker, but not S3 or another service in your tests. It should poke
|
||||
the application from the outside, at least as much as possible, but still be
|
||||
able to introspect the state of the app using the libraries. So for me
|
||||
integration tests are categorized as a greybox test. Somewhere in the middle of
|
||||
whitebox and blackbox.
|
||||
|
||||
To setup integration tests for a service in rust, is a bit different than what
|
||||
you're used to. First of all, you'll want to place the test file somewhere else
|
||||
than where they normally life (in the code beside the functionality is the usual
|
||||
place). As such you'd create a folder in your crate:
|
||||
|
||||
```bash
|
||||
tests/ # new folder
|
||||
src/
|
||||
```
|
||||
|
||||
Each file under tests will be module like we normally have it in rust, this will
|
||||
become important later.
|
||||
|
||||
A tests file looks like this
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn can_book_a_meal() -> Result<()> {
|
||||
let (endpoints, app) = setup().await?; // TODO: more on this in a bit
|
||||
|
||||
let resp = reqwest::post(endpoints.meal_planner_http).await?;
|
||||
|
||||
assert!(resp.status.is_success())
|
||||
|
||||
let meal_bookings = app.meal_planner_database().get_meal_bookings().await?;
|
||||
|
||||
// ... more asserts
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
There is a few different pieces we haven't gone through before, but the first
|
||||
important piece is the setup function. You'd want as a few as possible
|
||||
concurrent apps running, as such the setup can be shared across tests (this is
|
||||
only possible pr. file, as each file is a binary in of itself, as such they
|
||||
cannot share memory between them).
|
||||
|
||||
So the setup should setup an app once, let the tests do its thing, and once all
|
||||
of them are done, shut down.
|
||||
|
||||
```rust
|
||||
async fn setup() -> Result<(Endpoints, SharedApp)> {
|
||||
// You need a separate tokio runtime, as otherwise it would shutdown between each test
|
||||
|
||||
// OnceCell to only spawn a server once
|
||||
INIT.call_once(|| {
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
// Init the server
|
||||
let server = Server::new().await.unwrap();
|
||||
|
||||
// Set global options
|
||||
unsafe {
|
||||
ENDPOINTS = Some(server.endpoints.clone());
|
||||
APP = Some(server.app.clone());
|
||||
}
|
||||
|
||||
// Actually wait for the server, this should never terminate before the tests are done. I.e. start a webserver and stay blocking.
|
||||
server.start().await.unwrap();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for the server to come up, i.e. call a ping endpoint or something
|
||||
wait_for_server().await?;
|
||||
|
||||
return Ok(unsafe { (ENDPOINTS.unwrap(), APP.unwrap()) })
|
||||
}
|
||||
```
|
||||
|
||||
Again lots of technicalities (see flux_releaser for a more thorough example).
|
||||
Just remember that we start up a process once in a separate thread, which has
|
||||
its own runtime. Let the server run, and outside of that we wait.
|
||||
|
||||
A small disclaimer here, this is what I would constitute as arcane knowledge,
|
||||
thankfully you only have to do this once, and it can be packaged up, so that you
|
||||
don't have to deal with this complexity all the time. It is just too useful and
|
||||
essential for testing to not mention.
|
||||
|
||||
I will also stop here for now with integration testing, if you'd like a follow
|
||||
up let me know at `contact@kasperhermansen.com`.
|
||||
|
||||
### Unit testing
|
||||
|
||||
Depending on what you're doing in rust, unit testing can either be a breeze, or
|
||||
an absolute nightware. Essentially if you use structs all the way down with
|
||||
dependency injection shown in the previous section, without using traits, it is
|
||||
very difficult to do proper unittesting. I.e. you have no way of slicing
|
||||
functionality. If you use traits all the way down, then it will require a lot of
|
||||
boiler plate, or excessive usage of macros. Which I will touch on after this
|
||||
section.
|
||||
|
||||
What I recommend is:
|
||||
|
||||
- Using traits for IO
|
||||
- Splitting functionality to make the business logic parts isolated and
|
||||
testable, this is not always applicable, but does make things easier.
|
||||
|
||||
#### Split dat IO
|
||||
|
||||
IO, oh, IO without you we would just be a space heater, with you we're filled
|
||||
with heartbreak, and stupid proses somehow.
|
||||
|
||||
IO doesn't come equal, and when I mean IO in this case, I mean side effects
|
||||
pretty much, not everything that happens external to the program. I mean any
|
||||
external part of your application that we've got no control over. This means
|
||||
from a testing point of view, the database, sometimes filesystem, other
|
||||
services, http requests, etc. etc.
|
||||
|
||||
This is pretty much the only place outside of the strategy pattern, where I use
|
||||
traits, especially async traits.
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait MealPlannerDatabase {
|
||||
async fn book_meal(&self) -> Result<()>;
|
||||
}
|
||||
pub type DynMealPlannerDatabase = Arc<dyn MealPlannerDatabase>
|
||||
|
||||
#[async_trait]
|
||||
pub trait MealPlannerEvents {
|
||||
async fn meal_booked(&self) -> Result<()>
|
||||
}
|
||||
pub type DynMealPlannerEvents = Arc<dyn MealPlannerEvents>
|
||||
```
|
||||
|
||||
This means like in the previous sections that we can mock the external services,
|
||||
which allows us to focus on the business logic inside the `MealPlannerAPI`, or
|
||||
rather `MealPlannerService`
|
||||
|
||||
```rust
|
||||
pub struct MealPlannerService {
|
||||
// Please use the wrapper pattern shown in a previous section, this is just an example
|
||||
database: DynMealPlannerDatabase,
|
||||
events: DynMealPlannerEvents
|
||||
}
|
||||
|
||||
impl MealPlannerService {
|
||||
pub fn new(database: DynMealPlannerDatabase, events: DynMealPlannerEvents) -> Self {
|
||||
Self {
|
||||
database,
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn book_meal(&self) -> Result<()> {
|
||||
let meal_booking = self.generate_meal_booking();
|
||||
|
||||
self.database.book_meal(&meal_booking).await?;
|
||||
self.events.meal_booked(&meal_booking).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_meal_booking(&self) -> MealBooking {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
As you can see there isn't a terrible amount of meat on this logic, I'd actually
|
||||
normally argue that this shouldn't even be unit tested, but for completeness
|
||||
sake, lets just say that generate_meal_booking is unreasonably complicated and
|
||||
requires not just locking down its functionality, but helping guide development.
|
||||
|
||||
You can now choose to implement your own mocks for the `Database` and/or
|
||||
`Events`. And test the `book_meal` function to make sure the database and events
|
||||
are called with what you expect them too. Currently I'd either recommend rolling
|
||||
your own mocks, or using `mockall`.
|
||||
|
||||
#### Split dat class
|
||||
|
||||
It may be useful in rust to simply split your functionality into multiple parts,
|
||||
those that call external services, and simply isolating business logic.
|
||||
|
||||
```rust
|
||||
impl MealPlannerService {
|
||||
pub fn new(database: DynMealPlannerDatabase, events: DynMealPlannerEvents) -> Self {
|
||||
Self {
|
||||
database,
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn book_meal(&self) -> Result<()> {
|
||||
let meal_booking = self.generate_meal_booking();
|
||||
|
||||
self.database.book_meal(&meal_booking).await?;
|
||||
self.events.meal_booked(&meal_booking).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_meal_booking(&self) -> MealBooking {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now we can simply call `generate_meal_booking`, simple as that. But now you may
|
||||
say, but, but I don't get my precious 100% test coverage, and I'd like to ask if
|
||||
you're out here collecting points, or actually building software. Enough
|
||||
feathers ruffled, I'd highly recommend choosing wisely what to test, if you want
|
||||
100% test coverage, you're gonna trade that for increased boilerplate and
|
||||
complexity, and unless you're building a rocket, it may not be warranted.
|
||||
|
||||
This is it for testing, next one we're gonna move into a few general points
|
||||
|
||||
## Ergonomics
|
||||
|
||||
### To macro, or not to macro
|
||||
|
||||
Macros are useful, so much so, that they're tempting to use everywhere.
|
||||
`Procmacro` is literally crack cocaine, I will provide a word of caution though.
|
||||
Macros are another language inside rust, and can do anything the heart desires.
|
||||
First of all if you use macros, you will trade complexity and developer
|
||||
experience for decreased perceived complexity. Sometimes it is needed, other
|
||||
times it is a convenience, so be sure to choose wisely.
|
||||
|
||||
For example:
|
||||
|
||||
- async_trait is essential, rust doesn't have object safe traits without, or at
|
||||
least not without arcane knowledge, and increased boilerplate. This is the
|
||||
only non-struct procmacro I regularly use for filling gaps in functionality.
|
||||
- mockall is quite useful for generating mocks, though be careful with it, it
|
||||
can introduce unexpected code, and introduce general limitations on your
|
||||
traits and structs. I only use it for traits.
|
||||
|
||||
You should definitely use procmacros if they're essential for your app, such as
|
||||
in rocket, clap, tracing, leptos, etc. A good rule of thumb is, simply to really
|
||||
think if a procmacro is essential for your use-case. Often it is, most I've
|
||||
overused them in the past, and had a hell of a time cleaning them up.
|
||||
|
||||
### Defer for simplicity
|
||||
|
||||
Rust has enough tools and features to do a lot of things in 100 different ways.
|
||||
If you're serious about building services and product, defer for simplicity and
|
||||
be consistent. You could take a stance and say that you wont use async, or never
|
||||
use clone, etc. You'd end up taking on a whole load of complexity that would
|
||||
make the service quite unapproachable for further development. Raw dogging
|
||||
channels for request/reply is a nice feature, but honestly, it is a foundational
|
||||
block of functionality not a great api.
|
||||
|
||||
Keep things simple, and resist the need for creating abstractions for
|
||||
everything. It is okay to have the same code in a few places, and don't use
|
||||
macros for doing DRY. I've never seen it play out right
|
||||
|
||||
### Use crates, and build your own
|
||||
|
||||
Quite simply if you're building services, build your own crates, tailor them to
|
||||
your needs, develop a crate that automatically setups up a database connection,
|
||||
bundle your own logging setup that makes sure we export things as json etc.
|
||||
Implement your own context libraries for sharing variables throughout a call
|
||||
etc. There are a lot of libraries that isn't useful on crates.io for others, but
|
||||
if you choose to build small individual services, it can be quite useful to have
|
||||
easy to use out of the box functionality
|
||||
|
||||
### Workspaces, be aware
|
||||
|
||||
Workspaces are nice, and I actually default to them for my own services, but be
|
||||
careful I've got a tendency to make small libraries in these workspaces
|
||||
alongside my app. This can make it difficult to know where a crate comes from,
|
||||
and gives the service multiple responsibilities, or reason to be deployed /
|
||||
worked on. As such remember to keep services and workspaces focused on the topic
|
||||
at hand. That is unless you use a mono repo approach, but that is quite
|
||||
difficult to do with rusts compile times.
|
||||
|
||||
# Conclusion
|
||||
|
||||
I hoped that I've shown you some currently good practices for how to develop
|
||||
services in rust. We've covered anything I think is essential for building
|
||||
production ready code, which trades some performance for increased ergonomics,
|
||||
while keeping complexity at bay. It should be mentioned that this is just my own
|
||||
opinions and what feels right in 2024, where we're still missing crucial async
|
||||
features in rust. So it could change quite a bit over the next few years.
|
||||
|
||||
If you feel like something was unclear, or you'd like a topic to be expanded
|
||||
upon, let me know at `contact@kasperhermansen.com`.
|
||||
|
||||
Thanks a lot of reading, and I hope to see you at some point to a Rust Aarhus
|
||||
Meetup
|
Reference in New Issue
Block a user