我如何使用 Svelte、Redis 和 Rust 构建跨平台桌面应用程序

Avatar of Luke Edwards
Luke Edwards

DigitalOcean 为您的旅程各个阶段提供云产品。立即开始使用 200 美元的免费积分!

Cloudflare,我们有一款名为 Workers KV 的优秀产品,它是一个在全球范围内复制的键值存储层。它可以处理数百万个键,每个键都可以在 Worker 脚本内访问,延迟极低,无论请求来自世界上的哪个地方。Workers KV 非常棒——定价也很棒,其中包括一个慷慨的免费层级。

但是,作为 Cloudflare 产品线的老用户,我发现缺少了一样东西:**本地自省**。我的应用程序中有成千上万,有时甚至数十万个键,我经常希望有一种方法可以查询所有数据,对其进行排序,或者只是看看里面到底有什么。

好吧,最近,我很幸运地加入了 Cloudflare!更重要的是,我加入的正好是本季度的“快速获胜周”——也就是他们为期一周的黑客马拉松。鉴于我还没有足够长的时间来积累积压的任务(还没有),你最好相信我抓住了这个机会来实现自己的愿望。

所以,介绍完这些,让我告诉你我是如何构建 Workers KV GUI 的,它是一个使用 SvelteRedisRust 的跨平台桌面应用程序。

前端应用程序

作为一名 Web 开发人员,这是我熟悉的部分。我倾向于称之为“简单部分”,但是,考虑到你可以使用任何和所有 HTML、CSS 和 JavaScript 框架、库或模式,选择性瘫痪很容易出现……这可能也是你熟悉的事情。如果你有一个最喜欢的前端栈,很好,使用它!对于这个应用程序,我选择使用 Svelte,因为对我来说,它确实让事情变得简单,而且一直保持简单。

此外,作为 Web 开发人员,我们希望将所有工具都带过来。你当然可以!同样,这个阶段的项目与典型的 Web 应用程序开发周期没有区别。你可以预期运行 yarn dev(或某些变体)作为你的主要命令,并且感到宾至如归。为了保持“简单”的主题,我选择使用 SvelteKit,它是 Svelte 的官方框架和构建应用程序的工具包。它包括一个优化的构建系统、良好的开发体验(包括 HMR!)、基于文件系统的路由器,以及 Svelte 本身提供的所有功能。

作为一个框架,尤其是像 SvelteKit 这样负责自身工具的框架,它允许我纯粹地思考我的应用程序及其需求。事实上,就配置而言,我唯一需要做的就是告诉 SvelteKit 我想要构建一个在客户端运行的单页应用程序(SPA)。换句话说,我必须明确退出 SvelteKit 对我想要服务器的假设,这实际上是一个合理的假设,因为大多数应用程序都可以从服务器端渲染中获益。这就像附加 @sveltejs/adapter-static 包一样简单,它是一个专门为此目的而设计的配置预设。安装后,这是我的整个配置文件

// svelte.config.js
import preprocess from 'svelte-preprocess';
import adapter from '@sveltejs/adapter-static';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: preprocess(),

  kit: {
    adapter: adapter({
      fallback: 'index.html'
    }),
    files: {
      template: 'src/index.html'
    }
  },
};

export default config;

index.html 的更改是我的个人偏好。SvelteKit 使用 app.html 作为默认基本模板,但是旧习惯很难改。

仅仅过了几分钟,我的工具链就已经知道它正在构建一个 SPA,有一个路由器到位,并且一个开发服务器已经准备就绪。此外,由于 svelte-preprocess,如果我想要 TypeScript、PostCSS 和/或 Sass 支持(我确实想要),它们也都在那里。准备战斗!

该应用程序需要两个视图

  1. 一个输入连接详细信息的屏幕(默认/欢迎/主页)
  2. 一个实际查看数据的屏幕

