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