playpark
ホーム会社概要サービスソリューションブログお知らせお問い合わせ
playpark

あらゆる仕事を楽しむ

会社概要サービスソリューションお問い合わせ特定商取引法に基づく表記

© 2019-2026 合同会社playpark All Rights Reserved.

  1. ホーム
  2. ブログ
  3. 技術Tips
  4. 【Rust】「非生産的」なWebアプリを作ってみた - Yew + Rocketフルスタック開発
ブログ一覧に戻る
技術Tips

【Rust】「非生産的」なWebアプリを作ってみた - Yew + Rocketフルスタック開発

生産性と幸せは結びつかない。だから「非生産的の極み」みたいなアプリを本気で作ってみました。Yew + Rocket + Redis + PostgreSQLで作ったクリックカウンター「カゾエルくん」の技術解説です。

2026年2月23日34分で読める
RustWebAssemblyAPI開発マイクロサービスDocker
【Rust】「非生産的」なWebアプリを作ってみた - Yew + Rocketフルスタック開発

「生産性を上げろ」「効率化しろ」「最適化だ」

仕事で毎日のように言われ続けて、ふと気づいたんです。

生産性と幸せって、全然結びつかなくないですか?

子供と遊ぶ、旅行する、ペットの世話をする、焚き火をする。どれも「非効率」の極みなのに、そっちにこそ愛着を感じる。人間が幸せになる瞬間って、生産性の及ばないところにあるんじゃないか。

だから作ってみました。「非生産的の極み」みたいなアプリを。

クリックしたら数が増える。それだけ。本当にそれだけのアプリです。


「...で、なんでそれを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で全部やってみたい」という好奇心

アーキテクチャ:なぜこうなった

サービス構成

サービス技術役割一言
backendRust / RocketAPIサーバー安心のRust
frontendRust / Yew (WASM)SPARust統一の立役者
redisRedisキャッシュ速さ担当
postgresPostgreSQL 16永続化信頼性担当

データフロー

読み込み中...図を読み込み中

設計意図: ユーザー操作はRedisで高速応答、PostgreSQLでちゃんとデータ永続化。Redisはあくまでキャッシュなので、定期的にPostgreSQLへ同期して週次ステータス履歴などの集計・分析にも活用しています。

フロントエンド:Yew + WebAssembly

なぜYewなのか(本音)

「ReactでもVueでもいいじゃん」という声が聞こえてきそうですが...

  1. せっかくならRustで統一したい ← これが最大の理由
  2. 型安全でUIのバグを事前に防げる
  3. WASMで速い(らしい)
  4. 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設計

エンドポイントメソッド何するやつ
/sessionPOSTカウント+1(Redis)
/sessionPUTRedis→PostgreSQL同期
/session_counterGET累積カウント取得
/status_historyGET週次ステータス履歴
/healthGET生きてますか確認

セッション管理の実装

// 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で全部書けるようになると、その型安全性とパフォーマンスの恩恵を実感できます。

「非生産的」なアプリを作るのに、こんなに本気で技術投資する必要があったのか?という疑問はさておき、楽しかったのは間違いない。(沼にハマった感はある)


→ お問い合わせはこちら

この技術を活用した事例

記事の技術が実際のプロジェクトでどう活かされているかをご紹介します

【勤怠データ自動集計】入退室ログ×カオナビ連携で作業時間96%削減

入退室システムとカオナビを自動連携し、BigQueryへリアルタイム集約。日次集計作業を2時間から5分に削減した導入事例をご紹介します。

事例を読む
About playpark

「あらゆる仕事を楽しむ」をミッションに、業務自動化・AI活用を手がける合同会社です。このブログの運用自体も、自社開発のClaude Code Skillsで完全自動化しています。

会社概要サービス
一緒に実験しませんか?
ブログ一覧に戻る

関連記事

すべての記事
【勤怠データ自動集計】入退室ログ×カオナビ連携で作業時間96%削減
事例紹介
2026年1月13日8分で読める
【勤怠データ自動集計】入退室ログ×カオナビ連携で作業時間96%削減

入退室システムとカオナビを自動連携し、BigQueryへリアルタイム集約。日次集計作業を2時間から5分に削減した導入事例をご紹介します。

勤怠管理データ連携BigQuery+3

この技術を活用したサービス

記事の技術を使って、業務課題を解決しませんか?playparkのソリューションをご覧ください。

ソリューション一覧へ