Rust Web 全栈开发(八):添加功能并重构
Rust Web 全栈开发(八):添加功能并重构
参考视频:https://www.bilibili.com/video/BV1RP4y1G7KF
继续之前的 Actix 项目,项目现状如下所示:

项目重构
在 webservice/src 目录下新建一个 models 目录,把 models.rs 移动到该目录下,重命名为 course.rs。在 models 目录下新建一个 mod.rs,添加代码:
pub mod course;
在 webservice/src 目录下新建一个 dbaccess 目录,把 db_access.rs 移动到该目录下,重命名为 course.rs。在 dbaccess 目录下新建一个 mod.rs,添加代码:
pub mod course;
在 webservice/src 目录下新建一个 handlers 目录,在 handlers 目录下新建 3 个文件:course.rs、general.rs、mod.rs。
handlers/course.rs:
use crate::state::AppState;
use crate::dbaccess::course::*;
use crate::errors::MyError;
use crate::models::course::Course;
use actix_web::{web, HttpResponse};
pub async fn new_course(
new_course: web::Json<Course>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, MyError> {
post_new_course_db(&app_state.db, new_course.into())
.await
.map(|course| HttpResponse::Ok().json(course))
}
pub async fn get_courses_for_teacher(
app_state: web::Data<AppState>,
params: web::Path<i32>,
) -> Result<HttpResponse, MyError> {
let teacher_id = params.into_inner();
get_courses_for_teacher_db(&app_state.db, teacher_id)
.await
.map(|courses| HttpResponse::Ok().json(courses))
}
pub async fn get_course_detail(
app_state: web::Data<AppState>,
params: web::Path<(i32, i32)>,
) -> Result<HttpResponse, MyError> {
let (teacher_id, course_id) = params.into_inner();
get_course_details_db(&app_state.db, teacher_id, course_id)
.await
.map(|course| HttpResponse::Ok().json(course))
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::StatusCode;
use std::sync::Mutex;
use dotenv::dotenv;
use sqlx::mysql::MySqlPoolOptions;
use std::env;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
#[actix_rt::test]
async fn post_course_test() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let course = web::Json(Course {
teacher_id: 1,
name: "Test course".into(),
id: Some(3),
time: Some(NaiveDateTime::new(
NaiveDate::from_ymd_opt(2025, 7, 12).expect("Unknown date"),
NaiveTime::from_hms_opt(10, 15, 0).expect("Unknown time"),
)),
});
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
// 模拟添加课程的请求
let response = new_course(course, app_state).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn get_all_courses_success() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let teacher_id: web::Path<i32> = web::Path::from(1);
let response = get_courses_for_teacher(app_state, teacher_id).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn get_one_course_success() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let params: web::Path<(i32, i32)> = web::Path::from((1, 1));
let response = get_course_detail(app_state, params).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
handlers/general.rs:
use crate::state::AppState;
use actix_web::{web, HttpResponse};
pub async fn health_check_handler(app_state: web::Data<AppState>) -> HttpResponse {
println!("incoming for health check");
let health_check_response = &app_state.health_check_response;
let mut visit_count = app_state.visit_count.lock().unwrap();
let response = format!("{} {} times", health_check_response, visit_count);
*visit_count += 1;
HttpResponse::Ok().json(&response)
}
handlers/mod.rs:
pub mod course;
pub mod handlers;
对应修改 teacher_service.rs 中上述 3 个模块的路径定义:
#[path = "../dbaccess/mod.rs"]
mod dbaccess;
#[path = "../handlers/mod.rs"]
mod handlers;
#[path = "../models/mod.rs"]
mod models;
其他修改:
- 把 .env 文件提取到 webservice 目录之外
- 将各个文件中的引用从相对路径改为绝对路径
重构后的 Actix 项目结构:

扩充课程的字段
修改 models/course.rs,扩充 Course 结构体的字段,并新增 2 个课程结构体:CreateCourse 表示新增课程,UpdateCourse 表示修改课程。
use actix_web::web;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use crate::errors::MyError;
#[derive(Serialize, sqlx::FromRow, Debug, Clone)]
pub struct Course {
pub teacher_id: i32,
pub id: i32,
pub name: String,
pub time: Option<NaiveDateTime>,
pub description: Option<String>,
pub format: Option<String>,
pub structure: Option<String>,
pub duration: Option<String>,
pub price: Option<i32>,
pub language: Option<String>,
pub level: Option<String>,
}
/// 新建课程
#[derive(Deserialize, Debug, Clone)]
pub struct CreateCourse {
pub teacher_id: i32,
pub name: String,
pub time: Option<NaiveDateTime>,
pub description: Option<String>,
pub format: Option<String>,
pub structure: Option<String>,
pub duration: Option<String>,
pub price: Option<i32>,
pub language: Option<String>,
pub level: Option<String>,
}
// impl From<web::Json<CreateCourse>> for CreateCourse {
// fn from(course: web::Json<CreateCourse>) -> Self {
// CreateCourse {
// teacher_id: course.teacher_id,
// name: course.name.clone(),
// description: course.description.clone(),
// format: course.format.clone(),
// structure: course.structure.clone(),
// duration: course.duration.clone(),
// price: course.price,
// language: course.language.clone(),
// level: course.level.clone(),
// }
// }
// }
impl TryFrom<web::Json<CreateCourse>> for CreateCourse {
type Error = MyError;
fn try_from(course: web::Json<CreateCourse>) -> Result<Self, Self::Error> {
Ok(CreateCourse {
teacher_id: course.teacher_id,
name: course.name.clone(),
time: course.time.clone(),
description: course.description.clone(),
format: course.format.clone(),
structure: course.structure.clone(),
duration: course.duration.clone(),
price: course.price,
language: course.language.clone(),
level: course.level.clone(),
})
}
}
/// 修改课程
#[derive(Deserialize, Debug, Clone)]
pub struct UpdateCourse {
pub name: Option<String>,
pub time: Option<NaiveDateTime>,
pub description: Option<String>,
pub format: Option<String>,
pub structure: Option<String>,
pub duration: Option<String>,
pub price: Option<i32>,
pub language: Option<String>,
pub level: Option<String>,
}
impl From<web::Json<UpdateCourse>> for UpdateCourse {
fn from(course: web::Json<UpdateCourse>) -> Self {
UpdateCourse {
name: course.name.clone(),
time: course.time.clone(),
description: course.description.clone(),
format: course.format.clone(),
structure: course.structure.clone(),
duration: course.duration.clone(),
price: course.price,
language: course.language.clone(),
level: course.level.clone(),
}
}
}
对应数据库中的 course 表的设计也要修改:

修改 dbaccess
修改 dbaccess/course.rs 之前的 3 个函数,并新增 delete 和 update 相关函数。
use chrono::NaiveDateTime;
use crate::models::course::{Course, CreateCourse, UpdateCourse};
use crate::errors::MyError;
use sqlx::MySqlPool;
pub async fn post_new_course_db(pool: &MySqlPool, new_course: CreateCourse) -> Result<(), MyError> {
let _insert_query = sqlx::query!(
"INSERT INTO course (teacher_id, name, time, description, format, structure, duration, price, language, level)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_course.teacher_id,
new_course.name,
new_course.time,
new_course.description,
new_course.format,
new_course.structure,
new_course.duration,
new_course.price,
new_course.language,
new_course.level
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_course_db(pool: &MySqlPool, teacher_id: i32, course_id: i32) -> Result<String, MyError> {
let row = sqlx::query!(
"DELETE FROM course
WHERE teacher_id = ? AND id = ?",
teacher_id,
course_id
)
.execute(pool)
.await?;
Ok(format!("Deleted {:?} record", row))
}
pub async fn update_course_details_db(
pool: &MySqlPool,
teacher_id: i32,
course_id: i32,
update_course: UpdateCourse,
) -> Result<String, MyError> {
let current_course_row: Course = sqlx::query_as(
"SELECT * FROM course
WHERE teacher_id = ? and id = ?"
)
.bind(teacher_id)
.bind(course_id)
.fetch_one(pool) // 获取单条记录
.await
.map_err(|_err| MyError::NotFound("Course Id not found".into()))?;
let name: String = if let Some(name) = update_course.name {
name
} else {
current_course_row.name
};
let time: NaiveDateTime = if let Some(time) = update_course.time {
time
} else {
current_course_row
.time
.unwrap_or_default()
};
let description: String = if let Some(description) = update_course.description {
description
} else {
current_course_row
.description
.unwrap_or_default()
};
let format: String = if let Some(format) = update_course.format {
format
} else {
current_course_row
.format
.unwrap_or_default()
};
let structure: String = if let Some(structure) = update_course.structure {
structure
} else {
current_course_row
.structure
.unwrap_or_default()
};
let duration: String = if let Some(duration) = update_course.duration {
duration
} else {
current_course_row
.duration
.unwrap_or_default()
};
let level: String = if let Some(level) = update_course.level {
level
} else {
current_course_row
.level
.unwrap_or_default()
};
let language: String = if let Some(language) = update_course.language {
language
} else {
current_course_row
.language
.unwrap_or_default()
};
let price: i32 = if let Some(price) = update_course.price {
price
} else {
current_course_row
.price
.unwrap_or_default()
};
let row = sqlx::query!(
"UPDATE course
SET name = ?, time = ?, description = ?, format = ?, structure = ?, duration = ?, price = ?, language = ?, level = ?
WHERE teacher_id = ? AND id = ?",
name,
time,
description,
format,
structure,
duration,
price,
language,
level,
teacher_id,
course_id
)
.execute(pool)
.await?;
Ok(format!("Update {:?} record", row))
}
pub async fn get_courses_for_teacher_db(pool: &MySqlPool, teacher_id: i32) -> Result<Vec<Course>, MyError> {
let rows: Vec<Course> = sqlx::query_as(
"SELECT * FROM course
WHERE teacher_id = ?"
)
.bind(teacher_id)
.fetch_all(pool) // 获取所有记录
.await?;
Ok(rows)
}
pub async fn get_course_details_db(pool: &MySqlPool, teacher_id: i32, course_id: i32) -> Result<Course, MyError> {
let row = sqlx::query_as(
"SELECT * FROM course
WHERE teacher_id = ? and id = ?"
)
.bind(teacher_id)
.bind(course_id)
.fetch_optional(pool) // 获取单条记录
.await?;
if let Some(course) = row {
Ok(course)
} else {
Err(MyError::NotFound("Course didn't founded".into()))
}
}
修改 routers
修改 routers.rs,新增 2 个路由,对应删除和更新。
use crate::handlers::{course::*, general::*};
use actix_web::web;
pub fn general_routes(cfg: &mut web::ServiceConfig) {
cfg.route("/health", web::get().to(health_check_handler));
}
pub fn course_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/courses")
.route("/", web::post().to(post_new_course))
.route("/{teacher_id}", web::get().to(get_courses_for_teacher))
.route("/{teacher_id}/{course_id}", web::get().to(get_course_detail))
.route("/{teacher_id}/{course_id}", web::delete().to(delete_course))
.route("/{teacher_id}/{course_id}", web::put().to(update_course_detail))
);
}
修改 handlers
修改 handlers/course.rs,新增删除和更新的 Handler,并增加对应的 test。
use crate::state::AppState;
use crate::dbaccess::course::*;
use crate::errors::MyError;
use crate::models::course::{CreateCourse, UpdateCourse};
use actix_web::{web, HttpResponse};
pub async fn post_new_course(
new_course: web::Json<CreateCourse>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, MyError> {
post_new_course_db(&app_state.db, new_course.try_into()?)
.await
.map(|course| HttpResponse::Ok().json(course))
}
pub async fn get_courses_for_teacher(
app_state: web::Data<AppState>,
params: web::Path<i32>,
) -> Result<HttpResponse, MyError> {
let teacher_id = params.into_inner();
get_courses_for_teacher_db(&app_state.db, teacher_id)
.await
.map(|courses| HttpResponse::Ok().json(courses))
}
pub async fn get_course_detail(
app_state: web::Data<AppState>,
params: web::Path<(i32, i32)>,
) -> Result<HttpResponse, MyError> {
let (teacher_id, course_id) = params.into_inner();
get_course_details_db(&app_state.db, teacher_id, course_id)
.await
.map(|course| HttpResponse::Ok().json(course))
}
pub async fn delete_course(
app_state: web::Data<AppState>,
params: web::Path<(i32, i32)>,
) -> Result<HttpResponse, MyError> {
let (teacher_id, course_id) = params.into_inner();
delete_course_db(&app_state.db, teacher_id, course_id)
.await
.map(|response| HttpResponse::Ok().json(response))
}
pub async fn update_course_detail(
app_state: web::Data<AppState>,
update_course: web::Json<UpdateCourse>,
params: web::Path<(i32, i32)>,
) -> Result<HttpResponse, MyError> {
let (teacher_id, course_id) = params.into_inner();
update_course_details_db(&app_state.db, teacher_id, course_id, update_course.into())
.await
.map(|response| HttpResponse::Ok().json(response))
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::StatusCode;
use std::sync::Mutex;
use dotenv::dotenv;
use sqlx::mysql::MySqlPoolOptions;
use std::env;
use actix_web::ResponseError;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
#[actix_rt::test]
async fn post_course_test() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let course = web::Json(CreateCourse {
teacher_id: 1,
name: "Test course".into(),
time: Some(NaiveDateTime::new(
NaiveDate::from_ymd_opt(2025, 7, 12).expect("Unknown date"),
NaiveTime::from_hms_opt(10, 15, 0).expect("Unknown time"),
)),
description: Some("This is a course".into()),
format: None,
structure: None,
duration: None,
price: None,
language: Some("English".into()),
level: Some("Beginner".into()),
});
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
// 模拟添加课程的请求
let response = post_new_course(course, app_state).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn get_all_courses_success() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let teacher_id: web::Path<i32> = web::Path::from(1);
let response = get_courses_for_teacher(app_state, teacher_id).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn get_one_course_success() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let params: web::Path<(i32, i32)> = web::Path::from((1, 1));
let response = get_course_detail(app_state, params).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn get_one_course_failure() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let params: web::Path<(i32, i32)> = web::Path::from((1, 100));
let response = get_course_detail(app_state, params).await;
match response {
Ok(_) => println!("Something went wrong"),
Err(err) => assert_eq!(err.status_code(), StatusCode::NOT_FOUND),
}
}
#[actix_rt::test]
async fn update_course_success() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let params: web::Path<(i32, i32)> = web::Path::from((3, 4));
let update_param = web::Json(UpdateCourse {
name: Some("Course name changed".into()),
time: Some(NaiveDateTime::new(
NaiveDate::from_ymd_opt(2025, 7, 19).expect("Unknown date"),
NaiveTime::from_hms_opt(10, 15, 0).expect("Unknown time"),
)),
description: Some("This is another test course".into()),
format: None,
structure: None,
duration: None,
price: None,
language: Some("Chinese".into()),
level: Some("Intermediate".into())
});
let response = update_course_detail(app_state, update_param, params).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn delete_course_success() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let params: web::Path<(i32, i32)> = web::Path::from((1, 3));
let response = delete_course(app_state, params).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn delete_course_failure() {
// 检测并读取 .env 文件中的内容,若不存在也会跳过异常
dotenv().ok();
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL 没有在 .env 文件里设置");
// 创建数据库连接池
let db_pool = MySqlPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
let params: web::Path<(i32, i32)> = web::Path::from((1, 101));
let response = delete_course(app_state, params).await;
match response {
Ok(_) => println!("Something went wrong"),
Err(err) => assert_eq!(err.status_code(), StatusCode::NOT_FOUND),
}
}
}
测试
测试前的数据库:

在终端执行命令 cargo test --bin teacher_service,7 个测试都通过了:

测试后的数据库:

可以看出:新插入了一个课程,删除了一个课程,更新了一个课程。
cd 到 webservice,执行命令 cargo run,在浏览器中测试功能:

尾声
修改 handlers/course.rs,在 post_course_test 和 delete_course_success 两个测试函数上打上 #[ignore] 注释。
这样在之后执行命令 cargo test 时,会忽略这两个测试。
1002

被折叠的 条评论
为什么被折叠?



