域名 AXUM.RS 将于2025年10月到期。我们无意再对其进行续费,我们希望你能够接续这个域名,让更多 AXUM 开发者继续受益。
  • 方案1️⃣AXUM.RS 域名 = 3000
  • 方案2️⃣方案1️⃣ + 本站所有专题原始 Markdown 文档 = 5000
  • 方案3️⃣方案2️⃣ + 本站原始数据库 = 5500
如果你有意接续这份 AXUM 情怀,请与我们取得联系。
说明:
  1. 如果有人购买 AXUM.RS 域名(方案1️⃣),或者该域名到期,本站将启用新的免费域名继续提供服务。
  2. 如果有人购买了 AXUM.RS 域名,且同时购买了内容和/或数据库(方案2️⃣/方案3️⃣),本站将关闭。届时我们或许会以另一种方式与你再相遇。

应用骨架

本章我们将开始搭建本应用的骨架,包括:依赖、ResultAppError 以及通用数据库操作等。

依赖

# Cargo.toml
[dependencies]
tokio = { version="1", features = ["full"] }
serde = { version="1", features = ["derive"] }
axum = "0.4"
config = "0.11"
dotenv = "0.15"
tokio-postgres = "0.7"
tokio-pg-mapper = "0.2"
tokio-pg-mapper-derive = "0.2"
deadpool-postgres = { version = "0.10", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = "0.3"
pulldown-cmark = "0.9"
askama = "0.11"

除了在其它专题中讨论过的依赖之外,本项目新增了 pulldown-cmark。它用于将 Markdown 格式的文本转换成 HTML 格式。由于我们的博客使用 Markdown 书写,所以该依赖是必须的。

为了对错误进行统一处理,我们定义自己的错误类型AppError,与之相关的还有AppErrorType——枚举错误的类型。

// src/error.rs

#[derive(Debug)]
pub struct AppError {
    pub message: Option<String>,
    pub cause: Option<Box<dyn std::error::Error>>,
    pub types: AppErrorType,
}

其中:

  • message:用于存储错误的文本信息
  • cause:用于存储上游的错误
  • types:用于存储错误的类型

通用方法

我们还需要为它定义一些方法:

// src/error.rs

impl AppError {
    fn new(message:Option<String>, cause:Option<Box<dyn std::error::Error>>, types: AppErrorType) -> Self {
        Self { message, cause, types}
    }
    fn from_err(cause:Box<dyn std::error::Error>, types: AppErrorType) -> Self {
        Self::new(None, Some(cause), types)
    }
    fn from_str(msg:&str, types:AppErrorType) ->Self {
        Self::new(Some(msg.to_string()), None, types)
    }
    pub fn notfound_opt(message:Option<String>) -> Self {
        Self::new(message, None, AppErrorType::Notfound)
    }
    pub fn notfound_msg(msg:&str) -> Self {
        Self::notfound_opt(Some(msg.to_string()))
    }
    pub fn notfound()->Self {
        Self::notfound_msg("没有找到符合条件的数据")
    }
}

这几个方法都是用于构造一个AppError实例:

  • new:通过与结构体字段完全一致的参数进行构造
  • from_err:通过上游错误进行构造
  • from_str:通过文本信息进行构造
  • notfoud系列:构造“未找到”的实例

兼容标准库的Error

为了让AppError兼容标准库的std::error::Error,我们需要让这个结构体实现标准库的Error。由于标准库的Error要求实现Display,而Display又要求实现Debug,所以:

所以,我们的代码需要加上:

实现从相关依赖产生的错误的From

为了实现所有错误统一由AppError处理,我们必须将第三方依赖库相关的Error转换为AppError,其中最佳的实践是通过From trait

// src/error.rs

impl From<deadpool_postgres::PoolError> for AppError {
   fn from(err: deadpool_postgres::PoolError) -> Self {
      Self::from_err(Box::new(err), AppErrorType::Db)
   }
}

impl From<tokio_postgres::Error> for AppError {
    fn from(err: tokio_postgres::Error) -> Self {
        Self::from_err(Box::new(err), AppErrorType::Db)
    }
}

impl From<askama::Error> for AppError {
    fn from(err: askama::Error) -> Self {
        Self::from_err(Box::new(err), AppErrorType::Template)
    }
}

我们分别数据库连接池、数据库操作以及模板操作产生的错误转换成了AppError

实现IntoResponse

最后,我们还要让AppError实现IntoResponse,以便让其能作为axum的响应。

// src/error.rs

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        let msg = match self.message {
            Some(msg) => msg.clone(),
            None => "有错误发生".to_string(),
        };
        msg.into_response()
    }
}

