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

548 阅读19分钟

Cloudflare,我们有一个伟大的产品,叫做Workers KV,它是一个全球复制的键值存储层。它可以处理数以百万计的键,每一个键都可以从 Worker 脚本内以极低的延迟访问,无论在世界哪个地方收到请求。Workers KV很了不起--它的定价也是如此,包括一个慷慨的免费层。

然而,作为Cloudflare阵容的长期用户,我发现缺少一个东西:本地反省。我的应用程序中有数千个,有时甚至是数十万个键,我经常希望有一种方法来查询我的所有数据,对其进行分类,或者只是看一看,看看到底有什么。

最近,我很幸运地加入了Cloudflare!更重要的是,我是在本季度的 "快速赢利周"--也就是他们为期一周的黑客马拉松之前加入的。鉴于我加入的时间还不够长,还没有积累到一定的程度,你最好相信我抓住了这个机会来实现自己的愿望。

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

前端应用程序

作为一个网络开发者,这是_我熟悉的_部分。我很想把这称为 "容易的部分",但是,鉴于你可以使用任何和所有的_HTML、CSS_和JavaScript框架、库或模式,选择瘫痪很容易发生......这可能也很熟悉。如果你有一个最喜欢的前端堆栈,很好,就用它吧对于这个应用程序,我选择使用Svelte,因为对我来说,它肯定会使事情变得简单。

另外,作为网络开发者,我们希望能把所有的工具都带在身边。你当然可以!同样,这个阶段的项目与你典型的网络应用程序开发周期_没有什么不同_。你可以期望运行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,已经有了一个路由器,而且一个开发服务器已经准备就绪。此外,TypeScript、PostCSS和/或Sass支持在那里,如果我需要的话(我确实需要),感谢svelte-preprocess 。准备好隆隆声!

该应用程序需要两个视图。

  1. 一个输入连接细节的屏幕(默认/欢迎/主页
  2. 一个用于实际查看数据的屏幕

在SvelteKit的世界里,这意味着两个 "路由",SvelteKit规定,这些路由应该作为src/routes/index.svelte ,用于主页,而src/routes/viewer.svelte ,用于数据查看器页面。在一个真正的Web应用程序中,这第二个路由将映射到/viewer URL。虽然情况仍然如此,但我知道我的桌面应用程序不会有一个导航栏,这意味着这个URL将是不可见的......这意味着我如何称呼这个路由并不重要,只要它对我有意义。

这些文件的内容大多无关紧要,至少对这篇文章来说是这样。对于那些好奇的人来说,整个项目是开源的,如果你正在寻找一个Svelte或SvelteKit的例子,我欢迎你来看看。冒着听起来像个破唱片的风险,这里的重点是,我正在构建一个普通的网络应用。

在这个时候,我只是在设计我的视图,并四处投掷假的、硬编码的数据,直到我有一些看起来能工作的东西。我在这里呆了大约两天,直到一切看起来都很好,所有的交互性(按钮点击、表单提交等)都得到了充实。我把这称为一个 "工作 "的应用程序,或者说是一个模拟模型。

桌面应用程序工具化

在这一点上,一个功能齐全的SPA已经存在。它在网络浏览器中运行,并且是在网络浏览器中开发的。也许与直觉相反的是,这使它成为桌面应用程序的完美候选者!但如何做到呢?但是怎么做呢?

你可能听说过Electron。它是用网络技术构建跨平台桌面应用程序的最著名的工具。有许多大受欢迎和成功的应用程序都是用它构建的。Visual Studio Code、WhatsApp、Atom和Slack,仅举几例。它的工作方式是将你的网络资产与自己的Chromium安装和自己的Node.js运行时间捆绑。换句话说,当你安装一个基于Electron的应用程序时,它将附带一个额外的Chrome浏览器和一个完整的编程语言(Node.js)。这些都被嵌入到应用程序的内容中,无法回避,因为这些都是应用程序的依赖性,保证了它在任何地方都能稳定运行。正如你可能想象的那样,这种方法有点得不偿失--应用程序是相当庞大的(即超过100MB),并使用大量系统资源来运行。为了使用该应用程序,一个全新的/独立的Chrome浏览器在后台运行--这与打开一个新标签不太一样。

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

有了这些节省,Tauri应用程序的最小容量小于4MB,平均应用程序的重量小于20MB。在我的测试中,最小的NodeGui应用程序的重量约为16MB。一个最小的Electron应用程序很容易达到120MB。

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

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": "http://localhost: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 devyarn svelte-kit dev 命令来并行运行。开发工作流程完全在Tauri应用程序内进行,它现在正在代理localhost:3000 ,使我仍能获得HMR开发服务器的好处。

**重要提示:**在写这篇文章的时候,Tauri仍然处于测试阶段。这就是说,它感觉非常稳定,而且计划周详。我与该项目没有任何关系,但看起来Tauri 1.0可能会尽早进入稳定版本。我发现Tauri Discord是非常活跃和有帮助的,包括来自Tauri维护者的回复!他们甚至对我的一些不懂的问题进行了解答。在整个过程中,他们甚至回答了我的一些Rust问题。)

