Rust Web 全栈开发(十二):构建 WebAssembly 应用
Rust Web 全栈开发(十二):构建 WebAssembly 应用
参考视频:https://www.bilibili.com/video/BV1RP4y1G7KF
继续之前的 Actix 项目。
我们已经实现了一个 Web App,在网页端查看并操作数据库中教师的数据。现在我们想创建一个 WebAssembly App,查看并操作数据库中课程的数据。
项目配置
打开 webservice 的 Cargo.toml,添加配置:
actix-cors = "0.6.0-beta.10"
因为客户端和服务器在不同的端口,需要跨域。
修改 webservice/src/bin/teacher_service.rs:
use actix_cors::Cors;
...
#[actix_rt::main]
async fn main() -> io::Result<()> {
// 检测并读取 .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 shared_data = web::Data::new(AppState {
health_check_response: "I'm OK.".to_string(),
visit_count: Mutex::new(0),
// courses: Mutex::new(vec![]),
db: db_pool,
});
let app = move || {
let cors = Cors::default()
.allowed_origin("http://localhost:8082/")
.allowed_origin_fn(|origin, _req_head| {
origin.as_bytes().starts_with(b"http://localhost")
})
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
.allowed_headers(vec![
http::header::AUTHORIZATION,
http::header::ACCEPT,
http::header::CONTENT_TYPE,
])
.allowed_header(http::header::CONTENT_TYPE)
.max_age(3600);
App::new()
.app_data(shared_data.clone())
.app_data(web::JsonConfig::default().error_handler(|_err, _req| {
MyError::InvalidInput("Please provide valid json input".to_string()).into()
}))
.configure(general_routes)
.configure(course_routes)
.wrap(cors)
.configure(teacher_routes)
};
HttpServer::new(app).bind("127.0.0.1:3000")?.run().await
}
构建 wasm-client
回到 Actix 项目的最顶层,在终端中用下面的命令克隆项目模板:
cargo generate --git https://github.com/rustwasm/wasm-pack-template
wasm 模块名称为:wasm-client。
Actix 项目的 members 会自动添加:
在 wasm-client/src 目录下新建文件,编写代码。
wasm-client/src/models/mod.rs:
pub mod course;
wasm-client/src/models/course.rs:
use super::super::errors::MyError;
use serde::{Deserialize, Serialize};
use chrono::NaiveDateTime;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
#[derive(Debug, Serialize, Deserialize)]
pub struct Course {
pub teacher_id: i32,
pub id: i32,
pub name: String,
pub time: 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>,
}
pub async fn get_courses_by_teacher(teacher_id: i32) -> Result<Vec<Course>, MyError> {
let mut opts = RequestInit::new();
opts.method("GET");
opts.mode(RequestMode::Cors);
let url = &format!("http://localhost:3000/courses/{}", teacher_id);
let request = Request::new_with_str_and_init(&url, &opts)?;
request.headers().set("Accept", "application/json")?;
let window = web_sys::window().ok_or("no windows exists".to_string())?;
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
assert!(resp_value.is_instance_of::<Response>());
let resp: Response = resp_value.dyn_into().unwrap();
let json = JsFuture::from(resp.json()?).await?;
let courses: Vec<Course> = json.into_serde().unwrap();
Ok(courses)
}
pub async fn delete_course(teacher_id: i32, course_id: i32) -> () {
let mut opts = RequestInit::new();
opts.method("DELETE");
opts.mode(RequestMode::Cors);
let url = format!("http://localhost:3000/courses/{}/{}", teacher_id, course_id);
let request = Request::new_with_str_and_init(&url, &opts).unwrap();
request.headers().set("Accept", "application/json").unwrap();
let window = web_sys::window().unwrap();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await.unwrap();
assert!(resp_value.is_instance_of::<Response>());
let resp: Response = resp_value.dyn_into().unwrap();
let json = JsFuture::from(resp.json().unwrap()).await.unwrap();
let _courses: Course = json.into_serde().unwrap();
}
use js_sys::Promise;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub async fn add_course(name: String, description: String) -> Result<Promise, JsValue> {
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::Cors);
let str_json = format!(
r#"
{{
"teacher_id":1,
"name": "{}",
"description": "{}"
}}
"#,
name, description
);
opts.body(Some(&JsValue::from_str(str_json.as_str())));
let url = "http://localhost:3000/courses/";
let request = Request::new_with_str_and_init(&url, &opts)?;
request.headers().set("Content-Type", "application/json")?;
request.headers().set("Accept", "application/json")?;
let window = web_sys::window().ok_or("no windows exists".to_string())?;
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
assert!(resp_value.is_instance_of::<Response>());
let resp: Response = resp_value.dyn_into().unwrap();
Ok(resp.json()?)
}
wasm-client/src/lib.rs:
mod utils;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::{HtmlButtonElement};
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// "C" is the ABI the wasm target uses
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
fn confirm(s: &str) -> bool;
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
#[wasm_bindgen]
pub fn greet() {
alert("Hello, wasm-client!");
}
pub mod errors;
pub mod models;
use models::course::Course;
use crate::models::course::delete_course;
#[wasm_bindgen(start)]
pub async fn main() -> Result<(), JsValue> {
let window = web_sys::window().expect("no global window exists");
let document = window.document().expect("should have a document exists");
let left_body = document
.get_element_by_id("left-tbody")
.expect("left div not exists");
let courses: Vec<Course> = models::course::get_courses_by_teacher(1).await.unwrap();
for c in courses.iter() {
let tr = document.create_element("tr")?;
tr.set_attribute("id", format!("tr-{}", c.id).as_str())?;
// course id
let td = document.create_element("td")?;
td.set_text_content(Some(format!("{}", c.id).as_str()));
tr.append_child(&td)?;
// course name
let td = document.create_element("td")?;
td.set_text_content(Some(c.name.as_str()));
tr.append_child(&td)?;
// course time
let td = document.create_element("td")?;
td.set_text_content(Some(c.time.format("%Y-%m-%d").to_string().as_str()));
tr.append_child(&td)?;
// course description
let td = document.create_element("td")?;
if let Some(desc) = &c.description.clone() {
td.set_text_content(Some(desc.as_str()));
}
tr.append_child(&td)?;
// append button
let td = document.create_element("td")?;
let btn: HtmlButtonElement = document
.create_element("button")
.unwrap()
.dyn_into::<HtmlButtonElement>()
.unwrap();
let cid = c.id;
let click_closure = Closure::wrap(Box::new(
move |_event: web_sys::MouseEvent| {
let r = confirm(format!("Are you sure to delete course {}?", cid).as_str());
match r {
true => {
spawn_local(delete_course(1, cid));
alert("deleted!");
// reload
web_sys::window().unwrap().location().reload().unwrap();
}
_ => {}
}
}) as Box<dyn Fn(_)>);
// convert to `Function` and pass to `addEventListener`
btn.add_event_listener_with_callback("click", click_closure.as_ref().unchecked_ref())?;
// prevent memory leak
click_closure.forget();
btn.set_attribute("class", "btn btn-danger btn-sm")?;
btn.set_text_content(Some("Delete"));
td.append_child(&btn)?;
tr.append_child(&td)?;
left_body.append_child(&tr)?;
}
Ok(())
}
wasm-client/src/errors.rs:
use serde::Serialize;
#[derive(Debug, Serialize)]
pub enum MyError {
SomeError(String),
}
impl From<String> for MyError {
fn from(error: String) -> Self {
MyError::SomeError(error)
}
}
impl From<wasm_bindgen::JsValue> for MyError {
fn from(js_value: wasm_bindgen::JsValue) -> Self {
MyError::SomeError(js_value.as_string().unwrap())
}
}
构建项目
cd 到 wasm-pack-template 目录下,执行命令:
wasm-pack build
构建成功:
当构建完成后,我们可以在 pkg 目录中找到它的工件:
生成网页
在 wasm-client 目录下运行这个命令:
npm init wasm-app www
等待构建成功。
构建网页
修改 wasm-client/www/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello wasm-pack!</title>
<!--引入bootstrap 5-->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<noscript>This page contains webassembly and javascript content, please enable javascript in your browser.</noscript>
<nav class="navbar navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Wasm-client</a>
</div>
</nav>
<div class="m-3" style="height: 600px">
<div class="row">
<div class="col">
<div class="card border-info mb-3">
<div class="card-header">Course</div>
<div class="card-body">
<button type="button" class="btn btn-primary">Add</button>
</div>
<table class="table table-hover table-bordered table-sm">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Name</th>
<th scope="col">Time</th>
<th scope="col">Description</th>
<th scope="col">Option</th>
</tr>
</thead>
<tbody id="left-tbody"></tbody>
</table>
</div>
</div>
<div class="col">
<div class="card border-info mb-3">
<div class="card-header">Add Course</div>
<div class="card-body">
<form class="row g-3 needs-validation" id="form">
<div class="mb-3">
<label for="name" class="form-label">Course Name</label>
<input type="text" class="form-control" id="name" required placeholder="Please fill in Course name">
<div class="invalid-feedback">Please fill in course name</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" rows="3" placeholder="Please fill in description"></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="./bootstrap.js"></script>
</body>
</html>
修改 wasm-client/www/index.js:
import * as wasm from "wasm-client";
const myForm = document.getElementById("form");
myForm.addEventListener("submit", (e) => {
e.preventDefault();
const name = document.getElementById("name").value;
const desc = document.querySelector("#description").value;
wasm.add_course(name, desc).then((json) => {
alert('添加成功!')
window.location.reload();
});
});
添加并下载依赖
向 wasm-client/www/package.json 中添加依赖:
"dependencies": {
"wasm-client": "file:../pkg"
},
通过在 wasm-client/www 子目录下运行 npm install,确保本地开发服务器及其依赖已经安装:
测试
先 cd 到 webservice,执行 cargo run 命令,把服务器运行起来
新建一个终端,在新终端中 cd 到 wasm-client/www 目录中,运行以下命令:
npm run start
构建成功:
将 Web 浏览器导航到 localhost:8082/: