「Rustでバックエンド?学習コスト高すぎでしょ...」
その気持ち、わかります。所有権、ライフタイム、借用チェッカー...。「コンパイル通らない」と格闘した経験、ありませんか?
でも、一度コンパイルが通れば、ランタイムエラーで深夜に叩き起こされることがほぼない。この安心感、一度味わうと戻れません。
今回はyaoyorozチームで開発中のクリックカウンターアプリ「カゾエルくん」のバックエンド実装を題材に、Rocket Frameworkを使ったAPIサーバー構築を解説します。
なぜ「非生産的なアプリ」にRust?
yaoyorozは「なんかいつも不思議なものを作ってるチーム」として認知されることを目指すチームで、playpark LLCもメンバーとして参加しています。
カゾエルくんのコンセプトは「非生産的の極み」。クリックしてボタンを押したら数が増えるだけ。それ以上でもそれ以下でもない。
生産性と幸せは全然結びつかない。人間が幸せになる瞬間は、生産性の及ばないところにこそある。
子供と遊ぶ、旅行する、焚き火をする...どれも「非効率」なのに、そこにこそ愛着を感じる。だから「非生産的の極み」をあえて本気で作ってみようと。(哲学的だけど、技術スタックは真面目)
で、「簡単なテーマでRust勉強したい」という動機もあり、この組み合わせに。クリックカウンター程度なら学習コストの元が取れるという判断です。
この記事で学べること
- Rocket Frameworkの基本構成とルーティング
- SeaORMを使ったデータベース連携
- Redisキャッシュとの統合パターン
- クリーンアーキテクチャ(Handler → Service → Repository)
- tokio::spawnを使った組み込みCronジョブ
- Docker Composeでの開発環境構築
前提条件
- Rustの基本文法(所有権、トレイトで挫折しかけた経験があればなお良し)
- REST APIの概念
- Docker/PostgreSQLの基礎知識
プロジェクト概要
今回構築するのは、セッションベースのクリックカウンターAPI。アーキテクチャは以下の通りです。
| レイヤー | 技術 | 役割 |
|---|---|---|
| フレームワーク | Rocket 0.5 | HTTPハンドリング |
| ORM | SeaORM | PostgreSQL操作 |
| キャッシュ | deadpool-redis | Redis接続プール |
| 非同期 | tokio | Cronジョブ実行 |
APIエンドポイント
POST /session # クリックをカウント
PUT /session # Redis → PostgreSQL同期
GET /session_counter # 集計データ取得
PUT /status_history # 週次ステータス更新
GET /status_history # ステータス履歴取得
GET /health # ヘルスチェック
たった6エンドポイント。シンプルですが、これだけで本番運用に必要なパターンは一通り学べます。(「非生産的」なアプリなのに、設計は生産的)
1. プロジェクト構成
app/backend/
├── src/
│ ├── main.rs # エントリーポイント
│ ├── lib.rs # モジュール定義
│ ├── routes.rs # ルーティング
│ ├── config/ # 設定管理
│ ├── handlers/ # HTTPハンドラー
│ ├── services/ # ビジネスロジック
│ ├── repositories/ # データアクセス
│ ├── models/ # ドメインモデル
│ ├── cron/ # 定期実行タスク
│ └── http/ # HTTPユーティリティ
├── migration/ # DBマイグレーション
└── Cargo.toml
この構造はクリーンアーキテクチャに従っています。(「また設計パターンか...」と思った方、気持ちはわかります)
ただ、Rustの場合はこの分離がコンパイル時に強制されるので、「気づいたらFat Controller」みたいな事故が起きにくいんです。借用チェッカーに怒られながら書いた結果、自然とレイヤー分離されてた...なんてことも。
2. main.rs - エントリーポイント
実際のコードを見てみましょう。DIコンテナを使った依存性注入がポイントです。
use opt::config::AppConfig;
use opt::di::DiContainer;
use opt::routes::configure_routes;
use std::sync::Arc;
mod cron;
use cron::{spawn_status_history_cron_job, spawn_sync_cron_job};
#[macro_use]
extern crate rocket;
#[launch]
async fn rocket() -> _ {
// Initialize logger
env_logger::init();
log::info!("Starting Kazoeru-kun application...");
// Load configuration
let app_config = AppConfig::from_env()
.expect("Failed to load configuration");
// Build dependency injection container
let container = DiContainer::build(app_config)
.await
.expect("Failed to build dependency injection container");
// Start cron jobs
spawn_sync_cron_job(container.sync_service.clone());
spawn_status_history_cron_job(
container.status_service.clone(),
Arc::new(container.config.clone())
);
// Get CORS configuration
let cors = container.get_cors()
.expect("Failed to configure CORS");
// Build and configure Rocket application
rocket::build()
.manage(container.session_service.clone())
.manage(container.status_service.clone())
.manage(container.sync_service.clone())
.manage(container.health_service.clone())
.manage(container.session_repo.clone())
.manage(container.counter_repo.clone())
.manage(container.history_repo.clone())
.manage(container.redis_pool.clone())
.manage(container.db.clone())
.manage(container.config.clone())
.mount("/", configure_routes())
.attach(cors)
}
ポイント:
DiContainerで依存性を一元管理(テスト時のモック差し替えが楽).manage()でサービスをRocket全体に注入- Cronジョブはサーバー起動時に
tokio::spawnで開始
3. ハンドラー層 - 薄く保つ
ハンドラーはリクエスト/レスポンスの変換のみを担当します。ビジネスロジックは一切書かない。
use crate::http::cookie_utils::set_session_cookie;
use crate::models::response::{ErrorResponse, Response, SessionCount};
use crate::services::SessionService;
use rocket::{http::CookieJar, post, serde::json::Json, State};
use std::sync::Arc;
/// POST /session - セッションのクリックカウンタをインクリメント
#[post("/session")]
pub async fn create_session(
session_service: &State<Arc<SessionService>>,
cookies: &CookieJar<'_>,
) -> Result<Json<Response<SessionCount>>, Json<ErrorResponse>> {
// Cookieからセッション取得
let session_id = cookies.get("session").map(|cookie| cookie.value());
// ビジネスロジックはサービス層に委譲
match session_service.increment_session(session_id).await {
Ok(session_response) => {
// 新規セッションの場合はクッキーを設定
if let Some(expiration) = session_response.expiration {
set_session_cookie(cookies, &session_response.session_id, expiration);
}
Ok(Json(Response {
message: session_response.message,
result: SessionCount {
count: session_response.count,
},
}))
}
Err(e) => Err(Json(ErrorResponse {
error: format!("Failed to increment session: {e:?}"),
})),
}
}
やっていること:
- リクエストからデータ抽出(Cookie)
- サービス層に処理を委譲
- レスポンス形式に変換
やっていないこと:
- ビジネスロジック
- データベース操作
- キャッシュ操作
この「やっていないこと」を守れるかがクリーンアーキテクチャの肝。(守れなくなったらリファクタリングの合図です)
4. サービス層 - ビジネスロジックの集約
セッション管理のコアロジックはここに。RedisとDBの使い分け、有効期限の計算などを担当します。
use crate::repositories::{RepositoryError, SessionRepository};
use chrono::Local;
use std::sync::Arc;
use uuid::Uuid;
pub struct SessionService {
session_repo: Arc<dyn SessionRepository>,
}
impl SessionService {
pub fn new(session_repo: Arc<dyn SessionRepository>) -> Self {
Self { session_repo }
}
/// セッションをインクリメントし、必要に応じて新規作成・有効期限設定を行う
pub async fn increment_session(
&self,
session_id: Option<&str>,
) -> Result<SessionResponse, RepositoryError> {
let session_id = match session_id {
Some(id) if !id.is_empty() => {
// 既存セッションIDがある場合、有効性をチェック
let exists = self.session_repo.exists(id).await?;
if exists {
id.to_string()
} else {
// セッションが存在しない場合は新規作成
self.create_new_session().await?
}
}
_ => {
// セッションIDがない場合は新規作成
self.create_new_session().await?
}
};
// カウントをインクリメント
let count = self.session_repo.increment(&session_id).await?;
// 新規セッション(カウント1)の場合は有効期限を設定
let (message, is_new, expiration) = if count == 1 {
let expiration = self.calculate_expiration();
self.session_repo
.set_expiration(&session_id, expiration.timestamp())
.await?;
("新規セッションが登録されました".to_string(), true, Some(expiration))
} else {
("セッションカウントが更新されました".to_string(), false, None)
};
Ok(SessionResponse {
session_id,
count,
message,
is_new,
expiration,
})
}
/// セッションの有効期限を計算(翌日の0:00:00)
fn calculate_expiration(&self) -> chrono::DateTime<Local> {
use chrono::TimeZone;
Local::now()
.date_naive()
.succ_opt()
.and_then(|tomorrow| tomorrow.and_hms_opt(0, 0, 0))
.and_then(|dt| Local.from_local_datetime(&dt).single())
.unwrap_or_else(|| {
// フォールバック: 現在時刻から24時間後
Local::now() + chrono::Duration::hours(24)
})
}
}
RedisのINCRコマンドはアトミック。「カウントがズレた」問題とは無縁です。(Excelで集計してた頃が懐かしい...いや、懐かしくない)
設計のポイント:
Arc<dyn SessionRepository>でリポジトリを抽象化(モック差し替え可能)- 有効期限は「翌日0時」に統一(日次でリセット)
- エラーハンドリングは
Result型で伝播
5. リポジトリ層 - データアクセスの抽象化
トレイトでインターフェースを定義し、テスト時のモック化を容易にします。
use async_trait::async_trait;
use sea_orm::DbErr;
use chrono::NaiveDate;
#[async_trait]
pub trait SessionCounterRepository: Send + Sync {
async fn upsert(&self, session_id: &str, count: i64) -> Result<(), DbErr>;
async fn sum_count_between(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<i64, DbErr>;
}
実装側:
#[async_trait]
impl SessionCounterRepository for SessionCounterRepositoryImpl {
async fn sum_count_between(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<i64, DbErr> {
let result = SessionCounter::find()
.filter(session_counter::Column::CreatedAt.gte(start))
.filter(session_counter::Column::CreatedAt.lte(end))
.select_only()
.column_as(session_counter::Column::Count.sum(), "total")
.into_tuple::<Option<i64>>()
.one(&self.db)
.await?;
Ok(result.flatten().unwrap_or(0))
}
}
SeaORMのクエリビルダー、結構書きやすいです。型安全なSQLが書ける安心感。
6. 組み込みCronジョブ
tokio::spawnでバックグラウンドタスクを実行します。外部のcronデーモン不要。(設定ファイルが散らばらないの、地味にうれしい)
use super::config::CronJobConfig;
use std::future::Future;
use std::time::Duration;
/// Generic function to spawn a cron job with retry logic
pub fn spawn_cron_job<F, Fut>(config: CronJobConfig, task: F)
where
F: Fn() -> Fut + Send + 'static,
Fut: Future<Output = Result<String, Box<dyn std::error::Error + Send + Sync>>> + Send,
{
tokio::spawn(async move {
// Validate interval (minimum 1 second)
let validated_interval = if config.interval_secs < 1 {
log::warn!(
"Cron job '{}' interval too short ({}s), using minimum 1s",
config.name, config.interval_secs
);
1
} else {
config.interval_secs
};
// Handle initial delay if specified
if let Some(delay) = config.initial_delay {
log::info!(
"Starting cron job '{}' with initial delay: {:?}",
config.name, delay
);
tokio::time::sleep(delay).await;
}
// Create interval timer
let mut interval = tokio::time::interval(
Duration::from_secs(validated_interval)
);
loop {
interval.tick().await;
log::info!("Running scheduled task: {}", config.name);
// Retry logic
let mut retry_count = 0;
loop {
match task().await {
Ok(message) => {
log::info!("{}: {}", config.name, message);
break;
}
Err(e) if retry_count < config.max_retries => {
retry_count += 1;
log::warn!(
"{} failed (attempt {}/{}): {:?}",
config.name, retry_count, config.max_retries, e
);
tokio::time::sleep(
Duration::from_secs(config.retry_delay_secs)
).await;
}
Err(e) => {
log::error!(
"{} failed after {} attempts: {:?}",
config.name, config.max_retries, e
);
break;
}
}
}
}
});
}
特徴:
- 設定ベースのジョブ定義
- 自動リトライ機能(3回失敗したら諦める、という人間味)
- 構造化ログ出力
60秒ごとにRedis→PostgreSQL同期。これで「Redisが落ちてもデータは守られる」という安心感。(クリック数が消えたら...まあ、非生産的なアプリだからいいんですけど、やっぱり嫌ですよね)
7. Docker Compose環境
# docker-compose.yml
services:
backend:
build: ./app/backend
ports:
- '8881:8000'
environment:
- DATABASE_URL=postgresql://postgres:admin_password@db:5432/kazoerukun
- REDIS_URL=redis://redis:6379
- ALLOWED_ORIGINS=http://localhost:3001
depends_on:
- db
- redis
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: kazoerukun
POSTGRES_PASSWORD: admin_password
volumes:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- '6380:6379'
volumes:
postgres-data:
docker-compose up 一発で開発環境が立ち上がる。(環境構築で1日溶かした経験がある人には、この手軽さがわかるはず)
まとめ
Rocket Frameworkを使ったRust APIサーバーの構築パターンを紹介しました。
採用した設計:
- クリーンアーキテクチャ: Handler → Service → Repository の明確な分離
- DIコンテナ: 依存性の一元管理とテスタビリティ向上
- 組み込みCron: tokio::spawnによるバックグラウンドタスク
Rustを選ぶ理由:
- メモリ安全性(「ランタイムエラーで深夜対応」が減る)
- 高いパフォーマンス(レイテンシ数msの世界)
- 型システムによるバグの早期発見(コンパイラが厳しい分、本番が安心)
カゾエルくんは「非生産的の極み」というコンセプトですが、バックエンドは真面目に設計しています。本番運用ではFly.io + Neon + Upstashの構成で月額数ドルの低コスト運用も実現。
「Rustは学習コストが高い」と言われますが、一度身につければ、運用コストの低さで元が取れる。「クリックしたら数が増えるだけ」のアプリでRustを学ぶ...意外とアリかもしれません。
→ 気軽に相談する