作为骨架来说,现阶段对IntoResponse的实现已经足够了。

最后,我们看一下AppErrorType

AppErrorType

// src/error.rs

#[derive(Debug)]
pub enum AppErrorType {
    Db,
    Template,
    Notfound,
}

现阶段,我们只定义了3种错误类型,随着项目的推进,类型也会进行扩展。这3种错误类型分别是:

  • AppErrorType::Db:标识数据库相关的错误
  • AppErrorType::Template:标识模板渲染相关的错误
  • AppErrorType::Notfound:标识未找到的错误

Result

有了AppError,我们可以定义自己的Result了:

// src/lib.rs

pub type Result<T> = std::result::Result<T, error::AppError>;

通用数据库操作

数据库操作基本都是CRUD,所以我们可以对其进行抽象,编写通用的数据库操作。

以下代码片段位于 src/db/mod.rs 文件

以下代码片段位于 src/db/mod.rs 文件

获取 Statement 对象

基于安全和效率的考虑,我们的SQL语句需要进行预编译,然后通过预编译生成的Statement对象进行操作。所以,数据库操作首先就要编译SQL语句,并从中获取Statement

async fn get_stmt(client: &impl GenericClient, sql: &str) -> Result<Statement> {
    client.prepare(sql).await.map_err(AppError::from)
}

为了能同时处理普通的数据库连接和事务,我们将client定义为了泛型:tokio_postgres::GenericClient

这个函数预编译我们传的SQL语句,并返回Statement对象。如果发生错误,则map_err(AppError::from)会将这个错误转换成AppError

查询多条记录

async fn query<T>(
    client: &impl GenericClient,
    sql: &str,
    params: &[&(dyn ToSql + Sync)],
) -> Result<Vec<T>>
where
    T: FromTokioPostgresRow,
{
    let stmt = get_stmt(client, sql).await?;
    let result = client
        .query(&stmt, params)
        .await
        .map_err(AppError::from)?
        .iter()
        .map(|row| <T>::from_row_ref(row).unwrap())
        .collect::<Vec<T>>();
    Ok(result)
}

注意泛型T的约束:tokio_pg_mapper::FromTokioPostgresRow。这个trait定义了快速将数据库记录行转换为结构体的方法,比如:from_row_ref()

  • 通过client.query()从数据库查询数据,并将可能出现的错误通过map_err转换为AppError
  • iter()可以获取查询结果的迭代器
  • map()中使用from_row_ref()将每行记录转换为结构体
  • 最后使用collect()map()的结果转换为Vec

tokio_pg_mapper_derive::PostgresMapper可以让结构快速满足T的约束,比如后续章节出现的模型定义:

#[derive(PostgresMapper, Serialize)]
#[pg_mapper(table="categories")]
pub struct Category {
    pub id:i32,
    pub name:String,
    pub is_del:bool,
}

查询单条记录

async fn query_row_opt<T>(
    client: &impl GenericClient,
    sql: &str,
    params: &[&(dyn ToSql + Sync)],
    msg: Option<String>,
) -> Result<T>
where
    T: FromTokioPostgresRow,
{
    query(client, sql, params)
        .await?
        .pop()
        .ok_or(AppError::notfound_opt(msg))
}
  • 通过query()(这个是我们定义的query()函数,不是client::query())查询多条记录
  • 使用pop()取出第一行。如果没有记录,调用该方法会出现错误,我们将这个可能出现的错误转换为AppError

