服务器端渲染

前端框架让开发者可以使用高级语言和组件模型来创建 Web 应用程序。Web 应用程序需要被构建成静态网页,才能在浏览器中渲染。尽管很多前端框架是基于 JavaScript 的,比如 React 和 Vue,但是随着 Rust 吸引了更多的开发者,基于 Rust 的框架也在不断涌现。这些前端框架使用由 Rust 编译而成的 WebAssembly 来渲染 HTML DOM UI。他们使用 wasm-bindgen 来绑定 Rust 与 HTML DOM。这些框架都把 .wasm 文件发送到浏览器,在客户端渲染 UI,但其中的一些框架提供了对服务端渲染的支持。这意味着我们在服务器上运行 WebAssembly 代码,并构建 HTML DOM UI,然后将 HTML 内容发送到浏览器,以此在较慢的设备和网络环境下获得更好的性能以及更快的启动速度。

如果你对 JavaScript 技术栈以及服务端渲染框架感兴趣,比如 React,请查看我们关于 JavaScript 服务端渲染的章节

本文将探索如何在服务器上使用 WasmEdge 来渲染 Web UI。 我们选择使用 Percy,因为它在服务端渲染以及混合开发)领域较为成熟。Percy 同样提供了一个服务端渲染的示例。我们强烈建议你先去阅读这个示例,弄清楚它是如何工作的。Percy 默认的服务端渲染设置使用了一个原生的 Rust Web 服务器。对于服务器来说,Rust 代码被编译为原生机器码。然后,为了在服务器上运行用户的应用程序,我们需要一个沙箱。尽管我们可以在一个 Linux 容器(Docker)中运行原生代码,一个更高效且更快的方法是使用服务器上的 WebAssembly 虚拟机来运行编译好的代码,尤其是考虑到我们渲染的代码已经被编译成了 WebAssembly。

现在,让我们看一下在一个 WasmEdge 服务器上运行一个 Percy 服务端渲染的服务的步骤。

假设我们在 examples/isomorphic 文件夹中,创建一个新的包,和已有的 server 在同一个文件夹中。

cargo new server-wasmedge

当你把新的包加入到工作区时,你会收到一个警告,因此需要在 [workspace]members 中插入下面这行。文件位于 ../../Cargo.toml

"examples/isomorphic/server-wasmedge"

趁文件还开着,将这两行放在文件底部:

[patch.crates-io] wasm-bindgen = { git = "https://github.com/KernelErr/wasm-bindgen.git", branch = "wasi-compat" }

为什么我们需要一个 fork 的 wasm-bindgen?这是因为在浏览器中, wasm-bindgen 是将 Rust 和 HTML 连接起来必须的胶水。然而在服务器上,我们需要将 Rust 代码编译为针对 wasm32-wasi 目标的代码,这与 wasm-bindgen 是不兼容的。我们 fork 版本的 wasm-bindgen 有一些条件配置,可以为 wasm32-wasi 目标移除其生成的 .wasm 文件中的浏览器特定代码。

然后使用如下内容覆盖我们刚创建包的 Cargo.toml

[package] name = "isomorphic-server-wasmedge" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] wasmedge_wasi_socket = "0" querystring = "1.1.0" parsed = { version = "0.3", features = ["http"] } anyhow = "1" serde = { version = "1.0", features = ["derive"] } isomorphic-app = { path = "../app" }

wasmedge_wasi_socket 包是 WasmEdge 的 Socket 接口。这个工程还在开发中。下一步将 index.html 文件复制到包的根目录。

cp server/src/index.html server-wasmedge/src/

让我们用 Rust 代码在 WasmEdge 中创建一个 Web 服务! main.rs 程序监听到来的请求,并通过流发送响应。

  1. use std::io::Write;
  2. use wasmedge_wasi_socket::{Shutdown, TcpListener};
  3. mod handler;
  4. mod mime;
  5. mod response;
  6. fn main() {
  7. let server = TcpListener::bind("127.0.0.1:3000", false).unwrap();
  8. println!("Server listening on 127.0.0.1:3000");
  9. // Simple single thread HTTP server
  10. // For server with Pool support, see https://github.com/second-state/wasmedge_wasi_socket/tree/main/examples/poll_http_server
  11. loop {
  12. let (mut stream, addr) = server.accept(0).unwrap();
  13. println!("Accepted connection from {}", addr);
  14. match handler::handle_req(&mut stream, addr) {
  15. Ok((res, binary)) => {
  16. let res: String = res.into();
  17. let bytes = res.as_bytes();
  18. stream.write_all(bytes).unwrap();
  19. if let Some(binary) = binary {
  20. stream.write_all(&binary).unwrap();
  21. }
  22. }
  23. Err(e) => {
  24. println!("Error: {:?}", e);
  25. }
  26. };
  27. stream.shutdown(Shutdown::Both).unwrap();
  28. }
  29. }

