嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十八天!
經過前面幾天的努力,我們已經建立了一個功能完整的個人部落格後端系統。文章 CRUD、標籤管理、留言系統、搜尋功能都已經就位,API 也能正常工作。但是作為一個有追求的開發者,我們不能只滿足於「能跑」,還要追求「跑得快」!
今天我們要為 API 加上快取功能,讓讀者瀏覽你的部落格時有更流暢的體驗。我們會使用 moka
,這是一個專為 Rust 設計的高效能快取套件,功能強大且 API 友善!
高效能:使用無鎖演算法,並發效能出色
功能完整:支援 TTL、LRU、統計資訊等企業級功能
記憶體安全:純 Rust 實作,無需擔心記憶體洩漏
API 友善:非同步原生支援,與 tokio 完美整合
維護活躍:GitHub 上有活躍的社群和持續更新
讀多寫少:文章發布後很少修改,但會被反覆瀏覽
熱點集中:首頁、最新文章、熱門標籤被頻繁存取
SEO 需求:搜尋引擎爬蟲會產生大量請求
資源有限:個人 VPS 需要精打細算
更新 Cargo.toml
:
[dependencies]
# ... 現有依賴
moka = { version = "0.12", features = ["future"] }
新增 src/cache/mod.rs
:
use moka::future::Cache;
use std::time::Duration;
use serde::{Serialize, Deserialize};
pub mod post_cache;
pub mod tag_cache;
/// 快取配置
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub post_list_ttl: Duration,
pub post_detail_ttl: Duration,
pub tag_list_ttl: Duration,
pub max_capacity: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
// 文章列表快取 5 分鐘(可能有新文章)
post_list_ttl: Duration::from_secs(5 * 60),
// 文章詳情快取 30 分鐘(內容穩定)
post_detail_ttl: Duration::from_secs(30 * 60),
// 標籤列表快取 15 分鐘(標籤相對穩定)
tag_list_ttl: Duration::from_secs(15 * 60),
// 最大快取項目數(控制記憶體使用)
max_capacity: 1000,
}
}
}
/// 快取統計資訊
#[derive(Debug, Serialize)]
pub struct CacheStats {
pub entry_count: u64,
pub hit_count: u64,
pub miss_count: u64,
pub hit_rate: f64,
}
新增 src/cache/post_cache.rs
:
use super::CacheConfig;
use crate::dtos::{PostDetailResponse, PostListResponse};
use moka::future::Cache;
/// 文章快取服務
#[derive(Clone)]
pub struct PostCache {
// 文章列表快取
list_cache: Cache<String, Vec<PostListResponse>>,
// 單篇文章快取
detail_cache: Cache<String, PostDetailResponse>,
}
impl std::fmt::Debug for PostCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PostCache")
.field("list_cache", &format!("Cache with {} entries", self.list_cache.entry_count()))
.field("detail_cache", &format!("Cache with {} entries", self.detail_cache.entry_count()))
.finish()
}
}
impl PostCache {
pub fn new(config: &CacheConfig) -> Self {
Self {
list_cache: Cache::builder()
.max_capacity(config.max_capacity / 2) // 分配一半容量給列表
.time_to_live(config.post_list_ttl)
.build(),
detail_cache: Cache::builder()
.max_capacity(config.max_capacity / 2) // 分配一半容量給詳情
.time_to_live(config.post_detail_ttl)
.build(),
}
}
/// 快取文章列表
pub async fn cache_post_list(&self, key: String, posts: Vec<PostListResponse>) {
self.list_cache.insert(key, posts).await;
}
/// 取得快取的文章列表
pub async fn get_post_list(&self, key: &str) -> Option<Vec<PostListResponse>> {
self.list_cache.get(key).await
}
/// 快取文章詳情
pub async fn cache_post_detail(&self, slug: String, post: PostDetailResponse) {
// 只快取已發布的文章
if post.is_published {
self.detail_cache.insert(slug, post).await;
}
}
/// 取得快取的文章詳情
pub async fn get_post_detail(&self, slug: &str) -> Option<PostDetailResponse> {
self.detail_cache.get(slug).await
}
/// 清理文章相關快取(當文章被更新時)
pub async fn invalidate_post(&self, slug: &str) {
// 移除特定文章的快取
self.detail_cache.invalidate(slug).await;
// 清空所有列表快取(因為文章可能影響列表)
self.list_cache.invalidate_all();
}
/// 清理所有文章快取
pub async fn invalidate_all(&self) {
self.list_cache.invalidate_all();
self.detail_cache.invalidate_all();
}
/// 產生文章列表的快取鍵
pub fn generate_list_cache_key(
page: Option<u64>,
page_size: Option<u64>,
tag: Option<&str>,
) -> String {
format!(
"posts:page:{}:size:{}:tag:{}",
page.unwrap_or(1),
page_size.unwrap_or(10),
tag.unwrap_or("all")
)
}
/// 取得快取統計
pub fn stats(&self) -> serde_json::Value {
serde_json::json!({
"list_cache": {
"entry_count": self.list_cache.entry_count(),
},
"detail_cache": {
"entry_count": self.detail_cache.entry_count(),
}
})
}
pub async fn run_pending_tasks(&self) {
self.list_cache.run_pending_tasks().await;
self.detail_cache.run_pending_tasks().await;
}
}
新增 src/cache/tag_cache.rs
:
use moka::future::Cache;
use super::{CacheConfig, CacheStats};
use crate::dtos::TagResponse;
/// 標籤快取服務
#[derive(Debug, Clone)]
pub struct TagCache {
cache: Cache<String, Vec<TagResponse>>,
}
impl TagCache {
pub fn new(config: &CacheConfig) -> Self {
Self {
cache: Cache::builder()
.max_capacity(100) // 標籤快取不需要太多空間
.time_to_live(config.tag_list_ttl)
.build(),
}
}
/// 快取標籤列表
pub async fn cache_tags(&self, tags: Vec<TagResponse>) {
self.cache.insert("all_tags".to_string(), tags).await;
}
/// 取得快取的標籤列表
pub async fn get_tags(&self) -> Option<Vec<TagResponse>> {
self.cache.get("all_tags").await
}
/// 清理標籤快取(當標籤被更新時)
pub async fn invalidate_all(&self) {
self.cache.invalidate_all();
}
/// 取得快取統計
pub fn stats(&self) -> CacheStats {
CacheStats {
entry_count: self.cache.entry_count(),
hit_count: self.cache.hit_count(),
miss_count: self.cache.miss_count(),
hit_rate: self.cache.hit_rate(),
}
}
/// 執行快取維護
pub async fn run_pending_tasks(&self) {
self.cache.run_pending_tasks().await;
}
}
更新 src/state.rs
:
use sea_orm::DatabaseConnection;
use crate::{
config::Config,
cache::{PostCache, TagCache, CacheConfig}
};
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
pub config: Config,
// 新增快取服務
pub post_cache: PostCache,
pub tag_cache: TagCache,
}
impl AppState {
pub fn new(db: DatabaseConnection, config: Config) -> Self {
let cache_config = CacheConfig::default();
Self {
db,
config,
post_cache: PostCache::new(&cache_config),
tag_cache: TagCache::new(&cache_config),
}
}
}
在 src/main.rs
中加入快取維護任務:
mod app;
mod cache; // 新增快取模組
mod config;
mod database;
mod dtos;
mod docs;
mod entities;
mod error;
mod middleware;
mod routes;
mod services;
mod startup;
mod state;
use anyhow::Result;
use config::Config;
use database::establish_connection;
use state::AppState;
use tracing::info;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<()> {
// 載入環境變數
dotenvy::dotenv().ok();
// 初始化日誌
tracing_subscriber::fmt::init();
// 載入設定
let config = Config::from_env()?;
info!("設定載入完成");
// 建立資料庫連線
let db = establish_connection(&config).await?;
info!("資料庫連線建立完成");
// 建立應用程式狀態
let app_state = AppState::new(db, config.clone());
// 🆕 啟動快取維護任務
start_cache_maintenance_task(app_state.clone());
// 啟動服務
startup::run(app_state).await?;
Ok(())
}
/// 啟動背景快取維護任務
fn start_cache_maintenance_task(app_state: AppState) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60)); // 每分鐘
loop {
interval.tick().await;
// moka 會自動處理過期項目,這裡主要是觸發統計更新
app_state.post_cache.run_pending_tasks().await;
app_state.tag_cache.run_pending_tasks().await;
// 每 10 次輸出一次統計(每 10 分鐘)
static mut COUNTER: u32 = 0;
unsafe {
COUNTER += 1;
if COUNTER % 10 == 0 {
let post_stats = app_state.post_cache.stats();
let tag_stats = app_state.tag_cache.stats();
info!(
"快取統計 - 文章快取:{} 項目,命中率 {:.2}%",
post_stats["list_cache"]["entry_count"].as_u64().unwrap_or(0) +
post_stats["detail_cache"]["entry_count"].as_u64().unwrap_or(0),
post_stats["list_cache"]["hit_rate"].as_f64().unwrap_or(0.0) * 100.0
);
info!(
"快取統計 - 標籤快取:{} 項目,命中率 {:.2}%",
tag_stats.entry_count,
tag_stats.hit_rate * 100.0
);
}
}
}
});
}
// 更新文章路由處理器以使用快取
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
};
use validator::Validate;
use tracing::{info, debug};
use crate::{
dtos::{PostListQuery, PostListResponse, PostDetailResponse, CreatePostRequest, UpdatePostRequest},
error::AppError,
services::PostService,
state::AppState,
cache::PostCache,
};
/// 取得文章列表(帶快取)
pub async fn get_posts(
State(app_state): State<AppState>,
Query(query): Query<PostListQuery>,
) -> Result<Json<Vec<PostListResponse>>, AppError> {
// 產生快取鍵
let cache_key = PostCache::generate_list_cache_key(
query.page,
query.page_size,
query.tag.as_deref(),
);
// 先嘗試從快取取得
if let Some(cached_posts) = app_state.post_cache.get_post_list(&cache_key).await {
debug!("✅ 文章列表快取命中:{}", cache_key);
return Ok(Json(cached_posts));
}
// 快取未命中,從資料庫查詢
debug!("❌ 文章列表快取未命中,查詢資料庫:{}", cache_key);
let posts = PostService::get_published_posts(&app_state.db, query).await?;
// 將結果存入快取
app_state.post_cache.cache_post_list(cache_key.clone(), posts.clone()).await;
debug!("💾 文章列表已快取:{}", cache_key);
Ok(Json(posts))
}
/// 取得文章詳情(帶快取)
pub async fn get_post_by_slug(
State(app_state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<PostDetailResponse>, AppError> {
// 先嘗試從快取取得
if let Some(cached_post) = app_state.post_cache.get_post_detail(&slug).await {
debug!("✅ 文章詳情快取命中:{}", slug);
// 非同步更新瀏覽次數(不阻塞回應)
let db_clone = app_state.db.clone();
let post_id = cached_post.id;
tokio::spawn(async move {
let _ = PostService::increment_view_count(&db_clone, post_id).await;
});
return Ok(Json(cached_post));
}
// 快取未命中,從資料庫查詢
debug!("❌ 文章詳情快取未命中,查詢資料庫:{}", slug);
let post = PostService::get_post_by_slug_or_id(&app_state.db, &slug).await?;
// 非同步更新瀏覽次數
let db_clone = app_state.db.clone();
let post_id = post.id;
tokio::spawn(async move {
let _ = PostService::increment_view_count(&db_clone, post_id).await;
});
// 將結果存入快取
app_state.post_cache.cache_post_detail(slug.clone(), post.clone()).await;
debug!("💾 文章詳情已快取:{}", slug);
Ok(Json(post))
}
/// 建立文章(管理員 API)
pub async fn create_post(
State(app_state): State<AppState>,
Json(req): Json<CreatePostRequest>,
) -> Result<(StatusCode, Json<crate::dtos::PostResponse>), AppError> {
req.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
let post = PostService::create_post(&app_state.db, req).await?;
// 新增文章後清理相關快取
app_state.post_cache.invalidate_all().await;
app_state.tag_cache.invalidate_all().await;
info!("📝 文章建立成功,已清理相關快取");
Ok((StatusCode::CREATED, Json(post)))
}
/// 更新文章(管理員 API)
pub async fn update_post(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Json(req): Json<UpdatePostRequest>,
) -> Result<Json<PostDetailResponse>, AppError> {
req.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
// 先獲取舊文章資訊以便清理快取
let old_post = PostService::get_post_for_admin(&app_state.db, id).await?;
let updated_post = PostService::update_post(&app_state.db, id, req).await?;
// 清理相關快取
app_state.post_cache.invalidate_post(&old_post.slug).await;
if old_post.slug != updated_post.slug {
app_state.post_cache.invalidate_post(&updated_post.slug).await;
}
app_state.tag_cache.invalidate_all().await;
info!("✏️ 文章更新成功,已清理相關快取");
Ok(Json(updated_post))
}
/// 刪除文章(管理員 API)
pub async fn delete_post(
State(app_state): State<AppState>,
Path(id): Path<i32>,
) -> Result<Json<crate::dtos::DeletePostResponse>, AppError> {
// 先獲取文章資訊以便清理快取
let post = PostService::get_post_for_admin(&app_state.db, id).await?;
let result = PostService::delete_post(&app_state.db, id).await?;
// 清理相關快取
app_state.post_cache.invalidate_post(&post.slug).await;
app_state.tag_cache.invalidate_all().await;
info!("🗑️ 文章刪除成功,已清理相關快取");
Ok(Json(result))
}
// 假設在 src/routes/tags.rs 中
use axum::{extract::State, Json};
use tracing::debug;
use crate::{
dtos::TagResponse,
error::AppError,
services::TagService,
state::AppState,
};
/// 取得標籤列表(帶快取)
pub async fn get_tags(
State(app_state): State<AppState>,
) -> Result<Json<Vec<TagResponse>>, AppError> {
// 先嘗試從快取取得
if let Some(cached_tags) = app_state.tag_cache.get_tags().await {
debug!("✅ 標籤列表快取命中");
return Ok(Json(cached_tags));
}
// 快取未命中,從資料庫查詢
debug!("❌ 標籤列表快取未命中,查詢資料庫");
let tags = TagService::get_all_tags(&app_state.db).await?;
// 將結果存入快取
app_state.tag_cache.cache_tags(tags.clone()).await;
debug!("💾 標籤列表已快取");
Ok(Json(tags))
}
# 啟動服務
cargo run
# 預熱快取
curl -X POST http://localhost:3000/api/admin/cache/warmup
# 測試文章列表(應該從快取回傳)
time curl http://localhost:3000/api/posts
# 再次請求(應該更快)
time curl http://localhost:3000/api/posts
# 查看快取統計
curl http://localhost:3000/api/admin/cache/stats | jq
你應該會看到:
# 使用 ab 進行簡單的壓力測試
ab -n 1000 -c 10 http://localhost:3000/api/posts
# 查看快取統計,應該看到很高的命中率
curl http://localhost:3000/api/admin/cache/stats
今天我們成功為個人部落格加上了企業級的快取系統:
moka 快取優勢:
完整快取架構:
效能提升顯著:
開發與維護友善:
個人部落格最佳化:
明天我們將進入 Day 29:安全防護 - 保護你的創作平台!
我們會為個人部落格加入必要的安全機制:
我們會學習如何在保持個人部落格簡潔性的同時,加入足夠的安全防護!
今天我們使用 moka 為個人部落格建立了企業級的快取系統!從基本的記憶體快取到進階的效能監控,從安全防護到最佳實務,我們建立了一個完整而強大的快取架構。
moka 的高效能和豐富功能讓我們能夠以很少的代碼獲得顯著的效能提升。這就是選擇優秀開源套件的價值——站在巨人的肩膀上,專注於業務邏輯而不是重複造輪子!
通過今天的實作,你的部落格不僅功能完整,還具備了優秀的效能表現。這樣的架構足以支撐中等規模的流量,為你的創作之路提供堅實的技術基礎!
我們明天見!