注意,查询单行数据有多种方法,比如下文提到的client.query_one()。你可通过查看tokio-postgres文档来进行考量你喜欢的方式。

查询单列数据

async fn query_col<T>(
    client: &impl GenericClient,
    sql: &str,
    params: &[&(dyn ToSql + Sync)],
) -> Result<T>
where
    T: FromSqlOwned,
{
    let stmt = get_stmt(client, sql).await?;
    Ok(client
        .query_one(&stmt, params)
        .await
        .map_err(AppError::from)?
        .get(0))
}
  • 使用client.query_one()方法查询单行数据,并将可能产生的错误转换为AppError
  • 通过get(0)从单行数据中,获取第一列的数据

为了方便操作,我们同样定义了count(),用于查询SELECT COUNT(...)...的结果:

async fn count(
    client: &impl GenericClient,
    sql: &str,
    params: &[&(dyn ToSql + Sync)],
) -> Result<i64> {
    query_col(client, sql, params).await
}

执行

async fn execute(
    client: &impl GenericClient,
    sql: &str,
    args: &[&(dyn ToSql + Sync)],
) -> Result<u64> {
    let stmt = get_stmt(client, sql).await?;
    client.execute(&stmt, args).await.map_err(AppError::from)
}

在其它数据库中:

  • 查询:通常用于SELECT语句
  • 执行:通常用于UPDATE/DELETE/INSERT/存储过程调用/函数调用

正如上文所说,由于 Postgresql 的特殊性,对于INSERT

  • 如果需要返回新插入的ID,需要使用查询,并配合 INSERT ... RETURNING...
  • 如果不需要返回新插入的ID,请使用执行

Postgresql 的 RETURNING 非常强大、方便(UPDATE/DELETE等SQL语句中也能使用),不要使用其它数据库的思维去评价它,而是应该发掘它更大的作用。

Postgresql 的 RETURNING 非常强大、方便(UPDATE/DELETE等SQL语句中也能使用),不要使用其它数据库的思维去评价它,而是应该发掘它更大的作用。

分页查询

async fn pagination<T>(
    client: &impl GenericClient,
    sql: &str,
    count_sql: &str,
    params: &[&(dyn ToSql + Sync)],
    page: u32,
) -> Result<Paginate<Vec<T>>>
where
    T: FromTokioPostgresRow,
{
    let data = query(client, sql, params).await?;
    let total_records = count(client, count_sql, params).await?;
    Ok(Paginate::new(page, DEFAULT_PAGE_SIZE, total_records, data))
}

除了Paginate之外,其它的都不陌生:

  • 通用query()查询多条数据
  • 通过count()查询数据条数
  • 最后,通过Paginate::new 方法构造一个Paginate对象

Paginate对象定义在src/db/paginate.rs中。在src/db/mod.rs中对其进行重新导出,在外部可以使用crate::db::Paginate来调用(如果没有重新导出,它的引用路径是crate::db::paginate::Paginate)。

Paginate

// src/db/paginate.rs 

#[derive(Deserialize, Serialize)]
pub struct Paginate<T> {
    /// 当前页码
    pub page: u32,
    /// 分页大小
    pub page_size: u8,
    /// 总记录数
    pub total_records: i64,
    /// 分页数
    pub total_pages: i64,
    /// 数据
    pub data: T,
}

impl<T> Paginate<T> {
    /// 创建一个新的分页对象
    pub fn new(page: u32, page_size: u8, total_records: i64, data: T) -> Self {
        let total_pages = f64::ceil(total_records as f64 / page_size as f64) as i64;
        Self {
            page,
            page_size,
            total_records,
            total_pages,
            data,
        }
    }
}

本章代码位于01/应用骨架分支。下一章,我们将分别制作前台页面和后台管理页面的模板。

要查看完整内容,请先登录