Rust Web 实践零散

目前,仅是个人 Rust Web 开发中的一些记录。

涵盖:Rust Web 生态中的 Tide、actix-web、Yew、async-graphql、surf、handlebars-rust、rhai、jsonwebtoken,以及 mongodb 等。

完整的内容,还在进一步规划和整理之中。

学以聚之,问以辩之。终日乾乾,与时偕行。

💥 适用于生产环境的实践示例项目,请参阅 zzy/surfer(github 仓库)(将持续升级):

  • 纯粹 Rust 技术栈实现的博客系统,有兴趣请访问演示站点:。
  • 后端(backend)主要提供 graphql 服务,使用了 tide, async-graphql, jsonwebtoken, mongodb 等相关 crate。
  • Yew 前端(frontend)提供了 Rust 和 Wasm 的融合解决方案,使用 yew 构建 WebAssembly 标准的 web 前端,涉及 yew、graphql_client、web-sys、gloo、cookie 等相关 crate。
  • Handlebars 前端(frontend)提供 web 应用服务,使用了 tide, rhai, surf, graphql_client, handlebars-rust, cookie 等相关 crate。

💥 关于清洁的模板项目,采用了纯粹的 Rust 技术栈。包括(将持续升级):

请参阅 github 仓库 tide-async-graphql-mongodb 或者 actix-web-async-graphql-rbatis。 目前实现了如下功能(将持续升级):

  • 用户注册
  • 使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算
  • 整合 JWT 鉴权的用户登录
  • 密码修改
  • 资料更新
  • 用户查询和变更
  • 项目查询和变更
  • 使用基于 Rust 实现 graphql-client 获取 GraphQL 服务端数据
  • 渲染 GraphQL 数据到 handlebars-rust 模板引擎

欢迎您的参与,以及沟通交流。

Rust Web 后端开发

使用 Rust Web 框架 Tide 和 actix-web 构建异步 Rust GraphQL 服务——

目前实现了如下功能(将持续升级):

  • 用户注册
  • 使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算
  • 整合 JWT 鉴权的用户登录
  • 密码修改
  • 资料更新
  • 用户查询和变更
  • 项目查询和变更
  • 使用基于 Rust 实现 graphql-client 获取 GraphQL 服务端数据
  • 渲染 GraphQL 数据到 handlebars-rust 模板引擎

基于 tide + async-graphql + mongodb 构建 Rust 异步 GraphQL 服务

基于 基于 tide + async-graphql + mongodb,请参阅 github 仓库 tide-async-graphql-mongodb

目前实现了如下功能(将持续升级):

  • 用户注册
  • 使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算
  • 整合 JWT 鉴权的用户登录
  • 密码修改
  • 资料更新
  • 用户查询和变更
  • 项目查询和变更
  • 使用基于 Rust 实现 graphql-client 获取 GraphQL 服务端数据
  • 渲染 GraphQL 数据到 handlebars-rust 模板引擎

基于 tide + async-graphql + mongodb 构建 Rust 异步 GraphQL 服务(1)- 起步及 crate 选择

本系列博客中,我们使用 Tide + async-grapqhl + mongodb + jsonwebtoken + handlebars-rust 构建基于 Rust 技术栈的 GraphQL 服务,我们需要做到前后端分离。

需要说明的是:本博客即采用前述 Rust 技术栈搭建,目前仍然处于开发阶段。

  1. 后端主要提供 GraphQL 服务,使用到的 crate 包括:tideasync-graphql、jsonwebtoken、mongodb/bson、serde、ring、base64,以及 pinyin 等。
  2. 前端主要 WEB 应用服务,使用到 crate 包括:tiderhai、surf、graphql_client、handlebars-rust、cookie 等。

Rust 环境的配置,cargo 工具的使用,以及 Rust 程序设计语言和 GraphQL 的介绍和优势,在此不在赘述。您可以参阅如下资料学习 Rust 程序设计语言,以及 Rust 生态中的 GraphQL 实现。

其它概念性、对比类的内容,请您自行搜索。

工程的创建

文章的开始提到,我们要做到前后端分离。因此,前、后端需要各自创建一个工程。同时,我们要使用 cargo 对工程进行管理和组织。

  • 首先,创新本次工程根目录和 cargo 清单文件
mkdir rust-graphql
cd ./rust-graphql

touch Cargo.toml 

Cargo.toml 文件中,填入以下内容:

[workspace]
members = [
  "./backend",
  "./frontend",
]

文件中,workspace 是 cargo 中的工作区。cargo 中,工作区共享公共依赖项解析(即具有共享 Cargo.lock),输出目录和各种设置,如配置文件等的一个或多个包的集合。

虚拟工作区是 Cargo.toml 清单中,根目录的工作空间,不需要定义包,只列出工作区成员即可。

上述配置中,包含 2 个成员 \backend\\ 和 \frontend\\,即我们需要创建 2 个工程(请注意您处于 rust-graphql 目录中):前端和后端 —— 均为二进制程序,所以传递 --bin 参数,或省略参数。

cargo new backend --bin
cargo new frontend --bin

创建后,工程结构如下图所示——

工程结构

现在,工程已经创建完成了。

工具类 crate 安装

工程创建完成后,我们即可以进入开发环节了。开发中,一些工具类 crate 可以起到“善其事”的作用,我们需要先进行安装。

  • cargo-edit,包含 cargo addcargo rm,以及 cargo upgrade,可以让我们方便地管理 crate。
  • cargo-watch,监视项目的源代码,以了解其更改,并在源代码发生更改时,运行 Cargo 命令。

好的,我们安装这 2 个 crate。

cargo install cargo-edit
cargo install cargo-watch

安装依赖较多,如果时间较长,请配置 Rust 工具链的国内源

添加依赖 crate

接着,我们需要添加开发所需依赖项。依赖项的添加,我们不用一次性全部添加,我们根据开发需要,一步步添加。首先,从后端工程开始。

后端工程中,我们提供 GraphQL 服务,需要依赖的基本 crate 有 tide、async-std、async-graphql、mongodb,以及 bson。我们使用 cargo add 命令来安装,其将安装最新版本。

cd backend
cargo add tide async-std async-graphql mongodb bson

安装依赖较多,如果时间较长,请配置 Cargo 国内镜像源

执行上述命令后,rust-graphql/backend/Cargo.toml 内容如下所示——

...
[dependencies]
async-graphql = "2.6.0"
async-std = "1.9.0"
bson = "1.2.0"
mongodb = "1.2.0"
tide = "0.16.0"
...

至此,我们构建基于 Rust 技术栈的 Graphql 服务的后端基础工程已经搭建完成。暂时休息一会,我们开始构建一个最基本的 GraphQL 服务器。

谢谢您的阅读。


基于 tide + async-graphql + mongodb 构建 Rust 异步 GraphQL 服务(2)- 查询服务

上一篇文章中,我们对后端基础工程进行了初始化。其中,笔者选择 Rust 生态中的 4 个 crate:tide、async-std、async-graphql、mongodb(bson 主要为 mongodb 应用)。虽然我们不打算对 Rust 生态中的 crate 进行介绍和比较,但想必有朋友对这几个选择有些疑问,比如:tide 相较于 actix-web,可称作冷门、不成熟,postgresql 相较于 mongodb 操作的便利性等。

笔者在 2018-2019 年间,GraphQL 服务后端,一直使用的是 actix-web + juniper + postgresql 的组合,应用前端使用了 typescript + react + apollo-client,有兴趣可以参阅开源项目 actix-graphql-react

2020 年,笔者才开始了 tide + async-graphql 的应用开发,在此,笔者简单提及下选型理由——

  1. Tide:tide 的定位是最小的、实用的 Rust web 应用服务框架,基于 async-std。其相较于 Rust 社区中火热的 actix-web,确实可以说冷门。至于生态成熟度,也有诸多差距。但我们在提供 GraphQL 服务时,主要需要的是基础的 HTTP 服务器。tide 目前的功能和性能是完全满足的,并且经笔者测试后,对其健壮性也很满意。
  2. async-graphql:优秀的 crate,性能很棒,以及开发时的简洁性,个人对其喜欢程度胜过 juniper。
  3. 至于 postgresql 转为 mongodb,只是一时兴起。本来计划 elasticsearch 的,只是个人服务器跑起来不给力。

Rust 社区生态中,健壮的 web 应用服务框架很多,您可以参考 Rust web 框架比较 一文自行比较选择。

上文中,未有进行任何代码编写。本文中,我们将开启基础 GraphQL 服务的历程。包括如下内容:

1、构建 GraphQL Schema;

2、整合 Tide 和 async-graphql;

3、验证 query 服务;

4、连接 MongoDB;

5、提供 query 服务。

构建 GraphQL Schema

首先,让我们将 GraphQL 服务相关的代码都放到一个模块中。为了避免下文啰嗦,我称其为 GraphQL 总线。

cd ./rust-graphql/backend/src
mkdir gql
cd ./gql
touch mod.rs queries.rs mutations.rs

构建一个查询示例

  • 首先,我们构建一个不连接数据库的查询示例:通过一个函数进行求合运算,将其返回给 graphql 查询服务。此实例改编自 async-graphql 文档,仅用于验证环境配置,实际环境没有意义。

下面代码中,注意变更 EmptyMutation 和订阅 EmptySubscription 都是空的,甚至 mutations.rs 文件都是空白,未有任何代码,仅为验证服务器正确配置。

mod.rs 文件中,写入以下代码:


pub mod mutations;
pub mod queries;

use tide::{http::mime, Body, Request, Response, StatusCode};

use async_graphql::{
    http::{playground_source, receive_json, GraphQLPlaygroundConfig},
    EmptyMutation, EmptySubscription, Schema,
};

use crate::State;

use crate::gql::queries::QueryRoot;

pub async fn build_schema() -> Schema<QueryRoot, EmptyMutation, EmptySubscription> {
    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global mongodb datasource  in the schema object.
    // let mut schema = Schema::new(QueryRoot, MutationRoot, EmptySubscription)
    Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish()
}

pub async fn graphql(req: Request<State>) -> tide::Result {
    let schema = req.state().schema.clone();
    let gql_resp = schema.execute(receive_json(req).await?).await;

    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(Body::from_json(&gql_resp)?);

    Ok(resp.into())
}

pub async fn graphiql(_: Request<State>) -> tide::Result {
    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(playground_source(GraphQLPlaygroundConfig::new("graphql")));
    resp.set_content_type(mime::HTML);

    Ok(resp.into())
}

上面的示例代码中,函数 graphqlgraphiql 作为 tide 服务器的请求处理程序,因此必须返回 tide::Result

tide 开发很简便,本文不是重点。请参阅 tide 中文文档,很短时间即可掌握。

编写求和实例,作为 query 服务

queries.rs 文件中,写入以下代码:


pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    async fn add(&self, a: i32, b: i32) -> i32 {
        a + b
    }
}

整合 Tide 和 async-graphql

终于,我们要进行 tide 服务器主程序开发和启动了。进入 ./backend/src 目录,迭代 main.rs 文件:


mod gql;

use crate::gql::{build_schema, graphiql, graphql};

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    // tide logger
    tide::log::start();

    // 初始 Tide 应用程序状态
    let schema = build_schema().await;
    let app_state = State { schema: schema };
    let mut app = tide::with_state(app_state);

    // 路由配置
    app.at("graphql").post(graphql);
    app.at("graphiql").get(graphiql);

    app.listen(format!("{}:{}", "127.0.0.1", "8080")).await?;

    Ok(())
}

//  Tide 应用程序作用域状态 state.
#[derive(Clone)]
pub struct State {
    pub schema: async_graphql::Schema<
        gql::queries::QueryRoot,
        async_graphql::EmptyMutation,
        async_graphql::EmptySubscription,
    >,
}

注1:上面代码中,我们将 schema 放在了 tide 的状态 State 中,其作用域是应用程序级别的,可以很方便地进行原子操作。

注2:另外,关于 tide 和 async-graphql 的整合,async-graphql 提供了整合 crate:async_graphql_tide。但笔者测试后未使用,本文也未涉及,您感兴趣的话可以选择。

验证 query 服务

启动 tide 服务

以上,一个基础的基于 Rust 技术栈的 GraphQL 服务器已经开发成功了。我们验证以下是否正常,请执行——

cargo run

更推荐您使用我们前一篇文章中安装的 cargo watch 来启动服务器,这样后续代码的修改,可以自动部署,无需您反复对服务器进行停止和启动操作。

cargo watch -x "run"

但遗憾的是——此时,你会发现服务器无法启动,因为上面的代码中,我们使用了 #[async_std::main] 此类的 Rust 属性标记。所以,我们还需要稍微更改一下 backend/Cargo.toml 文件。

请注意,不是根目录 rust-graphql/Cargo.toml 文件。

同时,MongoDB 驱动程序中,支持的异步运行时 crate 为 tokio,我们其它如 tide 和 async-graphql 都是基于 async-std 异步库的,所以我们一并修改。最终,backend/Cargo.toml 文件内容如下(有强迫症,也调整了一下顺序,您随意):

...
[dependencies]
tide = "0.16.0"
async-std = { version = "1.9.0", features = ["attributes"] }

async-graphql = "2.6.0"
mongodb = { version = "1.2.0", default-features = false, features = ["async-std-runtime"] }
bson = "1.2.0"
...

再次执行 cargo run 命令,您会发现服务器已经启动成功,启动成功后的消息为:

tide::log Logger started
    level Info
tide::server Server listening on http://127.0.0.1:8080

执行 GraphQL 查询

请打开您的浏览器,输入 http://127.0.0.1:8080/graphiql,您会看到如下界面(点击右侧卡片 docs 和 schema 查看详细):

graphiql

如图中示例,在左侧输入:

query {
  add(a: 110, b: 11)
}

右侧的返回结果为:

{
  "data": {
    "add": 121
  }
}

基础的 GraphQL 查询服务成功!

连接 MongoDB

创建 MongoDB 数据源

为了做到代码仓库风格的统一,以及扩展性。目前即使只需要连接 MongoDB 数据库,我们也将其放到一个模块中。

下面的示例中,即使本地连接,我也开启了身份验证。请您自行配置数据库,或者免密访问。

cd ./rust-graphql/backend/src
mkdir dbs
touch ./dbs/mod.rs ./dbs/mongo.rs

mongo.rs 中,编写如下代码:


use mongodb::{Client, options::ClientOptions, Database};

pub struct DataSource {
    client: Client,
    pub db_budshome: Database,
}

#[allow(dead_code)]
impl DataSource {
    pub async fn client(&self) -> Client {
        self.client.clone()
    }

    pub async fn init() -> DataSource {
        // 解析 MongoDB 连接字符串到 options 结构体中
        let mut client_options =
            ClientOptions::parse("mongodb://mongo:mongo@localhost:27017")
                .await
                .expect("Failed to parse options!");
        // 可选:自定义一个 options 选项
        client_options.app_name = Some("tide-graphql-mongodb".to_string());

        // 客户端句柄
        let client = Client::with_options(client_options)
            .expect("Failed to initialize database!");

        // 数据库句柄
        let db_budshome = client.database("budshome");

        // 返回值 mongodb datasource
        DataSource { client: client, db_budshome: db_budshome }
    }
}

mod.rs 中,编写如下代码:


pub mod mongo;
// pub mod postgres;
// pub mod mysql;

创建集合及文档

在 MongoDB 中,创建集合 users,并构造几个文档,示例数据如下:

{
    "_id": ObjectId("600b7a5300adcd7900d6cc1f"),
    "email": "iok@rusthub.org",
    "username": "我是ok",
    "cred": "peCwspEaVw3HB05ObIpnGxgK2VSQOCmgxjzFEOY+fk0="
}
  • _id 是由 MongoDB 自动产生的,,与系统时间相关;
  • cred 是使用 PBKDF2 对用户密码进行加密(salt)和散列(hash)运算后产生的密码,后面会有详述。此处,请您随意。

提供 query 服务

Schema 中添加 MongoDB 数据源

前文小节我们创建了 MongoDB 数据源,欲在 async-graphql 中是获取和使用 MongoDB 数据源,由如下方法——

  1. 作为 async-graphql 的全局数据;
  2. 作为 Tide 的应用状态 State,优势是可以作为 Tide 服务器状态,进行原子操作;
  3. 使用 lazy-static.rs,优势是获取方便,简单易用。

如果不作前后端分离,为了方便前端的数据库操作,那么 2 和 3 是比较推荐的,特别是使用 crate lazy-static,存取方便。笔者看到很多开源实例和一些成熟的 rust-web 应用都采用 lazy-static。

虽然 2 和 3 方便、简单,以及易用。但是本应用中,我们仅需要 tide 作为一个服务器提供 http 服务,MongoDB 数据源也仅是为 async-graphql 使用。因此,我采用作为 async-graphql 的全局数据,将其构建到 Schema 中。

基于上述思路,我们迭代 backend/src/gql/mod.rs 文件:

pub mod mutations;
pub mod queries;

use tide::{http::mime, Body, Request, Response, StatusCode};

use async_graphql::{
    http::{playground_source, receive_json, GraphQLPlaygroundConfig},
    EmptyMutation, EmptySubscription, Schema,
};

use crate::State;

use crate::dbs::mongo;

use crate::gql::queries::QueryRoot;

pub async fn build_schema() -> Schema<QueryRoot, EmptyMutation, EmptySubscription> {
    // 获取 mongodb datasource 后,可以将其增加到:
    // 1. 作为 async-graphql 的全局数据;
    // 2. 作为 Tide 的应用状态 State;
    // 3. 使用 lazy-static.rs
    let mongo_ds = mongo::DataSource::init().await;

    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global mongodb datasource  in the schema object.
    // let mut schema = Schema::new(QueryRoot, MutationRoot, EmptySubscription)
    Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
        .data(mongo_ds)
        .finish()
}

pub async fn graphql(req: Request<State>) -> tide::Result {
    let schema = req.state().schema.clone();
    let gql_resp = schema.execute(receive_json(req).await?).await;

    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(Body::from_json(&gql_resp)?);

    Ok(resp.into())
}

pub async fn graphiql(_: Request<State>) -> tide::Result {
    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(playground_source(GraphQLPlaygroundConfig::new("graphql")));
    resp.set_content_type(mime::HTML);

    Ok(resp.into())
}

开发一个查询服务,自 MongoDB 集合 users 集合查询所有用户:

增加 users 模块,及分层阐述

一个完整的 GraphQL 查询服务,在本应用项目——注意,非 Tide 或者 GraphQL 技术分层——我们可以简单将其分为三层:

  • tide handle:发起一次 GraphQL 请求,通知 GraphQL 总线执行 GraphQL service 调用,以及接收和处理响应;
  • GraphQL 总线:分发 GraphQL service 调用;
  • service:负责执行具体的查询服务,从 MongoDB 数据获取数据,并封装到 model 中;

基于上述思路,我们想要开发一个查询所有用户的 GraphQL 服务,需要增加 users 模块,并创建如下文件:

cd ./backend/src
mkdir users
cd users
touch mod.rs models.rs services.rs

至此,本篇文章的所有文件已经创建,先让我们查看一下总体的 backend 工程结构,如下图所示:

backend 完成文件结构

其中 users/mod.rs 文件内容为:

pub mod models;
pub mod services;

我们也需要将 users 模块添加到 main.rs 中:

mod dbs;
mod gql;
mod users;

use crate::gql::{build_schema, graphiql, graphql};

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    // tide logger
    tide::log::start();

    // 初始 Tide 应用程序状态
    let schema = build_schema().await;
    let app_state = State { schema: schema };
    let mut app = tide::with_state(app_state);

    // 路由配置
    app.at("graphql").post(graphql);
    app.at("graphiql").get(graphiql);

    app.listen(format!("{}:{}", "127.0.0.1", "8080")).await?;

    Ok(())
}

//  Tide 应用程序作用域状态 state.
#[derive(Clone)]
pub struct State {
    pub schema: async_graphql::Schema<
        gql::queries::QueryRoot,
        async_graphql::EmptyMutation,
        async_graphql::EmptySubscription,
    >,
}

编写 User 模型

users/models.rs 文件中添加:

use serde::{Serialize, Deserialize};
use bson::oid::ObjectId;

#[derive(Serialize, Deserialize, Clone)]
pub struct User {
    pub _id: ObjectId,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::Object]
impl User {
    pub async fn id(&self) -> ObjectId {
        self._id.clone()
    }

    pub async fn email(&self) -> &str {
        self.email.as_str()
    }

    pub async fn username(&self) -> &str {
        self.username.as_str()
    }
}

上述代码中,User 结构体中定义的字段类型为 String,但结构体实现中返回为 &str,这是因为 Rust 中 String 未有默认实现 copy trait。如果您希望结构体实现中返回 String,可以通过 clone() 方法实现:

    pub async fn email(&self) -> String {
        self.email.clone()
    }

您使用的 IDE 比较智能,或许会有报错,先不要管,我们后面一并处理。

编写 service

users/services.rs 文件中添加代码,如下:

use async_graphql::{Error, ErrorExtensions};
use futures::stream::StreamExt;
use mongodb::Database;

use crate::users::models::User;

pub async fn all_users(db: Database) -> std::result::Result<Vec<User>, async_graphql::Error> {
    let coll = db.collection("users");

    let mut users: Vec<User> = vec![];

    // Query all documents in the collection.
    let mut cursor = coll.find(None, None).await.unwrap();

    // Iterate over the results of the cursor.
    while let Some(result) = cursor.next().await {
        match result {
            Ok(document) => {
                let user = bson::from_bson(bson::Bson::Document(document)).unwrap();
                users.push(user);
            }
            Err(error) => Err(Error::new("6-all-users")
                .extend_with(|_, e| e.set("details", format!("Error to find doc: {}", error))))
            .unwrap(),
        }
    }

    if users.len() > 0 {
        Ok(users)
    } else {
        Err(Error::new("6-all-users").extend_with(|_, e| e.set("details", "No records")))
    }
}

您使用的 IDE 比较智能,或许会有报错,先不要管,我们后面一并处理。

在 GraphQL 总线中调用 service

迭代 gql/queries.rs 文件,最终为:

use async_graphql::Context;

use crate::dbs::mongo::DataSource;
use crate::users::{self, models::User};

pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    // Get all Users,
    async fn all_users(
        &self,
        ctx: &Context<'_>,
    ) -> std::result::Result<Vec<User>, async_graphql::Error> {
        let db = ctx.data_unchecked::<DataSource>().db_budshome.clone();
        users::services::all_users(db).await
    }
}

Okay,如果您使用的 IDE 比较智能,可以看到现在已经是满屏的红、黄相配了。代码是没有问题的,我们只是缺少几个使用到的 crate。

  • 首先,执行命令:
cargo add serde futures
  • 其次,因为我们使用到了 serde crate 的 derive trait,因此需要迭代 backend/Cargo.toml 文件,最终版本为:
[package]
name = "backend"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
futures = "0.3.13"
tide = "0.16.0"
async-std = { version = "1.9.0", features = ["attributes"] }

serde = { version = "1.0.124", features = ["derive"] }
async-graphql = "2.6.0"
mongodb = { version = "1.2.0", default-features = false, features = ["async-std-runtime"] }
bson = "1.2.0"

现在,重新运行 cargo build,可以发现红、黄错误已经消失殆尽了。执行 cargo watch -x "run" 命令会发现启动成功。

最后,我们来执行 GraphQL 查询,看看是否取出了 MongoDB 中集合 users 中的所有数据。

左侧输入:

# Write your query or mutation here
query {
  allUsers {
    id
    email
    username
  }
}

右侧返回结果依赖于您在集合中添加了多少文档,如我的查询结果为:

{
  "data": {
    "allUsers": [
      {
        "email": "ok@rusthub.org",
        "id": "5ff82b2c0076cc8b00e5cddb",
        "username": "我谁24ok32"
      },
      {
        "email": "oka@rusthub.org",
        "id": "5ff83f4b00e8fda000e5cddc",
        "username": "我s谁24ok32"
      },
      {
        "email": "oka2@rusthub.org",
        "id": "5ffd710400b6b84e000349f8",
        "username": "我2s谁24ok32"
      },
      {
        "email": "afasf@rusthub.org",
        "id": "5ffdb3fa00bbdf3a007a2988",
        "username": "哈哈"
      },
      {
        "email": "oka22@rusthub.org",
        "id": "600b7a2700e7c21500d6cc1e",
        "username": "我22s谁24ok32"
      },
      {
        "email": "iok@rusthub.org",
        "id": "600b7a5300adcd7900d6cc1f",
        "username": "我是ok"
      },
      {
        "email": "iok2@rusthub.org",
        "id": "600b8064000a5ca30024199e",
        "username": "我是ok2"
      }
    ]
  }
}

好的,以上就是一个完成的 GraphQL 查询服务(此处应有掌声 :-))。

如果您一步步跟着操作,是很难不成功的,但总有例外,欢迎交流。

下篇摘要

目前我们成功开发了一个基于 Rust 技术栈的 GraphQL 查询服务,但本例代码是不够满意的,如冗长的返回类型 std::result::Result<Vec<User>, async_graphql::Error>,如太多的魔术代码。

下篇中,我们先不进行 GraphQL mutation 的开发。我将对代码进行重构——

  • 应用配置文件;
  • 代码抽象。

谢谢您的阅读。


基于 tide + async-graphql + mongodb 构建 Rust 异步 GraphQL 服务(3)- 重构

行文开始,先感谢几位指导的老师。根据指导,文章的标题做了更符合撰写目标和使用类库的更改,另外也修改了上篇文章中的笔误。因为笔者是先写 markdown 文件,然后粘贴到 web 页面。尤其是这个博客是笔者“三天打鱼两天晒网”的方式开发的,目前很不完善,所以粘贴时,调整顺序等情形,容易产生张冠李戴的情况。总之,欢迎各位指正。谢谢!

首先,我们通过 shell 命令 cd ./rust-graphql/backend 进入后端工程目录(下文中,将默认在此目录执行操作)。

配置信息的存储和获取

让我们设想正式生产环境的应用场景:

  • 服务器地址和端口的变更可能;
  • 服务功能升级,对用户暴露 API 地址的变更可能。如 rest api,graphql api,以及版本升级;
  • 服务站点密钥定时调整的可能;
  • 服务站点安全调整,jwt、session/cookie 过期时间的变更可能。

显然易见,我们应当避免每次变更调整时,都去重新编译一次源码——并且,大工程中,Rust 的编译速度让开发者注目。更优的方法是,将这些写入到配置文件中。或许上述第 4 点无需写入,但是文件存储到加密保护的物理地址,安全方面也有提升。

当然,实际的应用场景或许有更合适有优的解决方法,但我们先基于此思路来设计。Rust 中,dotenv crate 用来读取环境变量。取得环境变量后,我们将其作为静态或者惰性值来使用,静态或者惰性值相关的 crate 有 lazy_staticonce_cell 等,都很简单易用。此示例中,我们使用 lazy_static

创建 .env,添加读取相关 crate

增加这 2 个 crate,并且在 backend 目录创建 .env 文件。

cargo add dotenv lazy_static
touch .env

.env 文件中,写入如下内容:

# 服务器信息
ADDRESS=127.0.0.1
PORT=8080

# API 服务信息,“gql” 也可以单独提出来定义
GRAPHQL_PATH=v1
GRAPHIQL_PATH=v1i