handler.rs 中的代码解析收到的数据,并返回对应的响应。

  1. #![allow(unused)]
  2. fn main() {
  3. use crate::response;
  4. use anyhow::Result;
  5. use parsed::http::Response;
  6. use std::io::Read;
  7. use wasmedge_wasi_socket::{SocketAddr, TcpStream};
  8. pub fn handle_req(stream: &mut TcpStream, addr: SocketAddr) -> Result<(Response, Option<Vec<u8>>)> {
  9. let mut buf = [0u8; 1024];
  10. let mut received_data: Vec<u8> = Vec::new();
  11. loop {
  12. let n = stream.read(&mut buf)?;
  13. received_data.extend_from_slice(&buf[..n]);
  14. if n < 1024 {
  15. break;
  16. }
  17. }
  18. let mut bs: parsed::stream::ByteStream = match String::from_utf8(received_data) {
  19. Ok(s) => s.into(),
  20. Err(_) => return Ok((response::bad_request(), None)),
  21. };
  22. let req = match parsed::http::parse_http_request(&mut bs) {
  23. Some(req) => req,
  24. None => return Ok((response::bad_request(), None)),
  25. };
  26. println!("{:?} request: {:?} {:?}", addr, req.method, req.path);
  27. let mut path_split = req.path.split("?");
  28. let path = path_split.next().unwrap_or("/");
  29. let query_str = path_split.next().unwrap_or("");
  30. let query = querystring::querify(&query_str);
  31. let mut init_count: Option<u32> = None;
  32. for (k, v) in query {
  33. if k.eq("init") {
  34. match v.parse::<u32>() {
  35. Ok(v) => init_count = Some(v),
  36. Err(_) => return Ok((response::bad_request(), None)),
  37. }
  38. }
  39. }
  40. let (res, binary) = if path.starts_with("/static") {
  41. response::file(&path)
  42. } else {
  43. // render page
  44. response::ssr(&path, init_count)
  45. }
  46. .unwrap_or_else(|_| response::internal_error());
  47. Ok((res, binary))
  48. }
  49. }

