iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Rust

大家一起跟Rust當好朋友吧!系列 第 21

Day 21: 增加基礎設施 - 準備實戰開發

  • 分享至 

  • xImage
  •  

Day 21: 增加基礎設施 - 準備實戰開發

今天我們要為第二十天的小專案增加必要的基礎設施,並把程式碼 模組化與分層,讓 main.rs 更乾淨,為明天 SQL/SeaORM 實戰鋪路。

🎯 今天的目標

  1. 環境設定管理(.env)
  2. 基本錯誤處理(統一格式)
  3. 日誌系統tracing
  4. CORS 設定(前端整合)
  5. 基本中介層(請求追蹤)
  6. 程式架構分層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"

🔧 建立基礎設施

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 已經超清爽~🧼


🏗️ 為明天做準備

已完成 ✅

  • 設定管理、錯誤處理、日誌、CORS、中介層、分層架構

明天將加入 🚀

  • PostgreSQL、SeaORM、資料庫遷移、第一個 CRUD API

那麼我們明天見!!!


上一篇
Day 20: 選擇你的 Web 框架:Actix Web vs. Axum
下一篇
Day 22: 個人部落格的資料模型與 SeaORM 整合
系列文
大家一起跟Rust當好朋友吧!22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言