by:Rob Donnelly
源码可以从 GitHub 仓库获取。
1. 目的
将 Rust 异步进程集成进 Tauri 应用。具体来说是建立 Tauri webview 和 Rust 异步进程的双向通信,且任一方都可以发起通信。
Tauri 的主线程管理着 webview 和 Rust 异步进程。主线程位于二者间。
图 1:我们想要的 Tauri 应用的示意图
我们可以将这个目的拆成两个更小的问题:两两间的双向通信,
- webview (JavaScript)与主线程(Rust);
- 主线程(Rust)与异步进程(Rust)
2. 创建 Tauri 应用
首先我们得创建一个 Tauri 应用。
跟着 Tauri Getting Started 安装必要的依赖。
运行 create-tauri-app
npm create tauri-app
操作以下的输入/选择
? What is your app name? tauri-async
? What should the window title be? Tauri App
? What UI recipe would you like to add? create-vite
? Add "@tauri-apps/api" npm package? Yes
? Which vite template would you like to use? vue
接着构建运行应用
cd tauri-async
npm install
npm run tauri dev
图 2:使用 Vite + Vue 模板创建的 Tauri 应用
3. 异步进程
接着,我们需要明确该异步进程长什么样子。为了能适用更多的应用,我们将保持它的抽象性。
这个异步进程从 tokio::mpsc
(Multi-Producer, Single-Consumer)channel 获取输入,并输出到另一个 tokio::mpsc
channel。
我们将创建一个适合任意用途的异步进程模型。该模型一个是带循环的异步函数(async function),循环从输入 channel 中获取字符串,并转发到输出 channel。
我们的异步进程模型在编码在 src-tauri/src/main.rs
use tokio::sync::mpsc;
// ...
async fn async_process_model(
mut input_rx: mpsc::Receiver<String>,
output_tx: mpsc::Sender<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
loop {
while let Some(input) = input_rx.recv().await {
let output = input;
output_tx.send(output).await?;
}
}
}
即使 Tauri 使用并导出了一些 Tokio 类型(通过 tauri::async_runtime
模块),但并未导出我们需要的所有类型。所以我们需要添加 Tokio 依赖。我们还将添加 Tracing 和 Tracing Subscriber 依赖。
cd tauri-src
cargo add tokio --features full
cargo add tracing tracing-subscriber
4. Rust 与 JavaScript 的双向通信
Tauri 为 Rust 和 JavaScript 间的通信提供了两种机制:事件(Event)和命令(Command)。Tauri 的 Commands 文档和 Events 文档很好地涵盖了这些内容。
4.1 命令 vs 事件
事件可以从任意方向发送,而命令只能从 JavaScript 发向 Rust。
我喜欢用命令将消息从 JavaScript 发往 Rust。命令自动生成了许多模板(boiler palte)代码,如消息的反序列化和状态管理。因此,虽然事件可以从任意方向发送,但是命令更符合人体工程学(ergonomic)。
4.2 可能的简化
如果你的需求是以下其中一种,你可以仅使用异步的 Tauri 命令来简化通信(例如不用 Tauri 事件):
- 所有通信都由 JavaScript 发起
- 请求/响应是一对一或一对零的
否则,你还是得需要 Tauri 事件。这篇文章的目的是使得可以从任意方发起通信。这是需要使用 Tauri 事件的。
4.3 JavaScript 端
在 JavaScript 一端,我们可以分别用 Tauri API invoke
和 listen
发送命令和监听事件。
为了提供向 Rust 发送消息的接口以及在两侧报告消息的接口,我重写了 create-tauri-app
创建的 HelloWorld
Vue 组件。
将 src/components/HelloWorld.vue
的内容替换为以下代码。有趣的部分是 sendOutput()
函数和调用 listen()
。
<script setup>
import { ref } from 'vue'
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/tauri'
const output = ref("");
const outputs = ref([]);
const inputs = ref([]);
function sendOutput() {
console.log("js: js2rs: " + output.value)
outputs.value.push({ timestamp: Date.now(), message: output.value }) 2
invoke('js2rs', { message: output.value }) 3
}
await listen('rs2js', (event) => { 4
console.log("js: rs2js: " + event)
let input = event.payload
inputs.value.push({ timestamp: Date.now(), message: input }) 5
})
</script>
<template>
<div style="display: grid; grid-template-columns: auto auto;">
<div style="grid-column: span 2; grid-row: 1;">
<label for="input" style="display: block;">Message</label>
<input id="input" v-model="output">
<br>
<button @click="sendOutput()">Send to Rust</button> 1
</div>
<div style="grid-column: 1; grid-row: 2;">
<h3>js2rs events</h3>
<ol>
<li v-for="output in outputs">
{{output}}
</li>
</ol>
</div>
<div style="grid-column: 2; grid-row: 2;">
<h3>rs2js events</h3>
<ol>
<li v-for="input in inputs">
{{input}}
</li>
</ol>
</div>
</div>
</template>
- 点击按钮调用
sendOutput()
- 将 “js2rs” 消息添加到
outputs
数组,向用户显示发送了什么消息 - 通过 Tauri API
invoke
将 “js2rs” 消息发送到 Rust - 通过 Tauri API
listen
监听 “rs2js” 事件 - 将 “rs2js” 消息添加到
inputs
数组,向用户显示接收到了什么消息
4.3.1 题外话:(缺少) <Suspense>
要了我的命
如果我们现在运行应用,HelloWOrld
组件将不会渲染。如果我们打开 JavaScript 控制台(console),会发现一个错误:
图 3:“具有 async setup() 的组件必须嵌套在<Suspense>”
HelloWorld
组件现在在等待(awaiting) <script setup>
中的异步函数。如果一个 Vue 组件在 <script setup>
中包含顶级(top-level)await
语句,那么这个组件需要放在 <Suspense>
组件中。
为了修复这个错误,需要将 src/App.vue
按以下修改:
- <HelloWorld/>
+ <Suspense>
+ <HelloWorld/>
+ </Suspense>
4.3.2 结果
我们再运行应用,效果像这样:
图 4:修改 HelloWorld 组件后的 Tauri 应用
4.4 Rust 端
以下是主线程与 webview 双向通信 Rust 一端的代码。大部分主线程与异步进程双向通信的代码已经被注释掉了。
use tauri::Manager;
use tokio::sync::mpsc;
// ...
fn main() {
// ...
let (async_proc_input_tx, async_proc_input_rx) = mpsc::channel(1);
let (async_proc_output_tx, mut async_proc_output_rx) = mpsc::channel(1);
tauri::Builder::default()
// ...
.invoke_handler(tauri::generate_handler![js2rs])
.setup(|app| {
// ...
let app_handle = app.handle();
tauri::async_runtime::spawn(async move {
// A loop that takes output from the async process and sends it
// to the webview via a Tauri Event
loop {
if let Some(output) = async_proc_output_rx.recv().await {
rs2js(output, &app_handle);
}
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// A function that sends a message from Rust to JavaScript via a Tauri Event
fn rs2js<R: tauri::Runtime>(message: String, manager: &impl Manager<R>) {
info!(?message, "rs2js");
manager
.emit_all("rs2js", message)
.unwrap();
}
// The Tauri command that gets called when Tauri `invoke` JavaScript API is
// called
#[tauri::command]
async fn js2rs(
message: String,
state: tauri::State<'_, AsyncProcInputTx>,
) -> Result<(), String> { 1
info!(?message, "js2rs");
// ...
}
- 有状态的 Tauri 命令必须返回一个
Result
(见 tauri-apps/tauri#2533)。
5. 主线程与异步进程的双向通信
在 Rust 和 JavaScript 之间传递消息可能很简单,但在 Tauri 主线程与异步进程间传递消息则要复杂些。
异步进程的输入输出是由 tokio::mpsc
(多生产者,单消费者)channel 实现的。我们仅有一个生产者,但是对于单生产者、单消费者,并没有更具体的持久通道原语(more specific persistent channel primitive)。倒是有 tokio::oneshot
是单生产者、单消费者的,但顾名思义,它永远只能发送一个值。
5.1 题外话:谁拥有异步运行时?
默认情况下,Tauri 拥有并初始化 Tokio 运行时(runtime)。因而你不需要创建一个异步的 main
函数 或是使用 #[tokio::main]
注解。
为了增加灵活度,Tauri 允许我们拥有并初始化 Tokio 运行时。我们需要通过添加 #[tokio::main]
注解,为 main
添加 async
关键字,接着告诉 Tauri 来使用我们的 Tokio 运行时。
#[tokio::main]
async fn main() {
tauri::async_runtime::set(tokio::runtime::Handle::current());
// ...
}
5.1.1 Tauri 之内
如果我们在 Tauri 内部完成所有的异步调用,那么 Tauri 可以拥有和管理 Tokio 运行时。
fn main() {
// ...
tauri::Builder::default()
.setup(|app| {
tokio::spawn(async move {
async_process(
async_process_input_rx,
async_process_output_tx,
).await
});
Ok(())
}
// ...
}
这是我们将要使用的方式,因为它稍微简单一些。
5.1.2 Tauri 之外
如果我们需要在 Tauri 外部进行任何的异步调用,那么我们需要拥有和管理 Tokio 运行时。
#[tokio::main]
async fn main() {
tauri::async_runtime::set(tokio::runtime::Handle::current());
// ...
tokio::spawn(async move {
async_process(
async_process_input_rx,
async_process_output_tx,
).await
});
tauri::Builder::default()
// ...
}
5.2 创建 Channel
需要为两个方向都创建 tokio::mpsc
channel:异步进程的输入和异步进程的输出。
fn main() {
// ...
let (async_process_input_tx, async_process_input_rx) = mpsc::channel(1);
let (async_process_output_tx, async_process_output_rx) = mpsc::channel(1);
// ...
}
5.3 运行异步进程
我们一开始并没有获取 Tokio 运行时,所以我们需要在 tauri::Builder::setup()
中运行异步进程。
fn main() {
// ...
let (async_process_input_tx, async_process_input_rx) = mpsc::channel(1);
let (async_process_output_tx, async_process_output_rx) = mpsc::channel(1);
tauri::Builder::default()
// ...
.setup(|app| {
tokio::spawn(async move {
async_process(
async_process_input_rx,
async_process_output_tx,
).await
});
Ok(())
}
// ...
}
5.4 主线程到异步进程
从主线程向异步进程发送消息需要更复杂的技术。这种额外的复杂性源于我们的命令需要获得异步进程的输入 channel 的可变访问权。
回想一下,主线程通过 Tauri 命令从 JavaScript 接收消息。然后,命令需要通过异步进程的输入 channel 将消息转发到异步进程。命令需要访问 channel。我们该如何让命令能够访问 channel 呢?
解决方案是 tauri::State<T>
。我们可以使用 Tauri 的状态管理系统将输入 channel 传给命令。Tauri 命令指南涵盖了状态管理,但是缺少了关键部分:易变性。
我们需要输入 channel 的可变访问权,但 Tauri 管理的状态是不可变的,如果可以改变状态,那么状态又有什么用呢?我们该如何通过不可的变状态获得对输入 channel 的可变访问权?
解决方案是内部可变性(std::sync::Mutex<T>
),而“支持并发的最基础的内部可变类型是 Mutex<T>
”1。
我们不能使用 std::sync::Mutex<T>
,因为我需要在输入 channel 上等待(.await
)发送(send()
)完成,并且对 std::sync::Mutex<T>
的护卫(guard)并不能在 .await
中保持。然而,tokio::sync::Mutex<T>
可以。
首先,我们创建一个在输入 channel 上包装互斥量(mutex)的结构体。
struct AsyncProcInputTx {
inner: Mutex<mpsc::Sender<String>>,
}
这简化了类型签名,使得我们不用写 Mutex<mpsc::Sender<String>>
,只需 AsyncProcInputTx
。
接着,我们将输入 channel 放入一个互斥量中,再将互斥量包装进我们的结构体,并将其交给 Tauri 通过 tauri::Builder::manage
管理。
fn main() {
// ...
tauri::Builder::default()
.manage(AsyncProcInputTx {
inner: Mutex::new(async_proc_input_tx),
})
// ...
}
最后,我们可以在命令中访问这个不可变的状态:锁定互斥量以获得输入 channel 的可变访问权,将消息放入 channel,并在函数结束,守卫超出作用域时隐式解锁互斥锁。
#[tauri::command]
async fn js2rs(message: String, state: tauri::State<'_, AsyncProcInputTx>) -> Result<(), String> {
info!(?message, "js2rs");
let async_proc_input_tx = state.inner.lock().await;
async_proc_input_tx
.send(message)
.await
.map_err(|e| e.to_string())
}
5.5 异步进程到主线程
相比之下,从异步进程向主线程发送消息是微不足道的。
我们生成(spawn)一个异步进程,它从输出 channel 中提取消息,并将其转发到我们的 rs2js
函数。
fn main() {
// ...
tauri::Builder::default()
// ...
.setup(|app| {
// ...
let app_handle = app.handle();
tauri::async_runtime::spawn(async move {
loop {
if let Some(output) = async_proc_output_rx.recv().await {
rs2js(output, &app_handle);
}
}
});
Ok(())
})
// ...
}
6. 结果
下面的 demo 演示了三条从 webview 发送到 Rust 异步进程并返回的消息:“a”,“b”,“c”。
-
在前端,当点击 “Send to Rust” 按钮
a. 在页面的 “JS2RS events” 部分报告该消息,
b. 并将该消息发送到主线程。 -
在主线程
a. 收到这条消息,
b. 在终端报告这条消息,
c. 并将该消息发送到异步进程。 -
在异步进程
a. 收到这条消息,
b. 并将该条消息送回到主线程。 -
在主线程
a. 收到这条消息,
b. 在终端报告这条消息,
c. 并将该消息发送到前端。 -
在前端
a. 收到这条消息,
b. 并在页面的 “RS2js events” 部分报告该消息。
以下是 src-tauri/src/main.rs
的完整代码
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use tauri::Manager;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tracing::info;
use tracing_subscriber;
struct AsyncProcInputTx {
inner: Mutex<mpsc::Sender<String>>,
}
fn main() {
tracing_subscriber::fmt::init();
let (async_proc_input_tx, async_proc_input_rx) = mpsc::channel(1);
let (async_proc_output_tx, mut async_proc_output_rx) = mpsc::channel(1);
tauri::Builder::default()
.manage(AsyncProcInputTx {
inner: Mutex::new(async_proc_input_tx),
})
.invoke_handler(tauri::generate_handler![js2rs])
.setup(|app| {
tauri::async_runtime::spawn(async move {
async_process_model(
async_proc_input_rx,
async_proc_output_tx,
).await
});
let app_handle = app.handle();
tauri::async_runtime::spawn(async move {
loop {
if let Some(output) = async_proc_output_rx.recv().await {
rs2js(output, &app_handle);
}
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn rs2js<R: tauri::Runtime>(message: String, manager: &impl Manager<R>) {
info!(?message, "rs2js");
manager
.emit_all("rs2js", format!("rs: {}", message))
.unwrap();
}
#[tauri::command]
async fn js2rs(
message: String,
state: tauri::State<'_, AsyncProcInputTx>,
) -> Result<(), String> {
info!(?message, "js2rs");
let async_proc_input_tx = state.inner.lock().await;
async_proc_input_tx
.send(message)
.await
.map_err(|e| e.to_string())
}
async fn async_process_model(
mut input_rx: mpsc::Receiver<String>,
output_tx: mpsc::Sender<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
while let Some(input) = input_rx.recv().await {
let output = input;
output_tx.send(output).await?;
}
Ok(())
}