使用axum构建博客系统 - 应用骨架

发布时间 2023-10-21 20:26:06作者: CrossPython

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

依赖

# 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

为了对错误进行统一处理,我们定义自己的错误类型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,所以:

  • 在定义AppError时,我们使用了#[derive(Debug)]
  • 我们需要 impl std::fmt::Display for AppError
  • 我们还需要impl std::error::Error for AppError

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

// src/error.rs

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl std::error::Error for AppError{}

实现从相关依赖产生的错误的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 文件

获取 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

为了便于操作,我们为单行查询提供了几个重载的版本。特别要注意,由于 Postgresql 需要通过INSERT INTO ... RETURNING id的方式返回新插入记录的ID,所以在 Postgresql中如果需要返回新插入的ID,需要使用单行查询来执行INSERT语句。

/// 查询单条记录,并指定当记录不存在时,使用的错误信息
async fn query_row_msg<T>(
    client: &impl GenericClient,
    sql: &str,
    params: &[&(dyn ToSql + Sync)],
    msg: &str,
) -> Result<T>
where
    T: FromTokioPostgresRow,
{
    query_row_opt(client, sql, params, Some(msg.to_string())).await
}
/// 查询单条记录,当记录不存在时,使用默认的错误信息
async fn query_row<T>(
    client: &impl GenericClient,
    sql: &str,
    params: &[&(dyn ToSql + Sync)],
) -> Result<T>
where
    T: FromTokioPostgresRow,
{
    query_row_opt(client, sql, params, None).await
}
/// 插入记录并返回指定数据
async fn insert<T>(
    client: &impl GenericClient,
    sql: &str,
    params: &[&(dyn ToSql + Sync)],
    msg: &str,
) -> Result<T>
where
    T: FromTokioPostgresRow,
{
    query_row_msg(client, sql, params, msg).await
}

注意,查询单行数据有多种方法,比如下文提到的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语句中也能使用),不要使用其它数据库的思维去评价它,而是应该发掘它更大的作用。

分页查询

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,
        }
    }
}