今天我們要為第二十天的小專案增加必要的基礎設施,並把程式碼 模組化與分層,讓 main.rs
更乾淨,為明天 SQL/SeaORM 實戰鋪路。
tracing
)main.rs
變瘦)Cargo.toml
)[package]
name = "blog"
version = "0.1.0"
edition = "2024"
[dependencies]
# Web 框架 - 個人專案的最佳選擇
axum = { version = "0.8.4", features = ["macros"] }
tower = "0.5.2"
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
# 非同步運行時
tokio = { version = "1.0", features = ["full"] }
# JSON 處理 - 個人部落格必備
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
# OpenAPI / Swagger UI
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
# 日期時間 - 文章發布時間
chrono = { version = "0.4", features = ["serde"] }
# 簡單的日誌
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.2", features = ["env-filter"] }
# 錯誤處理 - 讓個人維護更輕鬆
thiserror = "2.0.16"
anyhow = "1.0.99"
http = "1.3.1"
.env
HOST=127.0.0.1
PORT=3000
PROTOCOL=http
BLOG_NAME=我的個人技術部落格
BLOG_DESCRIPTION=分享程式設計學習心得與生活感悟
BLOG_AUTHOR=你的名字
RUST_LOG=info
CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000
src/
├── main.rs
├── app.rs
├── docs.rs
├── middleware.rs
├── routes/
│ ├── mod.rs
│ ├── blog.rs
│ └── health.rs
├── startup.rs
├── config.rs
└── error.rs
src/config.rs
use std::env;
use anyhow::{Context, Result};
#[derive(Debug, Clone)]
pub struct Config {
pub host: String,
pub port: u16,
pub protocol: String,
pub blog_name: String,
pub blog_description: String,
pub blog_author: String,
pub cors_origins: Vec<String>,
}
impl Config {
pub fn from_env() -> Result<Self> {
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".into());
let port = env::var("PORT")
.unwrap_or_else(|_| "3000".into())
.parse::<u16>()
.context("PORT 必須是整數(u16)")?;
let protocol = env::var("PROTOCOL").unwrap_or_else(|_| "http".into());
let blog_name = env::var("BLOG_NAME").unwrap_or_else(|_| "我的個人技術部落格".into());
let blog_description = env::var("BLOG_DESCRIPTION")
.unwrap_or_else(|_| "分享程式設計學習心得與生活感悟".into());
let blog_author = env::var("BLOG_AUTHOR").unwrap_or_else(|_| "Blog Author".into());
let cors_origins = env::var("CORS_ORIGIN")
.unwrap_or_else(|_| "*".to_string())
.split(',')
.map(|s| s.trim().to_string())
.collect();
Ok(Self {
host,
port,
protocol,
blog_name,
blog_description,
blog_author,
cors_origins,
})
}
pub fn server_url(&self) -> String {
format!("{}://{}:{}", self.protocol, self.host, self.port)
}
pub fn sanitized_for_log(&self) -> Self {
self.clone()
}
}
src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("內部伺服器錯誤")]
InternalServerError,
#[error("找不到資源: {0}")]
NotFound(String),
#[error("請求無效: {0}")]
BadRequest(String),
#[error("未授權")]
Unauthorized,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AppError::InternalServerError => {
tracing::error!("內部伺服器錯誤");
(StatusCode::INTERNAL_SERVER_ERROR, "內部伺服器錯誤")
}
AppError::NotFound(msg) => {
tracing::warn!("資源未找到: {}", msg);
(StatusCode::NOT_FOUND, "找不到資源")
}
AppError::BadRequest(msg) => {
tracing::warn!("請求無效: {}", msg);
(StatusCode::BAD_REQUEST, "請求無效")
}
AppError::Unauthorized => {
tracing::warn!("未授權的存取嘗試");
(StatusCode::UNAUTHORIZED, "未授權")
}
};
let body = Json(json!({
"error": error_message,
"status": status.as_u16(),
"timestamp": chrono::Utc::now().to_rfc3339(),
}));
(status, body).into_response()
}
}
src/middleware.rs
use axum::{extract::Request, middleware::Next, response::Response};
use tracing::info;
pub async fn request_logging(request: Request, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let start = std::time::Instant::now();
let response = next.run(request).await;
let elapsed = start.elapsed();
let status = response.status();
info!(
method = %method,
uri = %uri,
status = %status,
elapsed = ?elapsed,
"請求處理完成"
);
response
}
src/routes/mod.rs
use axum::{routing::get, Router};
use crate::config::Config;
pub mod blog;
pub mod health;
pub fn router() -> Router<Config> {
Router::new()
.route("/", get(blog::blog_info))
.route("/health", get(health::health_check))
}
src/routes/blog.rs
use axum::{extract::State, Json};
use serde::Serialize;
use tracing::{info, instrument};
use utoipa::ToSchema;
use crate::{config::Config, error::AppError};
#[derive(Serialize, ToSchema)]
pub struct BlogInfo {
name: String,
description: String,
author: String,
status: String,
timestamp: String,
}
#[utoipa::path(
get,
path = "/",
tag = "blog",
responses(
(status = 200, description = "取得部落格資訊", body = BlogInfo)
)
)]
#[instrument]
pub async fn blog_info(
State(config): State<Config>,
) -> Result<Json<BlogInfo>, AppError> {
info!("回傳部落格資訊");
Ok(Json(BlogInfo {
name: config.blog_name.clone(),
description: config.blog_description.clone(),
author: config.blog_author.clone(),
status: "running".into(),
timestamp: chrono::Utc::now().to_rfc3339(),
}))
}
小提醒:Axum 0.8 的
State<T>
會 複製 你給的狀態(需要Clone
)。這裡我們直接用Config
(已Clone
),不特別包Arc
,小專案夠用。若未來狀態很大再換Arc<Config>
。
src/routes/health.rs
use axum::Json;
use serde::Serialize;
use tracing::{info, instrument};
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct HealthCheck {
status: String,
timestamp: String,
version: String,
}
#[utoipa::path(
get,
path = "/health",
tag = "health",
responses(
(status = 200, description = "健康檢查", body = HealthCheck)
)
)]
#[instrument]
pub async fn health_check() -> Json<HealthCheck> {
info!("執行健康檢查");
Json(HealthCheck {
status: "healthy".into(),
timestamp: chrono::Utc::now().to_rfc3339(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
src/docs.rs
use utoipa::OpenApi;
use crate::routes::{blog::BlogInfo, health::HealthCheck};
#[derive(OpenApi)]
#[openapi(
paths(
crate::routes::blog::blog_info,
crate::routes::health::health_check,
),
components(
schemas(BlogInfo, HealthCheck)
),
tags(
(name = "blog", description = "部落格資訊相關 API"),
(name = "health", description = "服務健康檢查")
),
info(
title = "個人部落格 API",
version = "0.1.0",
description = "個人部落格後端 API 服務"
)
)]
pub struct ApiDoc;
src/app.rs
// app.rs
use axum::{middleware, Router};
use http::HeaderValue;
use tower_http::cors::{Any, AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::{config::Config, docs::ApiDoc, middleware::request_logging, routes};
pub fn build_app(config: Config) -> Router {
let cors = if config.cors_origins.iter().any(|o| o == "*") {
// 開發期萬用
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
} else {
// 嚴格白名單
let origins = config
.cors_origins
.iter()
.filter_map(|o| HeaderValue::from_str(o).ok());
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods(Any)
.allow_headers(Any)
};
let api = routes::router().with_state(config);
Router::new()
.merge(api)
.merge(
SwaggerUi::new("/docs")
.url("/api-docs/openapi.json", ApiDoc::openapi()),
)
.layer(middleware::from_fn(request_logging))
.layer(TraceLayer::new_for_http())
.layer(cors)
}
src/startup.rs
use axum::Router;
use tokio::net::TcpListener;
use tracing::info;
use crate::{app::build_app, config::Config};
pub async fn run(config: Config) -> anyhow::Result<()> {
let addr = format!("{}:{}", config.host, config.port);
let server_url = config.server_url();
info!("設定載入完成: {:?}", config.sanitized_for_log());
let listener = TcpListener::bind(&addr).await?;
let app: Router = build_app(config);
info!("🚀 個人部落格服務啟動於 {}", server_url);
info!("📚 API 文件:{}/docs", server_url);
info!("🔍 健康檢查:{}/health", server_url);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut sigint = signal(SignalKind::interrupt()).expect("sigint handler");
let mut sigterm = signal(SignalKind::terminate()).expect("sigterm handler");
tokio::select! { _ = sigint.recv() => {}, _ = sigterm.recv() => {}, }
}
#[cfg(not(unix))]
{ let _ = tokio::signal::ctrl_c().await; }
println!("🛑 收到關閉信號,正在優雅關閉服務…");
}
src/main.rs
mod config;
mod error;
mod app;
mod docs;
mod middleware;
mod routes;
mod startup;
use config::Config;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let config = Config::from_env()?;
startup::run(config).await
}
cargo run
# 基本功能
curl http://127.0.0.1:3000/
curl http://127.0.0.1:3000/health
# 錯誤處理(不存在路由)
curl http://127.0.0.1:3000/nonexistent
# Swagger
# 瀏覽器開啟:http://127.0.0.1:3000/docs
你會看到:
✅ 統一 JSON 回應、✅ 乾淨的日誌、✅ 自動化 API 文件。
而且 main.rs
已經超清爽~🧼
那麼我們明天見!!!