15 questions d'entretien Rust backend sélectionnées, groupées par niveau d'ancienneté. Utilisez-les pour réviser les fondamentaux, les compromis pratiques et le raisonnement de production de niveau senior.
1Expliquez les règles de possession (ownership) de Rust et comment elles influencent les choix pratiques de backend tels que le clonage, l'emprunt (borrowing) et le partage de l'état de l'application.
Rust attribue un seul propriétaire à chaque valeur. Le déplacement d'une valeur non-`Copy` transfère la possession, et la valeur est libérée (dropped) lorsque son propriétaire sort de la portée. Le code peut soit emprunter des données de manière immuable via des références partagées, soit de manière mutable via une référence mutable exclusive. Dans le code backend, cela détermine si une fonction prend possession, emprunte temporairement, clone pour obtenir une valeur indépendante possédée, ou partage un état de longue durée via des descripteurs tels que `Arc`, avec des primitives de synchronisation ou d'autres primitives de concurrence lorsque des mutations partagées sont nécessaires.
use std::sync::Arc;
struct AppState {
service_name: String,
}
async fn handler(state: Arc<AppState>, request_id: String) -> String {
// Borrow: no ownership transfer of the String inside state.
let name: &str = &state.service_name;
// Clone only if an owned independent value is needed.
let owned_name = name.to_owned();
format!("{owned_name}:{request_id}")
}
fn configure(state: Arc<AppState>) {
// Cloning Arc increments the reference count; it does not clone AppState.
let state_for_route = Arc::clone(&state);
let _ = state_for_route;
}
2Quelle est la différence entre l'emprunt avec `&T` et `&mut T`, et comment ces règles affectent-elles les signatures d'API (Application Programming Interface) et de gestionnaires ?
`&T` est une référence partagée : elle permet un accès en lecture seule, et de nombreuses références partagées à la même valeur peuvent être actives simultanément. `&mut T` est une référence mutable exclusive : elle permet la mutation, mais tant qu'elle est active, aucune autre référence partagée ou mutable à la même valeur ne peut être utilisée. Ces règles façonnent les API en faisant en sorte que les fonctions en lecture seule acceptent `&T` ou des types empruntés plus restreints comme `&str`, tandis que les fonctions de mutation acceptent `&mut T`, prennent possession, ou utilisent la mutabilité interne (interior mutability)/la synchronisation. Les gestionnaires backend évitent généralement l'accès `&mut` direct à l'état partagé de l'application entre les requêtes concurrentes et utilisent plutôt des descripteurs partagés avec synchronisation ou d'autres modèles de propriété.
struct User {
name: String,
login_count: u64,
}
fn display_name(user: &User) -> &str {
&user.name
}
fn record_login(user: &mut User) {
user.login_count += 1;
}
fn main() {
let mut user = User { name: "Ada".into(), login_count: 0 };
let name = display_name(&user); // shared borrow for reading
println!("{name}");
record_login(&mut user); // exclusive mutable borrow for mutation
}
3Comment fonctionne `Box`, et quels problèmes l'allocation sur le tas via `Box` résout-elle ?
`Box<T>` est un pointeur intelligent propriétaire dont le `T` est stocké sur le tas (heap) tandis que le handle `Box` est une valeur de type pointeur de taille fixe. Il a une propriété unique et libère l'allocation sur le tas lorsqu'il sort de sa portée. `Box` fournit une indirection, ce qui est utile pour les grandes valeurs, les types récursifs nécessitant une taille connue, les valeurs de taille dynamique et les objets de trait (trait objects) comme `Box<dyn Trait>`. Déplacer un `Box` transfère la propriété de l'allocation sans déplacer ni copier la valeur elle-même stockée sur le tas.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Décrivez `Result` et `Option` ainsi que les principales façons idiomatiques de gérer l'absence et les opérations faillibles.
`Option<T>` représente soit `Some(T)` soit `None` et est utilisé pour l'absence attendue d'une valeur. `Result<T, E>` représente soit `Ok(T)` soit `Err(E)` et est utilisé pour les opérations qui peuvent échouer lorsque les informations d'erreur sont importantes. La gestion idiomatique inclut `match`, `if let`/`let else`, des combinateurs tels que `map`, `and_then`, `ok_or`, `unwrap_or`, et `unwrap_or_else`, ainsi que l'opérateur `?` pour la propagation anticipée depuis des fonctions retournant des types `Option` ou `Result` compatibles. `unwrap` et `expect` sont à réserver aux invariants, aux tests, aux prototypes ou aux situations irrécupérables, et non aux erreurs backend récupérables normales.
5Comment fonctionne l'opérateur `?`, et qu'est-ce qui permet la conversion entre différents types d'erreurs ?
L'opérateur `?` est un raccourci pour propager un échec. Pour un `Result`, `expr?` renvoie la valeur `Ok` et continue, ou renvoie prématurément de la fonction actuelle avec un `Err` ; pour un `Option`, il renvoie la valeur `Some` ou renvoie `None`. La fonction englobante doit renvoyer un type compatible. Pour `Result`, différents types d'erreurs peuvent se composer car l'erreur source est convertie dans le type d'erreur de la fonction en utilisant la conversion de style `From`/`Into`, souvent implémentée manuellement ou dérivée avec des caisses (crates) d'aide comme `thiserror`.
use std::{fs, io, num::ParseIntError};
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn read_number(path: &str) -> Result<i32, AppError> {
let s = fs::read_to_string(path)?; // io::Error -> AppError
let n = s.trim().parse::<i32>()?; // ParseIntError -> AppError
Ok(n)
}
6Expliquez le filtrage par motif sur les énumérations et comment le filtrage exhaustif améliore la correction du domaine et de l'API.
Les énumérations (enums) modélisent une valeur qui peut être l'une d'un ensemble fixe de variantes, et les variantes peuvent transporter des données. L'instruction `match` se branche selon la variante et peut déstructurer les données qu'elle contient. Rust vérifie que les correspondances d'énumérations sont exhaustives, à moins qu'un joker (wildcard) ou un motif de capture général ne soit utilisé. Cela améliore la correction du domaine et de l'API (Application Programming Interface) car lorsqu'un nouvel état ou une nouvelle variante de réponse est ajouté, le compilateur peut forcer le code qui correspond à cette énumération à décider comment gérer le nouveau cas, au lieu de l'ignorer silencieusement.
7Que sont les wrappers newtype, et comment améliorent-ils la sûreté des types pour les identifiants, l'argent, les tokens et les secrets ?
Un wrapper newtype est un type Rust distinct, souvent une structure tuple à un seul champ, qui encapsule une représentation existante, comme `struct UserId(Uuid);` ou `struct AccessToken(String);`. Il améliore la sûreté des types car le compilateur ne confondra pas des valeurs sémantiquement différentes qui partagent le même type sous-jacent, telles que `UserId` versus `OrderId`, les centimes versus les dollars, ou les chaînes de caractères brutes versus les tokens validés. Les newtypes peuvent également centraliser les règles de construction et de validation et contrôler des traits tels que `Display`, `Debug`, `Serialize`, ou le comportement de rédaction (masquage) pour les secrets.
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);
struct SecretToken(String);
impl std::fmt::Debug for SecretToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretToken(**redacted**)")
}
}
fn load_user(id: UserId) {
// query by user id
}
fn example(user_id: UserId, order_id: OrderId) {
load_user(user_id);
// load_user(order_id); // does not compile: expected UserId, found OrderId
}
8Comment fonctionne le trait `Drop` de Rust, y compris l'ordre de suppression, le nettoyage RAII et les limitations concernant le nettoyage asynchrone ?
`Drop` est le mécanisme de nettoyage déterministe de Rust. Lorsqu'une valeur possédée est détruite, par exemple lorsqu'elle sort de sa portée, Rust appelle automatiquement sa méthode `drop(&mut self)` si elle implémente `Drop`, puis libère ses champs. Cela prend en charge le RAII (Resource Acquisition Is Initialization) : les ressources comme les fichiers, les sockets, les verrous, les transactions, les permis ou les tampons sont liées à des valeurs/gardes possédantes et sont libérées lorsque ces valeurs sont supprimées. Les variables locales sont supprimées dans l'ordre inverse de leur création ; pour une structure avec `Drop`, la méthode `drop` de la structure s'exécute avant que ses champs ne soient libérés, et les champs sont libérés dans l'ordre de déclaration. `Drop::drop` est synchrone et ne peut pas être `async` ni `await`, donc un nettoyage asynchrone gracieux nécessite généralement une méthode asynchrone explicite de fermeture/arrêt/vidange ou une autre conception ; `Drop` ne peut effectuer qu'un nettoyage synchrone ou au meilleur effort.
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
struct Pair {
first: Guard,
second: Guard,
}
impl Drop for Pair {
fn drop(&mut self) {
println!("drop Pair");
}
}
fn main() {
let _a = Guard("a");
let _pair = Pair {
first: Guard("first"),
second: Guard("second"),
};
let _b = Guard("b");
}
9Expliquez les durées de vie et pourquoi les annotations explicites de durée de vie sont parfois requises dans les API (Application Programming Interface) en Rust.
Une durée de vie (lifetime) décrit la durée pendant laquelle une référence est valide, permettant au compilateur de rejeter les références pendantes. Les annotations de durée de vie sont des contraintes de compilation qui expriment des relations entre les références ; elles ne prolongent pas la durée de vie des données sous-jacentes et n'ajoutent pas de comportement d'exécution. De nombreuses signatures simples sont gérées par l'élision des durées de vie, mais des annotations explicites sont nécessaires lorsque le compilateur ne peut pas inférer la relation entre les références d'entrée et de sortie, lorsque les types stockent des références, ou lorsqu'une API a besoin d'exprimer des contraintes d'emprunt génériques.
fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
struct UserView<'a> {
name: &'a str,
}
fn main() {
let a = String::from("short");
let b = String::from("longer");
let result = longest(&a, &b);
let view = UserView { name: result };
println!("{}", view.name);
}
10Que signifie la durée de vie `'static`, et comment interagit-elle avec les tâches générées et la conception d'API backend ?
Sur une référence, `'static` signifie que les données référencées sont valides pour toute la durée du programme, comme c'est le cas pour les littéraux de chaîne ou les éléments `static`. Une contrainte comme `T: 'static` signifie que les valeurs de type `T` ne contiennent pas de références empruntées non-`'static`, elles peuvent donc être conservées pour une durée de vie arbitraire ; cela ne signifie pas que la valeur elle-même doit vivre éternellement ou ne peut pas être supprimée.
Les tâches générées (spawned tasks) nécessitent souvent des `Future`s `'static` car l'exécuteur peut continuer à les exécuter après que la pile d'appel (stack frame) d'où elles ont été générées soit revenue. Dans le code backend (côté serveur), cela signifie généralement déplacer des données possédées (owned data) dans la tâche avec `async move`, utiliser des types possédés, ou cloner des poignées partagées (shared handles) telles que `Arc`, plutôt que d'emprunter des variables locales.
Les API devraient utiliser des contraintes `'static` pour les fonctions de rappel (callbacks) stockées, les tâches de fond (background jobs) et l'état à longue durée de vie (long-lived state), mais éviter les contraintes `'static` inutiles pour les opérations empruntées à courte durée de vie.
use std::sync::Arc;
struct AppState {
name: String,
}
fn spawn_background(state: Arc<AppState>, user_id: String) {
tokio::spawn(async move {
// The task owns an Arc handle and a String, so it does not borrow this function's stack.
println!("{}:{user_id}", state.name);
});
}
11Décrivez comment Rust utilise les traits pour le polymorphisme, et comparez `impl Trait`, les génériques et `dyn Trait`.
Rust utilise les traits pour exprimer un comportement partagé. Les bornes génériques telles que `fn f<T: Trait>(x: T)` utilisent généralement le dispatch statique via la monomorphisation. `impl Trait` en position d'argument est principalement une syntaxe raccourcie pour un paramètre générique anonyme. `impl Trait` en position de retour cache un type de retour concret choisi par la fonction. `dyn Trait` est un objet trait utilisé derrière un pointeur tel que `&dyn Trait` ou `Box<dyn Trait>`; il utilise le dispatch dynamique via une `vtable` et supporte l'effacement de type à l'exécution / les valeurs hétérogènes, sous réserve des restrictions de sécurité d'objet (object-safety).
12Que sont les types associés sur les traits (traits), et quand sont-ils préférables aux paramètres de type génériques ?
Les types associés sont des placeholders de type nommés déclarés à l'intérieur d'un trait et spécifiés par chaque implémentation, par exemple `Iterator::Item`. Ils sont préférables lorsque le type fait partie du contrat du trait et que chaque implémenteur a un choix naturel pour celui-ci. Les paramètres de type génériques sont préférables lorsque l'appelant doit choisir le type ou lorsque le même implémenteur peut avoir besoin de plusieurs implémentations pour différents choix de type.
13Comment implémenteriez-vous le patron de l'outbox transactionnelle dans un service Rust ?
Pour implémenter le patron de l'outbox transactionnelle dans un service Rust, écrivez les mises à jour des données du domaine et les charges utiles des événements sortants de manière atomique au sein d'une seule transaction de base de données (par exemple, en utilisant `sqlx::Transaction`). Une table `outbox` stocke le sujet de destination, la charge utile et le statut ou l'horodatage de création. Un worker asynchrone en arrière-plan (ou un processus de Capture de Données Modifiées (Change Data Capture) comme Debezium) interroge ou diffuse ensuite les événements en attente et les publie vers le courtier de messages (tel que Kafka, RabbitMQ ou NATS). Lors de l'utilisation d'un worker d'interrogation en Rust (par exemple, dans une boucle `tokio::spawn`), l'utilisation de `SELECT ... FOR UPDATE SKIP LOCKED` permet à plusieurs instances de service de récupérer et d'expédier les lignes de la boîte d'envoi concurremment sans contention de verrouillage. Une fois la livraison par le courtier confirmée, le worker met à jour le statut de l'enregistrement de la boîte d'envoi ou supprime la ligne. Parce que les tentatives de réseau peuvent entraîner une livraison au moins une fois, les consommateurs en aval doivent être conçus pour gérer les messages de manière idempotente.
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub async fn create_user_with_outbox(
mut tx: Transaction<'_, Postgres>,
username: &str,
email: &str,
) -> Result<(), sqlx::Error> {
let user_id = Uuid::new_v4();
sqlx::query!(
"INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
user_id, username, email
)
.execute(&mut *tx)
.await?;
let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
sqlx::query!(
"INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
Uuid::new_v4(),
"user_events",
payload
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn run_outbox_relay(pool: PgPool) {
loop {
let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
let rows = sqlx::query!(
"SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
)
.fetch_all(&mut *tx)
.await;
if let Ok(events) = rows {
for event in events {
// publish_to_broker(&event.topic, &event.payload).await;
let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
.execute(&mut *tx)
.await;
}
let _ = tx.commit().await;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
14Comment concevriez-vous une opération en plusieurs étapes qui met à jour une base de données et appelle une API externe?
Concevoir une opération en plusieurs étapes qui englobe une écriture en base de données et un appel à une API (Application Programming Interface) externe nécessite de gérer les échecs partiels et les partitions réseau sans s'appuyer sur le commit en deux phases (2PC).
1. **Persister l'état / l'intention en premier** : Enregistrer un état initial (tel que `PENDING` ou `INITIATED`) et la charge utile requise dans la transaction de la base de données locale. Commiter cette transaction avant d'initier l'appel réseau externe pour éviter de maintenir des verrous de base de données et des emplacements de pool de connexions pendant les E/S (Entrées/Sorties) réseau lentes.
2. **Requête externe idempotente** : Appeler l'API externe en utilisant une clé d'idempotence déterministe (par exemple, dérivée de l'UUID de l'opération). Cela garantit que les tentatives répétées ne provoquent pas d'effets secondaires réels en double (comme une double facturation).
3. **Transition d'état et gestion des délais d'attente** : Si l'appel API renvoie une réponse réussie, mettre à jour l'enregistrement de la base de données à `COMPLETED`. S'il échoue de manière permanente, le marquer `FAILED` et exécuter des actions de compensation. Si l'appel expire ou renvoie une erreur réseau, marquer l'enregistrement comme `INDETERMINATE` / `REQUIRES_RECONCILIATION` et laisser un worker en arrière-plan réconcilier l'état en interrogeant le point d'accès de statut du fournisseur ou en réessayant avec la même clé d'idempotence.
use uuid::Uuid;
pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
let payment_id = Uuid::new_v4();
let idempotency_key = format!("pay_{}", payment_id);
// 1. Commit intent locally before making external network calls
sqlx::query!(
"INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
payment_id, user_id, amount, idempotency_key
)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// 2. Call external API with timeout and idempotency key
let client = reqwest::Client::new();
let api_result = client.post("https://api.payment.com/v1/charges")
.header("Idempotency-Key", &idempotency_key)
.json(&serde_json::json!({ "amount": amount }))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
// 3. Update state based on outcome; handle timeouts via reconciliation
match api_result {
Ok(resp) if resp.status().is_success() => {
sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Ok(_) => {
sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Err(_) => {
sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
15Comment concevriez-vous un backend Rust multi-locataire avec une isolation stricte des données et un routage dynamique ?
La conception d'un backend Rust multi-locataire implique trois domaines clés : l'extraction du locataire et la propagation du contexte, la stratégie d'isolation des données et la prévention des fuites.
1. **Extraction du Locataire et Propagation du Contexte** : Un middleware HTTP (Hypertext Transfer Protocol) (par exemple, dans Axum ou Actix-web) résout l'identité du locataire à partir des revendications JWT (JSON Web Token), des sous-domaines ou des en-têtes. Il construit un `TenantContext` fortement typé stocké dans les extensions de requête. Les gestionnaires utilisent des extracteurs de type Rust pour garantir que la logique métier ne peut pas s'exécuter sans un contexte de locataire valide.
2. **Stratégie d'Isolation des Données** :
* ***Base de données partagée avec sécurité au niveau des colonnes/lignes (RLS)*** : Tous les locataires partagent des tables avec une colonne `tenant_id`. L'isolation est appliquée via le RLS de PostgreSQL à l'aide de variables de session au niveau de la connexion (par exemple, `SET LOCAL app.tenant_id = $1`) ou de wrappers de constructeur de requêtes.
* ***Schéma par locataire*** : Les locataires partagent une instance de base de données mais ont des schémas isolés ; le routage ajuste le chemin de recherche par connexion.
* ***Base de données par locataire*** : Les locataires ont des bases de données séparées. Le service maintient un registre de pools (par exemple, `Arc<DashMap<TenantId, PgPool>>` ou un cache de pool LRU (Least Recently Used)) pour résoudre dynamiquement les pools de connexion.
3. **Prévention des Fuites** : Pour éviter la contamination inter-locataires dans les applications Tokio asynchrones, le contexte du locataire doit être passé explicitement à travers les limites de tâche plutôt que d'être stocké dans l'état global ou le stockage local aux threads (`thread_local!`), ce qui est rompu lors des migrations de threads worker asynchrones.
use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);
#[derive(Clone)]
pub struct AppState {
pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}
#[derive(Clone)]
pub struct TenantContext {
pub tenant_id: TenantId,
pub pool: PgPool,
}
#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
type Rejection = (axum::http::StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let tenant_header = parts.headers.get("X-Tenant-ID")
.and_then(|v| v.to_str().ok())
.ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
let tenant_id = TenantId(tenant_header.to_string());
let pool = match state.pool_registry.get(&tenant_id) {
Some(p) => p.value().clone(),
None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
};
Ok(TenantContext { tenant_id, pool })
}
}