|
| 1 | +--- |
| 2 | +title: 我用 Tauri 2 做了一个能跑 30 多种语言的代码运行器,聊聊插件化架构是怎么扛住的 |
| 3 | +date: 2026-06-06 |
| 4 | +author: CodeForge Team |
| 5 | +description: 我想要的东西其实很简单:打开就能写、写完一键就能跑、还能顺手把跑出来的 JSON 看明白。最好是个桌面应用,本地、快、不依赖网络。 |
| 6 | +tags: ['公告', '开始'] |
| 7 | +--- |
| 8 | + |
| 9 | +> 这是一篇实现向的文章。如果你只想看产品,项目在这里:<https://github.com/devlive-community/codeforge>。如果你也在用 Tauri 折腾桌面工具,下面这些架构上的取舍和坑,也许能帮你少走点弯路。 |
| 10 | +
|
| 11 | +## 起因:我想要一个「代码草稿本」,但没找到顺手的 |
| 12 | + |
| 13 | +事情的起点很朴素。 |
| 14 | + |
| 15 | +我经常需要快速验证一小段代码——可能是一段 Python 的正则、一个 Go 的并发写法、一段 Rust 的所有权实验。问题是,为了跑这几行临时代码,要么得新建一个项目、配一遍环境,要么开浏览器找在线 playground,但在线的那些通常只支持一两种语言,还动不动连不上。 |
| 16 | + |
| 17 | +我想要的东西其实很简单:**打开就能写、写完一键就能跑、还能顺手把跑出来的 JSON 看明白**。最好是个桌面应用,本地、快、不依赖网络。 |
| 18 | + |
| 19 | +找了一圈没有完全合手的,于是我自己做了一个,叫 CodeForge。做到现在它支持 30 多种语言的运行,从 Python、Node、Go、Rust 到 Swift、Haskell,甚至 AppleScript。这篇文章我想重点聊的不是功能清单,而是支撑这一切的那个东西——**插件化的语言系统**,以及它在工程上是怎么落地的。 |
| 20 | + |
| 21 | +## 第一个决定:为什么是 Tauri 2,而不是 Electron |
| 22 | + |
| 23 | +做桌面应用,绕不开的第一个问题就是用什么壳。 |
| 24 | + |
| 25 | +Electron 当然是最省心的选择,生态成熟、文档全、遇到问题基本都能搜到答案。我一开始也确实往那个方向想过。但越想越觉得它有两个我不愿意接受的代价:**包体积**和**内存占用**。一个定位「随手打开的代码草稿本」,如果装完一百多兆、空载就吃掉几百兆内存,那它从第一秒起就背离了我做它的初衷——那种「轻、快、即开即用」的手感,本身就是这个产品的核心体验。 |
| 26 | + |
| 27 | +Tauri 2 用系统自带的 WebView 渲染前端,后端是 Rust,最终产物可以做得很小,常驻内存也低得多。对一个主打轻量的工具来说,这个差别不是「优化项」,而是「成立与否」的问题。 |
| 28 | + |
| 29 | +代价也是有的,我得诚实地说: |
| 30 | + |
| 31 | +- WebView 在不同平台上行为不完全一致(macOS 的 WKWebView、Windows 的 WebView2、Linux 的 WebKitGTK),偶尔要写平台分支; |
| 32 | +- Rust 的学习曲线比 Node 陡,前期开发明显更慢; |
| 33 | +- 遇到问题时能搜到的现成答案比 Electron 少很多,不少坑得自己啃文档和 issue。 |
| 34 | + |
| 35 | +但对这个项目来说,这些代价换来的轻量和性能,我认为是值的。最终的技术栈是:前端 Vue 3 + TypeScript + CodeMirror 6,后端 Rust + Tauri 2,执行历史、AI 对话、代码片段、应用配置统一进 SQLite。 |
| 36 | + |
| 37 | +## 核心难题:30 多种语言,怎么才能不写成 30 个 if-else |
| 38 | + |
| 39 | +支持一种语言的运行很简单:找到解释器/编译器,拼一条命令,起个子进程,把输出抓回来。 |
| 40 | + |
| 41 | +但当语言数量往上涨,这种天真的写法会迅速崩坏成一坨巨大的分支判断——「如果是 Python 就这样、如果是 Go 就那样、如果是 Rust 还要先编译再运行……」。每加一种语言都要回头改核心逻辑,每一种语言的特殊性都在污染主流程。这是典型的、会随规模膨胀而失控的设计。 |
| 42 | + |
| 43 | +所以从一开始我就把它设计成**插件化**的。但我想强调的是它的灵魂不在「插件」这个词,而在于——**它是配置驱动的**。核心引擎不认识任何具体语言,它只认识一份描述「这门语言该怎么跑」的配置;绝大多数通用逻辑(拼命令、起进程、抓输出、清理)都写在统一的地方,而每个语言插件本身薄得惊人。 |
| 44 | + |
| 45 | +### 一个语言插件,薄到什么程度 |
| 46 | + |
| 47 | +落到 Rust 里,所有语言实现同一个 trait: |
| 48 | + |
| 49 | +```rust |
| 50 | +pub trait LanguagePlugin: Send + Sync { |
| 51 | + fn get_language_name(&self) -> &'static str; // 显示名,如 "Python 3" |
| 52 | + fn get_language_key(&self) -> &'static str; // 唯一键,如 "python3" |
| 53 | + fn get_file_extension(&self) -> String; // 扩展名 |
| 54 | + fn get_version_args(&self) -> Vec<&'static str>; // 版本探测参数 |
| 55 | + fn get_path_command(&self) -> String; // 探测工具链是否可用 |
| 56 | + fn get_default_config(&self) -> PluginConfig; // 默认配置(核心) |
| 57 | + fn get_default_command(&self) -> String; |
| 58 | + // …还有一组带默认实现的钩子,下面会讲 |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +真正承载「这门语言怎么跑」的,是它返回的那份 `PluginConfig`: |
| 63 | + |
| 64 | +```rust |
| 65 | +pub struct PluginConfig { |
| 66 | + pub enabled: bool, |
| 67 | + pub extension: String, |
| 68 | + pub language: String, |
| 69 | + pub run_command: Option<String>, // 命令模板,如 "python3 $filename" |
| 70 | + pub before_compile: Option<String>, // 运行前的准备命令 |
| 71 | + pub after_compile: Option<String>, // 运行后的清理命令 |
| 72 | + pub template: Option<String>, // 新建文件时的初始模板 |
| 73 | + pub timeout: Option<u64>, // 超时(默认 30s) |
| 74 | + pub execute_home: Option<String>, // 自定义工具链路径 |
| 75 | + // … |
| 76 | +} |
| 77 | +``` |
| 78 | + |
| 79 | +于是一个解释型语言的插件,几乎就是「填一张表」。这是 Python 3 插件的全部核心——没有任何执行逻辑,只是声明「我叫什么、扩展名是啥、用 `python3 $filename` 跑」: |
| 80 | + |
| 81 | +```rust |
| 82 | +fn get_default_config(&self) -> PluginConfig { |
| 83 | + PluginConfig { |
| 84 | + enabled: true, |
| 85 | + language: "python3".into(), |
| 86 | + extension: "py".into(), |
| 87 | + run_command: Some("python3 $filename".into()), // $filename 运行时被替换 |
| 88 | + before_compile: None, |
| 89 | + after_compile: None, |
| 90 | + template: Some("# 在这里输入 Python 3 代码".into()), |
| 91 | + timeout: Some(30), |
| 92 | + // … |
| 93 | + } |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +核心引擎拿到文件后,从配置里取出 `run_command`,把 `$filename` 替换成真实路径,剩下的——起进程、流式抓输出、算耗时——全是**与语言无关**的通用逻辑。加一门解释型语言,本质上就是再填一张这样的表。 |
| 98 | + |
| 99 | +### 编译型语言怎么塞进同一套抽象 |
| 100 | + |
| 101 | +解释型一步到位,麻烦的是 Rust、C/C++、Java 这类「先编译、再运行」的两步语言。我没有为它们单独造一套机制,而是用了两个朴素但好使的办法。 |
| 102 | + |
| 103 | +**第一招:用 shell 的 `&&` 把两步串成一条命令。** Rust 插件就是这么干的——它的运行命令实际展开成: |
| 104 | + |
| 105 | +```text |
| 106 | +rustc /path/to/main.rs -o /tmp/main && /tmp/main |
| 107 | +``` |
| 108 | + |
| 109 | +编译和运行被 `&&` 连成一个整体交给 shell。编译失败,`&&` 短路,编译器的报错就成了本次运行的输出——而这恰恰是「验证代码」场景里用户最想看到的东西,根本不需要单独搞一套错误展示。跑完之后,`after_compile` 配了 `rm -f /tmp/main` 把产物清掉。Windows 上则换成 `cmd /c "rustc ... -o main.exe && main.exe"`,平台差异用 `#[cfg(target_os)]` 分支隔开。 |
| 110 | + |
| 111 | +**第二招:`before_compile` / `after_compile` 两个钩子。** Go 插件的 `before_compile` 是 `go mod init temp 2>/dev/null || true`——临时跑一段 Go 片段得先有个 module,这个钩子就替用户把它补上了,`|| true` 保证已经初始化过时不报错。运行命令本身用 `go run $filename`,一步编译加运行。 |
| 112 | + |
| 113 | +Java 我直接用了 `java $filename`:Java 11 起支持单文件源码模式,一条命令就能编译加运行,连 `javac` 那一步都省了;再挂一个 `after_compile: rm -f *.class` 兜底清理可能残留的字节码。 |
| 114 | + |
| 115 | +你看,编译型语言的全部「特殊性」,最后都收敛成了配置里的几个字符串字段,没有一行渗进核心引擎。 |
| 116 | + |
| 117 | +### 这套设计最大的红利:加语言常常不用写代码 |
| 118 | + |
| 119 | +因为「怎么跑一门语言」已经被完整地编码进了 `PluginConfig`(命令模板 + 编译前后钩子 + 扩展名 + 超时),所以新增语言很多时候根本不需要写 Rust——填一份配置就行。我把这个能力直接开放给了用户:内置插件之外,还有一类 `CustomPlugin`,启动时从用户配置里加载,只要语言键不和内置的冲突,用户就能纯靠填配置给 CodeForge 加一门新语言。配置驱动设计在这里兑现了它最大的价值。 |
| 120 | + |
| 121 | +## 运行时的几个真实工程细节 |
| 122 | + |
| 123 | +把代码跑起来,魔鬼都在细节里。几个我觉得值得一提的: |
| 124 | + |
| 125 | +**工具链探测。** 用户机器上不一定装了对应工具链——没人会为了跑段 Haskell 恰好装了 GHC。所以每个插件都要给出探测自己的方式:`get_version_args` 提供版本参数(Python 是 `--version`、Java 是 `-version`、Go 是 `version`,各不相同),`get_path_command` 给出定位工具的命令——有些直接得很巧,比如 Python 用 `import sys; print(sys.executable)` 反查自己的解释器路径。探测不到就给一句明确的「未检测到 xxx」,而不是甩给用户一坨 `command not found`。 |
| 126 | + |
| 127 | +**在配置里设环境变量。** `before_compile` 不只能跑准备命令,还能写 `export KEY=value`(Unix)或 `set KEY=value`(Windows),插件会解析它、跨平台地设进环境,还会帮你展开 `$PATH` / `%PATH%`。这样一些需要特定环境变量才能跑的语言,不用改代码、配置里写一行就解决了。 |
| 128 | + |
| 129 | +**多标签并发与流式输出。** 每次运行请求都带一个 `task_id`,用它做事件路由:Rust 端读到一段子进程输出就带着 `task_id` 发事件,前端按 `task_id` 把输出投递到对应的标签页。于是多个标签可以同时各跑各的,输出实时流式刷新、互不串台,而不是憋到进程结束才一次性吐出来。 |
| 130 | + |
| 131 | +**「成功但没输出」也是一种结果。** 有个小细节我自己挺得意:运行成功、但 stdout/stderr 都为空时,后执行钩子会填一个 `END-NO-OUTPUT` 的哨兵值。否则用户点了运行、界面一片空白,根本分不清是「跑成功了但本来就没输出」还是「卡住了」。一个哨兵值,把这两种状态明确区分开。 |
| 132 | + |
| 133 | +**每语言可配的超时。** 默认 30 秒,但写死在配置里、可按语言调整,避免一段死循环把应用拖住。 |
| 134 | + |
| 135 | +## 顺手做的一件事:把结构化数据「看明白」 |
| 136 | + |
| 137 | +跑代码经常会吐出 JSON,而一坨压扁的 JSON 是很难读的。 |
| 138 | + |
| 139 | +所以我顺手给 JSON / XML / YAML 做了两种可视化:可折叠的**层级树**,以及把字段关系画成卡片 + 连线的**关系图**。Markdown 会实时渲染预览(内嵌 HTML 用 DOMPurify 净化,防 XSS)。还有一个我自己很喜欢的小功能——识别到文件是 GitHub Actions 的 workflow 时,自动把它渲染成 **Jobs 依赖的 DAG 图**:触发事件、各个 Job、它们之间的依赖和每个 Job 的 Steps,一目了然,比对着 YAML 数缩进强多了。 |
| 140 | + |
| 141 | +## 写在最后 |
| 142 | + |
| 143 | +做到现在,CodeForge 大概是我「最想自己天天用」的一个项目——它解决的就是我自己每天都会遇到的麻烦。配置驱动的插件化在前期看起来有点「过度设计」,但当语言数量从 5 种涨到 30 多种时,它的价值才真正显现:核心一直很干净,加语言变成了一件愉快的、甚至能交给用户自己完成的事。 |
| 144 | + |
| 145 | +如果你对实现感兴趣,或者也想要这么一个本地的多语言代码草稿本,欢迎来看看、点个 star、提 issue: |
| 146 | + |
| 147 | +**👉 <https://github.com/devlive-community/codeforge>** |
| 148 | + |
| 149 | +技术栈:Vue 3 + TypeScript + CodeMirror 6(前端)· Rust + Tauri 2(后端)· SQLite(存储)· 配置驱动的插件化语言系统(架构)。 |
| 150 | + |
| 151 | +对了,**你最希望它支持哪种语言、或者最想要什么功能?** 评论区告诉我,下一个版本说不定就给你安排上。 |
0 commit comments