【译】Tauri + Rust 异步进程

2,695 阅读10分钟

原文:Tauri + Async Rust Process

by:Rob Donnelly

源码可以从 GitHub 仓库获取。

1. 目的

将 Rust 异步进程集成进 Tauri 应用。具体来说是建立 Tauri webview 和 Rust 异步进程的双向通信,且任一方都可以发起通信。

Tauri 的主线程管理着 webview 和 Rust 异步进程。主线程位于二者间。

1.svg

图 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.png

图 2:使用 Vite + Vue 模板创建的 Tauri 应用

3. 异步进程

接着,我们需要明确该异步进程长什么样子。为了能适用更多的应用,我们将保持它的抽象性。

3.svg

这个异步进程从 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 的双向通信

4.svg

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 invokelisten 发送命令和监听事件。

为了提供向 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>
  1. 点击按钮调用 sendOutput()
  2. 将 “js2rs” 消息添加到 outputs 数组,向用户显示发送了什么消息
  3. 通过 Tauri API invoke 将 “js2rs” 消息发送到 Rust
  4. 通过 Tauri API listen 监听 “rs2js” 事件
  5. 将 “rs2js” 消息添加到 inputs 数组,向用户显示接收到了什么消息

4.3.1 题外话:(缺少) <Suspense> 要了我的命

如果我们现在运行应用,HelloWOrld 组件将不会渲染。如果我们打开 JavaScript 控制台(console),会发现一个错误:

5.png

图 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 结果

我们再运行应用,效果像这样:

6.png

图 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");
    // ...
}
  1. 有状态的 Tauri 命令必须返回一个 Result (见 tauri-apps/tauri#2533)。

5. 主线程与异步进程的双向通信

7.svg

在 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 主线程到异步进程

8.svg

从主线程向异步进程发送消息需要更复杂的技术。这种额外的复杂性源于我们的命令需要获得异步进程的输入 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 异步进程到主线程

9.svg

相比之下,从异步进程向主线程发送消息是微不足道的。

我们生成(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”。

  1. 在前端,当点击 “Send to Rust” 按钮
    a. 在页面的 “JS2RS events” 部分报告该消息,
    b. 并将该消息发送到主线程。

  2. 在主线程
    a. 收到这条消息,
    b. 在终端报告这条消息,
    c. 并将该消息发送到异步进程。

  3. 在异步进程
    a. 收到这条消息,
    b. 并将该条消息送回到主线程。

  4. 在主线程
    a. 收到这条消息,
    b. 在终端报告这条消息,
    c. 并将该消息发送到前端。

  5. 在前端
    a. 收到这条消息,
    b. 并在页面的 “RS2js events” 部分报告该消息。

webview 与 Rust 异步进程之间的双向通信演示

以下是 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(())
}

Footnotes

  1. From Rust-101, Part 15: Mutex, Interior Mutability (cont.), RwLock, Sync