在 SvelteKit 世界中,这对应于两个“路由”,SvelteKit 规定它们应该存在于 src/routes/index.svelte(用于主页)和 src/routes/viewer.svelte(用于数据查看器页面)。在一个真正的 Web 应用程序中,这个第二条路由将映射到 /viewer URL。虽然这种情况仍然存在,但我知道我的桌面应用程序不会有导航栏,这意味着 URL 将不可见……这意味着我调用这条路由的名字并不重要,只要它对我来说有意义就行。

这些文件的内容大多无关紧要,至少对于这篇文章来说是这样。对于那些好奇的人来说,整个 项目是开源的,如果你正在寻找 Svelte 或 SvelteKit 的示例,欢迎查看。为了避免听起来像一张破唱片,这里的重点是我正在构建一个普通的 Web 应用程序。

此时,我只是在设计我的视图,并在其周围抛出假的、硬编码的数据,直到我得到一些看起来可用的东西。我在这里呆了大约两天,直到所有东西看起来都很不错,而且所有交互(按钮点击、表单提交等)都得到了完善。我称之为一个“工作”应用程序,或者是一个模型。

桌面应用程序工具

此时,一个完全功能的 SPA 已经存在。它在 Web 浏览器中运行——并且是在 Web 浏览器中开发的。也许违反直觉的是,这使得它成为成为桌面应用程序的完美候选者!但是怎么做呢?

你可能听说过 Electron。它是使用 Web 技术构建跨平台桌面应用程序的最知名工具。有很多非常受欢迎和成功的应用程序都是用它构建的:Visual Studio Code、WhatsApp、Atom 和 Slack,仅举几例。它的工作原理是将你的 Web 资源与它自己的 Chromium 安装和它自己的 Node.js 运行时捆绑在一起。换句话说,当你安装一个基于 Electron 的应用程序时,它会带有一个额外的 Chrome 浏览器和一整套编程语言(Node.js)。这些都嵌入在应用程序内容中,无法避免,因为它们是应用程序的依赖项,保证它在任何地方都能一致地运行。正如你可能想象的那样,这种方法存在一些权衡——应用程序非常庞大(即超过 100MB),并且使用大量的系统资源来运行。为了使用该应用程序,一个全新的/独立的 Chrome 会在后台运行——这与打开一个新标签页并不完全一样。

幸运的是,有一些替代方案——我评估了 Svelte NodeGuiTauri。这两种选择都通过依赖操作系统提供的本机渲染器而不是嵌入 Chrome 的副本来完成相同的工作,从而提供了显着的应用程序大小和利用率节省。NodeGui 通过依赖 Qt 来做到这一点,Qt 是另一个将编译成本机视图的桌面/GUI 应用程序框架。但是,为了做到这一点,NodeGui 需要对你的应用程序代码进行一些调整,以便它可以将你的组件转换为Qt 组件。虽然我相信这肯定会奏效,但我对这种解决方案不感兴趣,因为我希望使用完全我所知道的,而不需要对我的 Svelte 文件进行任何调整。相比之下,Tauri 通过包装操作系统本机 Web 浏览器来实现节省——例如,在 macOS 上使用 Cocoa/WebKit,在 Linux 上使用 gtk-webkit2,在 Windows 上使用 Edge 的 Webkit。Web 浏览器实际上是浏览器,Tauri 使用它们是因为它们已经存在于你的系统中,这意味着我们的应用程序可以保持纯粹的 Web 开发产品。

有了这些节省,最小的 Tauri 应用程序不到 4MB,而平均应用程序的重量不到 20MB。在我的测试中,最小的 NodeGui 应用程序的重量约为 16MB。最小的 Electron 应用程序的重量轻松达到 120MB。

不用说,我选择了 Tauri。通过遵循 Tauri 集成 指南,我在我的 devDependencies 中添加了 @tauri-apps/cli 包,并初始化了项目

yarn add --dev @tauri-apps/cli
yarn tauri init