# 数据库配置
MONGODB_URI=mongodb://mongo:mongo@localhost:27017
MONGODB_BUDSHOME=budshome

# 站点安全相关,此处仅为内容填充,后面的文章中会使用
SITE_KEY=0F4EHz+1/hqVvZjuB8EcooQs1K6QKBvLUxqTHt4tpxE=
CLAIM_EXP=10000000000

Cargo.toml 文件:

[package]
name = "backend"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
futures = "0.3.13"
tide = "0.16.0"
async-std = { version = "1.9.0", features = ["attributes"] }

dotenv = "0.15.0"
lazy_static = "1.4.0"

async-graphql = "2.6.0"
mongodb = { version = "1.2.0", default-features = false, features = ["async-std-runtime"] }
bson = "1.2.0"
serde = { version = "1.0.124", features = ["derive"] }

读取配置文件并使用配置信息

对于配置信息的读取和使用,显然属于公用功能,我们将其归到单独的模块中。所以,需要创建 2 个文件:一个是模块标识文件,一个是将抽象出来共用的常量子模块。

cd ./src
mkdir util
touch ./util/mod.rs ./util/constant.rs

至此,本篇文章的所有文件都已经创建,我们确认一下工程结构。

backend 工程结构

  • util/mod.rs,编写如下代码:
pub mod constant;
  • 读取配置信息

util/constant.rs 中,编写如下代码:

use dotenv::dotenv;
use lazy_static::lazy_static;
use std::collections::HashMap;

lazy_static! {
    // CFG variables defined in .env file
    pub static ref CFG: HashMap<&'static str, String> = {
        dotenv().ok();

        let mut map = HashMap::new();

        map.insert(
            "ADDRESS",
            dotenv::var("ADDRESS").expect("Expected ADDRESS to be set in env!"),
        );
        map.insert(
            "PORT",
            dotenv::var("PORT").expect("Expected PORT to be set in env!"),
        );

        map.insert(
            "GRAPHQL_PATH",
            dotenv::var("GRAPHQL_PATH").expect("Expected GRAPHQL_PATH to be set in env!"),
        );
        map.insert(
            "GRAPHIQL_PATH",
            dotenv::var("GRAPHIQL_PATH").expect("Expected GRAPHIQL_PATH to be set in env!"),
        );

        map.insert(
            "MONGODB_URI",
            dotenv::var("MONGODB_URI").expect("Expected MONGODB_URI to be set in env!"),
        );
        map.insert(
            "MONGODB_BUDSHOME",
            dotenv::var("MONGODB_BUDSHOME").expect("Expected MONGODB_BUDSHOME to be set in env!"),
        );

        map.insert(
            "SITE_KEY",
            dotenv::var("SITE_KEY").expect("Expected SITE_KEY to be set in env!"),
        );
        map.insert(
            "CLAIM_EXP",
            dotenv::var("CLAIM_EXP").expect("Expected CLAIM_EXP to be set in env!"),
        );

        map
    };
}
  • 重构代码,使用配置信息,正确提供 GraphQL 服务

首先,src/main.rs 文件中引入 util 模块。并用 use 引入 constant 子模块,读取其惰性配置值。

mod dbs;
mod gql;
mod users;
mod util;

use crate::gql::{build_schema, graphiql, graphql};
use crate::util::constant::CFG;

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    // tide logger
    tide::log::start();

    // 初始 Tide 应用程序状态
    let schema = build_schema().await;
    let app_state = State { schema: schema };
    let mut app = tide::with_state(app_state);

    // 路由配置
    app.at(CFG.get("GRAPHQL_PATH").unwrap()).post(graphql);
    app.at(CFG.get("GRAPHIQL_PATH").unwrap()).get(graphiql);

    app.listen(format!(
        "{}:{}",
        CFG.get("ADDRESS").unwrap(),
        CFG.get("PORT").unwrap()
    ))
    .await?;

    Ok(())
}

//  Tide 应用程序作用域状态 state.
#[derive(Clone)]
pub struct State {
    pub schema: async_graphql::Schema<
        gql::queries::QueryRoot,
        async_graphql::EmptyMutation,
        async_graphql::EmptySubscription,
    >,
}

其次,src/gql/mod.rs 文件中,用 use 引入 constant 子模块,读取其惰性配置值。

pub mod mutations;
pub mod queries;

use crate::util::constant::CFG;
use tide::{http::mime, Body, Request, Response, StatusCode};

use async_graphql::{
    http::{playground_source, receive_json, GraphQLPlaygroundConfig},
    EmptyMutation, EmptySubscription, Schema,
};

use crate::State;

use crate::dbs::mongo;

use crate::gql::queries::QueryRoot;

pub async fn build_schema() -> Schema<QueryRoot, EmptyMutation, EmptySubscription> {
    // 获取 mongodb datasource 后,可以将其增加到:
    // 1. 作为 async-graphql 的全局数据;
    // 2. 作为 Tide 的应用状态 State;
    // 3. 使用 lazy-static.rs
    let mongo_ds = mongo::DataSource::init().await;

    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global mongodb datasource  in the schema object.
    // let mut schema = Schema::new(QueryRoot, MutationRoot, EmptySubscription)
    Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
        .data(mongo_ds)
        .finish()
}

pub async fn graphql(req: Request<State>) -> tide::Result {
    let schema = req.state().schema.clone();
    let gql_resp = schema.execute(receive_json(req).await?).await;

    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(Body::from_json(&gql_resp)?);

    Ok(resp.into())
}

pub async fn graphiql(_: Request<State>) -> tide::Result {
    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(playground_source(GraphQLPlaygroundConfig::new(
        CFG.get("GRAPHQL_PATH").unwrap(),
    )));
    resp.set_content_type(mime::HTML);

    Ok(resp.into())
}

最后,不要忘了 src/dbs/mongo.rs 文件中,用 use 引入 constant 子模块,读取其惰性配置值。


use crate::util::constant::CFG;

use mongodb::{Client, options::ClientOptions, Database};

pub struct DataSource {
    client: Client,
    pub db_budshome: Database,
}

#[allow(dead_code)]
impl DataSource {
    pub async fn client(&self) -> Client {
        self.client.clone()
    }

    pub async fn init() -> DataSource {
        // Parse a connection string into an options struct.
        // environment variables defined in .env file
        let mut client_options =
            ClientOptions::parse(CFG.get("MONGODB_URI").unwrap())
                .await
                .expect("Failed to parse options!");
        // Manually set an option.
        client_options.app_name = Some("tide-graphql-mongodb".to_string());

        // Get a handle to the deployment.
        let client = Client::with_options(client_options)
            .expect("Failed to initialize database!");

        // Get a handle to a database.
        let db_budshome = client.database(CFG.get("MONGODB_BUDSHOME").unwrap());

        // return mongodb datasource.
        DataSource { client: client, db_budshome: db_budshome }
    }
}

配置文件读取已经完成,我们测试看看。这次,我们浏览器中要打开的链接为 http://127.0.0.1:8080/v1i

graphiql ui

执行查询,一切正常。

graphql query

代码简洁性重构,定义公用类型

在上一篇构建 Rust 异步 GraphQL 服务:基于 tide + async-graphql + mongodb(2)- 查询服务文章中,gql/queries.rsusers/services.rs 代码中,all_users 函数/方法的返回值为冗长的 std::result::Result<Vec<User>, async_graphql::Error>。显然,这样代码不够易读和简洁。我们简单重构下:定义一个公用的 GqlResult 类型即可。

  • 首先,迭代 util/constant.rs 文件,增加一行:定义 GqlResult 类型别名:
use dotenv::dotenv;
use lazy_static::lazy_static;
use std::collections::HashMap;

pub type GqlResult<T> = std::result::Result<T, async_graphql::Error>;

lazy_static! {
    // CFG variables defined in .env file
    pub static ref CFG: HashMap<&'static str, String> = {
        dotenv().ok();

        let mut map = HashMap::new();

        map.insert(
            "ADDRESS",
            dotenv::var("ADDRESS").expect("Expected ADDRESS to be set in env!"),
        );
        map.insert(
            "PORT",
            dotenv::var("PORT").expect("Expected PORT to be set in env!"),
        );

        map.insert(
            "GRAPHQL_PATH",
            dotenv::var("GRAPHQL_PATH").expect("Expected GRAPHQL_PATH to be set in env!"),
        );
        map.insert(
            "GRAPHIQL_PATH",
            dotenv::var("GRAPHIQL_PATH").expect("Expected GRAPHIQL_PATH to be set in env!"),
        );

        map.insert(
            "MONGODB_URI",
            dotenv::var("MONGODB_URI").expect("Expected MONGODB_URI to be set in env!"),
        );
        map.insert(
            "MONGODB_BUDSHOME",
            dotenv::var("MONGODB_BUDSHOME").expect("Expected MONGODB_BUDSHOME to be set in env!"),
        );

        map.insert(
            "SITE_KEY",
            dotenv::var("SITE_KEY").expect("Expected SITE_KEY to be set in env!"),
        );
        map.insert(
            "CLAIM_EXP",
            dotenv::var("CLAIM_EXP").expect("Expected CLAIM_EXP to be set in env!"),
        );

        map
    };
}
  • 其次,迭代 gql/queries.rsusers/services.rs 文件,引入并让函数/方法返回 GqlResult 类型。

gql/queries.rs

use async_graphql::Context;

use crate::dbs::mongo::DataSource;
use crate::users::{self, models::User};
use crate::util::constant::GqlResult;

pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    // Get all Users,
    async fn all_users(&self, ctx: &Context<'_>) -> GqlResult<Vec<User>> {
        let db = ctx.data_unchecked::<DataSource>().db_budshome.clone();
        users::services::all_users(db).await
    }
}

users/services.rs

use async_graphql::{Error, ErrorExtensions};
use futures::stream::StreamExt;
use mongodb::Database;

use crate::users::models::User;
use crate::util::constant::GqlResult;

pub async fn all_users(db: Database) -> GqlResult<Vec<User>> {
    let coll = db.collection("users");

    let mut users: Vec<User> = vec![];

    // Query all documents in the collection.
    let mut cursor = coll.find(None, None).await.unwrap();

    // Iterate over the results of the cursor.
    while let Some(result) = cursor.next().await {
        match result {
            Ok(document) => {
                let user = bson::from_bson(bson::Bson::Document(document)).unwrap();
                users.push(user);
            }
            Err(error) => Err(Error::new("6-all-users")
                .extend_with(|_, e| e.set("details", format!("Error to find doc: {}", error))))
            .unwrap(),
        }
    }

    if users.len() > 0 {
        Ok(users)
    } else {
        Err(Error::new("6-all-users").extend_with(|_, e| e.set("details", "No records")))
    }
}

第一次重构,我们就到这个程度。下一篇,我们将进行 GraphQL 变更(mutation)的开发。

谢谢您的阅读。


基于 tide + async-graphql + mongodb 构建 Rust 异步 GraphQL 服务(4)- 变更服务,以及第二次重构

构建 Rust 异步 GraphQL 服务:基于 tide + async-graphql + mongodb(3)- 第一次重构之后,因这段时间事情较多,所以一直未着手变更服务的开发示例。现在私事稍稍告一阶段,让我们一起进行变更服务的开发,以及第二次重构。

一点意外

首先要说,和笔者沟通使用 Tide 框架做 Rust Web 开发的朋友之多,让笔者感到意外。因为 Tide 框架的社区,目前并不活跃,很多 bug 已经拖很久了。大部分实践,笔者接触到的 Rust Web 开发人员,都未选择 Tide 框架

对于使用 Tide 框架做 GraphQL 开发的朋友,笔者有一个基于 tide、async-graphql,以及 mongodb 实现 GraphQL 服务的较完整项目模板,实现了如下功能:

  • 用户注册
  • 使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算
  • 整合 JWT 鉴权的用户登录
  • 密码修改、资料更新
  • 用户查询和变更、项目查询和变更
  • 使用基于 Rust 实现 graphql-client 获取 GraphQL 服务端数据
  • 渲染 GraphQL 数据到 handlebars-rust 模板引擎

更多详细功能请参阅 github 仓库 tide-async-graphql-mongodb,欢迎朋友们参与,共同完善。

另外,基于此模板项目,笔者正在以“三天打鱼,两天晒网”的方式开发一个博客,即本博文发布的站点,也开源在 github 仓库 surfer。同样,欢迎朋友们参与,共同完善。

接下来,让我进行基于 tide + async-graphql + mongodb 开发 GraphQL 服务的第二次重构。

依赖项更新

构建 Rust 异步 GraphQL 服务:基于 tide + async-graphql + mongodb(3)- 第一次重构之后,已经大抵过去一个月时间了。这一个月以来,活跃的 Rust 社区生态,进行了诸多更新:Rust 版本已经为 1.51.0Rust 2021 版即将发布……本示例项目中,使用的依赖项 futuresmongodbbsonserde 等 crate 都有了 1-2 个版本的升级。特别是 async-graphql,在孙老师的辛苦奉献下,版本升级数量达到两位数,依赖项引入方式已经发生了变化。

你可以使用 cargo upgrade 升级,或者直接修改 Cargo.toml 文件,全部使用最新版本的依赖 crate:

[package]
name = "backend"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
futures = "0.3.14"
tide = "0.16.0"
async-std = { version = "1.9.0", features = ["attributes"] }

dotenv = "0.15.0"
lazy_static = "1.4.0"

async-graphql = { version = "2.8.4", features = ["bson", "chrono"] }
mongodb = { version = "1.2.1", default-features = false, features = ["async-std-runtime"] }
bson = "1.2.2"
serde = { version = "1.0.125", features = ["derive"] }

第二次重构:async-graphql 对象类型的使用

在另一个 Rust Web 技术栈示例项目基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(3) - 重构中,代码更为精简一些。因为我们使用了 async-graphql 的简单对象类型、复杂对象类型。

使用简单对象类型

上一篇文章中,我们使用的是 async-graphql普通对象类型,即 ./src/users/models.rs 文件如下所示:

#![allow(unused)]
fn main() {
...

pub struct User {
    pub _id: ObjectId,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::Object]
impl User {
    pub async fn id(&self) -> ObjectId {
    ...
    pub async fn email(&self) -> &str {
    ...
    pub async fn username(&self) -> &str {
    ...
}
}

如果在实现 User 类型时,并未有对字段的计算处理,那么这些 gettersetter 方法是否显得很多余?如果我们使用简单对象类型,则可以对代码进行精简,省略这些枯燥的 gettersetter 方法。

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(async_graphql::SimpleObject, Serialize, Deserialize, Clone, Debug)]
pub struct User {
    pub _id: ObjectId,
    pub email: String,
    pub username: String,
    pub cred: String,
}
}

注意,上部分代码块,使用普通对象类型,为了节省篇幅,我们使用 ... 表示省略粘贴部分代码;而使用简单对象类型的下部分代码块,是完整的。需要强调的是:如果对类型字段未有计算处理,使用简单对象类型可以对代码进行精简。

使用复杂对象类型

但有时,除了自定义结构体中的字段外,我们还需要返回一些计算后的数据。比如,我们要在邮箱应用中,显示发件人信息,一般是 username<email> 这样的格式。对此实现有两种方式:

使用普通对象类型

我们需要编写 gettersetter 方法,补充代码如下:

#![allow(unused)]
fn main() {
#[async_graphql::Object]
impl User {
    …… 原有字段 `getter`、`setter` 方法

    // 补充如下方法
    pub async fn from(&self) -> String {
        let mut from =  String::new();
        from.push_str(&self.username);
        from.push_str("<");
        from.push_str(&self.email);
        from.push_str(">");

        from
    }
}
}

使用复杂对象类型

async-graphql 的新版本中,可以将复杂对象类型和简单对象类型整合使用。这样,既可以省去省去满篇的 gettersetter,还可以自定义对结构体字段计算后的返回数据。如下 users/models.rs 文件,是完整的代码:

#![allow(unused)]
fn main() {
use bson::oid::ObjectId;
use serde::{Deserialize, Serialize};

#[derive(async_graphql::SimpleObject, Serialize, Deserialize, Clone, Debug)]
#[graphql(complex)]
pub struct User {
    pub _id: ObjectId,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::ComplexObject]
impl User {
    pub async fn from(&self) -> String {
        let mut from = String::new();
        from.push_str(&self.username);
        from.push_str("<");
        from.push_str(&self.email);
        from.push_str(">");

        from
    }
}
}

我们可以看到,GraphQL 的文档中,已经多了一个类型定义:

复杂对象类型 from 定义

执行查询,我们看看返回结果:

复杂对象类型 from 查询

变更服务

接下来,我们开发 GraphQL 的变更服务。示例中,我们以模型 -> 服务 -> 总线的顺序来开发。这个顺序并非固定,在实际开发中,可以根据自己习惯进行调整。

定义 NewUser 输入对象类型

在此,我们定义一个欲插入 users 集合中的结构体,包含对应字段即可,其为 async-graphql 中的 输入对象类型。需要注意的是,mongodb 中,_id 是根据时间戳自动生成,因此不需要定义此字段。cred 是计划使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算后的鉴权码,需要定义,但无需在新增是填写。因此,在此我们需要介绍一个 async-graphql 中的标记 #[graphql(skip)],其表示此字段不会映射到 GraphQL。

代码较简单,所以我们直接贴 users/models.rs 文件完整代码:

#![allow(unused)]
fn main() {
use bson::oid::ObjectId;
use serde::{Deserialize, Serialize};

#[derive(async_graphql::SimpleObject, Serialize, Deserialize, Clone, Debug)]
#[graphql(complex)]
pub struct User {
    pub _id: ObjectId,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::ComplexObject]
impl User {
    pub async fn from(&self) -> String {
        let mut from = String::new();
        from.push_str(&self.username);
        from.push_str("<");
        from.push_str(&self.email);
        from.push_str(">");

        from
    }
}

#[derive(Serialize, Deserialize, async_graphql::InputObject)]
pub struct NewUser {
    pub email: String,
    pub username: String,
    #[graphql(skip)]
    pub cred: String,
}
}

编写服务层代码,将 NewUser 结构体插入 MongoDB

服务层 users/services.rs 中,我们仅需定义一个函数,用于将 NewUser 结构体插入 MongoDB 数据库。我们从 GraphiQL/playground 中获取 NewUser 结构体时,因为我们使用了标记 #[graphql(skip)],所以 cred 字段不会映射到 GraphQL。对于 MongoDB 的文档数据库特性,插入是没有问题的。但查询时如果包括 cred 字段,对于不包含此字段的 MongoDB 文档,则需要特殊处理。我们目前仅是为了展示变更服务的实例,所以对于 cred 字段写入一个固定值。随着本教程的逐渐深入,我们会迭代为关联用户特定值,使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算后的鉴权码。

同时,实际应用中,插入用户时,我们应当设定一个用户唯一性的标志属性,以用来判断数据库是否已经存在此用户。本实例中,我们使用 email 作为用户的唯一性标志属性。因此,我们需要开发 get_user_by_email 服务。

再者,我们将 NewUser 结构体插入 MongoDB 数据库后,应当返回插入结果。因此,我们还需要开发一个根据 username 或者 email 查询用户的 GraphQL 服务。因为我们已经设定 email 为用户的唯一性标志属性,因此直接使用 get_user_by_email 查询已经插入用户即可。

MongoDB 数据库的 Rust 驱动使用,本文简要提及,不作详细介绍。

服务层 users/services.rs 文件完整代码如下:

#![allow(unused)]
fn main() {
use async_graphql::{Error, ErrorExtensions};
use futures::stream::StreamExt;
use mongodb::Database;

use crate::users::models::{NewUser, User};
use crate::util::constant::GqlResult;

pub async fn all_users(db: Database) -> GqlResult<Vec<User>> {
    let coll = db.collection("users");

    let mut users: Vec<User> = vec![];

    // 查询集合中的所有文档
    let mut cursor = coll.find(None, None).await.unwrap();

    // 数据游标结果迭代
    while let Some(result) = cursor.next().await {
        match result {
            Ok(document) => {
                let user =
                    bson::from_bson(bson::Bson::Document(document)).unwrap();
                users.push(user);
            }
            Err(error) => Err(Error::new("1-all-users").extend_with(|_, e| {
                e.set("details", format!("文档有错:{}", error))
            }))
            .unwrap(),
        }
    }

    if users.len() > 0 {
        Ok(users)
    } else {
        Err(Error::new("1-all-users")
            .extend_with(|_, e| e.set("details", "无记录")))
    }
}

// get user info by email
pub async fn get_user_by_email(db: Database, email: &str) -> GqlResult<User> {
    let coll = db.collection("users");

    let exist_document = coll.find_one(bson::doc! {"email": email}, None).await;

    if let Ok(user_document_exist) = exist_document {
        if let Some(user_document) = user_document_exist {
            let user: User =
                bson::from_bson(bson::Bson::Document(user_document)).unwrap();
            Ok(user)
        } else {
            Err(Error::new("2-email")
                .extend_with(|_, e| e.set("details", "email 不存在")))
        }
    } else {
        Err(Error::new("2-email")
            .extend_with(|_, e| e.set("details", "查询 mongodb 出错")))
    }
}

pub async fn new_user(db: Database, mut new_user: NewUser) -> GqlResult<User> {
    let coll = db.collection("users");

    new_user.email = new_user.email.to_lowercase();

    if self::get_user_by_email(db.clone(), &new_user.email).await.is_ok() {
        Err(Error::new("email 已存在")
            .extend_with(|_, e| e.set("details", "1_EMAIL_EXIStS")))
    } else {
        new_user.cred =
            "P38V7+1Q5sjuKvaZEXnXQqI9SiY6ZMisB8QfUOP91Ao=".to_string();
        let new_user_bson = bson::to_bson(&new_user).unwrap();

        if let bson::Bson::Document(document) = new_user_bson {
            // Insert into a MongoDB collection
            coll.insert_one(document, None)
                .await
                .expect("文档插入 MongoDB 集合时出错");

            self::get_user_by_email(db.clone(), &new_user.email).await
        } else {
            Err(Error::new("3-new_user").extend_with(|_, e| {
                e.set("details", "转换 BSON 对象为 MongoDB 文档时出错")
            }))
        }
    }
}
}

将服务添加到服务总线

查询服务对应的服务总线为 gql/queries.rs,变更服务对应的服务总线为 gql/mutations.rs。到目前为止,我们一直未有编写变更服务总线文件 gql/mutations.rs。现在,我们将 new_user 变更服务和 get_user_by_email 查询服务分别添加到变更和查询服务总线。

加上我们查询服务的 all_users 服务,服务总线共计 2 个文件,3 个服务。

查询服务总线 gql/queries.rs

#![allow(unused)]
fn main() {
use async_graphql::Context;

use crate::dbs::mongo::DataSource;
use crate::users::{self, models::User};
use crate::util::constant::GqlResult;

pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    // 获取所有用户
    async fn all_users(&self, ctx: &Context<'_>) -> GqlResult<Vec<User>> {
        let db = ctx.data_unchecked::<DataSource>().db_budshome.clone();
        users::services::all_users(db).await
    }

    //根据 email 获取用户
    async fn get_user_by_email(
        &self,
        ctx: &Context<'_>,
        email: String,
    ) -> GqlResult<User> {
        let db = ctx.data_unchecked::<DataSource>().db_budshome.clone();
        users::services::get_user_by_email(db, &email).await
    }
}
}

变更服务总线 gql/mutations.rs

#![allow(unused)]
fn main() {
use async_graphql::Context;

use crate::dbs::mongo::DataSource;
use crate::users::{
    self,
    models::{NewUser, User},
};
use crate::util::constant::GqlResult;

pub struct MutationRoot;

#[async_graphql::Object]
impl MutationRoot {
    // 插入新用户
    async fn new_user(
        &self,
        ctx: &Context<'_>,
        new_user: NewUser,
    ) -> GqlResult<User> {
        let db = ctx.data_unchecked::<DataSource>().db_budshome.clone();
        users::services::new_user(db, new_user).await
    }
}
}

第一次验证

查询服务、变更服务均编码完成,我们验证下开发成果。通过 cargo run 或者 cargo watch 启动应用程序,浏览器输入 http://127.0.0.1:8080/v1i,打开 graphiql/playgound 界面。

如果你的配置未跟随教程,请根据你的配置输入正确链接,详见你的 .env 文件配置项。

但是,如果你此时通过 graphiql/playgound 界面的 docs 选项卡查看,仍然仅能看到查询服务下有一个孤零零的 allUsers: [User!]!。这是因为,我们前几篇教程中,仅编写查询服务代码,所以服务器 Schema 构建时使用的是 EmptyMutation。我们需要将我们自己的变更服务总线 gql/mutations.rs,添加到 SchemaBuilder 中。

涉及 gql/mod.rsmain.rs 2 个文件。

将变更服务总线添加到 SchemaBuilder

gql/mod.rs 文件完整代码如下:

#![allow(unused)]
fn main() {
pub mod mutations;
pub mod queries;

use crate::util::constant::CFG;
use tide::{http::mime, Body, Request, Response, StatusCode};

use async_graphql::{
    http::{playground_source, receive_json, GraphQLPlaygroundConfig},
    EmptySubscription, Schema,
};

use crate::State;

use crate::dbs::mongo;

use crate::gql::{queries::QueryRoot, mutations::MutationRoot};

pub async fn build_schema() -> Schema<QueryRoot, MutationRoot, EmptySubscription>
{
    // 获取 mongodb datasource 后,可以将其增加到:
    // 1. 作为 async-graphql 的全局数据;
    // 2. 作为 Tide 的应用状态 State;
    // 3. 使用 lazy-static.rs
    let mongo_ds = mongo::DataSource::init().await;

    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global mongodb datasource  in the schema object.
    // let mut schema = Schema::new(QueryRoot, MutationRoot, EmptySubscription)
    Schema::build(QueryRoot, MutationRoot, EmptySubscription)
        .data(mongo_ds)
        .finish()
}

pub async fn graphql(req: Request<State>) -> tide::Result {
    let schema = req.state().schema.clone();
    let gql_resp = schema.execute(receive_json(req).await?).await;

    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(Body::from_json(&gql_resp)?);

    Ok(resp.into())
}

pub async fn graphiql(_: Request<State>) -> tide::Result {
    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(playground_source(GraphQLPlaygroundConfig::new(
        CFG.get("GRAPHQL_PATH").unwrap(),
    )));
    resp.set_content_type(mime::HTML);

    Ok(resp.into())
}
}

将变更服务总线添加到应用程序作用域状态

main.rs 文件完整代码如下:

mod dbs;
mod gql;
mod users;
mod util;

use crate::gql::{build_schema, graphiql, graphql};
use crate::util::constant::CFG;

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    // tide logger
    tide::log::start();

    // 初始 Tide 应用程序状态
    let schema = build_schema().await;
    let app_state = State { schema: schema };
    let mut app = tide::with_state(app_state);

    // 路由配置
    app.at(CFG.get("GRAPHQL_PATH").unwrap()).post(graphql);
    app.at(CFG.get("GRAPHIQL_PATH").unwrap()).get(graphiql);

    app.listen(format!(
        "{}:{}",
        CFG.get("ADDRESS").unwrap(),
        CFG.get("PORT").unwrap()
    ))
    .await?;

    Ok(())
}

//  Tide 应用程序作用域状态 state.
#[derive(Clone)]
pub struct State {
    pub schema: async_graphql::Schema<
        gql::queries::QueryRoot,
        gql::mutations::MutationRoot,
        async_graphql::EmptySubscription,
    >,
}

