はじめに
「Rustを学びたいけど、何から始めればいいかわからない」
「チュートリアルは終わったけど、実務で使えるレベルに到達できない」
バックエンドエンジニアがRustを習得する際、こうした悩みはよく聞かれます。本記事では、Rustをバックエンド開発で実戦投入できるレベルまで引き上げるための12ステップを、コード例とともに紹介します。
初心者向けに、各ステップで「何を学ぶのか」「なぜ必要なのか」「どう書くのか」を具体的に解説していきます。
下記の内容を具体的に記事化したものになります。
ステップ1: 基礎をマスターする - 所有権・借用・ライフタイム・Cargo
Rustの最大の特徴は「所有権(Ownership)」システムです。GCを使わずにメモリ安全性を保証する、Rustのコア機能です。ここを理解せずに先には進めません。
所有権の基本ルール
- Rustの各値には「所有者(owner)」と呼ばれる変数がある
- 所有者は同時に1つだけ
- 所有者がスコープを抜けると値は破棄される
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1の所有権がs2にムーブされる
// println!("{}", s1); // エラー! s1はもう使えない
println!("{}", s2); // OK
}
借用(Borrowing)
所有権を渡さずに値を参照したいときは「借用」を使います。
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 参照を渡す
println!("'{}'の長さは{}です", s1, len); // s1はまだ使える
}
fn calculate_length(s: &String) -> usize {
s.len()
}
ミュータブルな借用
値を変更したい場合は&mutを使います。ただし、同時に複数のミュータブルな借用はできません。
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // "hello, world"
}
fn change(s: &mut String) {
s.push_str(", world");
}
Cargoの基本
CargoはRustのビルドシステム兼パッケージマネージャーです。
# 新しいプロジェクトを作成
cargo new my_project
cd my_project
# ビルド
cargo build
# 実行
cargo run
# リリースビルド(最適化あり)
cargo build --release
# 依存関係を追加
cargo add tokio
Cargo.tomlで依存関係を管理します。
[package]
name = "my_project"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
ステップ2: Rustの真の力 - 構造体・列挙型・トレイト・ジェネリクス・パターンマッチ
Rustの表現力の中核を担う機能群です。これらを使いこなせるかで、コードの品質が大きく変わります。
構造体(Struct)
データをまとめる基本的な型です。
struct User {
name: String,
email: String,
age: u32,
}
impl User {
// 関連関数(コンストラクタのようなもの)
fn new(name: String, email: String, age: u32) -> Self {
Self { name, email, age }
}
// メソッド
fn greet(&self) -> String {
format!("こんにちは、{}さん!", self.name)
}
}
fn main() {
let user = User::new("田中".to_string(), "tanaka@example.com".to_string(), 30);
println!("{}", user.greet());
}
列挙型(Enum)
「とりうる値のパターン」を型で表現できます。Rustの列挙型は他言語より強力です。
enum PaymentMethod {
Cash,
CreditCard { number: String, holder: String },
BankTransfer(String), // 銀行コード
}
fn process_payment(method: PaymentMethod) {
match method {
PaymentMethod::Cash => println!("現金で支払い"),
PaymentMethod::CreditCard { number, holder } => {
println!("カード支払い: {} ({})", number, holder);
}
PaymentMethod::BankTransfer(bank_code) => {
println!("銀行振込: {}", bank_code);
}
}
}
トレイト(Trait)
他言語のインターフェースに近い概念ですが、より強力です。
trait Greet {
fn hello(&self) -> String;
// デフォルト実装も可能
fn hello_loud(&self) -> String {
format!("{}!!!", self.hello())
}
}
struct Japanese;
struct English;
impl Greet for Japanese {
fn hello(&self) -> String {
"こんにちは".to_string()
}
}
impl Greet for English {
fn hello(&self) -> String {
"Hello".to_string()
}
}
fn main() {
let j = Japanese;
let e = English;
println!("{}", j.hello_loud()); // こんにちは!!!
println!("{}", e.hello_loud()); // Hello!!!
}
ジェネリクス
型をパラメータ化して、再利用可能なコードを書けます。
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let numbers = vec![34, 50, 25, 100, 65];
println!("最大値: {}", largest(&numbers));
let chars = vec!['y', 'm', 'a', 'q'];
println!("最大値: {}", largest(&chars));
}
ステップ3: 適切なエラーハンドリング - Result・?演算子・thiserror・anyhow
Rustには例外機構がありません。代わりにResult<T, E>型でエラーを明示的に扱います。これがRustの堅牢性の源泉です。
Result型の基本
use std::fs::File;
use std::io::Read;
fn read_file(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path)?; // エラーなら即return
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(contents) => println!("{}", contents),
Err(e) => eprintln!("エラー: {}", e),
}
}
?演算子は「エラーなら即return、成功なら値を取り出す」というシンタックスシュガーです。
thiserrorでライブラリ向けエラー型を定義
自作ライブラリで独自のエラー型を定義するにはthiserrorが便利です。
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
pub enum UserError {
#[error("ユーザーが見つかりません: {0}")]
NotFound(String),
#[error("不正な入力: {field}")]
InvalidInput { field: String },
#[error("データベースエラー")]
DatabaseError(#[from] std::io::Error),
}
fn find_user(id: &str) -> Result<String, UserError> {
if id.is_empty() {
return Err(UserError::InvalidInput {
field: "id".to_string(),
});
}
Err(UserError::NotFound(id.to_string()))
}
anyhowでアプリケーション向けエラーハンドリング
アプリケーション側では、エラー型を細かく分けずにまとめて扱いたい場面が多いです。そんなときはanyhowが便利です。
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("設定ファイルの読み込みに失敗: {}", path))?;
Ok(contents)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
println!("{}", config);
Ok(())
}
使い分けの指針: ライブラリを書くときはthiserror、アプリケーションを書くときはanyhow。これが一般的なベストプラクティスです。
ステップ4: モジュール・クレート・プロジェクト構造
コードが大きくなったら、適切に分割しましょう。Rustには明確なモジュールシステムがあります。
典型的なプロジェクト構造
my_api/
├── Cargo.toml
├── src/
│ ├── main.rs # エントリーポイント
│ ├── lib.rs # ライブラリのルート
│ ├── config.rs # 設定関連
│ ├── handlers/ # HTTPハンドラ
│ │ ├── mod.rs
│ │ ├── user.rs
│ │ └── product.rs
│ ├── models/ # データモデル
│ │ ├── mod.rs
│ │ └── user.rs
│ └── db/ # データベース層
│ ├── mod.rs
│ └── user_repo.rs
└── tests/ # 統合テスト
└── integration_test.rs
モジュールの定義と利用
// src/lib.rs
pub mod handlers;
pub mod models;
pub mod db;
// src/handlers/mod.rs
pub mod user;
pub mod product;
// src/handlers/user.rs
use crate::models::user::User;
pub fn get_user(id: u32) -> Option<User> {
// ...
None
}
ワークスペース(大規模プロジェクト向け)
複数のクレートを1つのプロジェクトで管理したい場合は、ワークスペースを使います。
# Cargo.toml (ルート)
[workspace]
members = [
"api",
"worker",
"shared",
]
ステップ5: 非同期Rust - tokio・async/await
バックエンド開発では非同期処理が必須です。Rustではtokioがデファクトスタンダードです。
tokioのセットアップ
[dependencies]
tokio = { version = "1", features = ["full"] }
基本的なasync/await
use tokio::time::{sleep, Duration};
async fn fetch_data(id: u32) -> String {
sleep(Duration::from_secs(1)).await;
format!("データ {}", id)
}
#[tokio::main]
async fn main() {
let result = fetch_data(1).await;
println!("{}", result);
}
並行実行
複数の非同期処理を並行実行するにはtokio::join!やtokio::spawnを使います。
use tokio::time::{sleep, Duration};
async fn task(id: u32) -> u32 {
sleep(Duration::from_secs(1)).await;
id * 2
}
#[tokio::main]
async fn main() {
// join!で並行実行(すべて待つ)
let (a, b, c) = tokio::join!(task(1), task(2), task(3));
println!("{} {} {}", a, b, c); // 2 4 6
// spawnで別タスクとして起動
let handle = tokio::spawn(async {
sleep(Duration::from_secs(1)).await;
"別タスクの結果"
});
let result = handle.await.unwrap();
println!("{}", result);
}
なぜasync/awaitが重要か: I/Oバウンドな処理(DB・ネットワーク)で、スレッドをブロックせずに多数のリクエストを捌けるからです。バックエンドAPIでは必須の知識です。
ステップ6: CLIツールを作る - clap
実用的なツールを作りながら学ぶのが一番の近道です。clapでCLIツールを作ってみましょう。
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;
/// ファイル内の特定の文字列をカウントするツール
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
/// 対象ファイル
#[arg(short, long)]
file: String,
/// 検索する文字列
#[arg(short, long)]
pattern: String,
/// 大文字小文字を区別するか
#[arg(short, long, default_value_t = false)]
case_sensitive: bool,
}
fn main() -> std::io::Result<()> {
let args = Args::parse();
let contents = std::fs::read_to_string(&args.file)?;
let count = if args.case_sensitive {
contents.matches(&args.pattern).count()
} else {
contents.to_lowercase().matches(&args.pattern.to_lowercase()).count()
};
println!("'{}' が {} 回見つかりました", args.pattern, count);
Ok(())
}
実行例:
cargo run -- --file README.md --pattern "Rust"
自動で--helpも生成されるので便利です。
ステップ7: データを扱う - Serde・JSON・YAML・HTTPクライアント
バックエンドでは外部とのデータやり取りが頻繁に発生します。serdeはRustのシリアライズ/デシリアライズの標準ライブラリです。
JSONの扱い
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
id: u32,
name: String,
email: String,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 構造体 → JSON
let user = User {
id: 1,
name: "田中".to_string(),
email: "tanaka@example.com".to_string(),
};
let json = serde_json::to_string_pretty(&user)?;
println!("{}", json);
// JSON → 構造体
let json_str = r#"{"id": 2, "name": "佐藤", "email": "sato@example.com"}"#;
let parsed: User = serde_json::from_str(json_str)?;
println!("{:?}", parsed);
Ok(())
}
HTTPクライアント(reqwest)
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Post {
id: u32,
title: String,
body: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let post: Post = reqwest::get("https://jsonplaceholder.typicode.com/posts/1")
.await?
.json()
.await?;
println!("{:#?}", post);
Ok(())
}
ステップ8: REST APIを作る - Axum・ミドルウェア・バリデーション
ここが本記事のメイントピックの1つです。axumはtokioエコシステムと統合されたモダンなWebフレームワークです。
最小構成のAxumサーバー
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{
extract::Path,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
async fn get_user(Path(id): Path<u32>) -> Json<User> {
Json(User {
id,
name: format!("User{}", id),
})
}
async fn create_user(Json(payload): Json<CreateUser>) -> (StatusCode, Json<User>) {
let user = User {
id: 1,
name: payload.name,
};
(StatusCode::CREATED, Json(user))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("サーバー起動: http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
ミドルウェアとバリデーション
tower-httpとvalidatorを組み合わせます。
[dependencies]
axum = "0.7"
tower-http = { version = "0.5", features = ["trace", "cors"] }
validator = { version = "0.18", features = ["derive"] }
use axum::{extract::Json, http::StatusCode, routing::post, Router};
use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use validator::Validate;
#[derive(Deserialize, Validate)]
struct SignupRequest {
#[validate(length(min = 3, max = 30))]
username: String,
#[validate(email)]
email: String,
#[validate(length(min = 8))]
password: String,
}
async fn signup(Json(payload): Json<SignupRequest>) -> Result<&'static str, (StatusCode, String)> {
payload
.validate()
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
Ok("登録完了")
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/signup", post(signup))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
ステップ9: 高度な並行処理 - チャネル・tokioタスク・レート制限
複雑なバックエンドシステムでは、タスク間の協調が必要になります。
チャネルでタスク間通信
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(32);
// プロデューサ
tokio::spawn(async move {
for i in 0..5 {
tx.send(format!("メッセージ {}", i)).await.unwrap();
}
});
// コンシューマ
while let Some(msg) = rx.recv().await {
println!("受信: {}", msg);
}
}
セマフォによる並行数制限
外部APIの呼び出し数を制限する例です。
use std::sync::Arc;
use tokio::sync::Semaphore;
#[tokio::main]
async fn main() {
let semaphore = Arc::new(Semaphore::new(3)); // 同時実行は3つまで
let mut handles = vec![];
for i in 0..10 {
let permit = semaphore.clone().acquire_owned().await.unwrap();
let handle = tokio::spawn(async move {
println!("タスク {} 開始", i);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("タスク {} 完了", i);
drop(permit);
});
handles.push(handle);
}
for h in handles {
h.await.unwrap();
}
}
レート制限(governor)
[dependencies]
governor = "0.6"
use governor::{Quota, RateLimiter};
use std::num::NonZeroU32;
#[tokio::main]
async fn main() {
let quota = Quota::per_second(NonZeroU32::new(5).unwrap());
let limiter = RateLimiter::direct(quota);
for i in 0..10 {
limiter.until_ready().await;
println!("リクエスト {} 送信", i);
}
}
ステップ10: データベース層 - SQLx・PostgreSQL・コネクションプール
sqlxはコンパイル時にSQLをチェックしてくれる非同期データベースライブラリです。
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "macros", "chrono"] }
tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
コネクションプールとクエリ
use sqlx::postgres::PgPoolOptions;
use chrono::{DateTime, Utc};
#[derive(Debug, sqlx::FromRow)]
struct User {
id: i32,
name: String,
email: String,
created_at: DateTime<Utc>,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(10)
.connect("postgres://user:password@localhost/mydb")
.await?;
// INSERT
let user_id: i32 = sqlx::query_scalar(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id"
)
.bind("田中")
.bind("tanaka@example.com")
.fetch_one(&pool)
.await?;
// SELECT
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&pool)
.await?;
println!("{:?}", user);
Ok(())
}
マイグレーション
sqlx-cliでマイグレーションを管理できます。
cargo install sqlx-cli
sqlx migrate add create_users
# migrations/xxxx_create_users.sql を編集
sqlx migrate run
ステップ11: テストと品質 - 単体テスト・統合テスト・ベンチマーク
Rustはテストがビルドインで、書きやすいのが大きな強みです。
単体テスト
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, 1), 0);
}
}
cargo test
統合テスト
tests/ディレクトリに配置します。
// tests/api_test.rs
use my_api::create_app;
#[tokio::test]
async fn test_health_endpoint() {
let app = create_app();
// axum_testなどでHTTPリクエストをテスト
// ...
}
非同期テスト
#[tokio::test]
async fn test_async_function() {
let result = my_async_function().await;
assert_eq!(result, "expected");
}
プロパティベーステスト(proptest)
[dev-dependencies]
proptest = "1"
use proptest::prelude::*;
fn reverse(s: &str) -> String {
s.chars().rev().collect()
}
proptest! {
#[test]
fn reverse_twice_is_identity(s in "\\PC*") {
prop_assert_eq!(reverse(&reverse(&s)), s);
}
}
ステップ12: プロダクションレディに仕上げる
Docker化
# マルチステージビルド
FROM rust:1.75 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/my_api /usr/local/bin/
EXPOSE 3000
CMD ["my_api"]
構造化ログ(tracing)
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
use tracing::{info, error, instrument};
#[instrument]
async fn process_request(user_id: u32) -> Result<(), Box<dyn std::error::Error>> {
info!("リクエスト処理開始");
// ...
info!("処理完了");
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.json()
.init();
process_request(123).await.unwrap();
}
グレースフルシャットダウン
use tokio::signal;
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("シグナルハンドラの設定に失敗");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("SIGTERMハンドラの設定に失敗")
.recv()
.await;
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
println!("シャットダウン処理開始");
}
#[tokio::main]
async fn main() {
let app = axum::Router::new();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
設定管理(config + envy)
[dependencies]
config = "0.14"
serde = { version = "1", features = ["derive"] }
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct AppConfig {
database_url: String,
port: u16,
log_level: String,
}
fn load_config() -> Result<AppConfig, config::ConfigError> {
let config = config::Config::builder()
.add_source(config::File::with_name("config").required(false))
.add_source(config::Environment::with_prefix("APP"))
.build()?;
config.try_deserialize()
}
実践編: 作ってみるべき3つのプロジェクト
学習の総仕上げとして、以下のプロジェクトを自力で作ってみることを推奨します。
1. 認証サービス
- ユーザー登録・ログインAPI
- JWTトークン発行・検証
- パスワードハッシュ化(
argon2) - ミドルウェアによる認可
2. バックグラウンドワーカー
- キュー(Redis・RabbitMQ)からジョブを取得
- 並行処理で複数ジョブを実行
- リトライ・デッドレターキュー
3. APIスクレイパー
- 複数のエンドポイントを並行スクレイピング
- レート制限を守る
- データの保存(DB or ファイル)
これら3つを作り切れば、実務レベルのRustスキルが身についています。
ソースコードを読もう
最後に、自分の腕を上げたいなら人気クレートのソースコードを読むのが一番です。特におすすめは以下です。
- tokio: 非同期ランタイムの実装。Rustの非同期の本質が学べる
- axum: 型システムを活用したAPIデザインの教科書
- sqlx: マクロによるコンパイル時SQLチェックの仕組み
読むときは「まず全体の構造を俯瞰し、興味のある機能を深掘りする」という順序がおすすめです。
まとめ
Rustは学習コストが高い言語ですが、このロードマップに沿って進めれば、バックエンドエンジニアとして戦力になるレベルに到達できます。
ポイントは、各ステップで小さなものでも実際にコードを書いて動かすことです。読むだけでは身につきません。
12ステップを一つずつクリアして、ぜひ実戦投入できるRustスキルを身につけてください。