rust-fullstack - 解説
実装の詳細
アーキテクチャ概要
┌─────────────────────────────────────────────────────┐
│ Frontend (WASM) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Yew │ │ Router │ │ State (Context) │ │
│ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
│ └─────────────┴─────────────────┘ │
│ ↕ HTTP/JSON │
└─────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────┐
│ Backend (Axum) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Router │→ │ Handler │→ │ Service │ │
│ └──────────┘ └──────────┘ └────────┬─────────┘ │
│ ↓ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Auth │ │ Error │ │ Repository │ │
│ └──────────┘ └──────────┘ └────────┬─────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────┐ │
│ │ SQLx (Database) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
JWT認証の実装
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub user_id: i64,
pub exp: usize,
pub iat: usize,
}
pub fn create_token(user_id: i64, secret: &str) -> Result<String, AppError> {
let now = chrono::Utc::now();
let exp = now + chrono::Duration::hours(24);
let claims = Claims {
user_id,
exp: exp.timestamp() as usize,
iat: now.timestamp() as usize,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| AppError::Auth(e.to_string()))
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, AppError> {
decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(|e| AppError::Auth(e.to_string()))
}
// Axum Extractorとして実装
#[async_trait]
impl<S> FromRequestParts<S> for Claims
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Auth("Missing authorization header".into()))?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AppError::Auth("Invalid authorization header".into()))?;
let app_state = parts
.extensions
.get::<Arc<AppState>>()
.ok_or(AppError::Auth("Internal error".into()))?;
verify_token(token, &app_state.config.jwt_secret)
}
}
WASM API クライアント
// frontend/src/api.rs
use gloo_net::http::Request;
use gloo_storage::{LocalStorage, Storage};
use serde::{de::DeserializeOwned, Serialize};
const API_BASE: &str = "/api";
pub async fn fetch<T: DeserializeOwned>(
method: &str,
path: &str,
body: Option<impl Serialize>,
) -> Result<T, String> {
let url = format!("{}{}", API_BASE, path);
let mut request = match method {
"GET" => Request::get(&url),
"POST" => Request::post(&url),
"PUT" => Request::put(&url),
"DELETE" => Request::delete(&url),
_ => return Err("Invalid method".into()),
};
// 認証トークンを追加
if let Ok(token) = LocalStorage::get::<String>("token") {
request = request.header("Authorization", &format!("Bearer {}", token));
}
// ボディを追加
let request = if let Some(body) = body {
request
.header("Content-Type", "application/json")
.json(&body)
.map_err(|e| e.to_string())?
} else {
request
};
let response = request.send().await.map_err(|e| e.to_string())?;
if !response.ok() {
let error: ApiError = response.json().await.unwrap_or(ApiError {
error: "Unknown error".into(),
});
return Err(error.error);
}
response.json().await.map_err(|e| e.to_string())
}
// 型付きAPI関数
pub async fn login(email: &str, password: &str) -> Result<AuthResponse, String> {
let body = LoginRequest {
email: email.into(),
password: password.into(),
};
let response: AuthResponse = fetch("POST", "/auth/login", Some(body)).await?;
// トークンを保存
LocalStorage::set("token", &response.token).ok();
Ok(response)
}
pub async fn get_tasks() -> Result<Vec<Task>, String> {
fetch("GET", "/tasks", None::<()>).await
}
pub async fn create_task(title: &str, priority: &str) -> Result<Task, String> {
let body = CreateTaskRequest {
title: title.into(),
priority: Some(priority.into()),
description: None,
due_date: None,
};
fetch("POST", "/tasks", Some(body)).await
}
pub async fn delete_task(id: i64) -> Result<(), String> {
fetch("DELETE", &format!("/tasks/{}", id), None::<()>).await
}
よくある間違い
1. CORS設定の不備
// 間違い: CORS設定なし
let app = Router::new()
.route("/api/tasks", get(list_tasks));
// → ブラウザからアクセス不可
// 正しい: CORS設定
use tower_http::cors::{CorsLayer, Any};
let cors = CorsLayer::new()
.allow_origin(Any) // 本番では特定ドメインに制限
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/api/tasks", get(list_tasks))
.layer(cors);
2. パスワードの安全でない保存
// 間違い: パスワードを平文で保存
user.password = req.password.clone();
// 正しい: Argon2でハッシュ化
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|e| AppError::Auth(e.to_string()))
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| AppError::Auth(e.to_string()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
3. SQLインジェクション
// 間違い: 文字列補間
let query = format!("SELECT * FROM tasks WHERE title = '{}'", title);
sqlx::query(&query).fetch_all(&pool).await?;
// 正しい: パラメータ化クエリ
sqlx::query("SELECT * FROM tasks WHERE title = $1")
.bind(&title)
.fetch_all(&pool)
.await?;
4. WASM での非同期処理
// 間違い: 直接awaitを使用
#[function_component(TaskList)]
fn task_list() -> Html {
let tasks = api::get_tasks().await; // コンパイルエラー
}
// 正しい: spawn_localを使用
#[function_component(TaskList)]
fn task_list() -> Html {
let tasks = use_state(Vec::new);
{
let tasks = tasks.clone();
use_effect_with((), move |_| {
wasm_bindgen_futures::spawn_local(async move {
if let Ok(fetched) = api::get_tasks().await {
tasks.set(fetched);
}
});
|| ()
});
}
// ...
}
パフォーマンス最適化
バックエンド
// 接続プール設定
let pool = PgPoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(Duration::from_secs(3))
.connect(&database_url)
.await?;
// クエリ最適化
// N+1問題を避ける
sqlx::query_as::<_, TaskWithUser>(
r#"
SELECT t.*, u.username
FROM tasks t
JOIN users u ON t.user_id = u.id
WHERE t.user_id = $1
"#
)
.bind(user_id)
.fetch_all(&pool)
.await?;
フロントエンド
// メモ化
#[function_component(TaskItem)]
fn task_item(props: &TaskItemProps) -> Html {
// 重い計算をメモ化
let formatted_date = use_memo(
props.task.due_date.clone(),
|date| format_date(date)
);
html! { /* ... */ }
}
// 仮想スクロール(大量データ対応)
// yew-virtual-scroll を使用
発展トピック
WebSocket リアルタイム同期
// バックエンド
use axum::extract::ws::{WebSocket, WebSocketUpgrade};
async fn ws_handler(
ws: WebSocketUpgrade,
Extension(state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>) {
while let Some(msg) = socket.recv().await {
if let Ok(msg) = msg {
// メッセージ処理
// 他のクライアントにブロードキャスト
}
}
}
// フロントエンド
use gloo_net::websocket::{futures::WebSocket, Message};
async fn connect_ws() {
let ws = WebSocket::open("ws://localhost:8080/ws").unwrap();
let (mut write, mut read) = ws.split();
// 受信ループ
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
// 更新を処理
}
_ => {}
}
}
}
Service Worker オフライン対応
// service_worker.rs (WASM)
use wasm_bindgen::prelude::*;
use web_sys::{ServiceWorkerGlobalScope, FetchEvent, Response};
#[wasm_bindgen]
pub fn handle_fetch(event: FetchEvent) {
let request = event.request();
// キャッシュファースト戦略
let response_promise = async move {
let cache = caches().open("v1").await?;
if let Some(cached) = cache.match_request(&request).await? {
return Ok(cached);
}
let response = fetch(&request).await?;
cache.put(&request, response.clone()).await?;
Ok(response)
};
event.respond_with(&response_promise.into());
}