内容介绍
本专题将带你使用 axum 实现一个简洁易用的博客系统应用骨架
本章我们将开始搭建本应用的骨架,包括:依赖、`Result` 和 `AppError` 以及通用数据库操作等。模板
我们的博客分为“前台”和“后台”两部分。前台用于展示博客内容,后台用于管理博客。本章我们将编写前台和后台的基础模板以及对应的路由。分类管理
本章开始,我们将对博客的具体业务进行实现。首先,我们实现博客分类的管理功能。文章管理
本章我们将实现博客的文章管理功能。鉴权与登录
本章实现后台管理的鉴权,以及管理员的登录、注销功能。涉及的知识点有:cookie及中间件等。后台管理菜单及首页模板
目前,后台管理功能基本完成,但还有两个工作没做:清理后台管理的导航菜单以及后台管理首页的模板。网站首页
后台管理完成后,我们开始进入前台功能的开发。本章我们将完成博客首页的开发。分类文章列表
本章将实现博客的分类文章列表功能。文章详情
本章将实现博客文章的详情显示功能。存档文章列表
本章将实现存档文章列表功能。注意,本章涉及较多PostgreSQL知识,如果你对相关知识不熟悉,可以先让代码跑起来,再去了解相关知识。总结与作业
恭喜你,已经完成了本专题的学习。下面我们对本专题进行简要的总结。
应用骨架
- 547600
- 2022-03-26T11:10:11+08:00
本章我们将开始搭建本应用的骨架,包括:依赖、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
// 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
为了便于操作,我们为单行查询提供了几个重载的版本。特别要注意,由于 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 的
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/应用骨架分支。下一章,我们将分别制作前台页面和后台管理页面的模板。