15 обраних запитань для співбесіди з Rust бекенду, згрупованих за рівнем досвіду. Використовуйте їх для повторення основ, практичних компромісів та міркувань щодо виробничих рішень на Senior рівні.
1Поясніть правила володіння Rust та як вони впливають на практичні вибори в бекенді, такі як клонування, запозичення та спільне використання стану застосунку.
Rust надає кожному значенню одного власника. Переміщення значення, яке не реалізує трейт `Copy`, передає володіння, і значення звільняється, коли його власник виходить за межі області видимості. Код може запозичувати дані незмінно через спільні посилання або змінювано через виключне змінюване посилання. У бекенд-коді це визначає, чи функція приймає володіння, запозичує тимчасово, клонує, щоб отримати незалежне значення, що знаходиться у володінні, або ділиться довготривалим станом через дескриптори, такі як `Arc`, із синхронізацією чи іншими примітивами паралелізму, коли потрібна спільна зміна.
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;
}
2У чому різниця між запозиченням за допомогою `&T` та `&mut T`, і як ці правила впливають на сигнатури API та обробників?
`&T` — це спільне посилання: воно дозволяє доступ лише для читання, і багато спільних посилань на одне й те саме значення можуть бути активними одночасно. `&mut T` — це ексклюзивне змінюване посилання: воно дозволяє зміну, але поки воно активне, жодні інші спільні або змінювані посилання на те саме значення не можуть використовуватися. Ці правила формують API (Application Programming Interface), змушуючи функції лише для читання приймати `&T` або вужчі запозичені типи, такі як `&str`, тоді як функції, що змінюють дані, приймають `&mut T`, беруть володіння або використовують внутрішню змінюваність/синхронізацію. Обробники бекенду зазвичай уникають прямого доступу за допомогою `&mut` до спільного стану програми між паралельними запитами та натомість використовують спільні дескриптори разом із синхронізацією або інші шаблони володіння.
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
}
3Як працює `Box` і які проблеми вирішує виділення пам'яті в купі (heap allocation) за допомогою `Box`?
`Box<T>` — це розумний вказівник, що володіє, чий `T` зберігається в купі (heap), тоді як сам `Box` є значенням фіксованого розміру, подібним до вказівника. Він має єдине володіння (single ownership) і звільняє виділену пам'ять у купі, коли виходить з області видимості. `Box` забезпечує непряму адресацію (indirection), що допомагає при роботі з великими значеннями, рекурсивними типами, які потребують відомого розміру, значеннями з динамічним розміром, а також об'єктами трейтів (trait objects), такими як `Box<dyn Trait>`. Переміщення `Box` передає володіння виділеною пам'яттю без переміщення або копіювання самого значення, що зберігається в купі.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Опишіть `Result` та `Option` та основні ідеоматичні способи роботи з відсутністю значень та операціями, що можуть завершитися помилкою.
`Option<T>` представляє або `Some(T)`, або `None` і використовується для очікуваної відсутності значення. `Result<T, E>` представляє або `Ok(T)`, або `Err(E)` і використовується для операцій, що можуть завершитися помилкою, де інформація про помилку є важливою. Ідеоматична обробка включає `match`, `if let`/`let else`, комбінатори, такі як `map`, `and_then`, `ok_or`, `unwrap_or`, та `unwrap_or_else`, а також оператор `?` для раннього поширення помилок з функцій, що повертають сумісні типи `Option` або `Result`. `unwrap` та `expect` найкраще зарезервувати для інваріантів, тестів, прототипів або невідновлюваних ситуацій, а не для звичайних відновлюваних помилок бекенду.
5Як працює оператор `?` і що дозволяє конвертувати різні типи помилок?
Оператор `?` — це скорочений запис для поширення помилок. Для `Result` вираз `expr?` повертає значення `Ok` і продовжує виконання, або достроково завершує поточну функцію зі значенням `Err`; для `Option` він повертає значення `Some` або `None`. Охоплююча функція повинна повертати сумісний тип. Для `Result` різні типи помилок можуть компонуватися, оскільки вихідна помилка конвертується в тип помилки функції за допомогою перетворення у стилі `From`/`Into`, часто реалізованого вручну або отриманого за допомогою допоміжних крейтів, таких як `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)
}
6Поясніть зіставлення зі зразком (pattern matching) для перелічень (enums) та як вичерпне зіставлення покращує коректність доменної логіки та API (інтерфейсу прикладного програмування).
Перелічення (enums) моделюють значення, яке може бути одним з фіксованого набору варіантів, і ці варіанти можуть містити дані. Конструкція `match` розгалужується за варіантами і може деструктурувати дані всередині них. Rust перевіряє, що зіставлення перелічень є вичерпними, якщо не використовується шаблон-замінник (wildcard) або шаблон «впіймати все» (catch-all). Це покращує коректність доменної логіки та API, оскільки при додаванні нового стану або варіанта відповіді компілятор може змусити код, що зіставляється з ним, вирішити, як обробити новий випадок, замість того, щоб мовчки його ігнорувати.
7Що таке обгортки newtype, і як вони покращують типобезпеку для ідентифікаторів, грошей, токенів та секретів?
Обгортка newtype — це окремий тип Rust, часто кортежна структура з одним полем, яка обгортає існуюче представлення, наприклад, `struct UserId(Uuid);` або `struct AccessToken(String);`. Вона покращує типобезпеку, оскільки компілятор не плутатиме семантично різні значення, які мають однаковий базовий тип, наприклад, `UserId` проти `OrderId`, центи проти доларів, або необроблені рядки проти валідованих токенів. Newtype також можуть централізувати правила конструювання та валідації та контролювати трейти, такі як `Display`, `Debug`, `Serialize`, або поведінку при приховуванні (редакції) секретів.
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
}
8Як працює трейт `Drop` у Rust, включаючи порядок видалення, очищення за допомогою RAII та обмеження, пов'язані з асинхронним очищенням?
`Drop` — це детермінований хук для очищення в Rust. Коли власне значення знищується, наприклад, коли воно виходить з області видимості, Rust автоматично викликає його метод `drop(&mut self)`, якщо він реалізує трейт `Drop`, а потім видаляє його поля. Це підтримує RAII (Resource Acquisition Is Initialization): ресурси, такі як файли, сокети, блокування, транзакції, дозволи або буфери, прив'язані до власних значень/охоронців (guards) і звільняються, коли ці значення видаляються. Локальні змінні видаляються у зворотному порядку створення; для структури з `Drop` метод `drop` структури виконується до того, як будуть видалені її поля, а поля видаляються у порядку оголошення. `Drop::drop` є синхронним і не може бути `async` або очікуваним (awaited), тому для витонченого асинхронного очищення зазвичай потрібен явний асинхронний метод close/shutdown/flush або інший дизайн; `Drop` може виконувати лише синхронне або «найкраще зусилля» (best-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");
}
9Поясніть час життя (lifetimes) і чому явні анотації часу життя іноді потрібні в API в Rust.
Час життя (lifetime) описує, як довго посилання є дійсним, дозволяючи компілятору відхиляти висячі посилання. Анотації часу життя — це обмеження часу компіляції, які виражають зв'язки між посиланнями; вони не подовжують час життя базових даних і не додають поведінки під час виконання. Багато простих сигнатур обробляються елізією часу життя (lifetime elision), але явні анотації потрібні, коли компілятор не може вивести, як пов'язані вхідні та вихідні посилання, коли типи зберігають посилання, або коли API потрібно виразити загальні обмеження запозичення.
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);
}
10Що означає `'static` час життя (lifetime) і як він взаємодіє із запущеними завданнями (spawned tasks) та розробкою backend API?
Прив'язка `'static` до посилання означає, що дані, на які посилаються, дійсні протягом всієї програми, як, наприклад, рядкові літерали або `static` елементи. Обмеження типу, як `T: 'static`, означає, що значення типу `T` не містять не-`'static` запозичених посилань, тому вони можуть зберігатися протягом довільного часу життя; це не означає, що саме значення повинно існувати вічно або не може бути відкинуте. Запущені завдання часто вимагають `'static` ф'ючерсів, оскільки виконавець може продовжувати їх виконання після повернення кадру стеку, що породив завдання. У backend коді це зазвичай означає переміщення власних даних у завдання за допомогою `async move`, використання власних типів або клонування спільних дескрипторів, таких як `Arc`, а не запозичення локальних змінних. API повинні використовувати обмеження `'static` для збережених зворотних викликів (callbacks), фонових завдань та довгоживучого стану, але уникати непотрібних обмежень `'static` для короткоживучих запозичених операцій.
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);
});
}
11Опишіть, як Rust використовує трейти для поліморфізму, та порівняйте `impl Trait`, узагальнення (generics) та `dyn Trait`.
Rust використовує трейти для вираження спільної поведінки. Узагальнені обмеження, такі як `fn f<T: Trait>(x: T)`, зазвичай використовують статичну диспетчеризацію через мономорфізацію. `impl Trait` у позиції аргументу — це, здебільшого, скорочений запис для анонімного узагальненого параметра. `impl Trait` у позиції повернення приховує один конкретний тип, що повертається, який обирається функцією. `dyn Trait` — це об'єкт трейту, що використовується за покажчиком, наприклад `&dyn Trait` або `Box<dyn Trait>`; він використовує динамічну диспетчеризацію через таблицю віртуальних методів (vtable) і підтримує стирання типів під час виконання / гетерогенні значення з урахуванням обмежень безпеки об'єктів (object-safety restrictions).
12Що таке асоційовані типи в трейтах, і коли вони є кращими за узагальнені типові параметри?
Асоційовані типи – це іменовані заповнювачі типу, оголошені всередині трейту (trait) та задані кожною реалізацією, наприклад `Iterator::Item`. Вони є кращими, коли тип є частиною контракту трейту, і кожен реалізатор має для нього один природний вибір. Узагальнені типові параметри є кращими, коли викликаючий код повинен обирати тип або коли той самий реалізатор може потребувати декількох реалізацій для різних варіантів типу.
13Як би ви реалізували шаблон транзакційної вихідної скриньки (transactional outbox) у сервісі на Rust?
Щоб реалізувати шаблон транзакційної вихідної скриньки в сервісі на Rust, слід атомарно оновлювати доменні дані та вихідні дані подій в межах однієї транзакції бази даних (наприклад, використовуючи `sqlx::Transaction`). Таблиця `outbox` зберігає цільову тему, корисне навантаження та статус або часову мітку створення. Фоновий асинхронний воркер (або процес захоплення змін даних (Change Data Capture), такий як Debezium) потім опитує або передає потоком події, що очікують, та публікує їх до брокера повідомлень (таких як Kafka, RabbitMQ або NATS). При використанні воркера, що опитує, в Rust (наприклад, у циклі `tokio::spawn`), використання `SELECT ... FOR UPDATE SKIP LOCKED` дозволяє кільком екземплярам сервісу одночасно отримувати та відправляти рядки вихідної скриньки без конфліктів блокувань. Після підтвердження доставки брокером, воркер оновлює статус запису в таблиці `outbox` або видаляє рядок. Оскільки повторні спроби мережевої доставки можуть призвести до доставки щонайменше один раз (at-least-once delivery), нижні споживачі (downstream consumers) повинні бути спроектовані для ідемпотентної обробки повідомлень.
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;
}
}
14Як би ви спроєктували багатоетапну операцію, яка оновлює базу даних і викликає зовнішній API (Application Programming Interface)?
Проектування багатоетапної операції, яка охоплює запис у базу даних та виклик зовнішнього API (Application Programming Interface), вимагає управління частковими збоями та розділеннями мережі без опори на двофазну фіксацію (2PC (Two-Phase Commit)).
1. **Спочатку зафіксувати стан / намір**: Запишіть початковий стан (наприклад, `PENDING` або `INITIATED`) та необхідний корисний вантаж в межах локальної транзакції бази даних. Зафіксуйте цю транзакцію до ініціювання зовнішнього мережевого виклику, щоб уникнути утримання блокувань бази даних та слотів пулу з'єднань під час повільних операцій вводу/виводу мережі.
2. **Ідемпотентний зовнішній запит**: Викликайте зовнішній API, використовуючи детерміністичний ключ ідемпотентності (наприклад, отриманий з UUID (Universally Unique Identifier) операції). Це гарантує, що повторні спроби не призведуть до дублювання реальних побічних ефектів (наприклад, подвійного списання коштів).
3. **Зміна стану та обробка тайм-аутів**: Якщо виклик API повертає успішну відповідь, оновіть запис у базі даних до `COMPLETED`. Якщо він остаточно зазнає невдачі, позначте його як `FAILED` та виконайте компенсуючі дії. Якщо запит перевищує час очікування або повертає мережеву помилку, позначте запис як `INDETERMINATE` / `REQUIRES_RECONCILIATION` і дозвольте фоновому воркеру узгодити стан, запитуючи кінцеву точку статусу постачальника або повторно спробувавши з тим самим ключем ідемпотентності.
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(())
}
15Як би ви спроєктували багатоорендний (multi-tenant) Rust-бекенд із суворою ізоляцією даних та динамічною маршрутизацією?
Проектування багатоорендного Rust-бекенду передбачає три ключові області: ідентифікація орендаря, стратегія ізоляції даних та динамічна маршрутизація.
1. **Вилучення орендаря та поширення контексту (Tenant Extraction & Context Propagation):** HTTP-проміжне програмне забезпечення (middleware) (наприклад, в Axum або Actix-web) визначає ідентичність орендаря з JWT (JSON Web Token)-заяв (claims), субдоменів або заголовків. Воно створює строго типізований `TenantContext`, що зберігається в розширеннях запиту. Обробники використовують Rust-екстрактори типів, щоб забезпечити, що бізнес-логіка не може виконуватися без дійсного контексту орендаря.
2. **Стратегія ізоляції даних (Data Isolation Strategy):**
* ***Спільна база даних з безпекою на рівні стовпців / рядків (RLS — Row-Level Security):*** Усі орендарі використовують спільні таблиці з колонкою `tenant_id`. Ізоляція забезпечується за допомогою RLS PostgreSQL, використовуючи змінні сеансу на рівні з'єднання (наприклад, `SET LOCAL app.tenant_id = $1`) або обгорток для побудовника запитів.
* ***Схема на орендаря (Schema-per-Tenant):*** Орендарі використовують один екземпляр бази даних, але мають ізольовані схеми; маршрутизація коригує шлях пошуку для кожного з'єднання.
* ***База даних на орендаря (Database-per-Tenant):*** Орендарі мають окремі бази даних. Сервіс підтримує реєстр пулів (наприклад, `Arc<DashMap<TenantId, PgPool>>` або кеш пулу LRU (Least Recently Used)), щоб динамічно вирішувати пули з'єднань.
3. **Запобігання витокам (Leak Prevention):** Щоб уникнути перехресного забруднення між орендарями в асинхронних Tokio-додатках, контекст орендаря повинен явно передаватися через межі завдань, а не зберігатися у глобальному стані або локальному сховищі потоків (`thread_local!`), що порушується при міграціях асинхронних робочих потоків.
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 })
}
}