使用axum构建博客系统 - 分类管理

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

本章开始,我们将对博客的具体业务进行实现。首先,我们实现博客分类的管理功能。

数据库结构

CREATE TABLE categories  (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  is_del BOOLEAN NOT NULL DEFAULT FALSE
);
字段说明
id 主键。唯一标识,自增长。
name 分类名称
is_del 是否删除

数据模型

// src/model.rs

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

/// 分类ID
#[derive(PostgresMapper, Serialize)]
#[pg_mapper(table="categories")]
pub struct CategoryID {
    pub id:i32,
}

Category

该结构体的字段与数据表的字段一一对应。

CategoryID

为满足数据库通用操作的泛型约束,我们需要对新插入的ID单独定义一个结构体,以便在插入数据之后,能获取到返回的ID。

数据库操作

以下代码均位于 src/db/category.rs

创建新分类

pub async fn create(client: &Client, frm: &form::CreateCategory) -> Result<CategoryID> {
    // 名称是否存在
    let n = super::count(
        client,
        "SELECT COUNT(*) FROM categories WHERE name=$1",
        &[&frm.name],
    )
    .await?;
    if n > 0 {
        return Err(AppError::duplicate("同名的分类已存在"));
    }

    super::insert(
        client,
        "INSERT INTO categories (name, is_del) VALUES ($1, false) RETURNING id",
        &[&frm.name],
        "创建分类失败",
    )
    .await
}

form::CreateCategory是一个表单类,用于收集用户通过表单提交的数据。该类的定义请参见下一节的“表单类”部分。

  • 首先,我们判断表单提交的分类是否存在:使用提交的分类名称进行count(),如果结果大于0,说明该分类已存在
  • 如果提交的分类不存在,则插入到数据库中

获取所有分类

pub async fn list(client: &Client) -> Result<Vec<Category>> {
    super::query(
        client,
        "SELECT id,name,is_del FROM categories WHERE is_del=false ORDER BY id ASC LIMIT 1000",
        &[],
    )
    .await
}

我们从数据库中获取所有未删除的分类数据。按惯例,分类是不需要进行分页的——我们近乎肯定的假设,大部分情况下,博客的分类都不会很多。

删除或恢复分类

pub async fn del_or_restore(client: &Client, id: i32, is_del: bool) -> Result<bool> {
    let n = super::del_or_restore(client, "categories", &id, is_del).await?;
    Ok(n > 0)
}

这里调用了父模块的同名方法:

// src/db/mod.rs

async fn del_or_restore(
    client: &impl GenericClient,
    table:&str,
    id: &(dyn ToSql + Sync),
    is_del: bool,
) -> Result<u64> {
    let sql = format!("UPDATE {} SET is_del=$1 WHERE id=$2", table);
    execute(client, &sql, &[ &is_del, id]).await
}

我们是通过is_del来标识是否删除的,所以只要修改该字段对应的值就可以实现删除或恢复。

修改分类

pub async fn edit(client: &Client, frm: &form::EditCategory) -> Result<bool> {
    // 名称是否存在
    let n = super::count(
        client,
        "SELECT COUNT(*) FROM categories WHERE name=$1 AND id<>$2",
        &[&frm.name, &frm.id],
    )
    .await?;
    if n > 0 {
        return Err(AppError::duplicate("同名的分类已存在"));
    }

    let n = super::execute(
        client,
        "UPDATE categories SET name=$1 WHERE id=$2",
        &[&frm.name, &frm.id],
    )
    .await?;
    Ok(n > 0)
}

form::EditCategory也是一个表单类,其定义请参见下方的“表单类”部分。

注意,修改时判断是否存在的条件:除了限定名称之外,还要限定ID不等于当前要修改的分类的ID。

根据ID查找分类

pub async fn find(client: &Client, id:i32) ->Result<Category> {
    super::query_row(client, "SELECT id, name, is_del FROM categories WHERE id=$1 LIMIT 1", &[&id]).await
}

表单类

// src/form.rs

/// 创建分类的表单
#[derive(Deserialize)]
pub struct CreateCategory {
    pub name:String,
}

/// 修改分类的表单
#[derive(Deserialize)]
pub struct EditCategory {
    pub id:i32,
    pub name:String,
}

AppError

// src/error.rs
pub enum AppErrorType {
    // ...
    Duplicate,
}

impl AppError {
    // ...
    pub fn duplicate(msg: &str) -> Self {
        Self::from_str(msg, AppErrorType::Duplicate)
    }
}

新加了  AppErrorType::Duplicate枚举,以及对应的 duplicate(),用于标识记录是否已经存在。

配置

由于要对数据库进行操作,所以我们需要配置信息。

// src/config.rs

#[derive(Deserialize)]
pub struct WebConfig {
    pub addr:String,
}
#[derive(Deserialize)]
pub struct Config {
    pub web:WebConfig,
    pub pg: deadpool_postgres::Config,
}

