不久前,我帮助一位朋友构建了一个对话机器人原型,该机器人结合了自动语音识别 (ASR)、大型语言模型和文本转语音 (TTS) 技术。尽管我经历过 NCSA 浏览器时代,并开发过用于 DNA 测序研发的研究数据分析 Web 平台,但我后来将大部分精力转移到了生物信息学和机器学习中的算法和科学应用上。我错过了 Web 技术如何演变成多媒体/流媒体平台的过程。幸运的是,在学习了 MDN 文档和一些示例之后,我设法仅使用少量 JavaScript 就处理了网页中的媒体输入/输出。
然后,我使用 Hugging Face 的 Gradio 库在几天内构建了一个“MVP”(最小可行产品)。Gradio 的主要特点是它将前端和服务器端代码集成在库中。开发人员只需编写简单的 Python 代码来定义 UI 组件的输入和输出,并使用 Python 函数对 UI 事件进行编程响应。这种简单性非常适合 ML 开发人员以交互方式展示他们的工作。虽然我在“实时”应用程序(如流式传输音频和检测麦克风不活动)方面遇到了挑战,但一些巧妙的技巧帮助我克服了这些问题。Gradio优雅的UI极大地增强了MVP,给很多人留下了深刻的印象。我感谢 Gradio 开发团队的出色工作。
与此同时,我在想“如果我能只编写 Rust 代码来构建 SPA 会怎么样?”
我尝试了几种 Rust GUI 框架,例如 Dioxus、Leptos 和 Egui。虽然每个框架在预期用途方面都令人印象深刻,但它们也需要采用新的 UI 编程范例。同时,在 Gradio 中创建 UI 界面比从头开始编写每个组件要简单得多。我很好奇我可以用 Rust 以最小的努力模仿 Gradio 的设计。
请注意,Gradio 经过几年的发展,功能丰富,用 Rust 复制其功能可能是一个漫长的过程。Gradio 使用 Servlets 作为 UI 组件,使用 FastAPI 进行后端 HTTP 请求处理。
为了用 Rust 模拟我在 Gradio 中的体验,我尝试在前端使用 HTMX。它简单易学。使用 HTMX,我可以使用 Rust 代码和模板库执行 UI 组件的服务器端渲染,以响应 HTMX 的“老式 XMLHttpRequest”直接生成 HTML。😊我们可以使用 Axum Web 框架来处理 HTMX 的 HTTP 请求,从而使用 Rust 来提供 UI 组件并处理它们之间的交互。
我花了两个星期编写了“Tron”框架。虽然它还没有“准备好投入生产”,但对我来说,这是一个学习新事物的很好的实验。
通过构建它,我更加熟悉了使用 Rust 进行 Web 编程,并且更好地理解了 Tokio 运行时和异步编程。
然而,由于 Rust 的静态类型系统,Rust 中的代码不像 Gradio 的 Python 代码那样简洁。我也没有仅仅复制 Gradio 的输入/输出模型。目前,“动作”函数(Tokio 运行时中的一个 future)可以访问应用程序的完整上下文,与属于所有组件的数据进行交互,并管理应用程序的其他附加资产或状态。
这种灵活性允许更动态的设计,尽管开发人员必须小心不要滥用这种广泛的曝光。Rust 后端的一个优势是它可以轻松地与代码库中用 Rust 编写的任何后端服务集成。强大的类型检查和借用检查器有助于实现“无畏并发”。
一个简单的例子
Tron 应用程序通常需要设置几个逻辑代码块并将它们链接在一起。
- 导入依赖项
- 定义主函数以配置应用程序
- 定义一个函数来指定 SPA 中的组件
- 定义一个函数来生成初始 HTML 布局
- 定义一个函数来设置操作
- 定义一组操作函数和服务
main
函数是应用程序的入口点。它负责配置应用程序和设置组件。这是用于设置应用程序的样板代码。它看起来像这样:
// 这是应用程序的主要入口点
// 它设置应用程序配置和状态
// 然后通过调用 tron_app::run 启动应用程序
#[tokio::main]
async fn main() {
let app_config = tron_app::AppConfigure {
http_only: true,
..Default::default()
};
// 设置应用程序状态
let app_share_data = tron_app::AppData {
context: RwLock::new(HashMap::default()),
session_expiry: RwLock::new(HashMap::default()),
event_actions: RwLock::new(TnEventActions::default()),
build_context: Arc::new(Box::new(build_context)),
build_actions: Arc::new(Box::new(build_actions)),
build_layout: Arc::new(Box::new(layout)),
};
tron_app::run(app_share_data, app_config).await
}
它设置了包含应用程序状态的 context
成员和告诉 tron_app 运行时如何处理事件的 event_actions
成员。然后我们需要在 app_share_data
中传递三个函数 build_context
、build_actions
和 layout
;
build_context 函数
开发人员需要定义 fn build_context()
来创建包含一组组件的 context
。例如,以下代码创建一个 button
组件并返回一个包含它的上下文。
static BUTTON: &str = "button";// 这些函数分别用于构建应用程序上下文、
// 布局和事件操作
fn build_context() -> TnContext {
let mut context = TnContextBase::default();
let component_index = 0;
let mut btn = TnButton::new(component_index, BUTTON.into(), "click me".into());
btn.set_attribute(
"class".to_string(),
"btn btn-sm btn-outline btn-primary flex-1".to_string(),
);
btn.set_attribute("hx-target".to_string(), "#count".to_string());
btn.set_attribute("hx-swap".to_string(), "innerHTML".to_string());
context.asset.blocking_write().insert("count".into(), TnAsset::U32(0));
context.add_component(btn);
TnContext {
base: Arc::new(RwLock::new(context)),
}
}
请注意,我们为 button
组件设置了许多属性。hx-target
属性用于指定要更新的目标元素。hx-swap
属性用于指定如何更新目标元素。它默认为 outerHTML
。但是,在这种情况下,我们想要更新目标元素的 innerHTML。
我们还创建了一个组件级别的资源。此资源用于存储按钮被点击的次数。
布局
layout
函数应生成一个表示应用程序初始 HTML 布局的 String
,并在页面加载时发送到浏览器进行渲染。在本例中,我们使用 [askama](https://github.com/djc/askama)
模板库来帮助生成 HTML 布局。
与 Gradio 不同,开发人员需要了解 HTML 才能使用 build_context
函数中定义的组件创建布局。这当然比 Gradio 更加乏味,但提供了对布局更大的灵活性和控制力。下面是一个 layout
函数的简单示例,该函数用于一个按钮和一个 div
元素,该元素显示按钮被点击次数的计数器:
#[derive(Template)] // 这将生成代码...
#[template(path = "app_page.html", escape = "none")] // 使用此路径中的模板,相对于
// crate 根目录中的 `templates` 目录
struct AppPageTemplate {
button: String,
}fn layout(context: TnContext) -> String {
let context_guard = context.blocking_read();
let button = context_guard.render_to_string(BUTTON);
let html = AppPageTemplate {
button,
};
html.render().unwrap()
}
app_page.html
文件是一个模板文件,其中包含 HTML 代码。它看起来像这样定义了在哪里渲染 button
组件,并定义了 div
来显示计数器以及用于样式和布局的 CSS 类:
<div class="container mx-auto px-4">
<div class="flex flex-row p-1">
<div class="flex flex-row p-1 basic-2">
{{button}}
<div id="count" class="flex p-1">0</div>
</div>
</div>
</div>
操作函数
我们在这里的简单目标是每次用户单击按钮时计数器增加 1。我们可以通过创建一个操作来实现这一点,该操作是在单击按钮时调用的函数。
以下是简单操作函数的外观:它返回一个“固定 future”,以便 Tron 应用程序可以使用它来响应 HTTP 请求。“future”采用 context
、event
和 payload
(额外的可配置数据,例如客户端状态、值)并生成一个Option
类型的 TnHtmlResponse
。TnHtmlResponse
封装了 HTTP 响应头和 HTML 正文,允许开发人员在接收到响应后使用自定义标头来控制 HTMX 行为。
fn button_clicked(
context: TnContext,
event: TnEvent,
_payload: Value,
) -> Pin<Box<dyn Future<Output = TnHtmlResponse> + Send + Sync>> {
let action = async move {
tracing::info!(target: "tron_app", "{:?}", event);
if event.e_trigger != BUTTON {
None
} else {
let asset_ref = context.get_asset_ref().await;
let mut asset_guard = asset_ref.write().await;
let count = asset_guard.get_mut("count").unwrap();
let new_count = if let TnAsset::U32(count) = count {
*count += 1;
*count
} else {
0
};
Some((HeaderMap::new(), Html::from(format!("count: {new_count}"))))
}
};
Box::pin(action)
}
在上面的代码中,我们获取存储在上下文资源中的 count
,对其进行更新,然后在 HTTP 响应中返回新值。这将更新浏览器中的计数器。
构建操作
定义了一些操作后,我们需要将它们连接到相关的组件。当 Web 前端中的按钮触发点击事件时,应响应 fn button_clicked()
。我们需要将此通知 Tron 应用程序。这是通过定义一个 build_action
函数来实现的,该函数将组件连接到操作。
我们的 build_action
定义如下:
fn build_actions(context: TnContext) -> TnEventActions {
let mut actions = TnEventActions::default();
let index = context.blocking_read().get_component_index(BUTTON);
actions.insert(
index,
(TnActionExecutionMethod::Await, Arc::new(button_clicked)),
);
actions
}
它只是将 BUTTON
组件的索引和关联的操作插入到 TnEventActions
结构中并返回它。TnActionExecutionMethod::Await
表示将在操作完成执行后等待,然后再返回主事件循环。
或者,可以使用 TnActionExecutionMethod::Spawn
为操作生成一个新任务。在 Spawn
场景中,代码返回默认渲染结果,并忽略操作的结果。但是,Tron 框架具有服务器端代码,可以触发客户端组件上的事件。在这个简单的例子中,我们不需要服务器端触发器。然而,当客户端需要在完成较长的服务器端计算或 API 调用后做出响应时,它非常有用。
合在一起
此示例的完整代码可以在 [examples/04_simple_button_counter](https://github.com/cschin/tron/tree/main/examples/04_simple_button_counter)
目录中找到。
可以从存储库的根目录下的 shell 运行示例(假设已设置 Rust 环境):
cargo run --example 04_simple_button_counter
然后,您可以将浏览器指向 http://localhost:3001 以查看结果。
其他功能和更多示例
为了使用其他 AI 服务创建一个对话机器人,像 Tron App 库这样的后端框架需要超出基本示例的附加功能。以下是我实现的一些增强功能:
- 数据流: 启用前端的媒体播放。
- 通过事件流进行服务器端触发: 允许服务器端组件在没有用户输入的情况下更新客户端组件,从而使客户端呈现与服务器状态同步。
- 后台服务: 利用服务器端渲染来利用服务器计算资源。现在,可以使用 Rust 处理不适合客户端调用或需要处理的 API,从而为应用程序提供服务。可以通过 Tokio 的通道生成和管理这些服务,以进行相互通信。
您可以尝试运行 tron_widgets
示例以查看许多当前实现的小部件。查看代码以了解如何以编程方式创建组件以及处理简单按钮计数器示例之外的组件之间的交互。
我在创建对话机器人的过程中展示了一些更高级的功能。
下一步是什么
展望未来,重点将放在增强 Rust 在服务器端渲染中的使用,旨在简化那些不想在 JavaScript 上投入过多精力但仍希望拥有交互式前端的开发人员的开发过程。对于单页或机器学习演示,Tron App 的方法尤其有前途,因为开发人员的专业知识可能更倾向于后端逻辑而不是前端编程。
通过利用 Rust 的性能和安全功能,开发人员可以有效地处理服务器端计算和状态管理,同时最大限度地减少客户端代码。这不仅降低了 Web 应用程序的复杂性并提高了可靠性,而且还为将高级机器学习模型直接集成到 Web 界面中开辟了新的可能性,而无需通常的开销。
尽管 Rust 更冗长且学习曲线陡峭,但对于其可扩展到更复杂的系统而言,它仍然值得投资。强大的静态类型系统和借用检查器,以及性能卓越的异步运行时,有助于扩展项目规模并避免常见的编程错误,使其成为大型系统严肃开发工作的可靠选择。
如果我需要单页应用程序,我计划继续开发它。如果有任何读者觉得这很有用,或者想探索类似的想法以获得更好的框架,我很想与更多对此领域感兴趣的朋友联系。您的反馈和协作可以极大地丰富这个项目和社区。