response.rs 中的代码将静态资源和服务器渲染的内容打包成响应对象。 对后者来说,你可以看到服务端渲染发生于 app.render().to_string(),产生的字符串替换掉了 HTML 中的占位符。

  1. #![allow(unused)]
  2. fn main() {
  3. use crate::mime::MimeType;
  4. use anyhow::Result;
  5. use parsed::http::{Header, Response};
  6. use std::fs::{read};
  7. use std::path::Path;
  8. use isomorphic_app::App;
  9. const HTML_PLACEHOLDER: &str = "#HTML_INSERTED_HERE_BY_SERVER#";
  10. const STATE_PLACEHOLDER: &str = "#INITIAL_STATE_JSON#";
  11. pub fn ssr(path: &str, init: Option<u32>) -> Result<(Response, Option<Vec<u8>>)> {
  12. let html = format!("{}", include_str!("./index.html"));
  13. let app = App::new(init.unwrap_or(1001), path.to_string());
  14. let state = app.store.borrow();
  15. let html = html.replace(HTML_PLACEHOLDER, &app.render().to_string());
  16. let html = html.replace(STATE_PLACEHOLDER, &state.to_json());
  17. Ok((Response {
  18. protocol: "HTTP/1.0".to_string(),
  19. code: 200,
  20. message: "OK".to_string(),
  21. headers: vec![
  22. Header {
  23. name: "content-type".to_string(),
  24. value: MimeType::from_ext("html").get(),
  25. },
  26. Header {
  27. name: "content-length".to_string(),
  28. value: html.len().to_string(),
  29. },
  30. ],
  31. content: html.into_bytes(),
  32. }, None))
  33. }
  34. /// Get raw file content
  35. pub fn file(path: &str) -> Result<(Response, Option<Vec<u8>>)> {
  36. let path = Path::new(&path);
  37. if path.exists() {
  38. let content_type: MimeType = match path.extension() {
  39. Some(ext) => MimeType::from_ext(ext.to_str().get_or_insert("")),
  40. None => MimeType::from_ext(""),
  41. };
  42. let content = read(path)?;
  43. Ok((Response {
  44. protocol: "HTTP/1.0".to_string(),
  45. code: 200,
  46. message: "OK".to_string(),
  47. headers: vec![
  48. Header {
  49. name: "content-type".to_string(),
  50. value: content_type.get(),
  51. },
  52. Header {
  53. name: "content-length".to_string(),
  54. value: content.len().to_string(),
  55. },
  56. ],
  57. content: vec![],
  58. }, Some(content)))
  59. } else {
  60. Ok((Response {
  61. protocol: "HTTP/1.0".to_string(),
  62. code: 404,
  63. message: "Not Found".to_string(),
  64. headers: vec![],
  65. content: vec![],
  66. }, None))
  67. }
  68. }
  69. /// Bad Request
  70. pub fn bad_request() -> Response {
  71. Response {
  72. protocol: "HTTP/1.0".to_string(),
  73. code: 400,
  74. message: "Bad Request".to_string(),
  75. headers: vec![],
  76. content: vec![],
  77. }
  78. }
  79. /// Internal Server Error
  80. pub fn internal_error() -> (Response, Option<Vec<u8>>) {
  81. (Response {
  82. protocol: "HTTP/1.0".to_owned(),
  83. code: 500,
  84. message: "Internal Server Error".to_owned(),
  85. headers: vec![],
  86. content: vec![],
  87. }, None)
  88. }
  89. }

mime.rs 中的代码将资源文件的拓展名映射成 MIME 类型。

  1. #![allow(unused)]
  2. fn main() {
  3. pub struct MimeType {
  4. pub r#type: String,
  5. }
  6. impl MimeType {
  7. pub fn new(r#type: &str) -> Self {
  8. MimeType {
  9. r#type: r#type.to_string(),
  10. }
  11. }
  12. pub fn from_ext(ext: &str) -> Self {
  13. match ext {
  14. "html" => MimeType::new("text/html"),
  15. "css" => MimeType::new("text/css"),
  16. "map" => MimeType::new("application/json"),
  17. "js" => MimeType::new("application/javascript"),
  18. "json" => MimeType::new("application/json"),
  19. "svg" => MimeType::new("image/svg+xml"),
  20. "wasm" => MimeType::new("application/wasm"),
  21. _ => MimeType::new("text/plain"),
  22. }
  23. }
  24. pub fn get(self) -> String {
  25. self.r#type
  26. }
  27. }
  28. }

就这么多! 现在让我们来构建并运行 Web 应用程序。如果你对原来的示例进行了测试,那你可能已经编译好了客户端的 WebAssembly。

cd client ./build-wasm.sh

接下来,构建并运行服务器。

cd ../server-wasmedge cargo build --target wasm32-wasi OUTPUT_CSS="$(pwd)/../client/build/app.css" wasmedge --dir /static:../client/build ../../../target/wasm32-wasi/debug/isomorphic-server-wasmedge.wasm

访问 http://127.0.0.1:3000 你就会发现 Web 应用程序在正常工作。

并且,你可以将所有这些步骤放进一个脚本 ../start-wasmedge.sh 里。

#!/bin/bash cd $(dirname $0) cd ./client ./build-wasm.sh cd ../server-wasmedge OUTPUT_CSS="$(pwd)/../client/build/app.css" cargo run -p isomorphic-server-wasmedge

然后将下面这些内容放入 .cargo/config.toml 中。

[build] target = "wasm32-wasi" [target.wasm32-wasi] runner = "wasmedge --dir /static:../client/build"

在这之后,只需要运行一个命令 ./start-wasmedge.sh 就可以执行所有任务,构建并运行我们的 Web 应用程序!

我们也 fork 了 Percy 仓库,为你创建了一个可以直接构建的示例。尽情享受编程的乐趣吧!