Writing Clean Code in Rust: Harnessing Traits and Structs
Written on
Chapter 1: Introduction to Rust’s Clean Code Practices
Rust, a systems programming language renowned for its emphasis on safety and concurrency, has quickly become popular due to its efficient memory management, zero-cost abstractions, and a strong type system. A significant aspect that facilitates writing clean code in Rust is the use of traits, which are akin to interfaces in other programming languages. Traits enable powerful polymorphism and promote modular design.
In the code examples provided, we will explore how Rust utilizes traits and structs to develop clean, maintainable, and adaptable code. The primary focus will be on creating a user repository that can support both MongoDB and PostgreSQL databases. The following diagram illustrates the objective of our task.
Before we dive in, here are the dependencies required for our Rust project:
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-postgres = "0.7"
mongodb = "2.0"
bson = "1.2"
serde = { version = "1.0", features = ["derive"] }
async-trait = "0.1"
log = "0.4"
env_logger = "0.9"
Let’s begin step by step.
Section 1.1: Defining Data Structures and Traits
Before we delve into the implementation specifics, we need to establish our data structures and the operations we aim to support.
#[derive(Debug)]
pub struct User {
pub id: i64,
pub name: String,
pub email: String,
}
/// A trait outlining the fundamental functionalities for user repository operations.
#[async_trait]
pub trait UserRepository {
/// Retrieves a user by their ID.
///
/// # Arguments
///
/// * id - The ID of the user to retrieve.
///
/// # Returns
///
/// * User if retrieval is successful.
/// * String error message on failure.
async fn get_user_by_id(&self, id: i64) -> Result;
}
Here, we define a User struct and a UserRepository trait. This trait is essential to our design, ensuring that any repository—whether it connects to MongoDB, PostgreSQL, or another database—will implement the get_user_by_id method.
Section 1.2: Implementing the Trait for Specific Databases
For demonstration purposes, we will implement two repositories: one for MongoDB and another for PostgreSQL.
MongoDB Implementation:
/// Repository for accessing users stored in MongoDB.
pub struct MongoUserRepository {
collection: Collection,}
impl MongoUserRepository {
/// Creates a new MongoUserRepository with the specified MongoDB collection.
///
/// # Arguments
///
/// * collection - The MongoDB collection to interact with.
pub fn new(collection: Collection) -> Self {
MongoUserRepository { collection }}
}
#[async_trait]
impl UserRepository for MongoUserRepository {
async fn get_user_by_id(&self, id: i64) -> Result {
let filter = doc! { "id": id };
info!("Executing MongoDB find_one with filter: {:?}", filter);
match self.collection.find_one(filter.clone(), None).await {
Ok(Some(doc)) => {
info!("Document retrieved from MongoDB");
let user = User {
id: doc.get_i64("id").unwrap_or(0),
name: doc.get_str("name").unwrap_or_default().to_string(),
email: doc.get_str("email").unwrap_or_default().to_string(),
};
Ok(user)
}
Ok(None) => {
error!("No document found in MongoDB with the given filter: {:?}", filter);
Err("No user found".to_string())
}
Err(e) => {
error!("Error querying MongoDB: {}", e);
Err(e.to_string())
}
}
}
}
PostgreSQL Implementation:
/// Repository for accessing users stored in PostgreSQL.
pub struct PostgresUserRepository {
client: tokio_postgres::Client,}
impl PostgresUserRepository {
/// Creates a new PostgresUserRepository with the specified PostgreSQL client.
///
/// # Arguments
///
/// * client - The PostgreSQL client to interact with.
pub fn new(client: tokio_postgres::Client) -> Self {
PostgresUserRepository { client }}
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn get_user_by_id(&self, id: i64) -> Result {
let query = "SELECT id, name, email FROM users WHERE id = $1";
let stmt = match self.client.prepare(query).await {
Ok(statement) => statement,
Err(e) => {
error!("Failed to prepare the query: {}", e);
return Err("Failed to prepare the query".to_string());
}
};
info!("Executing query: {}", query);
match self.client.query(&stmt, &[&id]).await {
Ok(rows) => {
info!("Received {} rows from the database", rows.len());
for row in rows {
let user = User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
};
return Ok(user);
}
Err("No user found".to_string())
}
Err(e) => {
error!("Error executing the query: {}", e);
Err("Failed to execute the query".to_string())
}
}
}
}
This design reflects the principle of "Accept traits, return structs." Instead of tightly coupling our code to a specific database, we utilize the trait to define expected behavior, which is then implemented across various databases.
Section 1.3: Logging and Error Management
The code is equipped with comprehensive logging and error handling. By using the log crate, the program logs the executed queries and any errors encountered, ensuring transparency and facilitating debugging.
Section 1.4: Bringing It All Together
By integrating the get_user_from_database function, our main program structure becomes more modular and streamlined. This function abstracts the specifics of user retrieval by ID, allowing seamless interaction with both MongoDB and PostgreSQL, all made possible through Rust’s trait system.
/// Retrieves a user from the specified repository based on the given ID.
///
/// This function abstracts the underlying database-specific logic, ensuring a consistent retrieval process
/// regardless of the repository implementation (PostgreSQL, MongoDB, etc.). By accepting
/// a &dyn UserRepository parameter, it exemplifies the power of Rust's trait system and polymorphism,
/// enabling flexible database operations without tight coupling to a specific database type.
///
/// # Arguments
///
/// * repo - A reference to any struct that implements the UserRepository trait.
/// * id - The ID of the user to retrieve.
///
/// # Returns
///
/// A Result which is:
///
/// * Ok(User) if user retrieval is successful.
/// * Err(String) if there was an error during retrieval.
async fn get_user_from_database(repo: &dyn UserRepository, id: i64) -> Result {
repo.get_user_by_id(id).await}
/// Entry point for the application.
///
/// Initializes the logger, connects to PostgreSQL, and then connects to MongoDB.
/// Fetches a user with ID 1 using the generic get_user_from_database function and prints the result.
#[tokio::main]
async fn main() {
env_logger::init();
// PostgreSQL Connection
let (client, connection) = tokio_postgres::connect("host=localhost port=5432 user=postgres password=postgres dbname=db sslmode=disable", NoTls)
.await
.expect("Failed to connect to Postgres");
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("Postgres connection error: {}", e);}
});
let postgres_repo = PostgresUserRepository::new(client);
match get_user_from_database(&postgres_repo, 1).await {
Ok(user) => println!("User from PostgreSQL: {:?}", user),
Err(e) => eprintln!("Error: {}", e),
}
// MongoDB Connection
let client_options = mongodb::options::ClientOptions::parse("mongodb://root:pass@localhost:27017/").await.expect("Failed to parse MongoDB URI");
let mongo_client = Client::with_options(client_options).expect("MongoDB Client creation failed");
let collection = mongo_client.database("test").collection("users");
let mongo_repo = MongoUserRepository::new(collection);
match get_user_from_database(&mongo_repo, 1).await {
Ok(user) => println!("User from MongoDB: {:?}", user),
Err(e) => eprintln!("Error: {}", e),
}
}
This approach simplifies the main function, making it more focused on setup and execution. The specifics of data retrieval are handled by get_user_from_database, enhancing the readability and maintainability of the main function. The uniformity in data retrieval, irrespective of the underlying database, showcases the efficacy and adaptability of designing with traits in Rust.
Chapter 2: Docker Compose for Testing
version: "3.9"
services:
postgres:
image: postgres:14-alpine
container_name: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: db
volumes:
- ./postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
mongodb:
image: mongo:5.0.6-focal
container_name: mongodb
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: pass
MONGO_INITDB_DATABASE: db
volumes:
- ./mongo-data:/data/db
ports:
- "27017:27017"
Chapter 3: Incorporating Makefiles for Automation
To enhance our development workflow, we have introduced a Makefile. This invaluable tool allows us to automate various tasks associated with our project.
Within our Makefile, we have established two key targets:
Run Target: This target is designed to execute our Rust application. By setting the RUST_LOG environment variable to "info," we ensure that log messages at the "info" level (and above) are captured, keeping us informed about essential events and operations in our application. The command cargo run initiates the execution of our Rust program.
run:
RUST_LOG=info cargo run
Docs Target: One of Rust's notable strengths is its documentation capabilities. This target leverages the cargo doc command. The --no-deps flag ensures that only our crate's documentation is generated, excluding any external dependencies. The --open flag automatically opens the generated documentation in the default web browser, providing an immediate and interactive view of the structured comments and annotations from our codebase.
docs:
cargo doc --no-deps --open
By encapsulating these commands within a Makefile, we can execute complex tasks with simple commands like make run or make docs. This not only increases productivity but also ensures consistency across different development environments. When collaborating with others, this ensures that everyone can execute tasks in the same manner, minimizing the "it works on my machine" issues.
Conclusion
Traits in Rust function similarly to interfaces in other programming languages, but with the added benefits of Rust's type system and safety guarantees. By structuring our code around traits and subsequently providing concrete implementations with structs, we can achieve clean, modular, and highly maintainable code. This design pattern is particularly advantageous in scenarios like the one discussed here, where we aim to abstract the details of various databases while delivering a consistent API.
For those interested in similar concepts applied in Golang, please refer to my other article.
The first video titled "Rust Structs, Traits and Impl - YouTube" provides insights into the utilization of structs and traits in Rust, offering practical examples and explanations.
The second video titled "19 Impl and trait with struct in RUST - YouTube" explores the implementation of traits with structs in Rust, showcasing various use cases and best practices.