Whoops I Rewrote it in Rust: .NET 4 Database Migrator Migrated
I manage an ASP.NET C# project with a fairly small team that's already half a decade old as of time of writing. It's made up of many components and can be hard to manage at times. During a maintenance period last week, I had enough available time to upgrade the most outdated component of the project: a .NET 4 based database migration utility. The rest of the project is already on .NET 6, but this component was out of scope for maintenance for various reasons not worth mentioning.
Instead trudging through the mud of bringing the project up through .NET 4, Core 3, 5, then 6, I decided to experiment with Rust. For others from a similar background, I thought I'd share my experience. Please don't take this as a tutorial, I'm still learning Rust, and I'm quite early in my career in general.
IDE
First and most obvious, we use Visual Studio 2022 CE, which has no support for Rust (well, at least any I could find). Instead, I switched to Visual Studio Code, which does have great support for Rust. One issue I came across here was how rust-analyzer activates. In order to get proper Rust support, I needed to only open the project folder containing the cargo.toml file. Ideally, I'd be able to open the root folder for my solution instead, but this isn't a deal breaker.
Package Management
Quite simply, Cargo is great, but it's more of a tool than an app store. Using NuGet in Visual Studio provides a very integrated experience where looking for a dependency has your hand held most of the way. Conversely, Cargo is closer to NodeJS' NPM system, where you forgo a GUI for package management, and instead modify a package manifest. A small touch I like about cargo.toml's [dependencies] section is the ability to create sections for important dependencies.
# cargo.toml
[dependencies]
anyhow = "1.0"
regex = "1.6"
[dependencies.clap]
version = "4.0"
features = ["derive"]
[dependencies.tokio]
version = "1"
features = ["full"]
Considering some dependencies can have fairly lengthy lists of optional features, this splitting is really clean in my opinion.
Language
Rust and C# are quite different languages, and I've seen quite a few people argue that Rust isn't "for" C# projects, and is instead focused on C/++ work instead. I couldn't disagree more. For a language that isn't Object Orientated, Rust does a really good job of looking similar to C#. Compare the below samples I wrote up before I started:
// example.cs
using System;
interface FavouriteColour {
string FavouriteColour();
}
public struct User : FavouriteColour {
public string Name { get; private set; }
public string Email { get; private set; }
public static User Create(string name, string email) {
return new() {
Name = name,
Email = email
};
}
public string FavouriteColour() {
return "Blue";
}
}
//example.rs
use std;
pub trait FavouriteColour {
fn favourite_colour() -> String;
}
pub struct User {
pub name: String,
pub email: String,
}
impl User {
fn create(name: &str, email: &str) -> User {
User {
name: name.to_string(),
email: email.to_string()
}
}
}
impl FavouriteColour for User {
fn favourite_colour() -> String {
"Pink".to_string()
}
}
The biggest difference here (in my opinion) is the declaration of "object methods" in a separate impl block instead of in the main structure definition.
Writing our actual DB migrator was obviously more involved than the example above, but the actual method we follow was pretty much identical. The only major change we made was how we bundled the SQL scripts with the application. Previously, we used .NET EmbeddedResources to include the SQL files directly. Whilst we could do that in Rust as well, we instead had a crack at writing a build.rs which generates a library of the SQL strings. The end result is largely the same, but the benefit it gave us was IDE hints for all the migrations scripts we have within the Rust environment, something we never had in C#.
Safety
Something I really liked about rewriting this project in Rust was having a far more explicit list of failure points. In the .NET project, many libraries used didn't have potential exceptions listed, making it almost impossible to know ahead of time how the program could fail. We worked around this by writing extensive unit tests, but some things are just hard to validate (e.g., inducing networking issues). In contrast, every method we used returned a Result<T,E> structure, which explicitly states exactly what could go wrong with library methods.
What this allowed us to do was bubble-up all the possible errors up to our main() method and appropriately handle them where we have the most context. Similarly, the use of Option::<T>::None instead of null is really nice. C# allows the use of nullable types, annotated with a ?. The problem here is that reference types (like classes) are already nullable, meaning that in practice, there's very little difference between User and User? in code guarantees. All it does is indicate to the programmer that it really could be null. Rust's Option<User> type is entirely concrete, and code that ignores the potential lack of data is obvious to find.
async/await
C# has a very good async/await system in my opinion, so I was quite happy to see that Rust uses a very similar system for its asynchronous compute, with one major change:
// C#
var x = await myTask();
// Rust
let x = my_task().await;
This is a big deal in little world. We use a lot of fluent APIs, and this suffix version of await allows for the fluent chain to remain unbroken if we want it to.
// C#
var x = await (await getData().toListAsync()).summarise();
// Rust
let x = get_data()
.into_vec()
.await
.summarise()
.await;
I'm not here to convince anyone that the fluent chain is a good way of writing code, but the ability to do this is a fantastic freedom we just don't have in C#.
Compilation
This is something that actually surprised me. Rust has notoriously long compile times, but in this project I ended up with identical compilation times between the .NET 4 and Rust implementations. The reason for this (I think) comes down to how the CI/CD pipeline we use (Azure DevOps) works, rather than the languages themselves. For example, the .NET 4 project had to be compiled in a Windows pipeline, whereas the Rust project was compatible with the Ubuntu image instead.
In local development, the initial build of dependencies was definitely slower in Rust, but subsequent builds were at least as fast, if not faster. I'm not too familiar with the .NET compilation system, but I suspect that cargo was able to compile more of the project ahead of time compared to dotnet, allowing it to start running faster.
CI/CD
As alluded to above, we use Azure DevOps for CI/CD, and I was pleasantly surprised to see cargo was already included in the ubuntu-latest image. This meant that writing the pipeline for this project ended up being easier than the .NET 4 original, as we no longer had to explicitly install that version of .NET. One annoyance is that there are no built-in templates for cargo-related tasks, so you do have to manually call them through Bash tasks, but that's a mild annoyance at worst.
Performance
Because this project just executes a finite number SQL queries on a remote DB, there's not a lot of room for performance improvements. The time taken to perform a migration is identical, but that was entirely expected.
Closing Remarks
Overall, I'm really impressed with how Rust works. I don't expect to rewrite our whole ASP.NET project in Rust any time soon, if ever. But being able to write little utilities like our DB migrator in a way that can be so thoroughly validated is fantastic.
Thanks for reading, and feel free to post any questions!