Okay,大功告成,我们进行第二验证。

第二次验证

打开方式和注意事项和第一次验证相同。

正常启动后,如果你此时通过 graphiql/playgound 界面的 docs 选项卡查看,将看到查询和变更服务的列表都有了变化。如下图所示:

变更服务 new_user

插入一个新用户(重复插入)

插入的 newUser 数据为(注意,GraphQL 中自动转换为驼峰命名):

注意:示例仅为插入对象部分,你需要补充 mutation 声明和 API 方法。

    newUser: { 
      email: "budshome@rusthub.org", 
      username: "我是谁" 
    }

第一次插入,然会正确的插入结果:

{
  "data": {
    "newUser": {
      "cred": "P38V7+1Q5sjuKvaZEXnXQqI9SiY6ZMisB8QfUOP91Ao=",
      "email": "budshome@rusthub.org",
      "from": "我是谁<budshome@rusthub.org>",
      "id": "608954d900136b6c0041ae09",
      "username": "我是谁"
    }
  }
}

第二次重复插入,因为 email 已存在,则返回我们开发中定义的错误信息:

{
  "data": null,
  "errors": [
    {
      "message": "email 已存在",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "newUser"
      ],
      "extensions": {
        "details": "1_EMAIL_EXIStS"
      }
    }
  ]
}

请自己查看你的数据库,已经正常插入了目标数据。

至此,变更服务开发完成。

因为已经将更为完整的模板项目 tide-async-graphql-mongodb 放在了 github 仓库,所以本教程代码未有放在云上。如果你在实践中遇到问题,需要完成代码包,请联系我(微信号 yupen-com)。

下篇计划

变更服务开发完成后,后端我们告一阶段。下篇开始,我们进行前端的开发,仍然使用 Rust 技术栈:tide、rhai、handlebars-rust、surf,以及 graphql_client。

本次实践,我们称之为 Rust 全栈开发 ;-)

谢谢您的阅读!


基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务

基于 actix-web + async-graphql + rbatis + postgresql / mysql,请参阅 github 仓库 actix-web-async-graphql-rbatis

目前实现了如下功能(将持续升级):

  • 用户注册
  • 使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算
  • 整合 JWT 鉴权的用户登录
  • 密码修改
  • 资料更新
  • 用户查询和变更
  • 项目查询和变更
  • 使用基于 Rust 实现 graphql-client 获取 GraphQL 服务端数据
  • 渲染 GraphQL 数据到 handlebars-rust 模板引擎

基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(1) - 起步及 crate 选择

前段时间,笔者写了一个构建 Rust 异步 GraphQL 服务的系列博文,构建 Rust 异步 GraphQL 服务:基于 tide + async-graphql + mongodb,采用的 Rust web 框架是 Tide

感兴趣的朋友阅读以后,对 actix-web 更感兴趣。有几十位朋友建议笔者写个 actix-web + async-graphql 构建 GraphQL 服务的系列。看样子 Rust 的国内社区,虽然使用 Rust 的公司可能很少,但至少感兴趣的程序员基本面不小了。

因此本系列文章,笔者以 actix-web + async-graphql + rbatis + postgresql / mysql 技术栈为骨架,简单进行 GraphQL 服务构建的实践。actix-web 是极为优秀的 Rust web 框架,笔者在 2018-2019 年间,GraphQL 服务后端,也一直使用的是 actix-web + juniper + postgresql 的组合。

但目前工作场景,还是 mysql 居多,所以本系列实践,我们采用 mysql 数据库。但这次实践采用了 orm 框架 rbatis,所以对于 postgresql 的支持,会很方便。在系列文章最后,我们增加很少量的代码,即可支持 postgresql。并且,我们将一并实现 GraphQL 服务的多数据源支持。

tide + async-graphql + mongodb 系列类似,我们需要做到前后端分离。

  1. 后端:主要提供 GraphQL 服务,使用到的 crate 包括:actix-webasync-graphql、jsonwebtoken、rbatis、serde、ring、base64 等。
  2. 前端(handlebars-rust):主要提供 WEB 应用服务,使用到 crate 包括:actix-webrhai、surf、graphql_client、handlebars-rust、cookie 等。
  3. 前端(WebAssembly ):主要提供 WEB 应用服务,笔者实际项目中,和伙伴们还处于尝试初期。后面写到这部分时,我们再确定使用的技术栈。如果您对于 wasm 的使用已经熟悉,欢迎您的指导。

Rust 环境的配置,cargo 工具的使用,以及 Rust 程序设计语言和 GraphQL 的介绍和优势,在此不在赘述。您可以参阅如下资料学习 Rust 程序设计语言,以及 Rust 生态中的 GraphQL 实现。

以下建议了解,技术选型很丰富,我们不必拘泥。

  • Tide,Rust 官方团队开发的 HTTP 服务器框架。推荐作为了解,本系列文章中我们选择 actix-web。
  • Juniper 中文文档,推荐作为了解,本系列文章中我们选择 async-graphql。

其它概念性、对比类的内容,请您自行搜索。

工程的创建

文章的开始提到,我们要做到前后端分离。因此,前、后端需要各自创建一个工程。同时,我们要使用 cargo 对工程进行管理和组织。

  • 首先,创新本次工程根目录和 cargo 清单文件
mkdir actix-web-async-graphql
cd ./actix-web-async-graphql

touch Cargo.toml 

Cargo.toml 文件中,填入以下内容:

[workspace]
members = [
  "./backend",
  "./frontend-handlebars",
  "./frontend-wasm",
]

resolver = "2"

[profile.dev]
split-debuginfo = "unpacked"

注1resolver = "2" 是 Rust 1.51.0 中 cargo 工具新支持的设定项,主要用于解决依赖项管理的难题。目前,Cargo 的默认行为是:在依赖关系图中,当单个包被多次引用时,合并该包的特性。而启用 resolver 后,则可避免合并包,启用所有特性。但这种新的解析特性,可能会导致一些 crate 编译不止一次。具体参阅 Rust 1.51.0 已正式发布,及其新特性详述,或者 Rust 2021 版本特性预览,以及工作计划 中的 Cargo resolver 小节。

注2[profile.dev] 是 Rust 1.51.0 中的关于“拆分调试信息”的设定,这个主要是 macOS 上的改进。

文件中,workspace 是 cargo 中的工作区。cargo 中,工作区共享公共依赖项解析(即具有共享 Cargo.lock),输出目录和各种设置,如配置文件等的一个或多个包的集合。

虚拟工作区是 Cargo.toml 清单中,根目录的工作空间,不需要定义包,只列出工作区成员即可。

上述配置中,包含 3 个成员 backendfrontend-handlebars,以及 frontend-wasm 即我们需要创建 3 个工程(请注意您处于 actix-web-async-graphql 目录中):前端和后端 —— 均为二进制程序,所以传递 --bin 参数,或省略参数。

cargo new backend --bin
cargo new frontend-handlebars --bin
cargo new frontend-wasm --bin

注4:如果你不想要产生 git 信息,或者你有自己的 git 配置,请在 cargo 命令后再加上 --vcs none 参数。

创建后,工程结构如下图所示——

工程结构

我们可以看到,因为还未编译,没有 Cargo.lock 文件;main.rs 文件也是 Cargo 产生的默认代码。

现在,这个全新的工程,已经创建完成了。

工具类 crate 安装

工程创建完成后,我们即可以进入开发环节了。开发中,一些工具类 crate 可以起到“善其事”的作用,我们需要先进行安装。

  • cargo-edit,包含 cargo addcargo rm,以及 cargo upgrade,可以让我们方便地管理 crate。
  • cargo-watch,监视项目的源代码,以了解其更改,并在源代码发生更改时,运行 Cargo 命令。

好的,我们安装这 2 个 crate。

cargo install cargo-edit
cargo install cargo-watch

安装依赖较多,如果时间较长,请配置 Rust 工具链的国内源

添加依赖 crate

接着,我们需要添加开发所需依赖项。依赖项的添加,我们不用一次性全部添加,我们根据开发需要,一步步添加。首先,从后端工程开始。

后端工程中,我们提供 GraphQL 服务,需要依赖的基本 crate 有 tide、async-std、async-graphql、mongodb,以及 bson。我们使用 cargo add 命令来安装,其将安装最新版本。

cd backend
cargo add actix-web async-graphql rbatis

安装依赖较多,如果时间较长,请配置 Cargo 国内镜像源

执行上述命令后,actix-web-async-graphql/backend/Cargo.toml 内容如下所示——

...
[dependencies]
actix-web = "3.3.2"

async-graphql = "2.8.2"
rbatis = "1.8.83"
...

Rust 生态和社区中,发展是非常迅猛的。虽然 Rust 的稳定性、安全性非常高,但活跃的社区导致 crate 的迭代版本很快。所以我们使用的都是最新版本的 crate,跟上 Rust 生态的最新潮流。

依赖项支持特性(features)

本文开始,我们已经提到:本系列,我们将以 mysql、postgresql 作为数据库进行实践。rbatis 默认为特性为 all-database,支持包括 sqlite、sqlserver 等,我们不需要,所以限定特性为 mysql、postgresql 即可。

另外,async-graphql 从 2.6.3 开始,默认不激活所有特性,所以我们本次实践,也需要做一些设定。

最终,actix-web-async-graphql/backend/Cargo.toml 内容如下所示——

...
[dependencies]
[dependencies]
actix-web = "3.3.2"

async-graphql = { version = "2.8.2", features = ["bson", "chrono"] }
rbatis = { version = "1.8.83", default-features = false, features = ["mysql", "postgres"] }
...

至此,我们构建基于 Rust 技术栈的 Graphql 服务的后端基础工程已经搭建完成。下一篇文章中,我们开始构建一个最基本的 GraphQL 服务器。

谢谢您的阅读。


基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(2) - 查询服务

上一篇文章中,我们对后端基础工程进行了初始化,未有进行任何代码编写。本文中,我们将不再进行技术选型和优劣对比,直接基于 actix-web 和 async-graphql 构建异步 Rust GraphQL 服务的历程。本章主要是 GraphQL 查询服务,包括如下内容:

1、构建 GraphQL Schema;

2、整合 actix-web 和 async-graphql;

3、验证 query 服务;

4、连接 mysql;

5、提供 query 服务。

构建 GraphQL Schema

首先,让我们将 GraphQL 服务相关的代码都放到一个模块中。为了避免下文啰嗦,我称其为 GraphQL 总线。

cd ./actix-web-async-graphql/backend/src
mkdir gql
cd ./gql
touch mod.rs queries.rs mutations.rs

构建一个查询示例

  • 首先,我们构建一个不连接数据库的查询示例:通过一个函数进行求合运算,将其返回给 graphql 查询服务。此实例改编自 async-graphql 文档,仅用于验证环境配置,实际环境没有意义。

下面代码中,注意变更 EmptyMutation 和订阅 EmptySubscription 都是空的,甚至 mutations.rs 文件都是空白,未有任何代码,仅为验证服务器正确配置。

下面,我们需要编写代码。思路如下:

编写求和实例,作为 query 服务

queries.rs 文件中,写入以下代码:


pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    async fn add(&self, a: i32, b: i32) -> i32 {
        a + b
    }
}

mod.rs 中:构建 Schema,并编写请求处理(handler)函数

  • 通过 async-graphql SchemaBuilder,构建要在 actix-web 中使用的 GraphQL Schema,并接入我们自己的查询、变更,以及订阅服务。
  • 目前,我们首先要进行 actix-webasync-graphql 的集成验证,所以仅有求和作为查询服务,变更和订阅服务都是空的。
  • 同时,我们要进行 actix-web 中的请求处理(handler)函数的编写。

actix-web 的请求处理函数中,请求为 HttpRequest 类型,响应类型则是 HttpResponse。而 async-graphql 在执行 GraphQL 服务时,请求类型和返回类型与 actix-web 的并不同,需要进行封装处理。

async-graphql 官方提供了 actix-web 与 async-graphql 的集成 crate async-graphql-actix-web,功能很全。我们直接使用,通过 cargo add async-graphql-actix-web 命令添加到依赖项。然后,填入具体代码如下:

pub mod mutations;
pub mod queries;

use actix_web::{web, HttpResponse, Result};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql::{EmptyMutation, EmptySubscription, Schema};
use async_graphql_actix_web::{Request, Response};

use crate::gql::queries::QueryRoot;

// `ActixSchema` 类型定义,项目中可以统一放置在一个共用文件中。
// 但和 `actix-web` 和 `tide` 框架不同,无需放入应用程序`状态(State)`
// 所以此 `Schema` 类型仅是为了代码清晰易读,使用位置并不多,我们直接和构建函数一起定义。
// 或者,不做此类型定义,直接作为构建函数的返回类型。
type ActixSchema = Schema<
    queries::QueryRoot,
    async_graphql::EmptyMutation,
    async_graphql::EmptySubscription,
>;

pub async fn build_schema() -> ActixSchema {
    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global sql datasource  in the schema object.
    Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish()
}

pub async fn graphql(schema: web::Data<ActixSchema>, req: Request) -> Response {
    schema.execute(req.into_inner()).await.into()
}

pub async fn graphiql() -> Result<HttpResponse> {
    Ok(HttpResponse::Ok().content_type("text/html; charset=utf-8").body(
        playground_source(
            GraphQLPlaygroundConfig::new("/graphql").subscription_endpoint("/graphql"),
        ),
    ))
}

上面的示例代码中,函数 graphqlgraphiql 作为 actix-web 服务器的请求处理程序,因此必须返回 actix_web::HttpResponse

actix-web 开发本文不是重点,请参阅 actix-web 中文文档,很短时间即可掌握。

整合 actix-web 和 async-graphql

接下来,我们要进行 actix-web 服务器主程序开发和启动。进入 ./backend/src 目录,迭代 main.rs 文件:

mod gql;

use actix_web::{guard, web, App, HttpServer};