这会在 src 目录(Svelte 应用程序所在的位置)旁边创建一个 src-tauri 目录。这里存放着所有特定于 Tauri 的文件,这对于组织来说很好。

我以前从未构建过 Tauri 应用程序,但在查看了它的 配置文档 之后,我能够保留大多数默认值——除了像 package.productNamewindows.title 值这样的项目,当然。实际上,我唯一需要进行的更改是 build 配置,它必须与 SvelteKit 保持一致,以便进行开发并输出信息

// src-tauri/tauri.conf.json
{
  "package": {
    "version": "0.0.0",
    "productName": "Workers KV"
  },
  "build": {
    "distDir": "../build",
    "devPath": "https://127.0.0.1:3000",
    "beforeDevCommand": "yarn svelte-kit dev",
    "beforeBuildCommand": "yarn svelte-kit build"
  },
  // ...
}

distDir 与构建好的生产就绪资产的存放位置相关。此值从 tauri.conf.json 文件的位置解析,因此有一个 ../ 前缀。

devPath 是在开发期间代理的 URL。默认情况下,SvelteKit 在端口 3000 上生成一个开发服务器(当然可以配置)。我在第一阶段的浏览器中一直访问 localhost:3000 地址,所以这没什么不同。

最后,Tauri 有它自己的 devbuild 命令。为了避免处理多个命令或构建脚本的麻烦,Tauri 提供了 beforeDevCommandbeforeBuildCommand 钩子,允许你 tauri 命令运行之前运行任何命令。这是一个微妙但强大的便利!

SvelteKit CLI 是通过 `svelte-kit` 二进制名称访问的。例如,编写 `yarn svelte-kit build` 会告诉 `yarn` 获取其本地 `svelte-kit` 二进制文件(通过 `devDependency` 安装),然后告诉 SvelteKit 运行其 `build` 命令。

有了这些,我的根级 `package.json` 包含以下脚本

{
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tauri dev",
    "build": "tauri build",
    "prebuild": "premove build",
    "preview": "svelte-kit preview",
    "tauri": "tauri"
  },
  // ...
  "devDependencies": {
    "@sveltejs/adapter-static": "1.0.0-next.9",
    "@sveltejs/kit": "1.0.0-next.109",
    "@tauri-apps/api": "1.0.0-beta.1",
    "@tauri-apps/cli": "1.0.0-beta.2",
    "premove": "3.0.1",
    "svelte": "3.38.2",
    "svelte-preprocess": "4.7.3",
    "tslib": "2.2.0",
    "typescript": "4.2.4"
  }
}

集成后,我的生产命令仍然是 `yarn build`,它调用 `tauri build` 来真正打包桌面应用程序,但前提是 `yarn svelte-kit build` 已成功完成(通过 `beforeBuildCommand` 选项)。我的开发命令仍然是 `yarn dev`,它会并行启动 `tauri dev` 和 `yarn svelte-kit dev` 命令。开发工作流程完全在 Tauri 应用程序中进行,该应用程序现在代理了 `localhost:3000`,使我仍然可以享受 HMR 开发服务器的优势。

重要提示: 在撰写本文时,Tauri 仍处于测试版。也就是说,它感觉非常稳定且计划完善。我与该项目没有任何关系,但似乎 Tauri 1.0 可能很快就会进入稳定版本。我发现 Tauri Discord 非常活跃且乐于助人,包括来自 Tauri 维护人员的回复!他们甚至在整个过程中回答了我一些新手 Rust 问题。:)

连接到 Redis

此时,已经是快速赢取周的周三下午了,说实话,我开始担心在周五团队演示之前能否完成。为什么?因为我已经完成了一周的一半,即使我有一个外观精美的 SPA 一个有效的桌面应用程序,它仍然没有做任何事情。我整整一周都在看相同的假数据

您可能认为,因为我可以访问网页视图,我可以使用 `fetch()` 对我想要的工作者 KV 数据进行一些经过身份验证的 REST API 调用,并将所有数据转储到 `localStorage` 或 IndexedDB 表中……您完全正确! 但是,这并不是我对桌面应用程序用例的想法。

