「生産性を上げろ」「効率化しろ」「最適化だ」
仕事で毎日のように言われ続けて、ふと気づいたんです。
生産性と幸せって、全然結びつかなくないですか?
子供と遊ぶ、旅行する、ペットの世話をする、焚き火をする。どれも「非効率」の極みなのに、そっちにこそ愛着を感じる。人間が幸せになる瞬間って、生産性の及ばないところにあるんじゃないか。
だから作ってみました。「非生産的の極み」みたいなアプリを。
クリックしたら数が増える。それだけ。本当にそれだけのアプリです。
「...で、なんでそれをRustで作るの?」
せっかくRustでbackend作るなら、frontendもRustでやってみたかったからです。
ちょうどRustを勉強してみたかったし、シンプルなテーマがいいと思った。「クリックで数が増えるだけ」なら丁度いい。結論から言うと、フロントエンドもバックエンドも全部Rustで完成しました。
今回はフロントエンドにYew(WebAssembly)、バックエンドにRocketを採用したクリックカウンターアプリ「カゾエルくん」の技術構成を紹介します。(名前のセンスは突っ込まないで)
ちなみにこのアプリ、yaoyorozチームで開発中のプロジェクトです。playpark LLCもチームの一員として参画しています。(チーム開発、楽しいですよ)
この記事で学べること
- Rust + YewでSPA構築する方法(Rust統一フルスタック)
- Rocket frameworkのAPI設計パターン
- RedisとPostgreSQLの使い分け戦略
- Docker Composeで全部動かす構成
- 本番運用で遭遇した落とし穴とその対処法
前提条件
- Rustの基本(所有権、traitの理解)
- Docker知ってる
- 「Rustで全部やってみたい」という好奇心
アーキテクチャ:なぜこうなった
サービス構成
| サービス | 技術 | 役割 | 一言 |
|---|---|---|---|
| backend | Rust / Rocket | APIサーバー | 安心のRust |
| frontend | Rust / Yew (WASM) | SPA | Rust統一の立役者 |
| redis | Redis | キャッシュ | 速さ担当 |
| postgres | PostgreSQL 16 | 永続化 | 信頼性担当 |
データフロー
設計意図: ユーザー操作はRedisで高速応答、PostgreSQLでちゃんとデータ永続化。Redisはあくまでキャッシュなので、定期的にPostgreSQLへ同期して週次ステータス履歴などの集計・分析にも活用しています。
フロントエンド:Yew + WebAssembly
なぜYewなのか(本音)
「ReactでもVueでもいいじゃん」という声が聞こえてきそうですが...
- せっかくならRustで統一したい ← これが最大の理由
- 型安全でUIのバグを事前に防げる
- WASMで速い(らしい)
- Rustの学習がさらに深まる(学習コスト? 投資です)
実際のコード
// src/components/home.rs
use yew::prelude::*;
use gloo_net::http::Request;
#[function_component(Home)]
pub fn home() -> Html {
let count = use_state(|| 0u64);
let loading = use_state(|| false);
let onclick = {
let count = count.clone();
let loading = loading.clone();
Callback::from(move |_| {
let count = count.clone();
let loading = loading.clone();
// 連打防止(1秒間隔)
if *loading {
return;
}
loading.set(true);
wasm_bindgen_futures::spawn_local(async move {
let response = Request::post("/api/session")
.send()
.await;
if let Ok(resp) = response {
if let Ok(data) = resp.json::<SessionResponse>().await {
count.set(data.count);
}
}
loading.set(false);
});
})
};
html! {
<div class="counter-container">
<h1>{ "カゾエルくん" }</h1>
<p class="count">{ format!("{:,}", *count) }</p>
<button {onclick} disabled={*loading}>
{ if *loading { "..." } else { "クリック!" } }
</button>
</div>
}
}
ReactやVue経験者なら「あ、Hooksっぽい」と思うはず。use_stateやCallback、見覚えありますよね。Rust版Reactと言っても過言ではない(過言かも)。
ポイントはwasm_bindgen_futures::spawn_localで非同期処理を実行しているところ。WASMでもasync/awaitが使えるのは嬉しい。
バックエンド:Rocket Framework
API設計
| エンドポイント | メソッド | 何するやつ |
|---|---|---|
/session | POST | カウント+1(Redis) |
/session | PUT | Redis→PostgreSQL同期 |
/session_counter | GET | 累積カウント取得 |
/status_history | GET | 週次ステータス履歴 |
/health | GET | 生きてますか確認 |
セッション管理の実装
// src/handlers/session_handler.rs
use rocket::{post, State, http::CookieJar};
use rocket::serde::json::Json;
#[post("/session")]
pub async fn increment_session(
cookies: &CookieJar<'_>,
redis: &State<RedisPool>,
config: &State<AppConfig>,
) -> Result<Json<SessionResponse>, Status> {
// Cookieからセッション取得(なければUUID v4で新規作成)
let session_id = get_or_create_session(cookies);
// Redisのカウンタをインクリメント(INCR command)
let count = redis.incr(&session_id).await
.map_err(|_| Status::InternalServerError)?;
// セッションの有効期限を翌日0:00:00に設定
let expiration = calculate_midnight_expiration();
redis.expire(&session_id, expiration).await?;
// 新規セッションならCookieを設定
if count == 1 {
set_session_cookie(cookies, &session_id, expiration, config);
}
Ok(Json(SessionResponse {
session_id: session_id.clone(),
count,
is_new: count == 1,
}))
}
RocketのGuard機能が便利すぎる。Cookie処理もDIもスマートに書ける。Expressより書きやすいかも(言い過ぎ?)
サービス層でビジネスロジックを分離
// src/services/session.rs
pub struct SessionService {
session_repo: Arc<dyn SessionRepository>,
}
impl SessionService {
pub async fn increment_session(
&self,
session_id: Option<&str>
) -> Result<SessionResponse, AppError> {
// セッションIDがなければ新規作成
let id = session_id
.map(String::from)
.unwrap_or_else(|| Uuid::new_v4().to_string());
// Redisでカウントアップ
let count = self.session_repo.increment(&id).await?;
// 有効期限を翌日0:00:00(JST)に設定
let expiration = self.calculate_midnight_expiration();
self.session_repo.set_expiration(&id, expiration).await?;
Ok(SessionResponse {
session_id: id,
count,
expiration_at: expiration,
})
}
fn calculate_midnight_expiration(&self) -> i64 {
let tokyo = chrono_tz::Asia::Tokyo;
let now = Utc::now().with_timezone(&tokyo);
let tomorrow = now.date_naive().succ_opt().unwrap();
let midnight = tomorrow.and_hms_opt(0, 0, 0).unwrap();
midnight.and_local_timezone(tokyo)
.single()
.unwrap()
.timestamp()
}
}
「なんで有効期限を翌日0時にしてるの?」という疑問があるかもしれません。これは日ごとのクリック数を集計するため。毎日リセットされるので、ユーザーの日々の活動量が見えるようになります。(非生産的なアプリなのに活動量を測る矛盾)
データ永続化:二段構え戦略
Redis→PostgreSQL同期
// src/services/sync.rs
pub struct SyncService {
session_repo: Arc<dyn SessionRepository>,
counter_repo: Arc<dyn SessionCounterRepository>,
db: Arc<DatabaseConnection>,
}
impl SyncService {
pub async fn sync_redis_to_postgres(&self) -> Result<SyncResult, AppError> {
// Redisから全セッションデータを取得
let sessions = self.session_repo.get_all_sessions().await?;
let mut synced = 0;
let mut failed = 0;
// トランザクション内でupsert
let txn = self.db.begin().await?;
for (session_id, count) in sessions {
match self.counter_repo
.upsert(&session_id, count)
.await
{
Ok(_) => synced += 1,
Err(e) => {
log::warn!("Failed to sync {}: {:?}", session_id, e);
failed += 1;
}
}
}
txn.commit().await?;
Ok(SyncResult { synced, failed })
}
}
定期実行はtokio::spawnでアプリ内完結。外部のcronに依存しない。cronの設定を忘れて事故る未来を回避できます。
// src/main.rs(抜粋)
tokio::spawn(async move {
let mut interval = tokio::time::interval(
Duration::from_secs(60) // 60秒間隔
);
loop {
interval.tick().await;
if let Err(e) = sync_service.sync_redis_to_postgres().await {
log::error!("Sync failed: {:?}", e);
}
}
});
週次ステータス(Detail画面で表示)
// src/services/status.rs
pub async fn update_weekly_status(&self) -> Result<(), AppError> {
// 先週の期間を取得(月曜〜日曜)
let (start, end) = get_last_week_range();
// 既存レコードがあればスキップ(冪等性確保)
if self.is_already_recorded(&start, &end).await? {
log::info!("Status already recorded for {:?} - {:?}", start, end);
return Ok(());
}
// 先週のクリック総数を集計
let count = self.counter_repo
.sum_count_between(&start, &end)
.await?;
// 目標値との比較でステータス決定
let status = self.calculate_status(count);
self.history_repo.create(StatusHistory {
start_date: start,
end_date: end,
count,
status,
}).await?;
Ok(())
}
fn calculate_status(&self, count: i64) -> Status {
let target = self.config.target_value; // デフォルト10万
match count {
c if c >= target * 100 => Status::Spotless, // 3ランクアップ
c if c >= target * 10 => Status::Immaculate, // 2ランクアップ
c if c >= target => Status::Clean, // 1ランクアップ
_ => Status::Dirty, // 目標未達
}
}
「先週何回クリックされたか」を記録して、Detail画面でステータス表示に使っています。目標値の100倍で「Spotless」、未達なら「Dirty」。5段階評価で非生産的な活動を可視化(意味があるのかは不明)。
Docker Compose:全部入り
# docker-compose.yml
services:
backend:
build: ./app/backend
ports:
- '8881:8000'
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
environment:
- DATABASE_URL=postgres://postgres:postgres@postgres:5432/kazoerukun
- REDIS_URL=redis://redis:6379
- ALLOWED_ORIGINS=http://localhost:3001
- TARGET_VALUE=100000
- SYNC_JOB_INTERVAL_SECS=60
- STATUS_HISTORY_JOB_INTERVAL_SECS=86400
frontend:
build: ./app/frontend
ports:
- '3001:8080'
environment:
- APP_API_ROOT=http://localhost:8881
redis:
image: redis:alpine
ports:
- '6380:6379'
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=kazoerukun
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5
docker compose up -dで全部立ち上がる。環境構築で半日溶かす時代は終わり。
動作確認
ローカル環境での確認手順
# 1. リポジトリをクローン
git clone https://github.com/playpark-llc/kazoeru-kun.git
cd kazoeru-kun
# 2. Docker Composeで起動
docker compose up -d
# 3. ヘルスチェック
curl http://localhost:8881/health
# => {"status":"healthy","components":{"database":{"status":"healthy"},"redis":{"status":"healthy"}}}
# 4. セッションカウントのテスト
curl -X POST http://localhost:8881/session -c cookies.txt -b cookies.txt
# => {"session_id":"xxx","count":1,"is_new":true}
# 5. フロントエンドにアクセス
open http://localhost:3001
本番環境での確認
# ヘルスチェックエンドポイント
curl https://api.kazoeru-kun.yaoyoroz.org/health
# Kubernetes用のプローブも用意
curl https://api.kazoeru-kun.yaoyoroz.org/health/liveness
curl https://api.kazoeru-kun.yaoyoroz.org/health/readiness
注意点・Tips
1. PgBouncer互換性の罠
本番環境でPostgreSQLを使う場合、PgBouncer経由だとハマりがち。
DatabaseError: prepared statement "sqlx_s_1" already exists
PgBouncerのトランザクションプーリングモードでは、SQLxのプリペアドステートメントキャッシュが衝突します。解決策:
// src/di.rs
pub async fn create_database_connection(url: &str) -> Result<DatabaseConnection, DbErr> {
// ステートメントキャッシュを無効化
let url_with_params = format!("{}?statement-cache-capacity=0", url);
Database::connect(&url_with_params).await
}
2. クロスサイトCookieの設定
フロントエンド(Cloudflare Pages)とバックエンド(Fly.io)でドメインが異なる場合、Cookie設定に注意:
fn set_session_cookie(cookies: &CookieJar<'_>, session_id: &str, expiration: i64) {
let cookie = Cookie::build(("session", session_id.to_string()))
.path("/")
.same_site(SameSite::None) // クロスサイト許可
.secure(true) // HTTPS必須
.http_only(true)
.expires(OffsetDateTime::from_unix_timestamp(expiration).unwrap())
.build();
cookies.add(cookie);
}
SameSite=None + Secure=trueの組み合わせが必須。片方だけだと動きません。(ハマった経験あり)
3. タイムゾーン処理
日本時間での日付処理はchrono-tzを使う:
use chrono_tz::Asia::Tokyo;
fn get_last_week_range() -> (NaiveDate, NaiveDate) {
let now = Utc::now().with_timezone(&Tokyo);
let today = now.date_naive();
// 今週月曜から7日前 = 先週月曜
let days_since_monday = today.weekday().num_days_from_monday();
let this_monday = today - Duration::days(days_since_monday as i64);
let last_monday = this_monday - Duration::days(7);
let last_sunday = last_monday + Duration::days(6);
(last_monday, last_sunday)
}
DST(夏時間)の切り替わりタイミングでLocalResult::Ambiguousが返ることがあるので、ちゃんとハンドリングしましょう。(日本は夏時間ないけど、念のため)
4. Yewのビルド最適化
本番用ビルドでWASMサイズを削減:
# Cargo.toml
[profile.release]
opt-level = 'z' # サイズ最適化
lto = true # リンク時最適化
codegen-units = 1 # 並列度を下げて最適化
strip = true # シンボル削除
これでWASMファイルが約40%小さくなります。(具体的な数値は環境依存)
まとめ:Rust統一フルスタック、できた
| 項目 | 感想 |
|---|---|
| 型安全性 | コンパイル通ればだいたい動く(神) |
| パフォーマンス | WASM速い(体感) |
| 統一言語 | 学習コスト高い(正直) |
| メモリ安全 | 実行時エラー激減(これはガチ) |
| 開発体験 | cargo watchで快適(慣れれば) |
「せっかくRustでbackend作るならfrontendも」という好奇心で始めたけど、結果的にRustの良さを再発見するプロジェクトになりました。
正直、学習コストは高いです。借用チェッカーに怒られ、ライフタイムに悩み、async/awaitでハマり...。でも、一度Rustで全部書けるようになると、その型安全性とパフォーマンスの恩恵を実感できます。
「非生産的」なアプリを作るのに、こんなに本気で技術投資する必要があったのか?という疑問はさておき、楽しかったのは間違いない。(沼にハマった感はある)