use crate::gql::{build_schema, graphql, graphiql};

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let schema = build_schema().await;

    println!("GraphQL UI: http://127.0.0.1:8080");

    HttpServer::new(move || {
        App::new()
            .data(schema.clone())
            .service(web::resource("/graphql").guard(guard::Post()).to(graphql))
            .service(web::resource("/graphiql").guard(guard::Get()).to(graphiql))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

本段代码中,我们直接在 App 构建器中加入 schema,以及对于 graphqlgraphiql 这两个请求处理函数,我们也是在 App 构建器中逐次注册。这种方式虽然没有问题,但对于一个应用的主程序 main.rs 来讲,精简一些更易于阅读和维护。所以我们下一篇文章中对此迭代,通过 ServiceConfig 进行注册。

验证 query 服务

启动 actix-web 服务

以上,一个基础的基于 Rust 技术栈的 GraphQL 服务器已经开发成功了。我们验证以下是否正常,请执行——

cargo run

更推荐您使用我们前一篇文章中安装的 cargo watch 来启动服务器,这样后续代码的修改,可以自动部署,无需您反复对服务器进行停止和启动操作。

cargo watch -x \"run\"

但遗憾的是——此时,你会发现服务器无法启动,因为上面的代码中,我们使用了 #[actix_rt::main] 此类的 Rust 属性标记。编译器会提示如下错误信息:

error[E0433]: failed to resolve: use of undeclared crate or module `actix_rt`
 --> backend\src\main.rs:7:3
  |
7 | #[actix_rt::main]
  |   ^^^^^^^^ use of undeclared crate or module `actix_rt`
……
……
error[E0752]: `main` function is not allowed to be `async`
 --> backend\src\main.rs:8:1
  |
8 | async fn main() -> std::io::Result<()> {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

error: aborting due to 3 previous errors

Some errors have detailed explanations: E0277, E0433, E0752.
For more information about an error, try `rustc --explain E0277`.
error: could not compile `backend`

根据编译器提示,我们执行 cargo add actix-rt,然后重新运行。但很遗憾,仍然会报错:

thread 'main' panicked at 'System is not running', ……\actix-rt-1.1.1\src\system.rs:78:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

并且,我们查看 backend/Cargo.toml 文件时会发现,我们通过 cargo add 添加的依赖项是最新版,actix-rt = "2.2.0",但此处错误信息却是 actix-rt-1.1.1\src\system.rs:78:21。这是因为 actix-web 3.3.2,可以一起正常工作的 actix-rt 最高版本是 1.1.1。如果你想使用 actix-rt = "2.2.0",需要使用 actix-web 的测试版本,如下面配置:

……
actix = "0.11.0-beta.2"
actix-web = "4.0.0-beta.3"
actix-rt = "2.0.2"
……

我们的实践是为了生产环境使用,不尝鲜了。最终,我们更改 backend/Cargo.toml 文件如下:

[package]
name = "backend"
version = "0.1.0"
authors = ["zzy <ask@rusthub.org>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-web = "3.3.2"
actix-rt = "1.1.1"

async-graphql = { version = "2.8.2", features = ["chrono"] }
async-graphql-actix-web = "2.8.2"
rbatis = { version = "1.8.83", default-features = false, features = ["mysql", "postgres"] }

请注意,不是根目录 actix-web-async-graphql/Cargo.toml 文件。

再次执行 cargo run 命令,您会发现服务器已经启动成功。

执行 GraphQL 查询

请打开您的浏览器,输入 http://127.0.0.1:8080/graphiql,您会看到如下界面(点击右侧卡片 docs 和 schema 查看详细):

graphiql

如图中示例,在左侧输入:

query {
  add(a: 110, b: 11)
}

右侧的返回结果为:

{
  "data": {
    "add": 121
  }
}

基础的 GraphQL 查询服务成功!

连接 MySql

创建 MySql 数据池

为了做到代码仓库风格的统一,以及扩展性。目前即使只需要连接 MySql 数据库,我们也将其放到一个模块中。

cd ./actix-web-async-graphql/backend/src
mkdir dbs
touch ./dbs/mod.rs ./dbs/mysql.rs

mysql.rs 中,编写如下代码:

use rbatis::core::db::DBPoolOptions;
use rbatis::rbatis::Rbatis;

// 对于常量,应当统一放置
// 下一篇重构中,我们再讨论不同的方式
pub const MYSQL_URL: &'static str =
    "mysql://root:mysql@localhost:3306/budshome";

pub async fn my_pool() -> Rbatis {
    let rb = Rbatis::new();

    let mut opts = DBPoolOptions::new();
    opts.max_connections = 100;

    rb.link_opt(MYSQL_URL, &opts).await.unwrap();

    rb
}

mod.rs 中,编写如下代码:

// pub mod postgres;
pub mod mysql;

创建数据表及数据

在 mysql 中,创建 user 表,并构造几条数据,示例数据如下:

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int(0) UNSIGNED NOT NULL AUTO_INCREMENT,
  `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `cred` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'ok@rusthub.org', '我谁24ok32', '5ff82b2c0076cc8b00e5cddb');
INSERT INTO `user` VALUES (2, 'oka@rusthub.org', '我s谁24ok32', '5ff83f4b00e8fda000e5cddc');
INSERT INTO `user` VALUES (3, 'oka2@rusthub.org', '我2s谁24ok32', '5ffd710400b6b84e000349f8');

SET FOREIGN_KEY_CHECKS = 1;
  • id 是由 mysql 自动产生的,且递增;
  • cred 是使用 PBKDF2 对用户密码进行加密(salt)和散列(hash)运算后产生的密码,后面会有详述。此处,请您随意。

提供 query 服务

Schema 中添加 MySql 数据池

前文小节我们创建了 MySql 数据池,欲在 async-graphql 中是获取和使用 MySql 数据池,有如下方法——

  1. 作为 async-graphql 的全局数据;
  2. 作为 actix-web 的应用程序数据,优势是可以进行原子操作;
  3. 使用 lazy-static,优势是获取方便,简单易用。

如果不作前后端分离,为了方便前端的数据库操作,那么 2 和 3 是比较推荐的,特别是使用 crate lazy-static,存取方便,简单易用。rbatis 的官方实例,以及笔者看到其它开源 rust-web 项目,都采用 lazy-static。因为 rbatis 实现了 Send + Sync,是线程安全的,无需担心线程竞争。

虽然 2 和 3 方便、简单,以及易用。但是本应用中,我们仅需要 actix-web 作为一个服务器提供 http 服务,MySql 数据池也仅是为 async-graphql 使用。因此,我采用作为 async-graphql 的全局数据,将其构建到 Schema 中。

笔者仅是简单使用,如果您有深入的见解,欢迎您指导(微信号 yupen-com,或者页底邮箱)。

基于上述思路,我们迭代 backend/src/gql/mod.rs 文件:

pub mod mutations;
pub mod queries;

use actix_web::{web, HttpResponse, Result};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql::{EmptyMutation, EmptySubscription, Schema};
use async_graphql_actix_web::{Request, Response};

use crate::dbs::mysql::my_pool;
use crate::gql::queries::QueryRoot;

type ActixSchema = Schema<
    queries::QueryRoot,
    async_graphql::EmptyMutation,
    async_graphql::EmptySubscription,
>;

pub async fn build_schema() -> ActixSchema {
    // 获取 MySql 数据池后,可以将其增加到:
    // 1. 作为 async-graphql 的全局数据;
    // 2. 作为 Tide 的应用状态 State;
    // 3. 使用 lazy-static.rs
    let my_pool = my_pool().await;

    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global mysql pool  in the schema object.
    Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
        .data(my_pool)
        .finish()
}

pub async fn graphql(schema: web::Data<ActixSchema>, req: Request) -> Response {
    schema.execute(req.into_inner()).await.into()
}

pub async fn graphiql() -> Result<HttpResponse> {
    Ok(HttpResponse::Ok().content_type("text/html; charset=utf-8").body(
        playground_source(
            GraphQLPlaygroundConfig::new("/graphql")
                .subscription_endpoint("/graphql"),
        ),
    ))
}

实现查询服务,自 MySql user 表查询所有用户

增加 users 模块,及分层阐述

一个完整的 GraphQL 查询服务,在本应用项目——注意,非 actix-web 或者 GraphQL 技术分层——我们可以简单将其分为三层:

  • actix-web handler:发起一次 GraphQL 请求,通知 GraphQL 总线执行 GraphQL service 调用,以及接收和处理响应;
  • GraphQL 总线:分发 GraphQL service 调用;
  • services:负责执行具体的查询服务,从 MySql 数据表获取数据,并封装到 model 中;

基于上述思路,我们想要开发一个查询所有用户的 GraphQL 服务,需要增加 users 模块,并创建如下文件:

cd ./backend/src
mkdir users
cd users
touch mod.rs models.rs services.rs

至此,本篇文章的所有文件已经创建,先让我们查看一下总体的 backend 工程结构,如下图所示:

backend 完成文件结构

其中 users/mod.rs 文件内容为:

pub mod models;
pub mod services;

我们也需要将 users 模块添加到 main.rs 中:

mod gql;
mod dbs;
mod users;

use actix_web::{guard, web, App, HttpServer};

use crate::gql::{build_schema, graphql, graphiql};

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let schema = build_schema().await;

    println!("GraphQL UI: http://127.0.0.1:8080");

    HttpServer::new(move || {
        App::new()
            .data(schema.clone())
            .service(web::resource("/graphql").guard(guard::Post()).to(graphql))
            .service(
                web::resource("/graphiql").guard(guard::Get()).to(graphiql),
            )
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

编写 User 模型

users/models.rs 文件中添加:

use serde::{Serialize, Deserialize};

#[rbatis::crud_enable]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
    pub id: i32,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::Object]
impl User {
    pub async fn id(&self) -> i32 {
        self.id
    }

    pub async fn email(&self) -> &str {
        self.email.as_str()
    }

    pub async fn username(&self) -> &str {
        self.username.as_str()
    }
}

上述代码中,User 结构体中定义的字段类型为 String,但结构体实现中返回为 &str,这是因为 Rust 中 String 未有默认实现 copy trait。如果您希望结构体实现中返回 String,可以通过 clone() 方法实现:

    pub async fn email(&self) -> String {
        self.email.clone()
    }

您使用的 IDE 比较智能,或许会有报错,先不要管,我们后面一并处理。

编写 service

users/services.rs 文件中添加代码,这次比 MongoDB 少了很多代码。如下:

use async_graphql::{Error, ErrorExtensions};
use rbatis::rbatis::Rbatis;
use rbatis::crud::CRUD;

use crate::users::models::User;

pub async fn all_users(
    my_pool: &Rbatis,
) -> std::result::Result<Vec<User>, async_graphql::Error> {
    let users = my_pool.fetch_list::<User>("").await.unwrap();

    if users.len() > 0 {
        Ok(users)
    } else {
        Err(Error::new("1-all-users")
            .extend_with(|_, e| e.set("details", "No records")))
    }
}

您使用的 IDE 比较智能,或许会有报错,先不要管,我们后面一并处理。

在 GraphQL 总线中调用 service

迭代 gql/queries.rs 文件,最终为:

use async_graphql::Context;
use rbatis::rbatis::Rbatis;

use crate::users::{self, models::User};

pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    // Get all Users,
    async fn all_users(
        &self,
        ctx: &Context<'_>,
    ) -> std::result::Result<Vec<User>, async_graphql::Error> {
        let my_pool = ctx.data_unchecked::<Rbatis>();
        users::services::all_users(my_pool).await
    }
}

Okay,如果您使用的 IDE 比较智能,可以看到现在已经是满屏的红、黄相配了。代码是没有问题的,我们只是缺少几个使用到的 crate。

  • 首先,执行命令:
cargo add serde
  • 其次,因为我们使用到了 serde crate 的 derive trait,因此需要迭代 backend/Cargo.toml 文件,最终版本为:
[package]
name = "backend"
version = "0.1.0"
authors = ["zzy <ask@rusthub.org>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-web = "3.3.2"
actix-rt = "1.1.1"

async-graphql = { version = "2.8.2", features = ["chrono"] }
async-graphql-actix-web = "2.8.2"
rbatis = { version = "1.8.83", default-features = false, features = ["mysql", "postgres"] }

serde = { version = "1.0", features = ["derive"] }

现在,重新运行 cargo build,可以发现红、黄错误已经消失殆尽了。执行 cargo watch -x "run" 命令会发现启动成功。

最后,我们来执行 GraphQL 查询,看看是否取出了 MySql 中 user 表的所有数据。

左侧输入:

# Write your query or mutation here
query {
  allUsers {
    id
    email
    username
  }
}

右侧返回结果依赖于您在数据表中添加了多少条数据,如我的查询结果为:

{
  "data": {
    "allUsers": [
      {
        "email": "ok@rusthub.org",
        "id": 1,
        "username": "我谁24ok32"
      },
      {
        "email": "oka@rusthub.org",
        "id": 2,
        "username": "我s谁24ok32"
      },
      {
        "email": "oka2@rusthub.org",
        "id": 3,
        "username": "我2s谁24ok32"
      }
      ……
      ……
    ]
  }
}

好的,以上就是一个完成的 GraphQL 查询服务。

实例源码仓库在 github,欢迎您共同完善。

下篇摘要

目前我们成功开发了一个基于 Rust 技术栈的 GraphQL 查询服务,但本例代码是不够满意的,如冗长的返回类型 std::result::Result<Vec<User>, async_graphql::Error>,如太多的魔术代码。

下篇中,我们先不进行 GraphQL mutation 的开发。我将对代码进行重构——

  • 应用配置文件;
  • 代码抽象。

谢谢您的阅读,欢迎交流(微信号 yupen-com,或者页底邮箱)。


基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(3) - 重构

前 2 篇文章中,我们初始化搭建了工程结构,选择了必须的 crate,并成功构建了 GraphQL 查询服务:从 MySql 中获取了数据,并通过 GraphQL 查询,输出 json 数据。本篇文章,本应当进行 GraphQL 变更(mutation)服务的开发。但是,虽然代码成功运行,却存在一些问题,如:对于 MySql 数据库的连接信息,应当采取配置文件存储;通用公用的代码,应当组织和抽象;诸如此类以便于后续扩展,生产部署等问题。所以,本篇文章中我们暂不进行变更的开发,而是进行第一次简单的重构。以免后续代码扩大,重构工作量繁重。

本文受到了 GraphQL 开发部分,受到了 async-graphql 作者孙老师的指导;actix-web 部分,受到了庞老师的指导,非常感谢!

首先,我们通过 shell 命令 cd ./actix-web-async-graphql-rbatis/backend 进入后端工程目录(下文中,将默认在此目录执行操作)。

有朋友提议示例项目,名字也用的库多列一些,方便 github 搜索。虽然关系不大,但还是更名为 actix-web-async-graphql-rbatis。如果您是从 github 检出,或者和我一样命名,注意修改哈。

重构1:配置信息的存储和获取

让我们设想正式生产环境的应用场景:

  • 服务器地址和端口的变更可能;
  • 服务功能升级,对用户暴露 API 地址的变更可能。如 rest api,graphql api,以及版本升级;
  • 服务站点密钥定时调整的可能;
  • 服务站点安全调整,jwt、session/cookie 过期时间的变更可能。

显然易见,我们应当避免每次变更调整时,都去重新编译一次源码——并且,大工程中,Rust 的编译速度让开发者注目。更优的方法是,将这些写入到配置文件中。或许上述第 4 点无需写入,但是文件存储到加密保护的物理地址,安全方面也有提升。

当然,实际的应用场景或许有更合适有优的解决方法,但我们先基于此思路来设计。Rust 中,dotenv crate 用来读取环境变量。取得环境变量后,我们将其作为静态或者惰性值来使用,静态或者惰性值相关的 crate 有 lazy_staticonce_cell 等,都很简单易用。此示例中,我们使用 lazy_static

创建 .env,添加读取相关 crate

增加这 2 个 crate,并且在 backend 目录创建 .env 文件。

cargo add dotenv lazy_static
touch .env

.env 文件中,写入如下内容:

# 服务器信息
ADDRESS=127.0.0.1
PORT=8080

# API 服务信息,“gql” 也可以单独提出来定义
GQL_VER=v1
GIQL_VER=v1i

# 数据库配置
MYSQL_URI=mysql://root:mysql@localhost:3306/budshome

Cargo.toml 文件:

[package]
name = "backend"
version = "0.1.0"
authors = ["zzy <ask@rusthub.org>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-web = "3.3.2"
actix-rt = "1.1.1"

dotenv = "0.15.0"
lazy_static = "1.4.0"


async-graphql = { version = "2.8.2", features = ["chrono"] }
async-graphql-actix-web = "2.8.2"
rbatis = { version = "1.8.83", default-features = false, features = ["mysql", "postgres"] }

serde = { version = "1.0", features = ["derive"] }

读取配置文件并使用配置信息

对于配置信息的读取和使用,显然属于公用功能,我们将其归到单独的模块中。所以,需要创建 2 个文件:一个是模块标识文件,一个是将抽象出来共用的常量子模块。

cd ./src
mkdir util
touch ./util/mod.rs ./util/constant.rs

至此,本篇文章的所有文件都已经创建,我们确认一下工程结构。

backend 工程结构

  • util/mod.rs,编写如下代码:
pub mod constant;
  • 读取配置信息

util/constant.rs 中,编写如下代码:

#![allow(unused)]
fn main() {
use dotenv::dotenv;
use lazy_static::lazy_static;
use std::collections::HashMap;

lazy_static! {
    // CFG variables defined in .env file
    pub static ref CFG: HashMap<&'static str, String> = {
        dotenv().ok();

        let mut map = HashMap::new();

        map.insert(
            "ADDRESS",
            dotenv::var("ADDRESS").expect("Expected ADDRESS to be set in env!"),
        );
        map.insert(
            "PORT",
            dotenv::var("PORT").expect("Expected PORT to be set in env!"),
        );

        map.insert(
            "GQL_PATH",
            dotenv::var("GQL_PATH").expect("Expected GQL_PATH to be set in env!"),
        );
        map.insert(
            "GQL_VER",
            dotenv::var("GQL_VER").expect("Expected GQL_VER to be set in env!"),
        );
        map.insert(
            "GIQL_VER",
            dotenv::var("GIQL_VER").expect("Expected GIQL_VER to be set in env!"),
        );

        map.insert(
            "MYSQL_URI",
            dotenv::var("MYSQL_URI").expect("Expected MYSQL_URI to be set in env!"),
        );

        map
    };
}
}
  • 重构代码,使用配置信息,正确提供 GraphQL 服务

首先,src/main.rs 文件中引入 util 模块。并用 use 引入 constant 子模块。并重构 HttpServer 的绑定 IP 地址和端口信息,读取其惰性配置值。

mod util;
mod gql;
mod dbs;
mod users;

use actix_web::{guard, web, App, HttpServer};

use crate::util::constant::CFG;
use crate::gql::{build_schema, graphql, graphiql};

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let schema = build_schema().await;

    println!(
        "GraphQL UI: http://{}:{}",
        CFG.get("ADDRESS").unwrap(),
        CFG.get("PORT").unwrap()
    );

    HttpServer::new(move || {
        App::new()
            .data(schema.clone())
            .service(
                web::resource(CFG.get("GQL_VER").unwrap())
                    .guard(guard::Post())
                    .to(graphql),
            )
            .service(
                web::resource(CFG.get("GIQL_VER").unwrap())
                    .guard(guard::Get())
                    .to(graphiql),
            )
    })
    .bind(format!(
        "{}:{}",
        CFG.get("ADDRESS").unwrap(),
        CFG.get("PORT").unwrap()
    ))?
    .run()
    .await
}

其次,src/gql/mod.rs 文件中,用 use 引入 constant 子模块,读取其惰性配置值。

pub mod mutations;
pub mod queries;

use actix_web::{web, HttpResponse, Result};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql::{EmptyMutation, EmptySubscription, Schema};
use async_graphql_actix_web::{Request, Response};

use crate::util::constant::CFG;
use crate::dbs::mysql::my_pool;
use crate::gql::queries::QueryRoot;

type ActixSchema = Schema<
    queries::QueryRoot,
    async_graphql::EmptyMutation,
    async_graphql::EmptySubscription,
>;

pub async fn build_schema() -> ActixSchema {
    // 获取 mysql 数据池后,可以将其增加到:
    // 1. 作为 async-graphql 的全局数据;
    // 2. 作为 actix-web 的应用程序数据,优势是可以进行原子操作;
    // 3. 使用 lazy-static.rs
    let my_pool = my_pool().await;

    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global mysql pool in the schema object.
    Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
        .data(my_pool)
        .finish()
}

pub async fn graphql(schema: web::Data<ActixSchema>, req: Request) -> Response {
    schema.execute(req.into_inner()).await.into()
}

pub async fn graphiql() -> Result<HttpResponse> {
    Ok(HttpResponse::Ok().content_type("text/html; charset=utf-8").body(
        playground_source(
            GraphQLPlaygroundConfig::new(CFG.get("GQL_VER").unwrap())
                .subscription_endpoint(CFG.get("GQL_VER").unwrap()),
        ),
    ))
}

最后,不要忘了 src/dbs/mysql.rs 文件中,用 use 引入 constant 子模块,读取其惰性配置值。

#![allow(unused)]
fn main() {
use rbatis::core::db::DBPoolOptions;
use rbatis::rbatis::Rbatis;

use crate::util::constant::CFG;

pub async fn my_pool() -> Rbatis {
    let rb = Rbatis::new();

    let mut opts = DBPoolOptions::new();
    opts.max_connections = 100;

    rb.link_opt(CFG.get("MYSQL_URI").unwrap(), &opts).await.unwrap();

    rb
}
}

配置文件读取已经完成,我们测试看看。这次,我们浏览器中要打开的链接为 http://127.0.0.1:8080/v1i

graphiql ui

执行查询,一切正常。

graphql query

重构2:async-graphql 代码简洁性重构

定义公用类型

在上一篇基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(2) - 查询服务文章中,gql/queries.rsusers/services.rs 代码中,all_users 函数/方法的返回值为冗长的 std::result::Result<Vec<User>, async_graphql::Error>。显然,这样代码不够易读和简洁。我们简单重构下:定义一个公用的 GqlResult 类型即可。

  • 首先,迭代 util/constant.rs 文件,增加一行:定义 GqlResult 类型别名:
use dotenv::dotenv;
use lazy_static::lazy_static;
use std::collections::HashMap;

pub type GqlResult<T> = std::result::Result<T, async_graphql::Error>;

lazy_static! {
    // CFG variables defined in .env file
    pub static ref CFG: HashMap<&'static str, String> = {
        dotenv().ok();

        let mut map = HashMap::new();

        map.insert(
            \"ADDRESS\",
            dotenv::var(\"ADDRESS\").expect(\"Expected ADDRESS to be set in env!\"),
        );
 ……
 ……
 ……
  • 其次,迭代 gql/queries.rsusers/services.rs 文件,引入并让函数/方法返回 GqlResult 类型。

gql/queries.rs

#![allow(unused)]
fn main() {
use async_graphql::Context;
use rbatis::rbatis::Rbatis;

use crate::util::constant::GqlResult;
use crate::users::{self, models::User};
pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    // Get all Users
    async fn all_users(&self, ctx: &Context<'_>) -> GqlResult<Vec<User>> {
        let my_pool = ctx.data_unchecked::<Rbatis>();
        users::services::all_users(my_pool).await
    }
}
}

users/services.rs

#![allow(unused)]
fn main() {
use async_graphql::{Error, ErrorExtensions};
use rbatis::rbatis::Rbatis;
use rbatis::crud::CRUD;

use crate::util::constant::GqlResult;
use crate::users::models::User;

pub async fn all_users(my_pool: &Rbatis) -> GqlResult<Vec<User>> {
    let users = my_pool.fetch_list::<User>("").await.unwrap();

    if users.len() > 0 {
        Ok(users)
    } else {
        Err(Error::new("1-all-users")
            .extend_with(|_, e| e.set("details", "No records")))
    }
}
}

执行查询,一切正常。

async-graphql 对象类型重构

此重构受到了 async-graphql 作者孙老师的指导,非常感谢!孙老师的 async-graphql 项目仓库在 github,是 Rust 生态中最优秀的 GraphQL 服务端库,希望朋友们去 starfork

目前代码是可以运行的,但是总是感觉太冗余。比如 impl User 中大量的 getter 方法,这是老派的 Java 风格了。在 async-graphql 中,已经对此有了解决方案 SimpleObject,大家直接删去 impl User 即可。如下 user/models.rs 代码,是可以正常运行的:

async-graphql 简单对象类型

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};


#[rbatis::crud_enable]
#[derive(async_graphql::SimpleObject, Serialize, Deserialize, Clone, Debug)]
pub struct User {
    pub id: i32,
    pub email: String,
    pub username: String,
    pub cred: String,
}

// 下段代码直接不要,删除或注释
// #[async_graphql::Object]
// impl User {
//     pub async fn id(&self) -> i32 {
//         self.id
//     }
// 
//     pub async fn email(&self) -> &str {
//         self.email.as_str()
//     }
// 
//     pub async fn username(&self) -> &str {
//         self.username.as_str()
//     }
// }
}

这个 user/models.rs文件时完整的,请您删掉或者注释后,运行测试一次是否正常。

这个派生属性,在 async-graphql 中称之为简单对象,主要是省去满篇的 gettersetter

async-graphql 复杂对象类型

但有时,除了自定义结构体中的字段外,我们还需要返回一些计算后的数据。比如,我们要在邮箱应用中,显示发件人信息,一般是 username<email> 这样的格式。对此实现有两种方式:

第 1 种方式:async-graphql::Object 类型

使用 async-graphql::Object 类型。完善昨天的代码为(注意省略的部分不变):

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[rbatis::crud_enable]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
    pub id: i32,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::Object]
impl User {
    pub async fn id(&self) -> i32 {
        self.id
    }

    pub async fn email(&self) -> &str {
        ……
        ……
        ……

    // 补充如下方法
    pub async fn from(&self) -> String {
        let mut from =  String::new();
        from.push_str(&self.username);
        from.push_str("<");
        from.push_str(&self.email);
        from.push_str(">");

        from
    }
}
}

第 2 种方式,async_graphql::ComplexObject 类型

async-graphql 的新版本中,可以将复杂对象类型和简单对象类型整合使用。这样,既可以省去省去满篇的 gettersetter,还可以自定义对结构体字段计算后的返回数据。如下 users/models.rs 文件,是完整的代码:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[rbatis::crud_enable]
#[derive(async_graphql::SimpleObject, Serialize, Deserialize, Clone, Debug)]
#[graphql(complex)]
pub struct User {
    pub id: i32,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::ComplexObject]
impl User {
    pub async fn from(&self) -> String {
        let mut from =  String::new();
        from.push_str(&self.username);
        from.push_str("<");
        from.push_str(&self.email);
        from.push_str(">");

        from
    }
}
}

我们可以看到,GraphQL 的文档中,已经多了一个类型定义:

复杂对象类型

执行查询,我们看看返回结果:

{
  "data": {
    "allUsers": [
      {
        "cred": "5ff82b2c0076cc8b00e5cddb",
        "email": "ok@rusthub.org",
        "from": "我谁24ok32<ok@rusthub.org>",
        "id": 1,
        "username": "我谁24ok32"
      },
      {
        "cred": "5ff83f4b00e8fda000e5cddc",
        "email": "oka@rusthub.org",
        "from": "我s谁24ok32<oka@rusthub.org>",
        "id": 2,
        "username": "我s谁24ok32"
      },
      {
        "cred": "5ffd710400b6b84e000349f8",
        "email": "oka2@rusthub.org",
        "from": "我2s谁24ok32<oka2@rusthub.org>",
        "id": 3,
        "username": "我2s谁24ok32"
      }
    ]
  }
}

重构3:actix-web 代码整理

依赖项整理

此重构受到了庞老师的指点,非常感谢!

上一篇文章,服务器启动主程序时,我们可以使用 #[actix_web::main] 替代 #[actix_rt::main]。这种情况下,backend/Cargo.toml 文件中的依赖项 actix-rt 也可以直接删除。

路由组织

目前,GraphQL Schema 和路由,我们都直接在 main.rs 文件中注册到了 actix-web HttpServer 对象。笔者个人喜欢 main.rs 代码尽可能简单清晰——不是代码量越少越好,比如,GraphQL Schema 和路由,完全可以放在 gql 模块中,以后多了一个 rest 模块之类,各自模块中定义路由。

对此也应当重构,但实例简单,我们在此后端开发中仅作提及。在未来的前端开发中(使用 actix-web + surf + graphql-client + rhai + handlebars-rust 技术栈),因为需要复杂的路由,我们再做处理。

第一次重构,我们就到这个程度。下一篇,我们将进行 GraphQL 变更(mutation)的开发。

实例源码仓库在 github,欢迎您共同完善。


欢迎交流(指正、错别字等均可)。我的联系方式为页底邮箱,或者微信号 yupen-com。

yupen-com

谢谢您的阅读。


基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(4) - 变更服务,以及小重构

前 3 篇文章中,我们初始化搭建了工程结构,选择了必须的 crate,并成功构建了 GraphQL 查询服务,以及对代码进行了第一次重构。本篇文章,是我们进行 GraphQL 服务后端开发的最后一篇:变更服务。本篇文章之后,GraphQL 服务后端开发第一阶段告一阶段,之后我们进行 基于 Rust 的 Web 前端开发。本系列文章中,采用螺旋式思路,Web 前端基础开发之后,再回头进行 GraphQL 后端开发的改进。

自定义表名的小重构

有查阅基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(2) - 查询服务文章的朋友联系笔者,关于文章中 user 表和 User 结构体同名的问题。表名可以自定义的,然后在 rbatis 中指定即可。比如,我们将上一篇中的 user 表改名为 users,那么 async-graphql 简单对象的代码如下:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[rbatis::crud_enable(table_name:"users")]
#[derive(async_graphql::SimpleObject, Serialize, Deserialize, Clone, Debug)]
#[graphql(complex)]
pub struct User {
    pub id: i32,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::ComplexObject]
impl User {
    pub async fn from(&self) -> String {
        let mut from = String::new();
        from.push_str(&self.username);
        from.push_str("<");
        from.push_str(&self.email);
        from.push_str(">");

        from
    }
}
}

其中 #[rbatis::crud_enable(table_name:"users")] 中的表名,可以不用双引号包裹。示例代码的引号,仅是笔者习惯。

依赖项更新

基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务(3) - 重构之后,已经大抵过去半个月时间了。这半个月以来,活跃的 Rust 社区生态,进行了诸多更新:Rust 版本即将更新为 1.52.0,Rust 2021 版即将发布……本示例项目中,使用的依赖项 async-graphql / async-graphql-actix-web(感谢孙老师的辛勤奉献,建议看到此文的朋友,star 孙老师的 async-graphql 仓库)、rbatis 等 crate 都有了 1-2 个版本的升级。

你可以使用 cargo upgrade 升级,或者直接修改 Cargo.toml 文件,全部使用最新版本的依赖 crate:

[package]
name = "backend"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-web = "3.3.2"

dotenv = "0.15.0"
lazy_static = "1.4.0"


async-graphql = { version = "2.8.4", features = ["chrono"] }
async-graphql-actix-web = "2.8.4"
rbatis = { version = "1.8.84", default-features = false, features = ["mysql", "postgres"] }

serde = { version = "1.0.125", features = ["derive"] }

本系列文章中,对于 crate 的依赖,采取非必要不引入的原则。带最终完成,共计依赖项约为 56 个。

变更服务

接下来,我们开发 GraphQL 的变更服务。示例中,我们以模型 -> 服务 -> 总线的顺序来开发。这个顺序并非固定,在实际开发中,可以根据自己习惯进行调整。

定义 NewUser 输入对象类型

在此,我们定义一个欲插入 users 集合中的结构体,包含对应字段即可,其为 async-graphql 中的输入对象类型。需要注意的是,mysql 或 postgres 中,id 是自增主键,因此不需要定义此字段。cred 是计划使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算后的鉴权码,需要定义,但无需在新增时填写。因此,在此我们需要介绍一个 async-graphql 中的属性标记 #[graphql(skip)],其表示此字段不会映射到 GraphQL。

代码较简单,所以我们直接贴 users/models.rs 文件完整代码:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[rbatis::crud_enable(table_name:"users")]
#[derive(async_graphql::SimpleObject, Serialize, Deserialize, Clone, Debug)]
#[graphql(complex)]
pub struct User {
    pub id: i32,
    pub email: String,
    pub username: String,
    pub cred: String,
}

#[async_graphql::ComplexObject]
impl User {
    pub async fn from(&self) -> String {
        let mut from = String::new();
        from.push_str(&self.username);
        from.push_str("<");
        from.push_str(&self.email);
        from.push_str(">");

        from
    }
}

#[rbatis::crud_enable(table_name:"users")]
#[derive(async_graphql::InputObject, Serialize, Deserialize, Clone, Debug)]
pub struct NewUser {
    #[graphql(skip)]
    pub id: i32,
    pub email: String,
    pub username: String,
    #[graphql(skip)]
    pub cred: String,
}
}

编写服务层代码,将 NewUser 结构体插入数据库

服务层 users/services.rs 中,我们仅需定义一个函数,用于将 NewUser 结构体插入 mysql/postgres 数据库。我们从 GraphiQL/playground 中获取 NewUser 结构体时,因为我们使用了标记 #[graphql(skip)],所以 idcred 字段不会映射到 GraphQL。对于 mysql/postgres 的文档数据库特性,id 是自增字段;cred 我们设定为非空,所以对于其要写入一个固定值。随着本教程的逐渐深入,我们会迭代为关联用户特定值,使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算后的鉴权码。

同时,实际应用中,插入用户时,我们应当设定一个用户唯一性的标志属性,以用来判断数据库是否已经存在此用户。本实例中,我们使用 email 作为用户的唯一性标志属性。因此,我们需要开发 get_user_by_email 服务。

再者,我们将 NewUser 结构体插入 mysql/postgres 数据库后,应当返回插入结果。因此,我们还需要开发一个根据 username 或者 email 查询用户的 GraphQL 服务。因为我们已经设定 email 为用户的唯一性标志属性,因此直接使用 get_user_by_email 查询已经插入用户即可。

服务层 users/services.rs 文件完整代码如下:

#![allow(unused)]
fn main() {
use async_graphql::{Error, ErrorExtensions};
use rbatis::rbatis::Rbatis;
use rbatis::crud::CRUD;

use crate::util::constant::GqlResult;
use crate::users::models::{NewUser, User};

// 查询所有用户
pub async fn all_users(my_pool: &Rbatis) -> GqlResult<Vec<User>> {
    let users = my_pool.fetch_list::<User>("").await.unwrap();

    if users.len() > 0 {
        Ok(users)
    } else {
        Err(Error::new("1-all-users")
            .extend_with(|_, e| e.set("details", "No records")))
    }
}

// 通过 email 获取用户
pub async fn get_user_by_email(
    my_pool: &Rbatis,
    email: &str,
) -> GqlResult<User> {
    let email_wrapper = my_pool.new_wrapper().eq("email", email);
    let user = my_pool.fetch_by_wrapper::<User>("", &email_wrapper).await;

    if user.is_ok() {
        Ok(user.unwrap())
    } else {
        Err(Error::new("email 不存在")
            .extend_with(|_, e| e.set("details", "1_EMAIL_NOT_EXIStS")))
    }
}

// 插入新用户
pub async fn new_user(
    my_pool: &Rbatis,
    mut new_user: NewUser,
) -> GqlResult<User> {
    new_user.email = new_user.email.to_lowercase();

    if self::get_user_by_email(my_pool, &new_user.email).await.is_ok() {
        Err(Error::new("email 已存在")
            .extend_with(|_, e| e.set("details", "1_EMAIL_EXIStS")))
    } else {
        new_user.cred =
            "P38V7+1Q5sjuKvaZEXnXQqI9SiY6ZMisB8QfUOP91Ao=".to_string();
        my_pool.save("", &new_user).await.expect("插入 user 数据时出错");

        self::get_user_by_email(my_pool, &new_user.email).await
    }
}
}

将服务添加到服务总线

查询服务对应的服务总线为 gql/queries.rs,变更服务对应的服务总线为 gql/mutations.rs。到目前为止,我们一直未有编写变更服务总线文件 gql/mutations.rs。现在,我们将 new_user 变更服务和 get_user_by_email 查询服务分别添加到变更和查询服务总线。

加上我们查询服务的 all_users 服务,服务总线共计 2 个文件,3 个服务。

查询服务总线 gql/queries.rs

#![allow(unused)]
fn main() {
use async_graphql::Context;
use rbatis::rbatis::Rbatis;

use crate::util::constant::GqlResult;
use crate::users::{self, models::User};
pub struct QueryRoot;

#[async_graphql::Object]
impl QueryRoot {
    // 获取所有用户
    async fn all_users(&self, ctx: &Context<'_>) -> GqlResult<Vec<User>> {
        let my_pool = ctx.data_unchecked::<Rbatis>();
        users::services::all_users(my_pool).await
    }

    //根据 email 获取用户
    async fn get_user_by_email(
        &self,
        ctx: &Context<'_>,
        email: String,
    ) -> GqlResult<User> {
        let my_pool = ctx.data_unchecked::<Rbatis>();
        users::services::get_user_by_email(my_pool, &email).await
    }
}
}

变更服务总线 gql/mutations.rs

#![allow(unused)]
fn main() {
use async_graphql::Context;
use rbatis::rbatis::Rbatis;

use crate::users::{
    self,
    models::{NewUser, User},
};
use crate::util::constant::GqlResult;

pub struct MutationRoot;

#[async_graphql::Object]
impl MutationRoot {
    // 插入新用户
    async fn new_user(
        &self,
        ctx: &Context<'_>,
        new_user: NewUser,
    ) -> GqlResult<User> {
        let my_pool = ctx.data_unchecked::<Rbatis>();
        users::services::new_user(my_pool, new_user).await
    }
}
}

第一次验证

查询服务、变更服务均编码完成,我们验证下开发成果。通过 cargo run 或者 cargo watch 启动应用程序,浏览器输入 http://127.0.0.1:8080/v1i,打开 graphiql/playgound 界面。

如果你的配置未跟随教程,请根据你的配置输入正确链接,详见你的 .env 文件配置项。

但是,如果你此时通过 graphiql/playgound 界面的 docs 选项卡查看,仍然仅能看到查询服务下有一个孤零零的 allUsers: [User!]!。这是因为,我们前几篇教程中,仅编写查询服务代码,所以服务器 Schema 构建时使用的是 EmptyMutation。我们需要将我们自己的变更服务总线 gql/mutations.rs,添加到 SchemaBuilder 中。

仅仅涉及 gql/mod.rs 文件。

将变更服务总线添加到 SchemaBuilder

gql/mod.rs 文件完整代码如下:

#![allow(unused)]
fn main() {
pub mod mutations;
pub mod queries;

use actix_web::{web, HttpResponse, Result};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql::{EmptySubscription, Schema};
use async_graphql_actix_web::{Request, Response};

use crate::util::constant::CFG;
use crate::dbs::mysql::my_pool;
use crate::gql::{queries::QueryRoot, mutations::MutationRoot};

type ActixSchema = Schema<
    queries::QueryRoot,
    mutations::MutationRoot,
    async_graphql::EmptySubscription,
>;

pub async fn build_schema() -> ActixSchema {
    // 获取 mysql 数据池后,可以将其增加到:
    // 1. 作为 async-graphql 的全局数据;
    // 2. 作为 actix-web 的应用程序数据,优势是可以进行原子操作;
    // 3. 使用 lazy-static.rs
    let my_pool = my_pool().await;

    // The root object for the query and Mutatio, and use EmptySubscription.
    // Add global mysql pool  in the schema object.
    Schema::build(QueryRoot, MutationRoot, EmptySubscription)
        .data(my_pool)
        .finish()
}

pub async fn graphql(schema: web::Data<ActixSchema>, req: Request) -> Response {
    schema.execute(req.into_inner()).await.into()
}

pub async fn graphiql() -> Result<HttpResponse> {
    Ok(HttpResponse::Ok().content_type("text/html; charset=utf-8").body(
        playground_source(
            GraphQLPlaygroundConfig::new(CFG.get("GQL_VER").unwrap())
                .subscription_endpoint(CFG.get("GQL_VER").unwrap()),
        ),
    ))
}
}

Okay,大功告成,我们进行第二验证。

第二次验证

打开方式和注意事项和第一次验证相同。

正常启动后,如果你此时通过 graphiql/playgound 界面的 docs 选项卡查看,将看到查询和变更服务的列表都有了变化。如下图所示:

actix-graphql 变更服务

插入一个新用户(重复插入)

插入的 newUser 数据为(注意,GraphQL 中自动转换为驼峰命名):

   newUser: { 
      email: "budshome@rusthub.org", 
      username: "我是谁" 
    }

第一次插入,然会正确的插入结果:

{
  "data": {
    "newUser": {
        "cred": "P38V7+1Q5sjuKvaZEXnXQqI9SiY6ZMisB8QfUOP91Ao=",
        "email": "budshome@rusthub.org",
        "from": "我是谁<budshome@rusthub.org>",
        "id": 5,
        "username": "我是谁"
    }
  }
}

第二次重复插入,因为 email 已存在,则返回我们开发中定义的错误信息:

{
  "data": null,
  "errors": [
    {
      "message": "email 已存在",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "newUser"
      ],
      "extensions": {
        "details": "1_EMAIL_EXIStS"
      }
    }
  ]
}

请自己查看你的数据库,已经正常插入了目标数据。

至此,变更服务开发完成。

实例源码仓库在 github,欢迎您共同完善。

下篇计划

变更服务开发完成后,后端我们告一阶段。下篇开始,我们进行前端的开发,仍然使用 Rust 技术栈:actix-webrhaihandlebars-rustsurf,以及 graphql_client

本次实践,我们称之为 Rust 全栈开发 ;-)

谢谢您的阅读!


欢迎交流(指正、错别字等均可)。我的联系方式为页底邮箱,或者微信号 yupen-com。

yupen-com

Rust Web 前端开发

使用 Rust Web 前端框架 yew 和 handlebars-rust 模板开发 web 前端服务——

使用 tide、handlebars、rhai、graphql 开发 Rust web 前端

使用 tide、handlebars、rhai、graphql 开发 Rust web 前端,请参阅 github 仓库 actix-web-async-graphql-rbatis/frontend-handlebars

基于 Rust 技术生态,采用模板引擎,来实现 Rust web 前端的开发。实践过程中,我们通过 GraphQL 服务后端 API,获取 GraphQL 数据并解析。然后,在页面中,对用户列表、项目列表做以展示。

使用 tide、handlebars、rhai、graphql 开发 Rust web 前端(1)- crate 选择及环境搭建

目前,web 前端开发方面,通常有两种技术组合:一种是使用模板引擎,主要在服务器端渲染,这种方式对 seo 有较高要求的应用有利;同时,在后续优化方面,也较有优势。另一种则是前端框架,如 yew、react、vue、seed 一类,采用声明式设计;在保证性能下限的前提下,高效且灵活地进行快速开发。

另外,具体到 yew、react、vue、seed 来说,也有所不同:yew、seed 则是 WebAssembly 框架。WebAssembly 是 W3C 组织于 2019 年 12 月下旬才发布的新标准,还处于发展初期。前景或许更广阔一些,但目前落地的应用场景还比较罕见。

前时的文章《Rust 和 Wasm 的融合,使用 yew 构建 WebAssembly 标准的 web 前端》,即是对 Rust 生态中 WebAssembly 框架的实践。放眼整个 web 前端开发,都可以说是比较新颖的技术。但是对于生产环境,其小规模使用,或许都是一个挑战。如果你想使用 Rust 技术栈开发 web 应用,目前还是采用模板引擎的组合,较为稳妥一些。

实践目标

在以前的构建 Rust 异步 GraphQL 服务系列中,分别采用 tide + async-graphql + mongodbactix-web + async-graphql + rbatis + postgresql / mysql 开发了 GraphQL 服务后端。感兴趣的朋友可以参阅博文——

本次实践中,即是基于 Rust 技术生态,采用模板引擎,来实现 Rust web 前端的开发。实践过程中,我们通过 GraphQL 服务后端 API,获取 GraphQL 数据并解析。然后,在页面中,对用户列表、项目列表做以展示。

crate 的选择

Rust 生态中,成熟的模板引擎库非常多。askama 模板引擎的开发者,对下述出现较早的模板库进行了极其简单的测评,有兴趣可以参考 djc/template-benchmarks-rs

  • write!:基于标准库 write! 宏实现
  • handlebars:handlebars 模板的 Rust 实现
  • tera:基于 jinja2/django 模板规范
  • liquid:liquid 模板的 Rust 实现
  • askama:类型安全的、类 jinja 的编译型模板
  • horrorshow:使用 Rust 宏实现的模板
  • ructe:高效、类型安全的编译型模板
  • fomat:使用类 print/write/format 宏实现的小型模板
  • markup:快速、类型安全的模板
  • maud:Rust 实现的编译时 HTML 模板引擎
  • sailfish:简单、小型、快速的模板引擎

上述列表所提及模板,仅为开发较早,askama 模板引擎的开发者对其测评。另外,比较成熟的 Rust 模板引擎还有 mustache 规范的 rustache、rust-mustache,以及微型而极快的 tinytemplate 等等。

本系列文章,笔者选择了 handlebars-rust 模板引擎。评测中,其基准测试结果并不出众,但评测都有其局限性,仅可参考。而 handlebars-rust 对 rhai(Rust 的嵌入式脚本引擎)的支持方面,笔者非常感兴趣,是故选择。

HTTP 服务器框架,笔者选择了轻型的 tide(中文文档)。但是如果你对 actix-web 或者其它服务器端框架更感兴趣,或者想替换也是非常容易的,因为 cookie、GraphQL 客户端等代码都是通用的。

HTTP 客户端框架,笔者选择了 surf。如果你想使用 reqwest,替换仅为一行代码(将发送 GraphQL 请求时的 surf 函数,修改为 reqwest 函数即可)。

项目中,rhai(Rust 的嵌入式脚本引擎),主要用于开发页面脚本,作为 JavaScript 的一个替代方案。

嗯,本次实践用到的主要 crate,大概就是这些。

工程的创建

我们从零开始,进行本次实践。

在我们的实践项目根目录 tide-async-graphql-mongodb 或者 actix-web-async-graphql-rbatis 中,创建新的新的工程 frontend-handlebars。

GraphQL 服务后端,开源在 github,可以访问如下仓库获取源码:

cd tide-async-graphql-mongodb # 或 actix-web-async-graphql-rbatis
cargo new frontend-handlebars --vcs none

同时,需要在根目录的 Cargo.toml(不是 frontend-handlebars 目录中的 Cargo.toml)将 frontend-handlebars 项目添加到 workspace 部分:

[workspace]
members = [
    "./backend", 
    "./frontend-handlebars", 
    "./frontend-yew"
]

开发环境的配置

本文中,我们先进行开发环境的基础配置,整合各个 crate,并运行展示一个包含 handlebars 模板语法的 HTML 文件即可。因此,目前需要的主要 crate 仅为 tide、async-std,以及 handlebars-rust;另外,serdeserde_json crate 也是需要的。其中,async-std 需要启用特性 attributes,而 serde 需要启用特性 derive。我们使用 cargo-edit 工具,将它们加入到 frontend-handlebars 工程中。

cargo add async-std tide serde serde_json handlebars

此时,frontend-handlebars 项目中的 Cargo.toml 文件内容如下:

[package]
name = "frontend-handlebars"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
async-std = { version = "1.9.0", features = ["attributes"] }
tide = "0.16.0"

serde = { version = "1.0.126", features = ["derive"] }
serde_json = "1.0.64"

handlebars = "4.0.0"

代码开发

本文直接进入 Rust web 的开发演练,对于 Rust 的基础不做提及。

如果你没有 Rust 基础,《通过例子学 Rust》作为入门资料,是个很不错的选择。另外,机械工业出版社的《Rust 编程- 入门、实战与进阶》,非大块头的厚书。讲解了 Rust 核心语法后,注重编码能力训练,并且以 LeetCode 面试真题作为示例。

对于 handlebars 模板语法,我们也不做提及,官网资料很丰富,或者访问国内同步更新站点

虽然仅是演练,但笔者不建议将代码一股脑写入 main.rs 中。我们划分模块,分层实现。

handlebars 模板

在 frontend-handlebars 目录下,创建放置模板文件、静态文件的目录:

cd frontend-handlebars
mkdir templates
touch templates/index.html

templates/index.html 是包含 handlebars 语法的模板文件:

<!doctype html>
<html lang="zh">

  <head>
    <title>{{ app_name }}</title>

    <meta charset="utf-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="author" content="zzy, https://github.com/zzy/tide-async-graphql-mongodb">
  </head>

  <body>
    <center>
      <h1>{{ app_name }} </h1>
      <h3 style="padding-left: 20%;">-- {{ author }}</h3>

      <ul>
        <h2> &nbsp; </h2>
        <h2>
          <li><a href="/users">all users with token from graphql data</a></li>
        </h2>
        <h2>
          <li><a href="/projects">all projects from graphql data</a></li>
        </h2>
        <h2> &nbsp; </h2>
        <h2>
          <li><a href="http://127.0.0.1:8000/graphiql">graphql API</a></li>
        </h2>
      </ul>

    </center>
  </body>

</html>

模板渲染

对模板渲染,很显然是一个通用的处理过程。因此,我们将其抽象,放在通用类模块中。

cd frontend-handlebars/src
mkdir util
touch util/mod.rs util/common.rs

模板的渲染抽象,主要是实现:规范模板路径、注册模板,以及对模板压入渲染数据。util/mod.rsutil/common.rs 2 个文件,代码如下:

util/mod.rs

#![allow(unused)]
fn main() {
pub mod common;
}

util/common.rs

#![allow(unused)]
fn main() {
use handlebars::Handlebars;
use serde::Serialize;
use tide::{http::mime::HTML, Body, Response, StatusCode};

pub struct Tpl<'tpl> {
    pub name: String,
    pub reg: Handlebars<'tpl>,
}

impl<'tpl> Tpl<'tpl> {
    pub async fn new(rel_path: &str) -> Tpl<'tpl> {
        let tpl_name = &rel_path.replace("/", "_");
        let abs_path = format!("./templates/{}.html", rel_path);

        // create the handlebars registry
        let mut hbs_reg = Handlebars::new();
        hbs_reg.register_template_file(tpl_name, abs_path).unwrap();

        Tpl {
            name: tpl_name.to_string(),
            reg: hbs_reg,
        }
    }

    pub async fn render<T>(&self, data: &T) -> tide::Result
    where
        T: Serialize,
    {
        let mut resp = Response::new(StatusCode::Ok);
        resp.set_content_type(HTML);
        resp.set_body(Body::from_string(
            self.reg.render(&self.name, data).unwrap(),
        ));

        Ok(resp.into())
    }
}
}

路由开发

路由,其定义就放在专门的路由模块中:

cd frontend-handlebars/src
mkdir routes
touch routes/mod.rs

也可以在定义一个 home.rs 或者 index.rs,然后将其引入 mod.rs

目前,仅一个页面,所以仅需定义一个路由处理函数,配置一个路由路径即可。所以我们直接将 index 路由处理函数放在 mod.rs 文件中。但是,后续的用户列表、项目列表路由处理,我们会放在各自的模块中。

handlebars 语法规则,可以直接接收 json 格式的数据并解析展示。因此,routes/mod.rs 文件中,我们定义要在模板中展示的数据。代码内容如下:

#![allow(unused)]
fn main() {
use tide::{self, Server, Request};
use serde_json::json;

use crate::{State, util::common::Tpl};

pub async fn push_res(app: &mut Server<State>) {

    app.at("/").get(index);
}

async fn index(_req: Request<State>) -> tide::Result {
    let index: Tpl = Tpl::new("index").await;

    // make data and render it
    let data = json!({"app_name": "frontend-handlebars - tide-async-graphql-mongodb", "author": "zzy"});

    index.render(&data).await
}
}

应用入口

main.rs 作为 web 应用的入口,需要读取路由模块的配置,并将其压入到服务器(Serve)结构体中。这点在 tideactix-web 中,概念是一致的,写法稍有差别。

Statetide 服务器的状态(State)结构体,用于存放一些和服务器具有相同生命周期的对象或值。actix-web 中,概念同样一致。笔者此书仅为示例,表示 tide 有此特性。

mod routes;
mod util;

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    // tide logger
    tide::log::start();

    // Initialize the application with state.
    // Something in Tide State
    let app_state = State {};
    let mut app = tide::with_state(app_state);
    // app = push_res(app).await;
    routes::push_res(&mut app).await;

    app.listen(format!("{}:{}", "127.0.0.1", "3000")).await?;

    Ok(())
}

//  Tide application scope state.
#[derive(Clone)]
pub struct State {}

编译和运行

执行 cargo buildcargo run 后,如果你未自定义端口,请在浏览器中打开 http://127.0.0.1:3000 。可以发现,handlebars 模板文件 templates/index.html 中的 HTML 元素:title、h1,以及 h3 的值来自路由处理函数 async fn index(_req: Request<State>)

至此,使用 handlebars 模板的 Rust web 前端开发环境已经搭建成功。

谢谢您的阅读!


使用 tide、handlebars、rhai、graphql 开发 Rust web 前端(2)- 获取并解析 GraphQL 数据

上一篇文章《crate 选择及环境搭建》中,我们对 HTTP 服务器端框架、模板引擎库、GraphQL 客户端等 crate 进行了选型,以及对开发环境进行了搭建和测试。另外,还完成了最基本的 handlebars 模板开发,这是 Rust web 开发的骨架工作。本篇文章中,我们请求 GraphQL 服务器后端提供的 API,获取 GraphQL 数据并进行解析,然后将其通过 handlebars 模板展示。

本次实践中,我们使用 surf 库做为 HTTP 客户端,用来发送 GraphQL 请求,以及接收响应数据。对于 GraphQL 客户端,目前成熟的 crate,并没有太多选择,可在生产环境中应用的,唯有 graphql_client。让我们直接将它们添加到依赖项,不需要做额外的特征启用方面的设定:

cargo add surf graphql_client

如果你想使用 reqwest 做为 HTTP 客户端,替换仅为一行代码(将发送 GraphQL 请求时的 surf 函数,修改为 reqwest 函数即可)。

现在,我们的 Cargo.toml 文件内容如下:

[package]
name = "frontend-handlebars"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

[dependencies]
async-std = { version = "1.9.0", features = ["attributes"] }
tide = "0.16.0"

serde = { version = "1.0.126", features = ["derive"] }
serde_json = "1.0.64"

surf = "2.2.0"
graphql_client = "0.9.0"
handlebars = "4.0.0"

编写 GraphQL 数据查询描述

首先,我们需要从 GraphQL 服务后端下载 schema.graphql,放置到 frontend-handlebars/graphql 文件夹中。schema 是我们要描述的 GraphQL 查询的类型系统,包括可用字段,以及返回对象等。

然后,在 frontend-handlebars/graphql 文件夹中创建一个新的文件 all_projects.graphql,描述我们要查询的项目数据。项目数据查询很简单,我们查询所有项目,不需要传递参数:

query AllProjects {
  allProjects {
    id
    userId
    subject
    website
  }
}

最后,在 frontend-handlebars/graphql 文件夹中创建一个新的文件 all_users.graphql,描述我们要查询的用户数据。用户的查询,需要权限。也就是说,我们需要先进行用户认证,用户获取到自己在系统的令牌(token)后,才可以查看系统用户数据。每次查询及其它操作,用户都要将令牌(token)作为参数,传递给服务后端,以作验证。

query AllUsers($token: String!) {
  allUsers(
    token: $token
  ) {
    id
    email
    username
  }
}

用户需要签入系统,才能获取个人令牌(token)。此部分我们不做详述,请参阅文章《基于 tide + async-graphql + mongodb 构建异步 Rust GraphQL 服务》、《基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务》,以及项目 zzy/tide-async-graphql-mongodb 进行了解。

使用 graphql_client 构建查询体(QueryBody)

在此,我们需要使用到上一节定义的 GraphQL 查询描述,通过 GraphQLQuery 派生属性注解,可以实现与查询描述文件(如 all_users.graphql)中查询同名的结构体。当然,Rust 文件中,结构体仍然需要我们定义,注意与查询描述文件中的查询同名。如,与 all_users.graphql 查询描述文件对应的代码为:

#![allow(unused)]
fn main() {
type ObjectId = String;

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "./graphql/schema.graphql",
    query_path = "./graphql/all_users.graphql",
    response_derives = "Debug"
)]
struct AllUsers;
}

type ObjectId = String; 表示我们直接从 MongoDB 的 ObjectId 中提取其 id 字符串。

接下来,我们构建 graphql_client 查询体(QueryBody),我们要将其转换为 Value 类型。项目列表查询没有参数,构造简单。我们以用户列表查询为例,传递我们使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算后的令牌(token)。

本文实例中,为了演示,我们将令牌(token)获取后,作为字符串传送。实际应用代码中,当然是作为 cookie/session 参数来获取的,不会进行明文编码。

#![allow(unused)]
fn main() {
    // make data and render it
    let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJlbWFpbCI6ImlvazJAYnVkc2hvbWUuY29tIiwidXNlcm5hbWUiOiLmiJHmmK9vazIiLCJleHAiOjEwMDAwMDAwMDAwfQ.Gk98TjaFPpyW2Vdunn-pVqSPizP_zzTr89psBTE6zzfLQStUnBEXA2k0yVrS0CHBt9bHLLcFgmo4zYiioRBzBg";
    let build_query = AllUsers::build_query(all_users::Variables {
        token: token.to_string(),
    });
    let query = serde_json::json!(build_query);
}

使用 surf 发送 GraphQL 请求,并获取响应数据

相比于 frontend-yew 系列文章,本次 frontend-handlebars 实践中的 GraphQL 数据请求和响应,是比较简单易用的。

  • surf 库非常强大而易用,其提供的 post 函数,可以直接请求体,并返回泛型数据。
  • 因为在 hanlebars 模板中,可以直接接受并使用 json 数据,所以我们使用 recv_json() 方法接收响应数据,并指定其类型为 serde_json::Value
  • 在返回的数据响应体中,可以直接调用 Response<Data> 结构体中的 data 字段,这是 GraphQL 后端的完整应答数据。
#![allow(unused)]
fn main() {
    let gql_uri = "http://127.0.0.1:8000/graphql";
    let gql_post = surf::post(gql_uri).body(query);

    let resp_body: Response<serde_json::Value> = gql_post.recv_json().await.unwrap();
    let resp_data = resp_body.data.expect("missing response data");
}

let gql_uri = "http://127.0.0.1:8000/graphql"; 一行,实际项目中,通过配置环境变量来读取,是较好的体验。

数据的渲染

我们实现了数据获取、转换,以及部分解析。我们接收到的应答数据指定为 serde_json::Value 格式,我们可以直接将其发送给 handlebars 模板使用。因此,下述处理,直接转移到 handlebars 模板 —— html 文件中。是故,需要先创建 templates/users/index.html 以及 templates/projects/index.html 两个文件。

我们的数据内容为用户列表或者项目列表,很显然是一个迭代体,我们需要通过要给循环控制体来获取数据——handlebars 的模板语法我们不做详述(请参阅 handlebars 中文文档)。如,获取用户列表,使用 handlebars 模板的 #each 语法:

    <h1>all users</h1>

    <ul>
      {{#each allUsers as |u|}}
        <li><b>{{u.username}}</b></li>
        <ul>
          <li>{{ u.id }}</li>
          <li>{{ u.email }}</li>
        </ul>
      {{/each}}
    </ul>

基本上,技术点就是如上部分。现在,让我们看看,在上次实践《crate 选择及环境搭建》基础上新增、迭代的完整代码。

数据处理的完整代码

main.rs 文件,无需迭代。

routes/mod.rs 路由开发

增加用户列表、项目列表路由的设定。

#![allow(unused)]
fn main() {
use tide::{self, Server, Request};
use serde_json::json;

pub mod users;
pub mod projects;

use crate::{State, util::common::Tpl};
use crate::routes::{users::user_index, projects::project_index};

pub async fn push_res(app: &mut Server<State>) {
    app.at("/").get(index);
    app.at("users").get(user_index);
    app.at("projects").get(project_index);
}

async fn index(_req: Request<State>) -> tide::Result {
    let index: Tpl = Tpl::new("index").await;

    // make data and render it
    let data = json!({"app_name": "frontend-handlebars / tide-async-graphql-mongodb", "author": "我是谁?"});

    index.render(&data).await
}
}

routes/users.rs 用户列表处理函数

获取所有用户信息,需要传递令牌(token)参数。注意:为了演示,我们将令牌(token)获取后,作为字符串传送。实际应用代码中,是通过 cookie/session 参数来获取的,不会进行明文编码。

#![allow(unused)]
fn main() {
use graphql_client::{GraphQLQuery, Response};
use tide::Request;

use crate::{util::common::Tpl, State};

type ObjectId = String;

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "./graphql/schema.graphql",
    query_path = "./graphql/all_users.graphql",
    response_derives = "Debug"
)]
struct AllUsers;

pub async fn user_index(_req: Request<State>) -> tide::Result {
    let user_index: Tpl = Tpl::new("users/index").await;

    // make data and render it
    let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJlbWFpbCI6ImlvazJAYnVkc2hvbWUuY29tIiwidXNlcm5hbWUiOiLmiJHmmK9vazIiLCJleHAiOjEwMDAwMDAwMDAwfQ.Gk98TjaFPpyW2Vdunn-pVqSPizP_zzTr89psBTE6zzfLQStUnBEXA2k0yVrS0CHBt9bHLLcFgmo4zYiioRBzBg";
    let build_query = AllUsers::build_query(all_users::Variables {
        token: token.to_string(),
    });
    let query = serde_json::json!(build_query);

    let gql_uri = "http://127.0.0.1:8000/graphql";
    let gql_post = surf::post(gql_uri).body(query);

    let resp_body: Response<serde_json::Value> = gql_post.recv_json().await.unwrap();
    let resp_data = resp_body.data.expect("missing response data");

    user_index.render(&resp_data).await
}
}

routes/projects.rs 项目列表处理函数

项目列表的处理中,无需传递参数。

#![allow(unused)]
fn main() {
use graphql_client::{GraphQLQuery, Response};
use tide::Request;

use crate::{util::common::Tpl, State};

type ObjectId = String;

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "./graphql/schema.graphql",
    query_path = "./graphql/all_projects.graphql",
    response_derives = "Debug"
)]
struct AllProjects;

pub async fn project_index(_req: Request<State>) -> tide::Result {
    let project_index: Tpl = Tpl::new("projects/index").await;

    // make data and render it
    let build_query = AllProjects::build_query(all_projects::Variables {});
    let query = serde_json::json!(build_query);

    let gql_uri = "http://127.0.0.1:8000/graphql";
    let gql_post = surf::post(gql_uri).body(query);

    let resp_body: Response<serde_json::Value> = gql_post.recv_json().await.unwrap();
    let resp_data = resp_body.data.expect("missing response data");

    project_index.render(&resp_data).await
}
}

前端渲染的完整源码

templates/index.html 文件,无需迭代。

对于这部分代码,或许你会认为 headbody 部分,每次都要写,有些啰嗦。

实际上,这是模板引擎的一种思路。handlebars 模板认为:模板的继承或者包含,不足以实现模板重用。好的方法应该是使用组合的概念,如将模板分为 headheaderfooter,以及其它各自内容的部分,然后在父级页面中嵌入组合。

所以,实际应用中,这些不会显得啰嗦,反而会很简洁。本博客的 handlebars 前端源码 surfer/tree/main/frontend-handlebars 或许可以给你一点启发;至于具体使用方法,请参阅 handlebars 中文文档

templates/users/index.html 用户列表数据渲染

<!DOCTYPE html>
<html>

  <head>
    <title>all users</title>

    <link rel="icon" href="/static/favicon.ico">
    <link rel="shortcut icon" href="/static/favicon.ico">
  </head>

  <body>
    <a href="/"><strong>frontend-handlebars / tide-async-graphql-mongodb</strong></a>
    <h1>all users</h1>

    <ul>
      {{#each allUsers as |u|}}
        <li><b>{{u.username}}</b></li>
        <ul>
          <li>{{ u.id }}</li>
          <li>{{ u.email }}</li>
        </ul>
      {{/each}}
    </ul>

  </body>

</html>

templates/projects/index.html 项目列表数据渲染

<!DOCTYPE html>
<html>

  <head>
    <title>all projects</title>

    <link rel="icon" href="favicon.ico">
    <link rel="shortcut icon" href="favicon.ico">
  </head>

  <body>
    <a href="/"><strong>frontend-handlebars / tide-async-graphql-mongodb</strong></a>
    <h1>all projects</h1>

    <ul>
      {{#each allProjects as |p|}}
        <li><b>{{p.subject}}</b></li>
        <ul>
          <li>{{p.id}}</li>
          <li>{{p.userId}}</li>
          <li><a href="{{p.website}}" target="_blank">{{p.website}}</a></li>
        </ul>
      {{/each}}
    </ul>

  </body>

</html>

编译和运行

执行 cargo buildcargo run 后,如果你未自定义端口,请在浏览器中打开 http://127.0.0.1:3000 。

列表数据

至此,获取并解析 GraphQL 数据已经成功。

谢谢您的阅读!


使用 tide、handlebars、rhai、graphql 开发 Rust web 前端(3)- rhai 脚本、静态/资源文件、环境变量等

前 2 篇文章《crate 选择及环境搭建》《获取并解析 GraphQL 数据》中,我们已经整合应用 tide、graphql-client、handlebars,以及 surf,从 GraphQL 服务后端 API 获取 GraphQL 数据并解析、渲染到 html 模板。这已经是一个完整的技术组合,其成熟度足以用于生产环境,构建自己的想法和应用了。

handlebars 模板支持 JavaScript 脚本及助手代码,应用方面非常灵活和强大。handlebars-rust 模板引擎是对 handlebars 模板语法规范的 Rust 实现,在前文中评测中(详见 crate 选择及环境搭建),笔者提及:此次实践选择 handlebars-rust 模板引擎,主要是因为其对 rhai(Rust 的嵌入式脚本引擎)的支持方面,笔者非常感兴趣,是故选择。

所以本文是一个补充:我们补充整合 Rust 的嵌入式脚本引擎—— rhai 脚本语言的应用实践。开发者可以在项目中,用性能出众、语法类同 Rust 语言的 RhaiScript 脚本,替代 JavaScript,为数据展示和页面渲染提供辅助。

rhai 嵌入式脚本语言

基于 Rust 语言丰富和创新的特性,以及超高性能的执行效率,目前在开源界,产生了众多 Rust 语言的嵌入式脚本引擎。rhai 是新兴的,但创新性较高的一个。其在 Rust 语言特性之上实现,具有 no-std 特性,以及动态类型。编译时开销非常低,但执行效率很可观:在单核 2.3 GHz 的 Linux 虚拟机上,0.3 秒内,达到了超百万次迭代。

rhai 脚本语言可以独立使用,也可以嵌入 Rust 代码中使用。作为 Rust 的内嵌代码使用时,可以和原生 Rust 代码一样,可以调用其它 crate。尤其是,rhai 支持模块/库的动态加载、解析,并且支持最小构建。

  • 类似于 Rust + JavaScript,且具有动态类型。
  • 与 Rust 函数和类型紧密集成。
  • 通过外部作用域,将 Rust 变量/常量无损传递到脚本中,无需实现任何特殊特性。
  • 从 Rust 代码内,轻松调用脚本定义的函数。
  • 很少的依赖项,实际必须具有的仅 2 个第三方 Rust crate。
  • 动态:函数重载、运算符过载、函数指针可动态调度。
  • 动态加载的模块,以组织代码库。
  • 可以捕获共享变量的闭包。
  • 支持面向对象编程(OOP)。
  • 支持大多数构建目标,包括 no-std、WebAssembly(WASM)等。
  • 可自行精确禁用关键字、运算符,以限制语言。
  • 可用作 DSL。
  • ……

总体来说,rhai 提供了一种安全、简单的方式,向任何应用程序添加以 Rust 语法编写的脚本,但保持了 Rust 语言“零开销”的执行效率。rhai 可以给 Rust 生态带来很多扩展。详细优势可参阅 rhai 文档,其开源仓库 rhaiscript/rhai/examples 提供了大量示例代码:包括 Rust 内嵌代码、独立脚本。

Rust web 项目中对 rhai 的使用,主要是在模板的渲染过程中。如作为模板内嵌助手脚本,对获取到的 API 数据进行计算。由此说来,即是在 handlebars-rust 模板引擎层面的使用。

Cargo.toml 中,启用 handlebars 依赖项的 script_helper 特性。

handlebars = { version = "4.0.0", features = ["script_helper"] }

rhai 脚本示例

在目录 frontend-handlebars 中,创建 scripts 目录,以及 scripts/length.rhai 文件。

scripts/length.rhai 文件的代码是一个最简单的可用于 handlebars 模板的 rhai 脚本示例:计算传入的数据的字符长度。

#![allow(unused)]
fn main() {
let username = params[0];

username.len()
}

这个代码没什么需要解说的,如果一定要说点什么,那就是 params[0] 表示的含义是:由模板传入脚本的第一个参数,多个参数类推。

向模板注册脚本

src/routes/users.rs 文件中,在模板声明语句之后,注册脚本名称和文件路径:

#![allow(unused)]
fn main() {
    let mut user_index: Tpl = Tpl::new("users/index").await;
    user_index
        .reg
        .register_script_helper_file(
            "length",
            format!("{}{}", "scripts/", "length.rhai"),
        )
        .unwrap();
}

:实际项目中,将字面量 "scripts/" 通过配置环境变量来读取,是较好的体验。

模板中使用脚本

脚本助手的使用语法为 {{<helper_name> helper.args}}。我们注册脚本助手时,指定的助手名称为 length,所以 {{length u.username}} 即可。如 templates/users/index.html 中的用法:

    <ul>
      {{#each allUsers as |u|}}
        <li><b>{{u.username}} - (length: {{length u.username}})</b></li>
        <ul>
          <li>{{ u.id }}</li>
          <li>{{ u.email }}</li>
        </ul>
      {{/each}}
    </ul>

现在,编译和运行后,你可以看到用户列表页面,用户名的括号内,显示了用户名称的字符长度。

静态/资源文件、环境变量等

  • 静态/资源文件(样式表、图像、js,以及其它)的使用,是规范的 html 标签和元素的语法。静态/资源文件的服务路径,属于 tide 的路由配置:一个 serve_dir 方法,指定为放置静态/资源文件(样式表、图像、js,以及其它)的路径即可。如:
#![allow(unused)]
fn main() {
  app.at("/static").serve_dir("./static").unwrap();

  app.at("/").get(index);
  app.at("users").get(user_index);
  ……
}

至此,《使用 tide、handlebars、rhai、graphql 开发 Rust web 前端》第一阶段就结束了;第二阶段,将专注于 cookie/session、rhai、jwt-auth,以及复杂的 GraphQL 查询等。

谢谢您的阅读!


Rust 和 Wasm 的融合,使用 yew 构建 WebAssembly 标准的 web 前端

Rust 和 Wasm 的融合,使用 yew 构建 WebAssembly 标准的 web 前端,请参阅 github 仓库 tide-async-graphql-mongodb/frontend-yew

采用 Rust 生态中的前端技术,分别采用 Rust 生态中的 WebAssembly 框架 yew,以及 tide + handlebars-rust 模板引擎来实现。

web 前端中,我们通过 GraphQL 服务后端 API,获取 GraphQL 数据并做以展示。

目前,Rust 官方在 WebAssembly 上投入了不少精力。Rust 社区中,Rust + WebAssembly 的应用也比较热门,其文章和话题增长趋势明下。因此,我们追逐技术潮流,首先采用 WebAssembly 框架 yew 来实践 web 前端的开发。

Rust 和 Wasm 的融合,使用 yew 构建 WebAssembly 标准的 web 前端(1)- 起步及 crate 选择

在以前的构建 Rust 异步 GraphQL 服务系列中,分别采用 tide + async-graphql + mongodbactix-web + async-graphql + rbatis + postgresql / mysql 开发了 GraphQL 服务后端。感兴趣的朋友可以参阅博文——

在 GraphQL 后端开发第一阶段的第 4 篇末尾,笔者提到过,本次 Rust web 的开发实践,全栈采用 Rust 生态。因此,web 前端的开发,也采用 Rust 生态中的前端技术,分别采用 Rust 生态中的 WebAssembly 框架 yew,以及 tide + handlebars-rust 模板引擎来实现。

web 前端中,我们通过 GraphQL 服务后端 API,获取 GraphQL 数据并做以展示。

目前,Rust 官方在 WebAssembly 上投入了不少精力。Rust 社区中,Rust + WebAssembly 的应用也比较热门,其文章和话题增长趋势明下。因此,我们追逐技术潮流,首先采用 WebAssembly 框架 yew 来实践 web 前端的开发。

Rust 环境的配置,cargo 工具的使用,以及本次实践的前后端分离设计等,在此不再赘述。您可以参阅本文开始提交的 2 个博文系列,进行快速了解。

Rust WebAssmbly 框架库的选择

Rust 生态中,目前较为成熟的 WebAssmbly 框架库有 yewstack/yewseed-rs/seed,以及 chinedufn/percy。这 3 个框架,都非常优秀,推荐各位朋友去 github 给予其 star。

  • percy 已经在文档提到了服务器端渲染方案,结合实例和 API 文档来看,开发者投入了相当多的精力,以无私奉献。
  • seed 是 Rust 生态中较活跃的 WebAssembly 库,实例完善。且已经有可用于真实生产环境的样板案例 conduit,完成度很高,也非常美观。并且笔者粗略学习了其源码,编写方式也非常精简。seed 的主要开发者之一,Martin Kavík 正在开发针对 seed 的构建工具 seeder。结合 API 文档来看,是个设计和规划很优秀的库。
  • yew 是久经考验的 WebAssembly 库,贡献者众多,社区活跃。除了 API 文档,还拥有非常详尽的教程文档。

本次实践,因为未来版本的技术取舍和路线图方面,笔者选择 yew 框架。也许后续的拓展中,我们会对其它 2 个框架也做以实践体验。

工程的创建

在我们的实践项目根目录 tide-async-graphql-mongodb 或者 actix-web-async-graphql-rbatis 中,创建新的新的工程 frontend-yew。

GraphQL 服务后端,开源在 github,可以访问如下仓库获取源码:

cd tide-async-graphql-mongodb # 或 actix-web-async-graphql-rbatis
cargo new frontend-yew --vcs none

同时,需要在根目录的 Cargo.toml(不是 frontend-yew 目录中的 Cargo.toml)将 frontend-yew 项目添加到 workspace 部分:

[workspace]
members = [
    "./backend", 
    "./frontend-handlebars", 
    "./frontend-yew"
]

yew 开发环境配置

工具类 crate

yew 项目构建工具方面,目前成熟可用的主要有 rustwasm 官方开发和维护的 wasm-pack 以及 Anthony Dodd 开发的 trunk

wasm-pack 在对 yew 打包时,需要 node 环境和 rollup 或者 webpack 工具。而 trunk 则是完全的 Rust 技术栈开发,不需要 node 环境。并且代码有改动时,可自动化重新编译。也可指定启动服务时,自动在浏览器中打开页面等。

因此笔者选择 trunk,安装其需要 wasm-bindgen-cli 工具 crate。

cargo install trunk wasm-bindgen-cli

依赖项 crate

我们目前仅是 yew 开发环境的初始配置,所以需要的依赖项 crate 仅为 yew 和 wasm-bindgen。我们使用 cargo-edit 工具,将它们加入到 frontend-yew 工程中。

cargo add yew wasm-bindgen

此时,frontend-yew 项目中的 Cargo.toml 文件内容如下:

[package]
name = "frontend-yew"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
wasm-bindgen = "0.2.74"
yew = "0.18.0"

代码开发

实例代码来自 yew 官方示例,是一个计数器的应用。目前,我们是对基础开发环境的检测,因此代码暂不解释。但如果你熟悉 Rust 和 react.js,会发现代码可以猜出一个大概的意思。并不复杂。

main.rs

use yew::prelude::*;

enum Msg {
    AddOne,
}

struct Model {
    link: ComponentLink<Self>,
    value: i64,
}

impl Component for Model {
    type Message = Msg;
    type Properties = ();

    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self {
            link,
            value: 0,
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::AddOne => {
                self.value += 1;

                true
            }
        }
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        html! {
            <div>
                <button onclick=self.link.callback(|_| Msg::AddOne)>{ "+1" }</button>
                <p>{ self.value }</p>
            </div>
        }
    }
}

fn main() {
    yew::start_app::<Model>();
}

index.html

在 frontend-yew 目录中,创建 index.html 文件,代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Yew App</title>
  </head>
</html>

构建工具 trunk 配置(可选)

trunk 的配置,目前是可选的。trunk 默认的端口是 8080,笔者机器此端口已被占用。如果你的机器可以使用此端口,则暂不需要配置。如果你不想通过默认的 8080 端口访问页面,请在 frontend-yew 目录中,创建 trunk.toml 文件,代码如下:

[serve]
port = 3001
open = true
  • open 是指执行 trunk serve 命令时,是否自动在浏览器打开页面(代码修改后,trunk 自动重新编译时,不会打开浏览器页面)。其默认为 false
  • 注意:也可以将 index.html 文件不放在项目根目录,而是指定的配置位置(如 pulic、static 等)。但注意目前 trunk 在相对路径的识别方面,Windows 系统上会有些问题,如果你用的 Windows 系统,请注意这一点。Linux、macOS 无问题,为了各平台无缝切换,本文 index.html 文件直接放在 frontend-yew 目录中。

编译和运行

使用 trunk build 进行编译,我们会发现在 frontend-yew 目录中,出现了一个 dist 子目录。其中包含一个后缀为 *.wasm 的文件,即是我们 Rust 代码编译为 WebAssembly 格式的成果。

实际上,dist/index.html 文件也是对我们初始的 frontend-yew/index.html 文件的编译成果。后面的文章中,我们引入 icon、css/scss,以及 JavaScript 代码时,会对此进行介绍。

现在,我们运行命令 trunk serve。因为笔者配置了 trunk.toml,会自动在机器的默认浏览器新开一个标签 http://127.0.0.1:3001;如果你未配置 trunk.toml,则访问默认端口,请手动在浏览器新开页面 http://127.0.0.1:8080。

我们看看成果展现:是一个简单的计数器应用,点击加号,下方的数字会进行加 1 运算。

至此,yew 的开发环境已经搭建成功。

yew 示例项目sansx/yew-graphql-demo。此项目是 sansx 老师开发的 yew 示例。本系列文章中,笔者将对其深入参考 ;-)

谢谢您的阅读!


Rust 和 Wasm 的融合,使用 yew 构建 WebAssembly 标准的 web 前端(2)- 组件和路由

上一篇文章《起步及 crate 选择》中,我们介绍了选型原因,介绍了构建工具,以及搭建了 yew 的基本开发环境并测试。

本篇文章中,我们将开始 Yew 编码开发。我们本系列文章,侧重于实践体验。因此,文章中可能会对某些 yew 基础知识和需要注意的地方进行详细说明。但整体而言,对于 yew 的基础知识,笔者不做过多介绍。Yew 的基础资料很详实,其中文文档的国内在线阅读,请访问 https://yew-guide.rusthub.org。

需要说明的是,Yew 的基础知识方面,官方提供了很充实的资料,笔者不进行照抄和搬运。感兴趣的朋友可自行阅读;因为笔者的官网访问,一周总有几次着急查阅时,页面打不开,所以笔者在国内架设 Yew 中文文档的在线阅读服务器 https://yew-guide.rusthub.org。

实践目标

在以前的 web 前端开发中,笔者使用 tide + handlebars-rust + graphql_client 技术组合:其中 graphql_client 用于从 GraphQL 服务后端获取 GraphQL 数据,转换为 json 格式;json 格式数据可直接用于 handlebars-rust 模板引擎,做以展示;tide 作为服务器,用于路由分发,逻辑处理等。即 mvc 开发模式,这是传统的 web 开发方式。实现结果如下图所示:

实践目标

我们本次使用 yew 的实践,也希望实现相同的目标结果。

crate 引入

yew 是单页面开发方式,但我们希望实现三个数据的展示:主界面和导航菜单、用户列表,以及项目列表。获取 GraphQL 数据并解析的方式,我们放在下篇文章讲解。本篇文章中,我们仅开发主界面和导航组件、用户列表组件,以及项目列表组件。到访各自列表的路由,我们通过 yew-router 实现。当然,组件的开发,使用 yew

通过 cargo-edit 工具,我们将上述 crate 加入到 Carto.toml 中,使用各个 crate 的最新版本。

cargo add yew-router

上篇文章中,我们引入了 wasm-bindgenyew 两个 crate,所以 Carto.toml 文件总体内容如下(笔者根据习惯,通常会做些顺序的调整或添加空行,这无关紧要):

[package]
name = "frontend-yew"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

[dependencies]
yew = "0.18.0"
yew-router = "0.15.0"

wasm-bindgen = "0.2.74"

IDE 配置

俗话说,工欲善其事,必先利其器。yew 编码方面,一些 IDE 提供的特性可以给编码开发提供很大的辅助作用。目前,主要包括组件模板,以及为 yew 中的 html! 宏启用 HTML 代码的智能感知。

具体到 Rust IDE 方面,比较好使的集成开发环境主要有 vsCode 和 IntelliJ-Rust。IntelliJ-Rust 相对来说更智能一些;但笔者使用的是 vsCode,因为喜欢更多的手动操作。

yew 组件模板

vsCode

  • 导航菜单:File > Preferences > User Snippets;
  • 选择 Rust 语言;
  • 在 json 文件中,增加如下代码片段:

具体 Rust 代码之外的部分,可以使之中文化。

{
    "Create new Yew component": {
        "prefix": "yew component",
        "body": [
            "use yew::prelude::*;",
            "",
            "pub struct ${1} {",
            "    link: ComponentLink<Self>,",
            "}",
            "",
            "pub enum Msg {",
            "}",
            "",
            "impl Component for ${1} {",
            "    type Message = Msg;",
            "    type Properties = ();",
            "",
            "    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {",
            "        Self {",
            "            link,",
            "        }",
            "    }",
            "",
            "    fn update(&mut self, msg: Self::Message) -> ShouldRender {",
            "        match msg {}",
            "    }",
            "",
            "    fn change(&mut self, _props: Self::Properties) -> ShouldRender {",
            "        false",
            "    }",
            "",
            "    fn view(&self) -> Html {",
            "        html! {",
            "            ${0}",
            "        }",
            "    }",
            "}"
        ],
        "description": "Create a new Yew component without properties but with a message enum"
    }
}

使用方法:

  • Rust 文件的光标处,按 F1 键打开控制面板,然后输入snippet;
  • 输入上述代码片段中的 prefix 定义值,或者直接选择模板。

IntelliJ-Rust

  • 导航菜单:File | Settings | Editor | Live Templates;
  • 选择 Rust 并点击加号 + 图标,以增加模板;
  • 设定名字和描述;
  • 在模板文本中,粘贴如下代码片段:
#![allow(unused)]
fn main() {
use yew::prelude::*;

struct $NAME$ {
    link: ComponentLink<Self>
}

enum Msg {
}

impl Component for $NAME$ {
    type Message = Msg;
    type Properties = ();

    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self { 
            link 
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {}
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        html! {
            $HTML$
        }
    }
}
}

为 yew 中的 html! 宏启用 HTML 代码的智能感知

此项辅助特性,intellij-rust 目前无法提供。

vsCode 中,虽然不支持指定的 html! 宏语法,但是可以在 settings.json 中添加如下配置,以启用 HTML 代码的智能感知:

"emmet.includeLanguages": {
    "rust": "html",
}

yew 组件开发

本篇文章中,我们先实现最简单的 yew 组件开发,仅局限于一下几个方面:主界面/用户列表界面/项目列表界面的组件开发、各个界面的路由定义,以及界面间的路由跳转。

另外,还有 web 前端的主程序入口组件,我们将其放到路由定义部分,一起讲述。

暂不实现具体界面的数据获取、解析,以及展示;也未定义任何样式。

本篇文章仅是理解 yew 的启动、组件,以及路由。

yew 中,最基础的即是组件,其可管理自己的状态,并可以渲染为 DOM。组件是通过实现结构体的 Component trait 来创建的。

我们在 src 目录中,创建 pages 文件夹,并创建 mod.rshome.rsusers.rs,以及 projects.rs 四个文件。

mkdir ./src/pages
cd ./src/pages
touch mod.rs home.rs users.rs projects.rs

并编辑 mod.rs 文件,代码如下:

#![allow(unused)]
fn main() {
pub mod home;
pub mod users;
pub mod projects;
}

用户列表组件

上文提到,组件是通过实现结构体的 Component trait 来创建的。所以用户列表组件的创建,非常简单:

  • 定义一个 Users 结构体;
  • 为其实现 Component trait;
  • Message 表示组件可以处理以触发某些副作用的各种消息;
  • Properties 表示从父级组件传递到子级组件的信息;
  • 根据 yew 组件的声明周期,我们初始,至少需要实现 4 个方法:
    • create,组件创建方法,返回值为 Self
    • update,组件更新方法,返回值为 bool 类型的 ShouldRender。暂时我们未有实现,通过 unimplemented! 宏返回 false
    • change,组件更改方法,返回值为 bool 类型的 ShouldRender。同上,暂时我们未有实现,通过 unimplemented! 宏返回 false
    • view,组件展示为 DOM 的方法,返回值为 yew 中的 Html 类型,实质上是 VNode 枚举。

好的,在 users.rs 文件中,我们定义结构体并对之实现组件特质(trait):

#![allow(unused)]
fn main() {
use yew::prelude::*;

pub struct Users;

impl Component for Users {
    type Message = ();
    type Properties = ();

    fn create(_props: Self::Properties, _: ComponentLink<Self>) -> Self {
        Self
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        unimplemented!()
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        unimplemented!()
    }

    fn view(&self) -> Html {
        html! {
            <div>
            { "用户列表 - 蓝色" }
            </div>
        }
    }
}
}

项目列表组件、主界面组件

项目列表组件、主界面组件,同用户列表组件的定义完全一致,仅需要替换 users/Usersprojects/Projectshome/Home,然后复制到 projects.rshome.rs 文件中。

projects.rs 文件内容如下:

#![allow(unused)]
fn main() {
use yew::prelude::*;

pub struct Projects;

impl Component for Projects {
    type Message = ();
    type Properties = ();

    fn create(_props: Self::Properties, _: ComponentLink<Self>) -> Self {
        Self
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        unimplemented!()
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        unimplemented!()
    }

    fn view(&self) -> Html {
        html! {
            <div>
            { "项目列表 - 绿色" }
            </div>
        }
    }
}
}

不要忘了填充 home.rs 文件,下述我们要用到。

yew 路由定义,以及应用入口组件

yew 的路由,通过 yew-router 库实现。通常的应用模式为,定义一个枚举,对其需要进行属性派生 #[derive(Switch, Debug, Clone, PartialEq)]:关键的是 SwitchPartialEqSwitch trait 用于在该 trait 的实现者之间进行 Route 转换。PartialEq 是 yew 中的一个性能优化,避免组件的重新渲染。

对于枚举字段,应用属性注解语法 #[to = "<path>"],表示具体的分发路由。如本例中,我们如下定义:

#![allow(unused)]
fn main() {
#[derive(Switch, Debug, Clone, PartialEq)]
pub enum Route {
    #[to = "/users"]
    Users,
    #[to = "/projects"]
    Projects,
    #[to = "/"]
    Home,
}
}

重要:当前 yew-router 库的版本中,#[to = "/"] 必须放置在最后,否则其之后的 #[to = "/<path>"] 均无法正常工作。

如下定义是无法按照预期执行的,也许愿意测试一下:

#![allow(unused)]
fn main() {
#[derive(Switch, Debug, Clone, PartialEq)]
pub enum Route {
    #[to = "/"]
    Home,
    #[to = "/users"]
    Users,
    #[to = "/projects"]
    Projects,
}
}

但在 yew-router 库的开发版本中,此问题会解决。并且,新的版本中,会引入 Routable trait、属性 #[to = "<path>"] 会变为 #[at("<path>")]。我们的定义,在新的版本中,如下代码所示:

#![allow(unused)]
fn main() {
#[derive(Routable, PartialEq, Clone, Debug)]
pub enum Route {
    #[at("/")]
    Home,
    #[at("/users")]
    Users,
    #[at("/projects")]
    Projects,
}
}

看起来更符合 Rust 语言风格。但目前版本是不可用的,仅作了解。

main.rs 中,我们还需要组合上一节定义的界面组件,将其组合展示。因此,我们需要定义一个新的 yew 主程序入口组件,作为 web 前端应用的入口。

入口组件的定义方式中,我们要引入 yew-router 库提供的一个标签组件 RouterAnchor,该标签组件提供一个点击响应,可按照定义的路由进行导航。

yew-router 的新版本中,将使用 Router 函数,语法更简洁一些,相比性能也有提升。

另外,还有布局,和 JSX 语法扩展应用大抵相同。嗯,大概需要说明的就是这些,我们看看 main.rs 的完整代码:

use yew::prelude::*;
use yew_router::prelude::*;
use yew_router::components::RouterAnchor;

mod pages;
use pages::{home::Home, users::Users, projects::Projects};

#[derive(Switch, Debug, Clone, PartialEq)]
pub enum Route {
    #[to = "/users"]
    Users,
    #[to = "/projects"]
    Projects,
    #[to = "/"]
    Home,
}

struct App;

impl Component for App {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
        Self
    }

    fn update(&mut self, _: Self::Message) -> bool {
        unimplemented!()
    }

    fn change(&mut self, _: Self::Properties) -> bool {
        unimplemented!()
    }

    fn view(&self) -> Html {
        type Anchor = RouterAnchor<Route>;

        html! {
            <>
            <div>
                { "tide-async-graphql-mongodb / frontend-yew" }
            </div>
            <div>
                <Anchor route=Route::Users>
                    { "用户列表" }
                </Anchor>
                { " - " }
                <Anchor route=Route::Projects>
                    { "项目列表" }
                </Anchor>
                { " - " }
                <Anchor route=Route::Home>
                    { "主页" }
                </Anchor>
            </div>
            <main>
                <Router<Route, ()>
                    render = Router::render(|switch: Route| {
                        match switch {
                            Route::Users => html!{ <Users/> },
                            Route::Projects => html!{ <Projects/> },
                            Route::Home => html!{ <Home/> },
                        }
                    })
                />
            </main>
            </>
        }
    }
}

fn main() {
    yew::start_app::<App>();
}

好,代码到此结束。让我们运行一下,看看成果。

运行

执行 trunk serve 命令,浏览器会自动打开一个页面,或者手动在浏览器中访问 http://127.0.0.1:3001。如果你未按照上篇 trunk.toml 所介绍的配置,请访问你自定义的端口(默认为 8080)。

点击导航菜单,可以看到页面内容发生了改变,本文的目标已经达成。

注:或许你会发现一些小 bug,但请暂且容忍。

谢谢您的阅读!


Rust 和 Wasm 的融合,使用 yew 构建 web 前端(3)- 资源文件及小重构

前两篇文章《起步及 crate 选择》《组件和路由》中,我们介绍了选型原因,搭建了 yew 的基本开发环境,并进行了最基础的组件和路由编码。并且和 yew 中文文档的翻译者 sansx 老师及一些感兴趣的朋友进行了友好而热烈的交流。

关于交流心得,笔者感觉有必要提及一下,作为一个即要走路也要看路的技术认知:

  • 关于 html! 宏中的 <>……</>,这是因为 html! 宏仅能有一个根标签元素。<>……</> 充当了一个根标签,输出实际上是空的。另外,html! 宏中的标签必须闭合,即使 html5 标准中不需要 /> 的自闭合标签,也不能省略 />。如 <img src="path" />
  • yew 生产环境的应用。笔者仅是 yew 的初学者,理解不很恰当。根据对官方 API 文档的理解,个人认为当前版本用于生产环境,是一个不小的挑战(包括开发和维护)。但从项目源码、issues 讨论,以及路线规划来看,个人认为下个版本差强人意,待发布后,yew 用于生产环境是可以接受的。笔者也有此计划。
  • ssr 或者 seo 方面,yew 官方有计划,但未有实质进度。但笔者认为影响不大,网上几年前就有文章给出了结论:新时代的搜索引擎(Google、Yahoo、Bing、DuckDuckGo 等),能够像现代浏览器一样访问网站,能很好的抓取动态渲染后的内容,不用担心使用 yew 之类的框架而导致 seo 出现问题。几年过去了,搜索引擎的技术进步应该很大。再者,笔者认为现在信息传播的方式已经有所改变,国内尤为明显。最后,当国外搜索引擎已经收录大量中文站点的内容时,某些国内搜索引擎,却仅是首页甚至是未有收录;这样的情形,即使技术方面对 seo 很适配,估计也是不能解决收录问题的。
  • Rust 官方周报 393 期中有一篇技术,是关于 Rust + WebAssembly 为 deno 开发插件系统的,看起来前景很不错。基于 WebAssembly 的性能和特性,如果插件足够通用,说不定可发展为一个独立的职业。

前两篇文章中,我们实现的界面是非常简陋的,没有引入任何样式、图像等 web 应用必不可少的资源文件。本篇文章中,我们将实践如何对 yew 组件使用样式,组件包含图片等。严格来说,这部分是属于构建工具 trunk 的知识。trunk 工具在首篇文章《起步及 crate 选择》中已经提及,是完全的 Rust 技术栈开发,不同于 wasm-pack 那样需要 node 环境。其在样式方面,支持 css/sass/scss(scss 实质是 sass3 及之后的升级版,目前使用更广一些),我们都将进行实践。图像方面,笔者分别引入 icon 和在组件中放置 <img> 标签以作示例。其它 js 和数据等资源文件,未有设计,但使用和图像是类同的。

引入样式表

笔者在 frontend-yew 目录中,创建如下目录和结构,放置资源文件:

mkdir -p assets/{css, imgs, js, data}
cd assets/css
touch style.css style.sass style.scss

css 代码

我们分别有 css、sass,以及 scss,仅是为验证 trunk 对其都可以编译。

style.css

.home {
    background-color: red;
}

style.sass

$users-color: blue

.users
    background-color: $users-color;

style.scss

.logo-title {
    line-height: 40px;
    display:flex;
    align-items: center;
}

.nav {
    line-height: 30px;
    display:flex;
    align-items: center;
    margin-bottom: 20px;
    font-weight: bold;
}

$projects-color: green;

.projects {
    background-color: $projects-color;
}

将样式表加入 trunk 构建路径

trunk 工具构建时,资源文件通过 <link> 标签引入,但需要声明 data-trunk。我们要将上述三个样式表加入构建路径,在 index.html 文件中的 <head> 标签内,加入它们的路径:

<!doctype html>
<html lang="zh">

    <head>
        <title>tide-async-graphql-mongodb - frontend - yew</title>

        <meta charset="utf-8">

        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="author" content="zzy, https://github.com/zzy/tide-async-graphql-mongodb">

        <link data-trunk rel="css" href="assets/css/style.css">
        <link data-trunk rel="sass" href="assets/css/style.sass">
        <link data-trunk rel="scss" href="assets/css/style.scss">
    </head>

</html>

组件中使用 css

重要:以下均为代码片段,请注意文件名,以及不同的样式表压入方法。

使用 &str 字符串字面量

如在 main.rs 中的应用入口组件上,使用 style.scss 声明的样式:

#![allow(unused)]
fn main() {
    fn view(&self) -> Html {
        type Anchor = RouterAnchor<Route>;

        let home_cls = "nav";

        html! {
            <>
            <div class="logo-title">
                { "tide-async-graphql-mongodb / frontend-yew" }
            </div>
            <div class=home_cls>
                <Anchor route=Route::Users>
                    { "用户列表" }
                </Anchor>
                { " - " }
                ……
                ……
                ……
            </>
        }
    }
}

如在 users.rs 中的用户列表组件上,使用 style.sass 声明的样式:

#![allow(unused)]
fn main() {
    fn view(&self) -> Html {
        html! {
            <div class="users">
            { "用户列表 - 蓝色" }
            </div>
        }
    }
}

使用 classes!

yew 的近期版本中,新增了 classes! 宏,让样式表的压入更灵活,扩展性更强。

如在 home.rs 中的主界面组件上,使用 style.css 声明的样式:

#![allow(unused)]
fn main() {
    fn view(&self) -> Html {
        let home_cls = "home";

        html! {
            <div class=classes!(home_cls)>
               { "主界面 - 红色" }
            </div>
        }
    }
}

如在 projects.rs 中的项目列表组件上,使用 style.scss 声明的样式:

#![allow(unused)]
fn main() {
    fn view(&self) -> Html {
        html! {
            <div class=classes!("projects")>
            { "项目列表 - 绿色" }
            </div>
        }
    }
}

引入图像

笔者向 assets 目录中放入一个 favicon.png 图像,向 assets/imgs 目录中放入一个 budshome.png 图像。

icon 和 <img> 都是通过 <link> 标签加入到构建路径,但 rel 属性则不同:icon 图像的引入,定义为 rel="icon",而 <img> 使用的图像资源,则要在构建时复制:可以选择复制单个文件,也可以复制文件夹。

<link data-trunk rel="icon" href="assets/favicon.png">

<link data-trunk rel="copy-dir" href="assets/imgs"> 
# 或者复制单个文件
<link data-trunk rel="copy-file" href="assets/imgs/budshome.png">

笔者使用的是复制文件夹。至此,index.html 文件完整内容为:

<!doctype html>
<html lang="zh">

    <head>
        <title>tide-async-graphql-mongodb - frontend - yew</title>

        <meta charset="utf-8">

        <link data-trunk rel="icon" href="assets/favicon.png">

        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="author" content="zzy, https://github.com/zzy/tide-async-graphql-mongodb">

        <link data-trunk rel="css" href="assets/css/style.css">
        <link data-trunk rel="sass" href="assets/css/style.sass">
        <link data-trunk rel="scss" href="assets/css/style.scss">

        <link data-trunk rel="copy-dir" href="assets/imgs">
    </head>

</html>

在 yew 组件代码中,我们直接嵌入图像元素,注意此时图像路径从的根目录为 imgs

注意html! 宏中的标签必须闭合,即使 html5 标准中不需要 /> 的自闭合标签,也不能省略 />

#![allow(unused)]
fn main() {
    fn view(&self) -> Html {
        type Anchor = RouterAnchor<Route>;

        let home_cls = "nav";

        html! {
            <>
            <div class="logo-title">
                <img src="imgs/budshome.png" />
                { "tide-async-graphql-mongodb / frontend-yew" }
            </div>
            <div class=home_cls>
                <Anchor route=Route::Users>
                    { "用户列表" }
                ……
                ……
                ……
            </>
        }
    }
}

运行和测试

执行 trunk serve 命令,浏览器会自动打开一个页面,或者手动在浏览器中访问 http://127.0.0.1:3001。如果你未按照上篇 trunk.toml 所介绍的配置,请访问你自定义的端口(默认为 8080)。

点击导航菜单,可以看到页面内容有了一些基础的样式,也显示了图像元素,当然还是很简陋。但本文是示例说明资源文件的引入和构建,目标已经达成。

代码重构:精简 html! 宏中代码,提取为函数

有朋友联系,讨论 main.rs 文件中的 <main> 标签内代码是否为好的实践?是否应当提取为一个函数之类的?以保持 html! 宏中代码尽量精简。

笔者深以为然,函数相对来说是较好的实践。同时引申一下:yew 的新版本,增加了 yew-functional 函数组件包,目前还未发布为独立的 crate。

我们简单对其重构,增加一个 switch 函数,返回值为 yew 中的 Html 类型,实质上是 VNode 枚举。

#![allow(unused)]
fn main() {
fn switch(switch: Route) -> Html {
    match switch {
        Route::Users => {
            html! { <Users/> }
        }
        Route::Projects => {
            html! { <Projects/> }
        }
        Route::Home => {
            html! { <Home /> }
        }
    }
}
}

此时,main.rs 文件中的 <main> 标签内代码可精简为:

    <main>
        <Router<Route> render=Router::render(switch) />
    </main>

谢谢您的阅读!


Rust 和 Wasm 的融合,使用 yew 构建 web 前端(4)- 获取 GraphQL 数据并解析

在 Rust 生态,使用 yew 开发 WebAssembly 应用方面,我们已经介绍了《起步及 crate 选择》《组件和路由》,以及《资源文件及重构》。今天,我们介绍如果在 yew 开发的 wasm 前端应用中,与后端进行数据交互。我们的后端提供了 GraphQL 服务,让我们获取 GraphQL 数据并解析吧!

需要新引入一些 crate:使用 graphql_client 获取 GraphQL 数据,然后通过 serde 进行解析。wasm 需要绑定 web API,以发起请求调用和接受响应数据,需要使用 web-sys,但其可以通过 yew 库路径引入,无需加入到依赖项。但是,web-sys 中和 JavaScript Promise 绑定和交互方面,需要 wasm-bindgen-futures。总体上,我们需要引入:

cargo add wasm-bindgen-futures graphql_client serde serde_json

现在,我们的 Cargo.toml 文件内容如下:

[package]
name = "frontend-yew"
version = "0.1.0"
authors = ["我是谁?"]
edition = "2018"

[dependencies]
wasm-bindgen = "0.2.74"
wasm-bindgen-futures = "0.4.24"

yew = "0.18.0"
yew-router = "0.15.0"

graphql_client = "0.9.0"
serde = { version = "1.0.126", features = ["derive"] }
serde_json = "1.0.64"

编写 GraphQL 数据查询描述

首先,我们需要从 GraphQL 服务后端下载 schema.graphql,放置到 frontend-yew/graphql 文件夹中。schema 是我们要描述的 GraphQL 查询的类型系统,包括可用字段,以及返回对象等。

然后,在 frontend-yew/graphql 文件夹中创建一个新的文件 all_projects.graphql,描述我们要查询的项目数据。项目数据查询很简单,我们查询所有项目,不需要传递参数:

query AllProjects {
  allProjects {
    id
    userId
    subject
    website
  }
}

最后,在 frontend-yew/graphql 文件夹中创建一个新的文件 all_users.graphql,描述我们要查询的用户数据。用户的查询,需要权限。也就是说,我们需要先进行用户认证,用户获取到自己在系统的令牌(token)后,才可以查看系统用户数据。每次查询及其它操作,用户都要将令牌(token)作为参数,传递给服务后端,以作验证。

query AllUsers($token: String!) {
  allUsers(
    token: $token
  ) {
    id
    email
    username
  }
}

用户需要签入系统,才能获取个人令牌(token)。此部分我们不做详述,请参阅文章《基于 tide + async-graphql + mongodb 构建异步 Rust GraphQL 服务》、《基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务》,以及项目 zzy/tide-async-graphql-mongodb 进行了解。

请求(request)的构建

使用 graphql_client 构建查询体(QueryBody)

在此,我们需要使用到上一节定义的 GraphQL 查询描述,通过 GraphQLQuery 派生属性注解,可以实现与查询描述文件(如 all_users.graphql)中查询同名的结构体。当然,Rust 文件中,结构体仍然需要我们定义,注意与查询描述文件中的查询同名。如,与 all_users.graphql 查询描述文件对应的代码为:

#![allow(unused)]
fn main() {
#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "./graphql/schema.graphql",
    query_path = "./graphql/all_users.graphql",
    response_derives = "Debug"
)]
struct AllUsers;
type ObjectId = String;
}

type ObjectId = String; 表示我们直接从 MongoDB 的 ObjectId 中提取其 id 字符串。

接下来,我们构建 graphql_client 查询体(QueryBody),我们要将其转换为 Value 类型。项目列表查询没有参数,构造简单。我们以用户列表查询为例,传递我们使用 PBKDF2 对密码进行加密(salt)和散列(hash)运算后的令牌(token)。

本文实例中,为了演示,我们将令牌(token)获取后,作为字符串传送,实际应用代码中,当然是作为 cookie/session 参数来获取的,不会进行明文编码。

#![allow(unused)]
fn main() {
    let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJlbWFpbCI6ImFzZmZhQGRzYWZhLmNvbSIsInVzZXJuYW1lIjoi5a-G56CBMTExIiwiZXhwIjoxMDAwMDAwMDAwMH0.NyEN13J5trkn9OlRqWv2xMHshysR9QPWclo_-q1cbF4y_9rbkpSI6ern-GgKIh_ED0Czk98M1fJ6tzLczbdptg";
    let build_query = AllUsers::build_query(all_users::Variables {
        token: token.to_string(),
    });
    let query = serde_json::json!(build_query);
}

构造 web-sys 请求

构建 web-sys 请求时:

  • 我们需要设定请求的方法(method),GraphQL 请求须为 POST
  • 我们需要将 graphql_client 查询体(QueryBody)转换为字符串,压入到 web-sys 请求体中。
  • 可选地,我们需要声明查询请求是否为跨域资源共享(Cross-Origin Resource Sharing)。web-sys 请求中,默认为跨域资源共享。
#![allow(unused)]
fn main() {
    let mut req_opts = RequestInit::new();
    req_opts.method("POST");
    req_opts.body(Some(&JsValue::from_str(&query.to_string())));
    req_opts.mode(RequestMode::Cors); // 可以不写,默认为 Cors

    let gql_uri = "http://127.0.0.1:8000/graphql";
    let request = Request::new_with_str_and_init(gql_uri, &req_opts)?;
}

注 1:如果你遇到同源策略禁止读取的错误提示,请检查服务后端是否设定了 Access-Control-Allow-Origin 指令,指令可以用通配符 * 或者指定数据源链接地址(可为列表)。

注 2let gql_uri = "http://127.0.0.1:8000/graphql"; 一行,实际项目中,通过配置环境变量来读取,是较好的体验。

响应(response)数据的接收和解析

响应(response)数据的接收

响应(response)数据的接受部分代码,来自 sansx(yew 中文文档翻译者) 的 yew 示例项目 sansx/yew-graphql-demo

提交请求方面,web-sys 提供了四种方式:

  • Window::fetch_with_str
  • Window::fetch_with_request
  • Window::fetch_with_str_and_init
  • Window::fetch_with_request_and_init

我们使用 Window::fetch_with_request 提交请求,返回的数据为 JsValue,需要通过 dyn_into() 方法转换为响应(Response)类型。

#![allow(unused)]
fn main() {
    let window = yew::utils::window();
    let resp_value =
        JsFuture::from(window.fetch_with_request(&request)).await?;
    let resp: Response = resp_value.dyn_into().unwrap();
    let resp_text = JsFuture::from(resp.text()?).await?;
}

响应(response)数据的解析

我们接收到的数据是 JsValue 类型。首先,需要将其转换为 Value 类型,再提取我们需要的目标数据。本文示例中,我们需要的目标数据都是列表,所以转换为动态数组(Vector)。

#![allow(unused)]
fn main() {
    let users_str = resp_text.as_string().unwrap();
    let users_value: Value = serde_json::from_str(&users_str).unwrap();
    let users_vec =
        users_value["data"]["allUsers"].as_array().unwrap().to_owned();

    Ok(users_vec)
}

数据的渲染

我们实现了数据获取、转换,以及部分解析。但是,组件的状态和数据请求的关联——如前几篇文章所述——是通过 yew 中的 Message 关联的。如,组件和消息的定义:

#![allow(unused)]
fn main() {
pub struct Users {
    list: Vec<Value>,
    link: ComponentLink<Self>,
}

pub enum Msg {
    UpdateList(Vec<Value>),
}
}

组件定义方面,我们不再赘述。我们集中于数据展示渲染方面:yew 的 html! 宏中,是不能使用 for <val> in Vec<val> 这样的循环控制语句的,其也不能和 html! 宏嵌套使用。但 html! 宏中提供了 for 关键字,用于对包含项(item)类型为 VNode 的迭代体(即实现了 Iterator)进行渲染。如用户列表的渲染代码:

#![allow(unused)]
fn main() {
   fn view(&self) -> Html {
        let users = self.list.iter().map(|user| {
            html! {
                <div>
                    <li>
                        <strong>
                            { &user["username"].as_str().unwrap() }
                            { " - length: " }
                            { &user["username"].as_str().unwrap().len() }
                        </strong>
                    </li>
                    <ul>
                        <li>{ &user["id"].as_str().unwrap() }</li>
                        <li>{ &user["email"].as_str().unwrap() }</li>
                    </ul>
                </div>
            }
        });

        html! {
            <>
                <h1>{ "all users" }</h1>
                <ul>
                    { for users }
                </ul>
            </>
        }
    }
}
}

对于项目列表的数据展示,是类似的,不过我们需要注意的一点为:yew 中的数据输出,有字面量和 IntoPropValue 两种。前者比较灵活:String 和 &str 均可;而后者须为实现 IntoPropValue<std::option::Option<Cow<'static, str>>> 特质(trait)的类型,如 String。比如:项目列表中,对于链接的 href 属性,必须是实现了 IntoPropValue<std::option::Option<Cow<'static, str>>> 特质(trait)的 String,而直接输出的字面量则不同。

#![allow(unused)]
fn main() {
    <a href={ project["website"].as_str().unwrap().to_owned() }>
        { &project["website"].as_str().unwrap() }
    </a>
}

完整代码

推荐你从项目 zzy/tide-async-graphql-mongodb 下载完整代码,更欢迎你做出任何贡献。

pages/users.rs

#![allow(unused)]
fn main() {
use graphql_client::GraphQLQuery;
use serde_json::Value;
use std::fmt::Debug;
use wasm_bindgen::{prelude::*, JsCast};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use yew::web_sys::{Request, RequestInit, RequestMode, Response};
use yew::{html, Component, ComponentLink, Html, ShouldRender};

#[derive(Debug, Clone, PartialEq)]
pub struct FetchError {
    err: JsValue,
}

impl From<JsValue> for FetchError {
    fn from(value: JsValue) -> Self {
        Self { err: value }
    }
}

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "./graphql/schema.graphql",
    query_path = "./graphql/all_users.graphql",
    response_derives = "Debug"
)]
struct AllUsers;
type ObjectId = String;

async fn fetch_users() -> Result<Vec<Value>, FetchError> {
    let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJlbWFpbCI6ImFzZmZhQGRzYWZhLmNvbSIsInVzZXJuYW1lIjoi5a-G56CBMTExIiwiZXhwIjoxMDAwMDAwMDAwMH0.NyEN13J5trkn9OlRqWv2xMHshysR9QPWclo_-q1cbF4y_9rbkpSI6ern-GgKIh_ED0Czk98M1fJ6tzLczbdptg";
    let build_query = AllUsers::build_query(all_users::Variables {
        token: token.to_string(),
    });
    let query = serde_json::json!(build_query);

    let mut req_opts = RequestInit::new();
    req_opts.method("POST");
    req_opts.body(Some(&JsValue::from_str(&query.to_string())));
    req_opts.mode(RequestMode::Cors); // 可以不写,默认为 Cors

    let gql_uri = "http://127.0.0.1:8000/graphql";
    let request = Request::new_with_str_and_init(gql_uri, &req_opts)?;

    let window = yew::utils::window();
    let resp_value =
        JsFuture::from(window.fetch_with_request(&request)).await?;
    let resp: Response = resp_value.dyn_into().unwrap();
    let resp_text = JsFuture::from(resp.text()?).await?;

    let users_str = resp_text.as_string().unwrap();
    let users_value: Value = serde_json::from_str(&users_str).unwrap();
    let users_vec =
        users_value["data"]["allUsers"].as_array().unwrap().to_owned();

    Ok(users_vec)
}

pub struct Users {
    list: Vec<Value>,
    link: ComponentLink<Self>,
}

pub enum Msg {
    UpdateList(Vec<Value>),
}

impl Component for Users {
    type Message = Msg;
    type Properties = ();

    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self { list: Vec::new(), link }
    }

    fn rendered(&mut self, first_render: bool) {
        let link = self.link.clone();
        if first_render {
            spawn_local(async move {
                let res = fetch_users().await;
                link.send_message(Msg::UpdateList(res.unwrap()))
            });
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::UpdateList(res) => {
                self.list = res;
                true
            }
        }
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        let users = self.list.iter().map(|user| {
            html! {
                <div>
                    <li>
                        <strong>
                            { &user["username"].as_str().unwrap() }
                            { " - length: " }
                            { &user["username"].as_str().unwrap().len() }
                        </strong>
                    </li>
                    <ul>
                        <li>{ &user["id"].as_str().unwrap() }</li>
                        <li>{ &user["email"].as_str().unwrap() }</li>
                    </ul>
                </div>
            }
        });

        html! {
            <>
                <h1>{ "all users" }</h1>
                <ul>
                    { for users }
                </ul>
            </>
        }
    }
}
}

pages/projects.rs

#![allow(unused)]
fn main() {
use graphql_client::GraphQLQuery;
use serde_json::Value;
use std::fmt::Debug;
use wasm_bindgen::{prelude::*, JsCast};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use yew::web_sys::{Request, RequestInit, RequestMode, Response};
use yew::{html, Component, ComponentLink, Html, ShouldRender};

#[derive(Debug, Clone, PartialEq)]
pub struct FetchError {
    err: JsValue,
}

impl From<JsValue> for FetchError {
    fn from(value: JsValue) -> Self {
        Self { err: value }
    }
}

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "./graphql/schema.graphql",
    query_path = "./graphql/all_projects.graphql",
    response_derives = "Debug"
)]
struct AllProjects;
type ObjectId = String;

async fn fetch_projects() -> Result<Vec<Value>, FetchError> {
    let build_query = AllProjects::build_query(all_projects::Variables {});
    let query = serde_json::json!(build_query);

    let mut opts = RequestInit::new();
    opts.method("POST");
    opts.body(Some(&JsValue::from_str(&query.to_string())));
    opts.mode(RequestMode::Cors); // 可以不写,默认为 Cors

    let gql_uri = "http://127.0.0.1:8000/graphql";
    let request = Request::new_with_str_and_init(gql_uri, &opts)?;

    let window = yew::utils::window();
    let resp_value =
        JsFuture::from(window.fetch_with_request(&request)).await?;
    let resp: Response = resp_value.dyn_into().unwrap();
    let resp_text = JsFuture::from(resp.text()?).await?;

    let projects_str = resp_text.as_string().unwrap();
    let projects_value: Value = serde_json::from_str(&projects_str).unwrap();
    let projects_vec =
        projects_value["data"]["allProjects"].as_array().unwrap().to_owned();

    Ok(projects_vec)
}

pub struct Projects {
    list: Vec<Value>,
    link: ComponentLink<Self>,
}

pub enum Msg {
    UpdateList(Vec<Value>),
}

impl Component for Projects {
    type Message = Msg;
    type Properties = ();

    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self { list: Vec::new(), link }
    }

    fn rendered(&mut self, first_render: bool) {
        let link = self.link.clone();
        if first_render {
            spawn_local(async move {
                let res = fetch_projects().await;
                link.send_message(Msg::UpdateList(res.unwrap()))
            });
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::UpdateList(res) => {
                self.list = res;
                true
            }
        }
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        let projects = self.list.iter().map(|project| {
            html! {
                <div>
                    <li>
                        <strong>{ &project["subject"].as_str().unwrap() }</strong>
                    </li>
                    <ul>
                        <li>{ &project["userId"].as_str().unwrap() }</li>
                        <li>{ &project["id"].as_str().unwrap() }</li>
                        <li>
                            <a href={ project["website"].as_str().unwrap().to_owned() }>
                                { &project["website"].as_str().unwrap() }
                            </a>
                        </li>
                    </ul>
                </div>
            }
        });

        html! {
            <>
                <h1>{ "all projects" }</h1>
                <ul>
                    { for projects }
                </ul>
            </>
        }
    }
}
}

运行和测试

执行 trunk serve 命令,浏览器会自动打开一个页面,或者手动在浏览器中访问 http://127.0.0.1:3001。如果你未按照上篇 trunk.toml 所介绍的配置,请访问你自定义的端口(默认为 8080)。

此次 WebAssembly 实践成果,如下图片所示,你可以和第二篇文章《组件和路由》中设定实现目标做以对比。

yew 实现结果

结语

yew 开发 WebAssembly 前端的系列文章,本文即告以第一阶段。

如果你下载源码,也可以浏览器的性能基准测试功能,简单对模板引擎开发的 web 前端,和 yew 开发的 web 前端进行性能的简单比较。

总体体验而言,笔者个人认为,yew 开发熟练以后,效率是较高的。同时,也可更加专注于业务。

后续的文章中,我们将进行更加深入的应用。

谢谢您的阅读!


yew SSR 服务器端渲染

Yewwasm(WebAssembly)框架,可谓 Rust 生态中最受关注的项目之一,github 点赞数量接近 20k。其性能在和其它 js 前端库评测中,也很出彩。多个评测文章中,可以和轻量级的 preact 等性能等同,相较于 react.js 和 vue.js,具有多方面的优势。

感兴趣的请参阅文章:

默认情况下,Yew 组件仅在客户端渲染。当浏览者访问网站时,服务器会向浏览器发送一个没有任何实际内容的框架 html 文件,以及一个 WebAssembly 包。所有需要展现的内容,都是由 WebAssembly 包在客户端呈现的。

这种方法适用于大多数网站,但有几点不足:

  • 在下载整个 WebAssembly 绑定包并完成初始渲染之前,用户将无法看到任何内容。这点就受限于网络,有些用户可能体验不佳。
  • 最大的问题是搜索引擎。一些搜索引擎不支持动态呈现的 web 内容,即使在支持的搜索引擎中,搜索排名也是比较低的。目前,笔者使用 yew 也开发了几个 wasm 应用:对于图像处理、数据可视化等,涉及搜索较少,搜索引擎的问题可以忽略;对于 web——有些朋友可能要说这个不是 wasm 的适宜场景——但很多开发者(包括笔者)不这样认为,因此也是用 yew 开发了一个实验性的博客应用。google 搜索收录,关键词读取,问题不大。但有些评测文章中的支持 js 页面动态渲染的 bing、yandex 等境外众多搜索引擎,并不能收录,或许是因为 wasm 绑定包和 js 动态渲染处理不同。至于国内的,表现更差。再者,即使 google 可以搜索收录,但缓存是读不到任何内容的(包括文字版缓存)。

在此不必纠结于技术细节,总之因以上原因,yew 社区中,对于 SSR 的呼声一直较高。2019 年,即有一个专门的 github/project 主题,是 SSR 方案的讨论。

为了解决上述问题,yew 终于有了解决方案,最早可能将于下一代版本(0.20.x)中发布,将可以在服务器端呈现我网站。虽说 Yew 的服务器端渲染(SSR,Server-side Rendering)方案还处于实验性质,但技术概念非常棒:通过极少的工作,即可构建一个与客户端无关的 SSR 应用。虽然有些功能还未完善,但已经初具端倪,可以使用于静态网站的构建了。

是挺令 Rust 爱好者兴奋的(此处应有掌声 :-))))!

笔者对于当前的初步 SSR 方案,进行了浅尝,体验很棒——代码改动极少,可以具体到一句话:通过 ServerRenderer 渲染器包裹你当前的 yew 主入口组件即可。

我们开始吧,作为服务器的分别是:tide、actix-web,以及 warp。展现的数据,通过读取 rest API 来获取。

启用 Yew 的 SSR 方案,需要使用开发版本,目前最新版本(0.19.3)不支持

yew 服务器端渲染(SSR,Server-side Rendering)的概念和原理

SSR 如何工作?

如上文所述,yew 提供了一个服务器渲染器 ServerRenderer,以渲染服务器端页面。

比如,你现有的 wasm 入口,即为 yew::start_app::<App>(); 的这个 App 组件。我们要对其做服务器端渲染:首先使用 ServerRenderer::<App>::new() 创建一个渲染器,然后调用 renderer.render().await。如此简单,即可完成服务器端渲染。

如下代码为笔者的 wasm 入口组件:

struct App;

impl Component for App {
    type Message = ();
    type Properties = ();

    fn create(_ctx: &Context<Self>) -> Self {
        Self
    }

    fn view(&self, _ctx: &Context<Self>) -> Html {
        html! {
            <BrowserRouter>
                <Header />

                <main class="ps-relative t64">
                    <Switch<Routes> render={ Switch::render(switch) } />
                </main>

                <Copyright />

                <LoadJs />
            </BrowserRouter>
        }
    }
}

fn main() {
    set_panic_hook();

    yew::start_app::<App>();
}

如何对其渲染?增加一个方法即可:

struct App;

impl Component for App {
        // 与上文代码相同
}

// 1、增加异步运行时属性
#[async_std::main] // 或者 #[tokio::main]
// 2、声明为异步方法
async fn main() {
    // 3、创建一个渲染器
    let renderer = ServerRenderer::<App>::new();
    // 4、对 wasm 入口组件进行渲染
    let rendered = renderer.render().await;

    println!("{}", rendered);
}

笔者的 yew 项目源码在 surfer/frontend-yew,你可以下载后运行。或许你只是想尝试一下,请直接复制下端代码:

use yew::prelude::*;
use yew::ServerRenderer;

#[function_component]
fn App() -> Html {
    html! {<div>{"Hello, World!"}</div>}
}

#[tokio::main]
async fn main() {
    let renderer = ServerRenderer::<App>::new();

    let rendered = renderer.render().await;

    // Prints: <div>Hello, World!</div>
    println!("{}", rendered);
}

代码迭代时极为精简吧!

组件生命周期(Component Lifecycle)

yew 的服务器端渲染中,推荐使用函数组件(function components)

钩子(hooks)中,use_effect 以及 use_effect_with_deps 将不能正常工作。其它所有的钩子(hooks)都可以正常使用,直到组件第一次呈现为 html

Web APIs 将不可用。如果你想使用 web_sys ,请注意逻辑隔离。

推荐使用函数组件(function components)。虽然结构体组件(Struct Components)在服务器端渲染时仍然可用,但安全逻辑界限不明显,所以推荐使用函数组件(function components)

服务器端渲染时的数据获取

数据获取,是服务器端渲染的基础功能,但也是重点和难点。目前,yew 试图使用组件 <Suspense /> 解决此问题。

概念和原理就简化到这个地步吧,毕竟目前还是实验性质的,后续的改动或许完全不同。

我们上面说到服务器端渲染时的数据读取,这个是互联网应用的最基础功能。我们接下来,通过读取公开的 github 和 httpbin 的 REST API,来演示 yew 中,如何在服务器端渲染时,异步读取并展示数据。

yew + tide + surf 组合的 yew ssr 示例

代码很简单并清晰,仅作几点说明:

  • 使用 State 来异步读取后端数据。
  • 使用钩子(hooks),返回 SuspensionResult 来渲染页面,展示数据。
  • 上文所述,渲染的数据格式是字符串(String),因此要转换为 tide 服务器的 html 数据类型 tide::Result。下问不同服务器类同,不再赘述。

再次说明:启用 Yew 的 SSR 方案,需要使用开发版本,目前最新版本(0.19.3)不支持

Cargo.toml

[package]
name = "yew-ssr-tide"
version = "0.0.1"
edition = "2021"

[dependencies]
async-std = { version = "1.10.0", features = ["attributes"] }
tide = "0.17.0-beta.1"
yew = { path = "../../../packages/yew", features = ["ssr"] }

surf = "2.3.2"
serde = { version = "1.0.136", features = ["derive"] }

因为现在是作为 bin 应用,所以一个 main.rs 文件即可

use std::cell::RefCell;
use std::rc::Rc;

use async_std::task;
use serde::{Deserialize, Serialize};
use tide::{http::mime, Request, Response, StatusCode};

use yew::prelude::*;
use yew::suspense::{Suspension, SuspensionResult};

#[derive(Serialize, Deserialize, Clone)]
struct UserResponse {
    login: String,
    name: String,
    blog: String,
    location: String,
}

async fn fetch_user() -> UserResponse {
    // surf works for both non-wasm and wasm targets.
    let mut resp = surf::get("https://api.github.com/users/zzy")
        .header("User-Agent", "request")
        .await
        .unwrap();
    println!("Status: {:#?}", resp.status());

    let user_resp: UserResponse = resp.body_json().await.unwrap();

    user_resp
}

pub struct UserState {
    susp: Suspension,
    value: Rc<RefCell<Option<UserResponse>>>,
}

impl UserState {
    fn new() -> Self {
        let (susp, handle) = Suspension::new();
        let value: Rc<RefCell<Option<UserResponse>>> = Rc::default();

        {
            let value = value.clone();
            // we use async-std spawn local here.
            task::spawn_local(async move {
                let user = fetch_user().await;
                {
                    let mut value = value.borrow_mut();
                    *value = Some(user);
                }

                handle.resume();
            });
        }

        Self { susp, value }
    }
}

#[hook]
fn use_user() -> SuspensionResult<UserResponse> {
    let user_state = use_state(UserState::new);

    let result = match *user_state.value.borrow() {
        Some(ref user) => Ok(user.clone()),
        None => Err(user_state.susp.clone()),
    };

    result
}

#[function_component]
fn Content() -> HtmlResult {
    let user = use_user()?;

    Ok(html! {
        <div>
            <div>{"Login name: "}{ user.login }</div>
            <div>{"User name: "}{ user.name }</div>
            <div>{"Blog: "}{ user.blog }</div>
            <div>{"Location: "}{ user.location }</div>
        </div>
    })
}

#[function_component]
fn App() -> Html {
    let fallback = html! {<div>{"Loading..."}</div>};

    html! {
        <Suspense {fallback}>
            <Content />
        </Suspense>
    }
}

async fn render(_: Request<()>) -> tide::Result {
    let content = task::spawn_blocking(move || {
        task::block_on(async {
            let renderer = yew::ServerRenderer::<App>::new();

            renderer.render().await
        })
    })
    .await;

    let resp_content = format!(
        r#"<!DOCTYPE HTML>
            <html>
                <head>
                    <title>yew-ssr with tide example</title>
                </head>
                <body>
                    <h1>yew-ssr with tide example</h1>
                    {}
                </body>
            </html>
            "#,
        content
    );

    let mut resp = Response::new(StatusCode::Ok);
    resp.set_body(resp_content);
    resp.set_content_type(mime::HTML);

    Ok(resp.into())
}

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    let mut server = tide::new();
    server.at("/").get(render);
    println!("You can view the website at: http://localhost:8080");
    server.listen("127.0.0.1:8080").await?;
    Ok(())
}

如何运行?

上文提到,现在是作为一个 bin 应用,因此 cargo run 后,浏览器中打开 http://localhost:8080/ 即可看到读取的 github REST API 公开数据。

本工程完整代码在 github/yew-ssr-tide

第二个出场的是 yew + actix-web + reqwest 代表队

注意的地方和上一个 tide 示例相同,区别就在于 async-stdtide,以及 surf 的代码 API。

Cargo.toml

[package]
name = "yew-ssr-actix-web"
version = "0.0.1"
edition = "2021"

[dependencies]
tokio = { version = "1.17.0", features = ["full"] }
actix-web = "4.0.0-rc.1"
yew = { path = "../../../packages/yew", features = ["ssr"] }

reqwest = { version = "0.11.9", features = ["json"] }
serde = { version = "1.0.136", features = ["derive"] }

main.rs

use std::cell::RefCell;
use std::rc::Rc;

use actix_web::{get, App as ActixApp, Error, HttpResponse, HttpServer};
use tokio::task::LocalSet;
use tokio::task::{spawn_blocking, spawn_local};

use serde::{Deserialize, Serialize};

use yew::prelude::*;
use yew::suspense::{Suspension, SuspensionResult};

#[derive(Serialize, Deserialize, Clone)]
struct UserResponse {
    login: String,
    name: String,
    blog: String,
    location: String,
}

async fn fetch_user() -> UserResponse {
    // reqwest works for both non-wasm and wasm targets.
    let resp = reqwest::Client::new()
        .get("https://api.github.com/users/zzy")
        .header("User-Agent", "request")
        .send()
        .await
        .unwrap();
    println!("Status: {}", resp.status());
    
    let user_resp = resp.json::<UserResponse>().await.unwrap();

    user_resp
}

pub struct UserState {
    susp: Suspension,
    value: Rc<RefCell<Option<UserResponse>>>,
}

impl UserState {
    fn new() -> Self {
        let (susp, handle) = Suspension::new();
        let value: Rc<RefCell<Option<UserResponse>>> = Rc::default();

        {
            let value = value.clone();
            // we use tokio spawn local here.
            spawn_local(async move {
                let user = fetch_user().await;
                {
                    let mut value = value.borrow_mut();
                    *value = Some(user);
                }

                handle.resume();
            });
        }

        Self { susp, value }
    }
}

#[hook]
fn use_user() -> SuspensionResult<UserResponse> {
    let user_state = use_state(UserState::new);

    let result = match *user_state.value.borrow() {
        Some(ref user) => Ok(user.clone()),
        None => Err(user_state.susp.clone()),
    };

    result
}

#[function_component]
fn Content() -> HtmlResult {
    let user = use_user()?;

    Ok(html! {
        <div>
            <div>{"Login name: "}{ user.login }</div>
            <div>{"User name: "}{ user.name }</div>
            <div>{"Blog: "}{ user.blog }</div>
            <div>{"Location: "}{ user.location }</div>
        </div>
    })
}

#[function_component]
fn App() -> Html {
    let fallback = html! {<div>{"Loading..."}</div>};

    html! {
        <Suspense {fallback}>
            <Content />
        </Suspense>
    }
}

#[get("/")]
async fn render() -> Result<HttpResponse, Error> {
    let content = spawn_blocking(move || {
        use tokio::runtime::Builder;
        let set = LocalSet::new();

        let rt = Builder::new_current_thread().enable_all().build().unwrap();

        set.block_on(&rt, async {
            let renderer = yew::ServerRenderer::<App>::new();

            renderer.render().await
        })
    })
    .await
    .expect("the thread has failed.");

    Ok(HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(format!(
            r#"<!DOCTYPE HTML>
                <html>
                    <head>
                        <title>yew-ssr with actix-web example</title>
                    </head>
                    <body>
                        <h1>yew-ssr with actix-web example</h1>
                        {}
                    </body>
                </html>
            "#,
            content
        )))
}

#[actix_web::main] // or #[tokio::main]
async fn main() -> std::io::Result<()> {
    let server = HttpServer::new(|| ActixApp::new().service(render));
    println!("You can view the website at: http://localhost:8080/");
    server.bind(("127.0.0.1", 8080))?.run().await
}

运行方式同 tide

本工程完整代码在 github/yew-ssr-actix-web

yew + warp + reqwest

注意的地方和上一个 tide 示例相同,区别就在于 async-stdtide,以及 surf 的代码 API。

Cargo.toml

[package]
name = "yew-ssr-warp"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
tokio = { version = "1.17.0", features = ["full"] }
warp = "0.3.2"
yew = { path = "../../../packages/yew", features = ["ssr"] }
reqwest = { version = "0.11.9", features = ["json"] }
serde = { version = "1.0.136", features = ["derive"] }
uuid = { version = "0.8.2", features = ["serde"] }

main.rs

use std::cell::RefCell;
use std::rc::Rc;

use serde::{Deserialize, Serialize};
use tokio::task::LocalSet;
use tokio::task::{spawn_blocking, spawn_local};
use uuid::Uuid;
use warp::Filter;
use yew::prelude::*;
use yew::suspense::{Suspension, SuspensionResult};

#[derive(Serialize, Deserialize)]
struct UuidResponse {
    uuid: Uuid,
}

async fn fetch_uuid() -> Uuid {
    // reqwest works for both non-wasm and wasm targets.
    let resp = reqwest::get("https://httpbin.org/uuid").await.unwrap();
    println!("Status: {}", resp.status());
    
    let uuid_resp = resp.json::<UuidResponse>().await.unwrap();

    uuid_resp.uuid
}

pub struct UuidState {
    s: Suspension,
    value: Rc<RefCell<Option<Uuid>>>,
}

impl UuidState {
    fn new() -> Self {
        let (s, handle) = Suspension::new();
        let value: Rc<RefCell<Option<Uuid>>> = Rc::default();

        {
            let value = value.clone();
            // we use tokio spawn local here.
            spawn_local(async move {
                let uuid = fetch_uuid().await;

                {
                    let mut value = value.borrow_mut();
                    *value = Some(uuid);
                }

                handle.resume();
            });
        }

        Self { s, value }
    }
}

impl PartialEq for UuidState {
    fn eq(&self, rhs: &Self) -> bool {
        self.s == rhs.s
    }
}

#[hook]
fn use_random_uuid() -> SuspensionResult<Uuid> {
    let s = use_state(UuidState::new);

    let result = match *s.value.borrow() {
        Some(ref m) => Ok(*m),
        None => Err(s.s.clone()),
    };

    result
}

#[function_component]
fn Content() -> HtmlResult {
    let uuid = use_random_uuid()?;

    Ok(html! {
        <div>{"Random UUID: "}{uuid}</div>
    })
}

#[function_component]
fn App() -> Html {
    let fallback = html! {<div>{"Loading..."}</div>};

    html! {
        <Suspense {fallback}>
            <Content />
        </Suspense>
    }
}

async fn render() -> String {
    let content = spawn_blocking(move || {
        use tokio::runtime::Builder;
        let set = LocalSet::new();

        let rt = Builder::new_current_thread().enable_all().build().unwrap();

        set.block_on(&rt, async {
            let renderer = yew::ServerRenderer::<App>::new();

            renderer.render().await
        })
    })
    .await
    .expect("the thread has failed.");

    format!(
        r#"<!DOCTYPE HTML>
<html>
    <head>
        <title>Yew SSR Example</title>
    </head>
    <body>
        {}
    </body>
</html>
"#,
        content
    )
}

#[tokio::main]
async fn main() {
    let routes = warp::any().then(|| async move { warp::reply::html(render().await) });

    println!("You can view the website at: http://localhost:8080/");

    warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;
}

运行方式同 tide

此文仅是简单的模板示例分享,笔者将要对已经开发的 wasm 博客进行服务器端渲染的改造,以和 handlebars-rust 模板开发进行体验和比较。以后若有体会,再与各位朋友分享。

谢谢您的阅读!


Rust 和 Wasm 的融合,使用 yew 构建 WebAssembly 博客应用的体验报告

WebAssembly 相对其它 web 标准来说,稍显新颖。但 wasm 的应用范畴和方向,却十分广阔。关于其优势所在,本文不做赘述,网上有许多分析比较的文章。我们从 Rust 周报趋势来领会,可以发现 Rust 官方在 WebAssembly 上投入了不少精力。Rust 社区中,Rust + WebAssembly 的应用也比较热门,其文章和话题增长趋势显著。

因此,笔者对 Rust 和 Wasm 的融合非常感兴趣,在此兴趣驱动之下,开发了一个前端较完整的 WebAssembly 博客应用。虽然,就开发博客而言,对 WebAssembly 技术来说,是大材小用,并且也非 wasm 技术的优势所在。但不可否认,web 应用(包括移动互联网应用)始终是主流,以及未来方向。

并且,Rust 前端开发方面,相较于其它 js 前端库/框架,也并非没有优势。关于 Rust web 前端库/框架的评测,可以参阅文章《Rust web 前端库/框架评测,以及和 js 前端库/框架的比较》

因此,本文从一个简单但前端完整的博客,对 Rust 生态中赞数最多的 wasm 框架 yew 进行稍完整的体验。

Rust 生态中,较完整的 wasm 框架主要有 yewseed,以及 percy。笔者进行简单的试用和比较后,选择了 yew

本文是体验报告的初始版本,仅从开发效率、性能、部署、团队,以及扩展 5 个方面简述。

开发效率

作为开发人员,开发效率绝对是最关心的一个技术栈选择考量点。笔者对 Python web 开发(flask、django、sanic,以及 aiohttp 等)、JavaEE,以及 NodeJS 的 react 稍有经验,可谈熟悉。需要说明的是:本文的比较,角度是独立构建一个完整的、前后端分离的 web 应用。所以不必对前述所列技术中,有些是侧重于后端,有些是纯前端,而感到疑惑。比如 react 必定要结合数据服务后端来比较的。

本次 yew 体验开发中,后端是采用 tide + async-graphql + mongodb 构建的 GraphQL API(感兴趣可参阅《基于 tide + async-graphql + mongodb 构建 Rust GraphQL 服务》,或者《基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建 Rust GraphQL 服务》)。

就笔者体验而言,熟练后,开发效率非常可观。不同的技术,不能下绝对结论。但如果说新的起步项目,仅考虑技术选择,笔者一定会选择 Rust wasm/模板库 + async-graphql 的组合。

Rust 生态内部比较,笔者则可以大胆给一个说法,yew 开发 Rust web,要比 handlebars-rust 模板方式高效一些。笔者原先先用 handlebars-rust 模板构建了博客(《使用 handlebars、rhai 开发 Rust web 前端》),此次 yew 构建按的博客,是一次重写。

性能

运行性能方面,基本和《Rust web 前端库/框架评测,以及和 js 前端库/框架的比较》文中所述一致。笔者开始使用 yew v0.18,后来改为 yew master,简单测试,性能是有所提升的。相信 yew v0.19 发布后,性能会有很大提升。

部署

部署需要从 3 个方面来说:

  • 打包。根据 yew 官方建议,机制压缩(包括 panic 剔除)后,此简单博客应用打包为 450k 左右。rustwasm 官方团队的 wasm-pack 打包模式,对不同的 wasm 包可以分割应用的。笔者使用了 trunk 打包工具,若要分割 wasm 包,目前则需要通过独立的 yew 项目的模式。
  • 发布。发布则比较简单,编译为 html 文件后,直接使用应用服务器,如 nginx、apache 则可。目前 Rust 生态中,也有专用的 wasm 服务器。
  • 使用。使用方面,即是 web 应用体验。但如果希望网站对 seo 更友好,则不太合适。

团队

目前,此项目是笔者个人开发的。根据开发过程来看,如果团队应用,可能需要有较高的设计要求。否则,要么要分散为很多小项目,要么打包文件会过大。

扩展

扩展实质上是生态,目前的 Rust wasm,生态还处于起步阶段,扩展或许数量看起来还行,但和其它如 NodeJS 生态比起来,质量则差太远。比如有些 yew/wasm 的样式库,要么巨丑,要么完成度接近于 1(如果按 1-10 划分完成度)。

但是,rustwasm 官方一直致力于直接使用 js 生态,笔者本次也直接使用了不少 js 脚本,感觉总体来说,生态不是问题!无须担心,完全撑得起复杂 web 应用。

以上仅是个人使用的浅显体验,仅供参考。如果您想深入使用,建议参考一些资料,实践后再下结论。

完整源码,包括数据等,都开源在 github/zzy/surfer,欢迎您给予指导,或者功能贡献。

题外话——

  • 没想到通常阅读量 300 左右的 Rust 官方周报翻译,竟然有近百位朋友联系笔者问询中断原因。笔者 7 月份外出,8 月初才回家。所以 Rust 官方周报翻译一直中断。如果您愿意翻译,欢迎有偿投稿。当然,对于其它文章,不限于技术,笔者也非常欢迎您有偿投稿
  • WebAssembly 应用开发方面,欢迎交流,特别是游戏开发、AR/VR 方面。

谢谢您的阅读!


Rust web 前端库/框架评测,以及和 js 前端库/框架的比较

最初,js-framework-benchmark 这个项目,如同名称含义,仅是评测 js 生态的框架性能的。后来,作者增加了 Rust 实现的 WebAssembly 库和框架,如 wasm-bindgen、stdweb、yew,以及 seed 等的评测。

评测指标比较丰富,可信度也较高。包括:

  • 行创建:页面加载后,创建 1000 行的消耗时间(无预热)。
  • 大批量行创建:创建 10000 行的消耗时间(无预热)。
  • 添加行到大容量表格:在 10000 行的表格上添加 1000 行的消耗时间(无预热)。
  • 行替换:替换表格中 1000 行的全部内容的消耗时间(5 次预热)。
  • 部分更新:对于有 10000 行的表格,每 10行 更新一次文本的消耗时间(5 次预热)。
  • 行选择:点击某行,让其突出显示,计算响应消耗时间(5 次预热)。
  • 行交换:对于有 1000 行的表格,交换 2 行时的消耗时间(5 次预热)。
  • 行删除:删除 1000 行表格的消耗时间(5 次预热迭代)。
  • 行清除:清除 10000 行的表格数据的消耗时间(无预热)。
  • 就绪(加载)内存:页面加载后的内存使用情况。
  • 运行内存:添加 1000 行后的内存使用情况。
  • 更新内存:对于 1000 行的表格,执行 5 次更新后的内存使用情况。
  • 替换内存:对于 100 行的表格,执行 5 次替换后的内存使用情况。
  • 重复清除内存:对于 1000 行的表格,执行 5 次创建和清除后的内存使用情况。
  • 启动时间:加载、解析 JavaScript 代码,以及呈现页面的消耗时间。
  • 持续交互:TimeToConsistentlyInteractive,其是比较悲观的 TTI 度量指标——当 CPU 和网络都较空闲时,即不再有超过 50ms 的 CPU 任务。
  • 脚本启动时间:ScriptBootUpTtime 度量指标,解析、编译、评估所有页面脚本所消耗的时间,单位为毫秒。
  • 主线程工成本:MainThreadWorkCost 度量指标,执行在主线程上的工作所消耗的总时间。包括样式、布局等。
  • 总数据量:TotalByteWeight 度量指标,加载到页面中的所有资源的网络传输成本(压缩后)。

另外,评测结果分类上,分为关键指标结果和非关键指标结果。

Rust web 前端库/框架在所有前端库/框架的位置

从评测结果来看,wasm-bindgen 性能和 vanillajs 处于同一水平,甚至有几项已经超越。虽然说,目前 wasm-bindgen 还处于初级阶段,但其总需要通过 web-sys 和 js-sys 与 JavaScript 交互。所以除非前端技术有什么超级大的变革,否则 wasm-bindgen 最好的性能估计也就是总体和原生 vanillajs 保持持平吧。

评测仓库未有 markdown 文件,所以笔者对评测结果截图:

消耗时间(毫秒)± 95%

rust 消耗时间

启动指标(含移动终端)

rust 消耗时间

内存分配(MB)± 95%

rust 消耗时间

Rust web 前端库/框架评测概览

Rust web 前端库中,参与评测的有 8 个:wasm-bindgen、stdweb、yew、seed、simi、dominator、maple,以及 delorean。后面 4 个处于起步阶段(是指项目完成度的起步,非存在时间的长短),具体大略信息如下:simi(起步阶段,gitlab)、dominator(看项目描述不错,并未使用虚拟 DOM,而是使用原生 DOM 以获取最大的性能)、maple(起步阶段)、delorean(起步阶段)。

具体来说,wasm-bindgen、stdweb 是 wasm 模块与 JavaScript 之间的高层次交互库。虽然也属于 web 前端,但与 yew、seed 等框架是不同,类似 vanillajs 与 reactjs、vuejs。所以 wasm-bindgen、stdweb 肯定是各项评测性能都胜出的。

评测仓库未有 markdown 文件,所以笔者对评测结果截图:

消耗时间(毫秒)± 95%

rust 消耗时间

启动指标(含移动终端)

rust 消耗时间

内存分配(MB)± 95%

rust 消耗时间

yew 框架和其它流行前端框架的评测比较

具体到还处于初始阶段的 yew 框架来说,虽然功能已经较齐全,但还是很不成熟的。笔者在文章《Rust 和 Wasm 的融合,使用 yew 构建 web 前端(3)- 资源文件及小重构》中曾提及:yew 生产环境的应用。笔者仅是 yew 的初学者,理解不很恰当。根据对官方 API 文档的理解,个人认为当前版本(yew 0.18)用于生产环境,是一个不小的挑战(包括开发和维护)。但从项目源码、issues 讨论,以及路线规划来看,个人认为下个版本(yew 0.19),差强人意。等待发布后,yew 0.19 用于个人或者团队的生产环境,是可以接受的。

但从 yew 的性能评测结果,以及和 reactjs、angularjs 的比较来看,是完全可以接受的。

评测仓库未有 markdown 文件,所以笔者对评测结果截图:

消耗时间(毫秒)± 95%

rust 消耗时间

启动指标(含移动终端)

rust 消耗时间

内存分配(MB)± 95%

rust 消耗时间

至于另一个较完善的 Rust web 前端框架 seed,处于较靠后的位置,截图没有体现。更详细全面的所有 web 前端库/框架的评测和对比,请参阅页面 js-framework-benchmark/current.html

当然,所有的评测场景都是有局限性的,生产环境的性能表现,关联到了太多的额外因素。所以任何评测结果,都仅只能做为一个参考。

谢谢您的阅读!


Rust web 框架比较

Rust 程序设计语言开发的 Web 框架,大致实现、功能、特性如下——

Table of Contents

Server frameworks

There are several interesting frameworks to build web applications with Rust:

If you need a more low level control you can choose between these libraries:

Outdated server frameworks

Client frameworks

To build web clients with Rust, you can choose between these libraries:

Outdated client frameworks

Frontend frameworks (WASM)

Since WASM support is available in most browsers we can use Rust to build web applications :)

Outdated frontend frameworks

Supplemental libraries

Websocket

Templating

Comparison

High-Level Server Frameworks

Namerocketwarpironactix-webgothamThrustertide
LicenseRocket licensewarp licenseIron licenseActix-web licenseGotham licenseThruster licenseTide license
VersionRocket versionwarp versionIron versionActix-web versionGotham versionThruster versionTide version
Github StarsRocket starswarp starsIron starsActix-web starsGotham starsThruster starsTide stars
ContributorsRocket contributorswarp contributorsIron contributorsActix-web contributorsGotham contributorsThruster contributorsTide contributors
ActivityRocket activitywarp activityIron activityActix-web activityGotham activityThruster activityTide activity
Base frameworkhyperhyperhypertokiohypertokio (or hyper)async-std
HTTPS supportyesyesyesyes
HTTP/2 supportyes?yesnoyes
Asyncnoyesyesyesyesyes
Stable Rustnoyesyesyesyes

Low-Level Frameworks

Namehyperh2tiny-http
LicenseHyper licenseH2 licenseTiny-http license
VersionHyper versionH2 versionTiny-http version
Github StarsHyper starsH2 starsTiny-http stars
ContributorsHyper contributorsH2 contributorsTiny-http contributors
ActivityHyper activityH2 activityTiny-http activity
Serveryesyesyes
Clientyesyes?
HTTPS supportyesnoyes
HTTP/2 supportsolicityes?
Asyncyesyes

Frontend Frameworks

Nameyewpercydodrioseedsaurondracosquarksmithydominatormogwai
LicenseYew licensePercy licenseDodrio licenseSeed licensesauron licenseDraco licenseSquark licenseSmithy licenseDominator licenseMogwai license
VersionYew versionPercy versionDodrio versionSeed versionsauron versionDraco versionSquark versionSmithy versionDominator versionMogwai version
Github StarsYew starsPercy starsDodrio starsSeed starssauron starsDraco starsSquark starsSmithy starsDominator starsMogwai stars
ContributorsYew contributorsPercy contributorsDodrio contributorsSeed contributorssauron contributorsDraco contributorsSquark contributorsSmithy contributorsDominator contributorsMogwai contributors
ActivityYew activityPercy activityDodrio activitySeed activitysauron activityDraco activitySquark activitySmithy activityDominator activityMogwai activity
Stable Rustyesno?yes?yesnononono
Virtual DOMyesyesyesyes?yesyesyesnono

Middleware & Plugins

Nameirongothamnickelrouilleactix-webThruster
Static File Servingyesno^yesn/ayesyes
Mountingyesyesyesn/ayesno
Loggingyesyesnon/ayesyes
JSON-Body-Parsingyesyesyesn/ayesyes (via Serde)
Sessionsyesyes?n/ayesyes
Cookiesyesyes?n/ayesyes
PostgreSQL middleware?no^yesn/ayes
SQLite middleware?no^yesn/ayes
Redis middleware?no^yesn/ayes
MySQL middleware?no^yesn/ayes

(^ Planned in current roadmap)

Websocket Libraries

Namewebsocketws-rstwisttungsteniteactix-web
LicenseWebsocket licenseWs-rs licenseTwist licenseTungstenite licenseActix-web license
VersionWebsocket versionWs-rs versionTwist versionTungstenite versionActix-web version
Github StarsWebsocket starsWs-rs starsTwist starsTungstenite starsActix-web stars
ContributorsWebsocket contributorsWs-rs contributorsTwist contributorsTungstenite contributorsActix-web contributors
ActivityWebsocket activityWs-rs activityTwist activityTungstenite activityActix-web activity
Serveryesyesyesyesyes
Clientyesyesyesyesyes
Base framework- / tokiomiotokio- / tokiotokio
Asyncno / yesyesyesno / yesyes

Resources

Blog posts

2018

Until 2017

Demos

Real-world web projects using Rust

JS & asm.js & WASM

Examples

Benchmark