嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十二天!
終於來到實戰週了!經過前三天的規劃與準備,今天我們要正式開始建立個人部落格的「骨架」—— 資料模型與資料庫整合。這是整個系統的基礎,就像蓋房子要先打地基一樣重要!
今天我們會使用 SeaORM,這是一個現代化的 Rust ORM 框架,讓我們能用型別安全的方式操作資料庫。記住,我們設計的是個人部落格,所以會保持簡潔但功能完整的設計理念!
首先更新我們的 Cargo.toml
,加入資料庫相關的依賴:
[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","chrono"] }
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"
sea-orm = { version = "1.1.4", features = [
"sqlx-postgres", # PostgreSQL 支援
"runtime-tokio-rustls", # 非同步運行時
"macros", # 巨集支援
"with-chrono", # 時間型別整合
"with-uuid", # UUID 支援
] }
sea-orm-migration = "1.1.4"
在開始寫程式碼之前,讓我們先理解 SeaORM 的三個核心概念:
定義資料表的結構與關聯,包含欄位定義、主鍵、索引等。
純資料結構,代表從資料庫查詢回來的一行資料,是不可變的。
可變的資料結構,用於插入、更新資料庫。支援部分更新和驗證。
這個設計讓我們能區分「讀取」和「寫入」的資料結構,增加型別安全性!
更新 .env
檔案:
# 伺服器設定
HOST=127.0.0.1
PORT=3000
PROTOCOL=http
# 部落格資訊
BLOG_NAME=我的個人技術部落格
BLOG_DESCRIPTION=分享程式設計學習心得與生活感悟
BLOG_AUTHOR=你的名字
# 🆕 資料庫設定
DATABASE_URL=postgres://blog_user:blog_password@localhost:5432/personal_blog
DATABASE_MAX_CONNECTIONS=10
# 日誌設定
RUST_LOG=info
CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000
建立 docker-compose.dev.yml
:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: personal_blog
POSTGRES_USER: blog_user
POSTGRES_PASSWORD: blog_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- type: bind
source: /mnt/c/Users/Jake/Workspace/blog/blog-backend/initdb
target: /docker-entrypoint-initdb.d
command: >
postgres
-c log_statement=all
-c log_min_duration_statement=0
-c shared_preload_libraries=pg_stat_statements
volumes:
postgres_data:
建立 init.sql
:
-- 初始化個人部落格資料庫
-- 建立必要的擴展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 設定時區(根據你的需求調整)
SET timezone = 'Asia/Taipei';
啟動開發環境:
docker-compose -f docker-compose.dev.yml up -d
更新 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>,
// 🆕 資料庫設定
pub database_url: String,
pub database_max_connections: u32,
}
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(|_| "部落格作者".into());
let cors_origins = env::var("CORS_ORIGIN")
.unwrap_or_else(|_| "*".into())
.split(',')
.map(|s| s.trim().to_string())
.collect();
// 🆕 資料庫設定
let database_url = env::var("DATABASE_URL")
.context("請設定 DATABASE_URL 環境變數")?;
let database_max_connections = env::var("DATABASE_MAX_CONNECTIONS")
.unwrap_or_else(|_| "10".into())
.parse::<u32>()
.context("DATABASE_MAX_CONNECTIONS 必須是整數")?;
Ok(Self {
host,
port,
protocol,
blog_name,
blog_description,
blog_author,
cors_origins,
database_url,
database_max_connections,
})
}
pub fn server_url(&self) -> String {
format!("{}://{}:{}", self.protocol, self.host, self.port)
}
// 為了安全,在日誌中隱藏敏感資訊
pub fn sanitized_for_log(&self) -> Self {
let mut config = self.clone();
config.database_url = "postgres://***:***@***/***".to_string();
config
}
}
建立 src/database.rs
:
use sea_orm::{Database, DatabaseConnection, DbErr};
use tracing::info;
use crate::config::Config;
pub async fn establish_connection(config: &Config) -> Result<DatabaseConnection, DbErr> {
info!("正在連接到資料庫...");
let mut opt = sea_orm::ConnectOptions::new(&config.database_url);
opt.max_connections(config.database_max_connections)
.min_connections(1)
.connect_timeout(std::time::Duration::from_secs(8))
.acquire_timeout(std::time::Duration::from_secs(8))
.idle_timeout(std::time::Duration::from_secs(8))
.max_lifetime(std::time::Duration::from_secs(8))
.sqlx_logging(true) // 在開發期間顯示 SQL 查詢
.sqlx_logging_level(log::LevelFilter::Info);
let db = Database::connect(opt).await?;
info!("資料庫連線成功!");
Ok(db)
}
// 健康檢查:測試資料庫連線
pub async fn health_check(db: &DatabaseConnection) -> Result<(), DbErr> {
use sea_orm::Statement;
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
"SELECT 1".to_string(),
))
.await?;
Ok(())
}
現在來設計我們的核心實體!記住,我們要的是「簡單而不簡陋」的設計。
建立 src/entities/post.rs
:
use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, ToSchema)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(skip_deserializing)]
#[schema(example = 1)]
pub id: i32,
#[sea_orm(column_type = "String(StringLen::N(255))")]
#[schema(example = "我的第一篇 Rust 文章")]
pub title: String,
#[sea_orm(column_type = "Text")]
#[schema(example = "今天開始學習 Rust...")]
pub content: String,
#[sea_orm(column_type = "String(StringLen::N(500))", nullable)]
#[schema(example = "這篇文章分享我學習 Rust 的心得...")]
pub excerpt: Option<String>,
#[sea_orm(column_type = "String(StringLen::N(255))", unique)]
#[schema(example = "my-first-rust-article")]
pub slug: String,
#[sea_orm(default_value = "false")]
#[schema(example = true)]
pub is_published: bool,
#[sea_orm(default_value = "0")]
#[schema(example = 42)]
pub view_count: i32,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub created_at: DateTimeUtc,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub updated_at: DateTimeUtc,
#[sea_orm(nullable)]
#[schema(value_type = Option<String>, example = "2024-01-15T10:30:00Z")]
pub published_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
// 一篇文章有多個留言
#[sea_orm(has_many = "super::comment::Entity")]
Comments,
// 一篇文章經由 pivot post_tag 連到多個 tag
#[sea_orm(has_many = "super::post_tag::Entity")]
PostTags,
}
// 經由 pivot 取得 tags
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
super::tag::Relation::PostTags.def()
}
fn via() -> Option<RelationDef> {
Some(Relation::PostTags.def().rev())
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
use sea_orm::ActiveValue::{NotSet, Set};
let now = chrono::Utc::now();
Self {
id: NotSet,
title: NotSet,
content: NotSet,
excerpt: NotSet,
slug: NotSet,
is_published: Set(false),
view_count: Set(0),
// 由這裡幫你預設時間
created_at: Set(now),
updated_at: Set(now),
published_at: NotSet,
}
}
async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
use sea_orm::ActiveValue::{NotSet, Set};
let now = chrono::Utc::now();
if insert {
if !self.created_at.is_set() {
self.created_at = Set(now);
}
if !self.is_published.is_set() {
self.is_published = Set(false);
}
if !self.view_count.is_set() {
self.view_count = Set(0);
}
}
self.updated_at = Set(now);
let published = matches!(self.is_published, Set(true));
let published_at_empty = matches!(self.published_at, NotSet | Set(None));
if published && published_at_empty {
self.published_at = Set(Some(now));
}
if matches!(self.is_published, Set(false)) {
self.published_at = Set(None);
}
Ok(self)
}
}
建立 src/entities/tag.rs
:
use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, ToSchema)]
#[sea_orm(table_name = "tags")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(skip_deserializing)]
#[schema(example = 1)]
pub id: i32,
#[sea_orm(column_type = "String(StringLen::N(100))", unique)]
#[schema(example = "rust")]
pub name: String,
#[sea_orm(column_type = "String(StringLen::N(255))", nullable)]
#[schema(example = "Rust 程式語言相關文章")]
pub description: Option<String>,
#[sea_orm(column_type = "String(StringLen::N(7))", default_value = "#6B7280")]
#[schema(example = "#8B5CF6")]
pub color: String,
#[sea_orm(default_value = "0")]
#[schema(example = 5)]
pub post_count: i32,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub created_at: DateTimeUtc,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::post_tag::Entity")]
PostTags,
}
impl Related<super::post::Entity> for Entity {
fn to() -> RelationDef {
super::post::Relation::PostTags.def()
}
fn via() -> Option<RelationDef> {
Some(Relation::PostTags.def().rev())
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
use sea_orm::ActiveValue::{NotSet, Set};
let now = chrono::Utc::now();
Self {
id: NotSet,
name: NotSet,
description: NotSet,
color: Set("#6B7280".to_string()),
post_count: Set(0),
created_at: Set(now),
updated_at: Set(now),
}
}
async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
use sea_orm::ActiveValue::{Set};
let now = chrono::Utc::now();
if insert {
if !self.color.is_set() {
self.color = Set("#6B7280".to_string());
}
if !self.post_count.is_set() {
self.post_count = Set(0);
}
}
self.updated_at = Set(now);
// color 基本校驗
if let Set(ref mut c) = self.color {
if c.len() != 7 || !c.starts_with('#') {
*c = "#6B7280".to_string();
}
}
Ok(self)
}
}
建立 src/entities/post_tag.rs
:
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)]
#[sea_orm(table_name = "post_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub post_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: i32,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::post::Entity",
from = "Column::PostId",
to = "super::post::Column::Id"
)]
Post,
#[sea_orm(
belongs_to = "super::tag::Entity",
from = "Column::TagId",
to = "super::tag::Column::Id"
)]
Tag,
}
impl Related<super::post::Entity> for Entity {
fn to() -> RelationDef {
Relation::Post.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
created_at: Set(chrono::Utc::now()),
..ActiveModelTrait::default()
}
}
}
建立 src/entities/comment.rs
:
use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize, ToSchema)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "comment_status")]
pub enum CommentStatus {
#[sea_orm(string_value = "pending")]
Pending,
#[sea_orm(string_value = "approved")]
Approved,
#[sea_orm(string_value = "rejected")]
Rejected,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, ToSchema)]
#[sea_orm(table_name = "comments")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(skip_deserializing)]
#[schema(example = 1)]
pub id: i32,
#[schema(example = 1)]
pub post_id: i32,
#[sea_orm(column_type = "String(StringLen::N(100))")]
#[schema(example = "讀者小明")]
pub author_name: String,
#[sea_orm(column_type = "String(StringLen::N(255))")]
#[schema(example = "reader@example.com")]
pub author_email: String,
#[sea_orm(column_type = "String(StringLen::N(255))", nullable)]
#[schema(example = "https://example.com")]
pub author_website: Option<String>,
#[sea_orm(column_type = "Text")]
#[schema(example = "很棒的文章!學到很多東西。")]
pub content: String,
pub status: CommentStatus,
#[sea_orm(column_type = "String(StringLen::N(45))", nullable)]
pub ip_address: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub user_agent: Option<String>,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub created_at: DateTimeUtc,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub updated_at: DateTimeUtc,
#[sea_orm(nullable)]
#[schema(value_type = Option<String>, example = "2024-01-15T10:30:00Z")]
pub approved_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::post::Entity",
from = "Column::PostId",
to = "super::post::Column::Id"
)]
Post,
}
impl Related<super::post::Entity> for Entity {
fn to() -> RelationDef {
Relation::Post.def()
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
use sea_orm::ActiveValue::{NotSet, Set};
let now = chrono::Utc::now();
Self {
id: NotSet,
post_id: NotSet,
author_name: NotSet,
author_email: NotSet,
author_website: NotSet,
content: NotSet,
status: Set(CommentStatus::Pending),
ip_address: NotSet,
user_agent: NotSet,
created_at: Set(now),
updated_at: Set(now),
approved_at: NotSet,
}
}
async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
use sea_orm::ActiveValue::{NotSet, Set};
let now = chrono::Utc::now();
if insert {
if !self.created_at.is_set() {
self.created_at = Set(now);
}
if !self.status.is_set() {
self.status = Set(CommentStatus::Pending);
}
}
// 每次存檔都更新 updated_at
self.updated_at = Set(now);
// 若狀態為 approved 且 approved_at 還沒設,補上時間
let is_approved = matches!(self.status, Set(CommentStatus::Approved));
let approved_at_empty = matches!(self.approved_at, NotSet | Set(None));
if is_approved && approved_at_empty {
self.approved_at = Set(Some(now));
}
Ok(self)
}
}
建立 src/entities/mod.rs
:
pub mod post;
pub mod tag;
pub mod post_tag;
pub mod comment;
pub use post::{Entity as Post, Model as PostModel, ActiveModel as PostActiveModel};
pub use tag::{Entity as Tag, Model as TagModel, ActiveModel as TagActiveModel};
pub use post_tag::{Entity as PostTag, Model as PostTagModel, ActiveModel as PostTagActiveModel};
pub use comment::{Entity as Comment, Model as CommentModel, ActiveModel as CommentActiveModel, CommentStatus};
pub mod queries {
use sea_orm::*;
use super::*;
/// 查詢已發布的文章
pub fn published_posts() -> Select<post::Entity> {
post::Entity::find()
.filter(post::Column::IsPublished.eq(true))
.order_by_desc(post::Column::PublishedAt)
}
/// 查詢已審核的留言
pub fn approved_comments_for_post(post_id: i32) -> Select<comment::Entity> {
comment::Entity::find()
.filter(comment::Column::PostId.eq(post_id))
.filter(comment::Column::Status.eq(CommentStatus::Approved))
.order_by_asc(comment::Column::CreatedAt)
}
/// 查詢標籤及其文章數
pub fn tags_with_post_count() -> Select<tag::Entity> {
tag::Entity::find()
.filter(tag::Column::PostCount.gt(0))
.order_by_desc(tag::Column::PostCount)
}
}
# 在專案根目錄執行
cargo install sea-orm-cli
# 初始化遷移
sea-orm-cli migrate init
這會建立 migration
目錄和相關檔案。
建立 migration/src/m20241122_000001_create_tables.rs
:
use sea_orm_migration::prelude::*;
use crate::extension::postgres::Type;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// posts
manager
.create_table(
Table::create()
.table(Posts::Table)
.if_not_exists()
.col(
ColumnDef::new(Posts::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Posts::Title).string_len(255).not_null())
.col(ColumnDef::new(Posts::Content).text().not_null())
.col(ColumnDef::new(Posts::Excerpt).string_len(500))
.col(
ColumnDef::new(Posts::Slug)
.string_len(255)
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(Posts::IsPublished)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(Posts::ViewCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(Posts::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Posts::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(Posts::PublishedAt).timestamp_with_time_zone())
.to_owned(),
)
.await?;
// tags
manager
.create_table(
Table::create()
.table(Tags::Table)
.if_not_exists()
.col(
ColumnDef::new(Tags::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(Tags::Name)
.string_len(100)
.not_null()
.unique_key(),
)
.col(ColumnDef::new(Tags::Description).string_len(255))
.col(
ColumnDef::new(Tags::Color)
.string_len(7)
.not_null()
.default("#6B7280"),
)
.col(
ColumnDef::new(Tags::PostCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(Tags::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Tags::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
// post_tags
manager
.create_table(
Table::create()
.table(PostTags::Table)
.if_not_exists()
.col(ColumnDef::new(PostTags::PostId).integer().not_null())
.col(ColumnDef::new(PostTags::TagId).integer().not_null())
.col(
ColumnDef::new(PostTags::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.primary_key(Index::create().col(PostTags::PostId).col(PostTags::TagId))
.foreign_key(
ForeignKey::create()
.name("fk_post_tags_post_id")
.from(PostTags::Table, PostTags::PostId)
.to(Posts::Table, Posts::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_post_tags_tag_id")
.from(PostTags::Table, PostTags::TagId)
.to(Tags::Table, Tags::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// enum comment_status
manager
.create_type(
Type::create()
.as_enum(CommentStatus::Enum)
.values([
CommentStatus::Pending,
CommentStatus::Approved,
CommentStatus::Rejected,
])
.to_owned(),
)
.await?;
// comments
manager
.create_table(
Table::create()
.table(Comments::Table)
.if_not_exists()
.col(
ColumnDef::new(Comments::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Comments::PostId).integer().not_null())
.col(ColumnDef::new(Comments::AuthorName).string_len(100).not_null())
.col(ColumnDef::new(Comments::AuthorEmail).string_len(255).not_null())
.col(ColumnDef::new(Comments::AuthorWebsite).string_len(255))
.col(ColumnDef::new(Comments::Content).text().not_null())
.col(
ColumnDef::new(Comments::Status)
.enumeration(
CommentStatus::Enum,
[
CommentStatus::Pending,
CommentStatus::Approved,
CommentStatus::Rejected,
],
)
.not_null()
// 明確 cast 成 PostgreSQL enum
.default(Expr::cust("'pending'::comment_status")),
)
.col(ColumnDef::new(Comments::IpAddress).string_len(45))
.col(ColumnDef::new(Comments::UserAgent).text())
.col(
ColumnDef::new(Comments::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Comments::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(Comments::ApprovedAt).timestamp_with_time_zone())
.foreign_key(
ForeignKey::create()
.name("fk_comments_post_id")
.from(Comments::Table, Comments::PostId)
.to(Posts::Table, Posts::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// indexes
manager
.create_index(
Index::create()
.name("idx_posts_published")
.table(Posts::Table)
.col(Posts::IsPublished)
.col(Posts::PublishedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_posts_slug")
.table(Posts::Table)
.col(Posts::Slug)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_comments_post_status")
.table(Comments::Table)
.col(Comments::PostId)
.col(Comments::Status)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// drop indexes
manager
.drop_index(Index::drop().name("idx_comments_post_status").to_owned())
.await?;
manager
.drop_index(Index::drop().name("idx_posts_slug").to_owned())
.await?;
manager
.drop_index(Index::drop().name("idx_posts_published").to_owned())
.await?;
// drop tables (FK 依賴順序)
manager
.drop_table(Table::drop().table(Comments::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(PostTags::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Tags::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Posts::Table).to_owned())
.await?;
// drop enum type
manager
.drop_type(Type::drop().name(CommentStatus::Enum).to_owned())
.await?;
Ok(())
}
}
// ===== Idens =====
#[derive(Iden)]
enum Posts {
Table,
Id,
Title,
Content,
Excerpt,
Slug,
IsPublished,
ViewCount,
CreatedAt,
UpdatedAt,
PublishedAt,
}
#[derive(Iden)]
enum Tags {
Table,
Id,
Name,
Description,
Color,
PostCount,
CreatedAt,
UpdatedAt,
}
#[derive(Iden)]
enum PostTags {
Table,
PostId,
TagId,
CreatedAt,
}
#[derive(Iden)]
enum Comments {
Table,
Id,
PostId,
AuthorName,
AuthorEmail,
AuthorWebsite,
Content,
Status,
IpAddress,
UserAgent,
CreatedAt,
UpdatedAt,
ApprovedAt,
}
#[derive(Iden)]
enum CommentStatus {
#[iden = "comment_status"] // PostgreSQL enum type name
Enum,
#[iden = "pending"]
Pending,
#[iden = "approved"]
Approved,
#[iden = "rejected"]
Rejected,
}
更新 migration/src/lib.rs
:
pub use sea_orm_migration::prelude::*;
mod m20241122_000001_create_tables;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20241122_000001_create_tables::Migration),
]
}
}
在專案根目錄執行:
# 設定環境變數
export DATABASE_URL="postgres://blog_user:blog_password@localhost:5432/personal_blog"
# 執行遷移
sea-orm-cli migrate up
# 檢查遷移狀態
sea-orm-cli migrate status
建立 src/state.rs
:
use sea_orm::DatabaseConnection;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
pub config: Config,
}
impl AppState {
pub fn new(db: DatabaseConnection, config: Config) -> Self {
Self { db, config }
}
}
更新 src/main.rs
:
mod app;
mod config;
mod database;
mod docs;
mod entities;
mod error;
mod middleware;
mod routes;
mod startup;
mod state;
use anyhow::Result;
use config::Config;
use database::establish_connection;
use state::AppState;
use tracing::info;
#[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!("資料庫連線建立完成");
// 🆕 執行遷移(開發期間自動執行)
#[cfg(debug_assertions)]
{
use sea_orm_migration::MigratorTrait;
migration::Migrator::up(&db, None).await?;
info!("資料庫遷移完成");
}
// 🆕 建立應用程式狀態
let app_state = AppState::new(db, config.clone());
// 啟動服務
startup::run(app_state).await?;
Ok(())
}
更新 src/routes/mod.rs
:
use axum::{routing::get, Router};
use crate::state::AppState;
pub mod blog;
pub mod health;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(blog::blog_info))
.route("/health", get(health::health_check))
}
更新 src/startup.rs
:
use axum::Router;
use tokio::net::TcpListener;
use tracing::info;
use crate::{app::build_app, state::AppState};
pub async fn run(app_state: AppState) -> anyhow::Result<()> {
let addr = format!("{}:{}", app_state.config.host, app_state.config.port);
let server_url = app_state.config.server_url();
info!("設定載入完成: {:?}", app_state.config.sanitized_for_log());
let listener = TcpListener::bind(&addr).await?;
let app: Router = build_app(app_state);
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() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
info!("🛑 個人部落格服務正在關閉...");
}
更新 src/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::{docs::ApiDoc, middleware::request_logging, routes, state::AppState};
pub fn build_app(app_state: AppState) -> Router {
let cors = if app_state.config.cors_origins.iter().any(|o| o == "*") {
// 開發期萬用
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
} else {
// 嚴格白名單
let origins = app_state
.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(app_state);
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/routes/blog.rs
:
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::state::AppState;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BlogInfo {
#[schema(example = "我的個人技術部落格")]
pub name: String,
#[schema(example = "分享程式設計學習心得與生活感悟")]
pub description: String,
#[schema(example = "你的名字")]
pub author: String,
#[schema(example = "0.1.0")]
pub version: String,
#[schema(example = "2024-01-15T10:30:00Z")]
pub timestamp: String,
}
#[utoipa::path(
get,
path = "/",
tag = "blog",
responses(
(status = 200, description = "部落格基本資訊", body = BlogInfo)
)
)]
pub async fn blog_info(State(app_state): State<AppState>) -> Json<BlogInfo> {
Json(BlogInfo {
name: app_state.config.blog_name.clone(),
description: app_state.config.blog_description.clone(),
author: app_state.config.blog_author.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
})
}
更新 src/routes/health.rs
:
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{database, state::AppState};
#[derive(Serialize, Deserialize, ToSchema)]
pub struct HealthCheck {
#[schema(example = "healthy")]
pub status: String,
#[schema(example = "2024-01-15T10:30:00Z")]
pub timestamp: String,
#[schema(example = "0.1.0")]
pub version: String,
// 🆕 資料庫狀態
#[schema(example = "connected")]
pub database: String,
}
#[utoipa::path(
get,
path = "/health",
tag = "health",
responses(
(status = 200, description = "服務健康狀態", body = HealthCheck),
(status = 503, description = "服務不可用")
)
)]
pub async fn health_check(State(app_state): State<AppState>) -> Json<HealthCheck> {
// 🆕 檢查資料庫連線
let database_status = match database::health_check(&app_state.db).await {
Ok(_) => "connected".to_string(),
Err(_) => "disconnected".to_string(),
};
Json(HealthCheck {
status: if database_status == "connected" {
"healthy".to_string()
} else {
"unhealthy".to_string()
},
timestamp: chrono::Utc::now().to_rfc3339(),
version: env!("CARGO_PKG_VERSION").to_string(),
database: database_status,
})
}
今天我們完成了個人部落格的資料基礎建設:
SeaORM 掌握:
個人化設計:
開發基礎:
型別安全:
明天我們將進入 Day 23:文章系統 - 創作者的核心功能!
我們會實作個人部落格最重要的功能:
POST /api/admin/posts
)GET /api/posts
)我們會看到 SeaORM 如何讓資料庫操作變得既安全又優雅!
今天我們打好了強大的資料基礎,明天開始就要讓這些資料「活」起來了!讓我們用 Rust 的型別安全優勢,建立一個既穩定又好用的個人創作平台!
我們明天見!