将所有数据保存到某种浏览器内存储中是完全可行的,但这会将数据本地保存到您的计算机上。这意味着,如果您有团队成员尝试做同样的事情,每个人都必须在他们自己的机器上获取和保存所有数据。理想情况下,这个工作者 KV 应用程序应该可以选择连接到外部数据库并与之同步。这样,在团队环境中工作时,每个人都可以调整到同一个数据缓存以节省时间和资金。当处理数百万个键时,这就会开始变得重要,正如前面提到的,工作者 KV 中并不少见。

经过一番思考,我决定使用 Redis 作为我的后端存储,因为它也是一个键值存储。这很棒,因为 Redis 已经将键视为一等公民,并提供了我想要排序和过滤行为(即,我可以将工作转嫁出去,而不是自己实现!)。当然,Redis 很容易安装和运行,无论是在本地还是在容器中,而且有许多托管 Redis 作为服务提供商可供选择,如果有人选择走这条路。

但是,我如何连接到它呢?我的应用程序基本上就是一个运行 Svelte 的浏览器选项卡,对吧?是的,但它也远不止这些。

您知道,Electron 成功的一部分是,它确实保证了 Web 应用程序在每个操作系统上都呈现良好,但它还附带了一个 Node.js 运行时。作为一名 Web 开发人员,这就像将一个后端 API 直接包含在我的客户端中一样。基本上,“……但它在我的机器上运行”的问题消失了,因为所有用户(不知情的情况下)都在运行完全相同的 `localhost` 设置。通过 Node.js 层,您可以与文件系统交互,在多个端口上运行服务器,或包含一堆 `node_modules` 来(我只是在这里随意说)连接到 Redis 实例。功能强大的东西。

我们不会因为使用 Tauri 而失去这种超能力!它是一样的,但略有不同。

Tauri 应用程序不是包含 Node.js 运行时,而是使用 Rust 构建的,Rust 是一种低级系统语言。这就是 Tauri 本身与操作系统交互并“借用”其原生网页视图的方式。所有 Tauri 工具包都是编译的(通过 Rust),这使得构建的应用程序保持小巧且高效。但是,这也意味着我们,应用程序开发人员,可以将任何额外的 crates(“npm 模块”等效项)包含到构建的应用程序中。当然,还有一个恰如其分地命名的 redis 箱子,作为一个 Redis 客户端驱动程序,允许工作者 KV GUI 连接到任何 Redis 实例。

在 Rust 中,`Cargo.toml` 文件类似于我们的 `package.json` 文件。这就是定义依赖项和元数据的 место。在 Tauri 设置中,它位于 `src-tauri/Cargo.toml`,因为再次强调,与 Tauri 相关的所有内容都位于此目录中。Cargo 还具有在依赖项级别定义的“功能标志”的概念。(我能想到的最接近的类比是使用 `npm` 访问模块的内部或导入一个命名的子模块,尽管它仍然不太一样,因为在 Rust 中,功能标志会影响包的构建方式。)

# src-tauri/Cargo.toml
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.0-beta.1", features = ["api-all", "menu"] }
redis = { version = "0.20", features = ["tokio-native-tls-comp"] }

上面将 `redis` 箱子定义为依赖项,并选择加入 `“tokio-native-tls-comp”` 功能,文档中说明该功能是 TLS 支持所必需的。

好了,所以我终于有了我需要的一切。在周三结束之前,我必须让我的 Svelte 与我的 Redis 通信。四处查看后,我注意到所有重要的事情似乎都发生在 `src-tauri/main.rs` 文件中。我注意到 `#[command]` 宏,我知道我之前在当天的 Tauri 示例 中看到过,所以我就研究复制了示例文件中的各个部分,看看根据 Rust 编译器哪些错误出现和消失。