impl Config {
    pub fn from_env()->Result<Self, config::ConfigError> {
        let mut cfg = config::Config::new();
        cfg.merge(config::Environment::new())?;
        cfg.try_into()
    }
}

配置文件示例(.env):

WEB.ADDR=127.0.0.1:9527
PG.HOST=pg.axum.rs
PG.PORT=5432
PG.USER=blog
PG.PASSWORD=axum.rs
PG.DBNAME=blog
PG.POOL.MAX_SIZE=30

状态共享

为了在handler间共享数据库连接池,我们需要定义状态共享:

// src/lib.rs

/// 共享状态
pub struct AppState {
    /// 数据库连接
    pub pool: deadpool_postgres::Pool,
}

同时,需要将状态共享加入到main()中:

// src/main.rs

#[tokio::main]
async fn main() {
   // ...
    dotenv().ok();
    let cfg = config::Config::from_env().expect("初始化配置失败");
    let pool = cfg.pg.create_pool(None, tokio_postgres::NoTls).expect("创建数据库连接池失败");


    let frentend_routers = frontend::router();
    let backend_routers = backend::router();
    let app = Router::new()
        .nest("/", frentend_routers)
        .nest("/admin", backend_routers)
        .layer(Extension(Arc::new(AppState { pool})));

   //...
}

模板

以下模板位于 templates/backend 目录

母模板 base.html

<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
                <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
                    <h1 class="h2">{% block title%}标题{%endblock%}</h1>
                   {% block toolbar %}{%endblock%} 
                </div>
                <div>
                    {% block msg%} {%endblock%}
                    {%block content%}内容{%endblock%}
                </div>
            </main>

母模板增加了各个块的定义。

提示信息模板 msg.html

为了显示提示信息,增了该模板

{% match msg %}
{% when Some with (msg) %}
  <div class="alert alert-info" role="alert">
    {{msg}}
  </div>
{% when None %}
{% endmatch %}

以下模板均位于 templates/backend/category 目录

添加分类模板 add.html

{% extends "./../base.html" %}
{% block title%}添加分类{%endblock%}
{% block toolbar %} {% include "./toolbar.html" %} {%endblock%}
{% block content %}
<form method="post" action="/admin/category/add">
    <div class="mb-3">
        <label for="name" class="form-label">名称</label>
        <input type="text" name="name" id="name" class="form-control" placeholder="分类名称" required>
    </div>
    <button type="submit" class="btn btn-primary">提交</button>
</form>
{%endblock%}

修改分类模板 edit.html

{% extends "./../base.html" %}
{% block title%}修改分类{%endblock%}
{% block toolbar %} {% include "./toolbar.html" %} {%endblock%}
{% block content %}
<form method="post" action="/admin/category/edit/{{item.id}}">
    <input type="hidden" value="{{item.id}}" name="id">
    <div class="mb-3">
        <label for="name" class="form-label">名称</label>
        <input type="text" name="name" id="name" class="form-control" placeholder="分类名称" value="{{ item.name }}" required>
    </div>
    <button type="submit" class="btn btn-primary">提交</button>
</form>
{%endblock%}

分类列表模板 index.html

{% extends "./../base.html" %}
{% block title%}所有分类{%endblock%}
{% block toolbar %} {% include "./toolbar.html" %} {%endblock%}
{% block msg %} {%include "../msg.index"%} {%endblock%}
{% block content %}
<table class="table table-striped table-hover">
    <thead>
        <tr>
            <th>ID</th>
            <th>名称</th>
            <th>操作</th>
        </tr>
    </thead>
    <tbody>
        {% for item in list%}
        <tr>
            <td>{{ item.id }}</td>
            <td>{{ item.name }}</td>
            <td>
                <a href="/admin/category/edit/{{ item.id }}" class="btn btn-primary btn-sm">修改</a>
                <a href="/admin/category/del/{{ item.id }}" class="btn btn-danger btn-sm" onclick="return confirm('确定删除「{{ item.name }}」');">删除</a>
            </td>
        </tr>
        {%endfor%}
    </tbody>
</table>
{%endblock%}

工具栏模板 toolbar.html


<div class="btn-toolbar mb-2 mb-md-0">
    <div class="btn-group me-2">
        <a href="/admin/category" type="button" class="btn btn-sm btn-outline-secondary">列表</a>
        <a href="/admin/category/add" type="button" class="btn btn-sm btn-outline-secondary">添加</a>
    </div>
</div>

视图类

// src/view/backend/category.rs

#[derive(Template)]
#[template(path="backend/category/add.html")]
pub struct Add {}

#[derive(Template)]
#[template(path="backend/category/index.html")]
pub struct Index{
    pub list: Vec<Category>,
    pub msg: Option<String>,
}
#[derive(Template)]
#[template(path="backend/category/edit.html")]
pub struct Edit{
    pub item: Category,
}