连接到Redis

此时此刻,已经是快赢周的周三下午了,而且--说实话--我开始为在周五的团队演示前完成工作而紧张。为什么呢?因为我已经完成了本周的一半,尽管我有一个漂亮的SPA_在一个_可以工作的桌面应用程序_内_,但它仍然没有做_任何事情。我_整个_星期都在看同样的假_数据

你可能会想,_因为_我可以访问webview,我可以使用fetch() ,对我想要的Workers KV数据进行一些认证的REST API调用,并将其全部转储到localStorageIndexedDB表中......你**是100%正确的!**然而,这并不是我对我的桌面应用程序的用例所想的。

将所有的数据保存到某种浏览器内的存储中是完全可行的,但是它将数据保存_在你的机器上_。这意味着,如果你有团队成员试图做同样的事情,_每个人_也必须在_自己的机器_上获取和保存所有的数据。理想情况下,这个Workers KV应用程序应该有选项可以连接到外部数据库并与之同步。这样,当在团队环境中工作时,每个人都可以调整到相同的数据缓存,以节省时间--和几块钱。当处理数百万个钥匙时,这就开始变得很重要了,如前所述,在Workers KV中这是很常见的。

在考虑了一段时间后,我决定使用Redis作为我的备份存储,因为它_也_是一个键值存储。这很好,因为Redis已经把键作为一等公民对待,并提供了我想要的排序和过滤行为(也就是说,我可以把工作移交给它,而不是自己实现它!)。当然,Redis很容易在本地或容器中安装和运行,如果有人选择走这条路,还有许多托管的Redis即服务供应商。

但是,我怎样才能连接到它呢?我的应用程序基本上是一个运行Svelte的浏览器标签,对吗?是的,但也远不止如此。

你看,Electron的成功部分在于,是的,它保证了网络应用在每个操作系统上都能很好地呈现,但它也带来了一个Node.js运行时。作为一个网络开发者,这很像在我的客户端内直接包含一个后端API。基本上,"......但它在我的机器上工作 "的问题消失了,因为所有的用户都(在不知情的情况下)运行完全相同的localhost 设置。通过Node.js层,你可以与文件系统互动,在多个端口上运行服务器,或者包括一堆node_modules ,我只是在这里吐槽一下--连接到Redis实例。强大的东西。

我们并没有因为使用Tauri而失去这种超级力量。它是一样的,但略有不同。

Tauri应用程序没有包括Node.js运行时,而是用Rust(一种低级系统语言)构建。这就是Tauri_本身_与操作系统互动的方式,并 "借用 "了它的本地webviewer。所有的Tauri工具包都被编译了(通过Rust),这使得构建的应用程序保持小而高效。然而,这也意味着_我们_,应用程序的开发者,可以将任何额外的crate--相当于 "npm模块"--纳入所构建的应用程序。当然,有一个名字很恰当的 rediscrate,作为 Redis 客户端驱动程序,它允许 Workers 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 crate作为依赖关系,并选择了"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 命令,当运行时,它_期望有_两个参数:nameage 。当定义时,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 "命令。它也已经控制了一个webview(我们已经讨论过了),但在这样做的时候_,它_在前端(webview内容)和后端之间起到了桥梁作用,后端由Tauri APIs和我们编写的任何额外代码组成,比如greet 命令。Tauri允许我们在这座桥上发送消息,这样两个世界就可以相互沟通。

A component diagram of a basic Tauri application.

开发者负责webview的内容,可以选择包括自定义Rust模块和/或定义自定义命令。Tauri控制webviewer和事件桥,包括所有的消息序列化和反序列化。

前端可以通过从任何一个(已经包含的)@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! ,这实际上是console.log ,对于Rust来说,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 按钮。当这个按钮被点击时(而且_只有_在这时--因为成本的原因),一个JavaScrip函数被调用,它负责连接到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。我相信你之前已经听说过了,而且你无疑会再次听到。所有权规则、借用检查和单字符语法标记的含义(顺便说一下,这些标记并不容易搜索到)只是我遇到的几个路障。