最终,Tauri 应用程序能够再次运行,我了解到 `#[command]` 宏以某种方式将底层函数封装起来,以便它可以接收“上下文”值(如果您选择使用它们),并接收预解析的参数值。此外,作为一种语言,Rust 会进行很多类型转换。例如

use tauri::{command};

#[command]
fn greet(name: String, age: u8) {
  println!("Hello {}, {} year-old human!", name, age);
}

这会创建一个 `greet` 命令,当运行时,需要两个参数:`name` 和 `age`。在定义时,`name` 值是一个字符串值,`age` 是一个 `u8` 数据类型(即,一个整数)。但是,如果两者都缺失,Tauri 会抛出错误,因为命令定义说允许任何内容是可选的。

要将 Tauri 命令实际连接到应用程序,它必须被定义为 `tauri::Builder` 组合的一部分,该组合位于 `main` 函数中。

use tauri::{command};

#[command]
fn greet(name: String, age: u8) {
  println!("Hello {}, {} year-old human!", name, age);
}

fn main() {
  // start composing a new Builder chain
  tauri::Builder::default()
    // assign our generated "handler" to the chain
    .invoke_handler(
      // piece together application logic
      tauri::generate_handler![
        greet, // attach the command
      ]
    )
    // start/initialize the application
    .run(
      // put it all together
      tauri::generate_context!()
    )
    // print <message> if error while running
    .expect("error while running tauri application");
}

Tauri 应用程序编译并知道它拥有一个“greet”命令。它也已经控制了一个网页视图(我们已经讨论过),但在这样做时,它充当了前端(网页视图内容)和后端之间的桥梁,后端包括 Tauri API 和我们编写的任何额外代码,比如 `greet` 命令。Tauri 允许我们在桥梁之间发送消息,以便这两个世界可以相互通信。

A component diagram of a basic Tauri application.
开发人员负责网页视图内容,可以选择包含自定义 Rust 模块和/或定义自定义命令。Tauri 控制网页视图和事件桥,包括所有消息序列化和反序列化。

前端可以通过导入任何(已包含的)`@tauri-apps` 包中的功能,或依赖于 `window.__TAURI__` 全局变量来访问此“桥梁”,该全局变量对整个客户端应用程序可用。具体来说,我们对 invoke 命令感兴趣,该命令接受一个命令名称和一组参数。如果有任何参数,它们必须被定义为一个对象,其中键与 Rust 函数预期的参数名称匹配。

在 Svelte 层中,这意味着我们可以这样做来调用 Rust 层中定义的 `greet` 命令

<!-- Greeter.svelte -->
<script>
  function onclick() {
    __TAURI__.invoke('greet', {
      name: 'Alice',
      age: 32
    });
  }
</script>

<button on:click={onclick}>Click Me</button>

当单击此按钮时,我们的终端窗口(无论 `tauri dev` 命令在哪里运行)都会打印

Hello Alice, 32 year-old human!

再次强调,这是因为 `println!` 函数,它实际上是 Rust 的 `console.log`,`greet` 命令使用了它。它出现在终端的控制台窗口中(而不是浏览器控制台),因为此代码仍然在 Rust/系统端运行。

也可以从 Tauri 命令将某些内容发送回客户端,因此让我们快速更改 `greet`

use tauri::{command};

#[command]
fn greet(name: String, age: u8) {
  // implicit return, because no semicolon!
  format!("Hello {}, {} year-old human!", name, age)
}

// OR

#[command]
fn greet(name: String, age: u8) {
  // explicit `return` statement, must have semicolon
  return format!("Hello {}, {} year-old human!", name, age);
}

意识到我会多次调用 `invoke`,而且有点懒,我提取了一个轻量级的客户端辅助程序来整合这些内容

// @types/global.d.ts
/// <reference types="@sveltejs/kit" />

type Dict<T> = Record<string, T>;

declare const __TAURI__: {
  invoke: typeof import('@tauri-apps/api/tauri').invoke;
}