添加分类视图Add

没有额外的字段,单纯的关联模板文件。

分类列表视图Index

  • list:所有未删除的分类
  • msg:提示信息

修改分类视图Edit

  • item:读取需要修改的分类的当前信息,以便填充到表单中

handler

以下代码位于 src/handler/backend/category.rs 文件

添加分类

/// 添加分类UI
pub async fn add_ui()->Result<HtmlView> {
    let handler_name = "backend/category/add_ui";
    let tmpl = Add{};
    render(tmpl).map_err(log_error(handler_name))
}

/// 添加分类
pub async fn add(
    Extension(state):Extension<Arc<AppState>>,
    Form(frm):Form<form::CreateCategory>,
) -> Result<RedirectView> {
    let handler_name = "backend/category/add";
    let client = get_client(&state).await.map_err(log_error(handler_name))?;
    category::create(&client, &frm).await.map_err(log_error(handler_name))?;
    redirect("/admin/category?msg=分类添加成功")
}

add_ui:显示添加分类的模板渲染出来的页面。

add:处理添加分类的逻辑。添加成功后,跳转到分类列表页。这里出现的 RedirectView 、get_client() 和 redirect() 请参见本章后面部分的说明。

分类列表

pub async fn index(
    Extension(state):Extension<Arc<AppState>>,
    Query(args):Query<Args>,
) ->Result<HtmlView> {
    let handler_name = "backend/category/index";
    let client = get_client(&state).await.map_err(log_error(handler_name))?;
    let list = category::list(&client).await.map_err(log_error(handler_name))?;
    let tmpl = Index { list, msg:args.msg };
    render(tmpl).map_err(log_error(handler_name))
}

从数据库中读取所有分类,并将通过模板将其渲染。其中的Args表示URL查询参数,它的定义在本章的下文。

删除分类

pub async fn del(
    Extension(state):Extension<Arc<AppState>>,
    Path(id):Path<i32>,
) -> Result<RedirectView> {
    let handler_name = "backend/category/del";
    let client = get_client(&state).await.map_err(log_error(handler_name))?;
    category::del_or_restore(&client, id, true).await.map_err(log_error(handler_name))?;
    redirect("/admin/category?msg=分类删除成功")
}

修改分类

pub async fn edit_ui(
    Extension(state):Extension<Arc<AppState>>,
    Path(id):Path<i32>,
) -> Result<HtmlView> {
    let handler_name = "backend/category/edit_ui";
    let client = get_client(&state).await.map_err(log_error(handler_name))?;
    let item = category::find(&client, id).await.map_err(log_error(handler_name))?;
    let tmpl = Edit { item };
    render(tmpl).map_err(log_error(handler_name))
}

pub async fn edit(
    Extension(state):Extension<Arc<AppState>>,
    Form(frm):Form<EditCategory>,
)->Result<RedirectView> {
    let handler_name = "backend/category/edit";
    let client = get_client(&state).await.map_err(log_error(handler_name))?;
    category::edit(&client, &frm).await.map_err(log_error(handler_name))?;
    redirect("/admin/category?msg=分类修改成功")
}

edit_ui:从数据库读取指定分类的数据,并渲染到页面上。

edit:修改分类的处理逻辑。

RedirectView

用于跳转的视图:

// src/handler/mod.rs
type RedirectView = (StatusCode, HeaderMap, ());

redirect()

跳转到指定的URL:

// src/handler/mod.rs

fn redirect(url:&str) -> Result<RedirectView> {
    let mut hm = HeaderMap::new();
    hm.append(header::LOCATION,url.parse().unwrap()) ;
    Ok((StatusCode::FOUND, hm, ()))
}

get_client()

从连接池中获取数据库连接:

async fn get_client(state: &AppState) -> Result<Client> {
   state.pool.get().await.map_err(AppError::from)
}

Args

// src/handler/backend/mod.rs
#[derive(Deserialize)]
pub struct Args {
    pub msg: Option<String>,
    pub page: Option<u32>,
}
impl Args {
    pub fn msg(&self) -> String {
        self.msg.clone().unwrap_or("".to_string())
    }
    pub fn page(&self) -> u32 {
        self.page.unwrap_or(0)
    }
}
  • msg字段:可选的提示消息
  • page字段:可选的分页页码
  • msg()方法:如果msg字段为None,返回空字符串""
  • page()方法:如果page字段为None,返回0

路由

把handler加到路由中:

pub fn router() -> Router {
    let category_router = Router::new()
        .route("/", get(category::index))
        .route("/add", get(category::add_ui).post(category::add))
        .route("/del/:id", get(category::del))
        .route("/edit/:id", get(category::edit_ui).post(category::edit))
        ;
    Router::new()
        .route("/", get(index))
        .nest("/category", category_router)
}