// src/lib/tauri.ts
export function dispatch(command: string, args: Dict<string|number>) {
  return __TAURI__.invoke(command, args);
}

之前的 `Greeter.svelte` 然后重构为

<!-- Greeter.svelte -->
<script lang="ts">
  import { dispatch } from '$lib/tauri';

  async function onclick() {
    let output = await dispatch('greet', {
      name: 'Alice',
      age: 32
    });
    console.log('~>', output);
    //=> "~> Hello Alice, 32 year-old human!"
  }
</script>

<button on:click={onclick}>Click Me</button>

太好了!现在已经是周四了,我还没有编写任何 Redis 代码,但至少我知道如何将应用程序大脑的两个半部分连接在一起。现在是时候梳理客户端代码并替换事件处理程序中的所有 `TODO`,并将它们连接到实际代码了。

在这里,我将免去您这些细枝末节,因为从这里开始它就非常特定于应用程序了——而且主要是 Rust 编译器痛扁我的故事。另外,探索细枝末节正是 该项目开源 的原因!

在高级别上,一旦使用给定的详细信息建立了 Redis 连接,就可以在 `/viewer` 路由中访问 `SYNC` 按钮。当单击此按钮(而且在单击此按钮时,因为要考虑成本)时,会调用一个 JavaScript 函数,该函数负责 连接到 Cloudflare REST API 并为每个键调度一个 `“redis_set”` 命令。这个 redis_set 命令 在 Rust 层中定义(所有基于 Redis 的命令也都在此处定义),并负责将键值对实际写入 Redis。

从 Redis 读取数据是一个非常相似的过程,只是反过来。例如,当 /viewer 启动时,所有键都应该列出并准备就绪。用 Svelte 的术语来说,这意味着当 /viewer 组件挂载时,我需要 dispatch 一个 Tauri 命令。这几乎是逐字发生的 这里。此外,点击侧边栏中的键名会显示有关键的更多“详细信息”,包括其过期时间(如果有)、其元数据(如果有)以及其实际值(如果已知)。为了优化成本和网络负载,我们决定只在命令时获取键的值。这引入了 REFRESH 按钮,单击 它时,会再次与 REST API 交互,然后 发出命令,以便 Redis 客户端可以单独更新该键。

我不是要仓促地结束,但一旦你看到了 JavaScript 和 Rust 代码之间的一个成功的交互,你就会看到所有交互!我的周四和周五早上剩下的时间只是定义新的请求-回复对,这感觉很像给自己发送 PINGPONG 消息。

结论

对我来说 - 我想对许多其他 JavaScript 开发人员来说 - 过去一周的挑战是学习 Rust。我敢肯定你以前听过这个,并且毫无疑问你会再次听到它。所有权规则、借用检查以及单个字符语法标记的含义(顺便说一句,这些标记不容易搜索)只是我遇到的几个障碍。再次,非常感谢 Tauri Discord 的帮助和友善!

这也表示使用 Tauri *不是一个挑战* - 这是一个巨大的解脱。我肯定会在未来再次使用 Tauri,尤其是在知道我可以使用 **只有 webviewer** 的情况下。深入研究和/或添加 Rust 部分是“额外材料”,只有在 *我的应用程序* 需要它时才需要。

对于那些想知道的人,因为我找不到其他地方提及它:在 macOS 上,Workers KV GUI 应用程序的重量不到 13 MB。我对此感到 **非常兴奋**!

当然,SvelteKit 确实让这个时间表成为可能。它不仅为我节省了一整天配置工具带的时间,而且即时 HMR 开发服务器可能为我节省了几个小时手动刷新浏览器的时间 - 然后是 Tauri 查看器。

如果你已经看到这里 - 真是令人印象深刻!非常感谢你的时间和关注。提醒一下,该项目 在 GitHub 上可用,最新预编译的二进制文件始终可以通过其 发布页面 获得。