
几周前,我们正式推出 Dagger Cloud v3,全新的 Dagger Cloud 用户界面。v3 与 v2 之间一个显著的区别在于,新的用户界面是用 Go 语言编写,并基于 WebAssembly(WASM)实现的。乍看之下,这似乎是一个不同寻常的选择——在开发 Web UI 时,Go 通常不是人们首先想到的语言——但我们有充分的理由。在这篇博文中,我将详细阐述我们选择 WebAssembly 的原因,我们在实现过程中遇到的挑战(以及我们是如何解决的这些问题)以及最终的结果。
两套代码库 = 更多的工作量,更少的功能
Dagger 的工作原理是构建操作的有向无环图(DAG),并对其进行评估(通常是并行评估)。本质上,这是一个很难直观展示的过程。为了帮助用户更好地理解,我们提供了两种实时可视化界面:Dagger Terminal UI(TUI),包含在 Dagger CLI 中,以及 Dagger Cloud,一个在线 Web 仪表盘。Dagger TUI 是用 Go 实现的,而 Dagger Cloud(v3 之前)是用 React 编写的。
我们希望这两个用户界面尽可能保持一致。然而,实时解析 Dagger 事件流并生成用户界面的过程相当复杂。在一些更复杂的场景中,事件流可能包含数十万个 OpenTelemetry Span 数据,管理这些数据结构的复杂性会迅速攀升。这导致 Web UI 常常因数据量过大而变得卡顿和缓慢。为了解决这一性能瓶颈,我们不得不为 React 应用程序采用一种不同的实现方式。
因此,我们最终有了两个实现相同目标的界面,一个使用 TypeScript 和 React 语言及生态系统,另一个则使用完全不同的语言和生态系统(Go)。我们无法在这两者之间轻松地共享业务逻辑。作为一个小团队,我们需要快速交付功能,不得不为每个功能分别实现两次,这对我们的开发速度造成了巨大的阻碍。
我们开始考虑一种新的方法,主要要达成两个目标:
统一代码库,消除重复,能够更快推出新功能。
构建清晰、流畅的 Web UI,与 Terminal UI 的速度和性能相当。
Go + WebAssembly
我们的初步目标是让 Dagger Cloud 和 TUI 共享同一个代码库。我们很早就决定采用 Go 代码库。从技术上讲,我们也可以反过来,使用 TypeScript 开发 TUI,但考虑到我们主要是 Go 工程师团队,选择 Go 能让其他成员更容易参与其中,无论是添加新功能还是帮忙调试问题。除了统一为一种语言外,这一选择还为我们带来了灵活性,打破了团队内部的隔阂。
既然我们决定直接在浏览器中运行 Go 代码,WebAssembly 就成了顺理成章的选择。但这仍然带来了一些挑战:
Go + WebAssembly 组合不如 React 和其他 JavaScript 框架成熟。没有现成的组件库可用,开发工具也不够丰富,等等。我们清楚地意识到,我们将不得不从头开始构建大部分用户界面组件。
大多数浏览器对 WebAssembly 应用程序设定了硬性 2 GB 内存限制。我们预计在查看大量追踪信息时这可能会是一个问题,我们知道我们需要进行大量的优化,减少内存使用并确保用户界面的稳定性。不过,这也有积极的一面——对 WebAssembly UI 所做的任何内存优化也会惠及 TUI 用户,因为它们现在共享同一个代码库。
降低项目风险
接下来的问题便是:“我们该如何构建它?”我们选择使用 go-app(https://go-app.dev/)框架开发新的基于 WebAssembly 的用户界面。go-app 是一个专为 WebAssembly 渐进式 Web 应用程序(PWA)设计的高级框架。它保留了 Go 语言的核心优势,例如快速编译和原生静态类型支持,同时采用了基于组件的用户界面模型,与 React 类似,这使得迁移过程变得更加容易。
由于 Go + WebAssembly 组合并不是主流,Dagger 团队内部对其可行性自然存在一些怀疑。例如,目前没有真正成熟的 go-app UI 组件生态系统,我们不得不自行开发,但我们不清楚这一过程会有多容易或多困难。我们还担心与其他服务(Tailwind、Auth0、Intercom、PostHog)的集成,以及同时渲染数百个需要实时更新的组件的性能问题。
为了回答这些问题并降低项目风险,我花了将近一个月的时间进行原型设计,目标是尽可能多地使用 go-app 重新实现现有的用户界面。事实证明,我们没有遇到太多障碍:WebAssembly 已经是一个文档完备的开放标准,而大多数其他问题都可以在 go-app 的文档中找到答案。正如预期的那样,最大的挑战是内存使用限制,这需要进行精心的设计和优化。
从原型到生产
在成功完成一个可行的概念验证后,团队的信心大幅提升,我们随即启动了“awesome wasm”项目,致力于打造一个生产级的实现。以下是这段旅程的一些关键要点:
内存使用无疑是项目成功面临的最大挑战。我化了大量时间研究如何渲染超过 20 万行的日志输出而不崩溃。我们因此对虚拟终端渲染库进行了深度优化,同时这也显著降低了 TUI 的内存使用(正如前面提到的,共享代码库意味着在一个界面的优化成果可以无缝惠及另一个界面)。
在处理大量 JSON 数据时,Go WASM 的解析速度较慢,这促使我们对架构进行了重大调整,我们构建了一个“智能后端”,通过 WebSockets 增量加载数据,并采用了 Go 语言中相对鲜为人知的 encoding/gob 格式。
最初,WASM 文件大小约为 32MB。通过应用 Brotli 压缩技术,我们将其缩减至约 4.6MB。我们曾尝试在 CDN 中实时进行 Brotli 压缩,但由于文件太大,最终决定将压缩步骤放在构建流程中。
除了内存挑战之外,我们最初的大多数担忧都证明是多余的。编写用户界面组件并不难,与其他服务的集成也非常顺利,我还找到了高效处理组件实时更新的方法。
我找到了一些有用的 NPM 包,想知道是否可以在 Go 中使用它们。WebAssembly 提供了一个可以连接 Go 和 JavaScript 的接口,于是我构建了一个使用 Browserify 加载 NPM 包的 Dagger 模块。这个模块允许我们生成一个可以嵌入到 Go 应用程序中的 JavaScript 文件。这样一来,我们就可以主要使用 Go 开发,在必要时也能加载用原生 JavaScript 实现的辅助工具。
我不是 React 专家,在我看来,React 的组件实现方式有点僵化,而 go-app 则灵活得多。在 go-app 中,你可以随时更新任何组件,有更多优化的自由度。例如,我需要优化一个渲染超过 15 万行输出的组件,我能够尝试不同的方法,并从中选择效果最好的一种,让整个优化过程变得更加轻松!
虽然 go-app 没有像 React 那样的内置浏览器开发工具,但我可以借助 Go 自身的工具(例如 pprof)以及浏览器自带的分析器来进行分析和调试。这非常有助于检查函数调用、跟踪 CPU 和内存使用情况以及评估不同优化方法对内存使用的影响。
使用 go-app 的另一个意外收获是:由于 Dagger Cloud 是一个 PWA,它可以打包成桌面或移动应用。这意味着用户可以像启动原生应用一样启动 Dagger Cloud,获得全屏浸入式体验,而无需先打开浏览器,它甚至可以在桌面任务栏或移动设备的程序坞拥有一个专有的图标。
几周前,我们向 Dagger 指挥官们推出了 Dagger Cloud v3,用以收集反馈,并计划在不久后向所有人正式开放。
优势分析
从 React 切换到 WASM,不仅使所有 Dagger 界面的用户体验更加一致,还在渲染大型和复杂的追踪信息时实现了更高的整体性能和更低的内存使用。
从工程角度来看,这也为我们团队带来了显著的好处。优化工作往往与实际实现功能的工作量相当,甚至更多。我们因此能够避免分别优化 Web UI 和 TUI,而是将精力集中在交付新功能上,实在是令人欣慰。
你们也应该这么做吗?
Dagger Cloud v3 在 Dagger 社区引起了热议,最近我们被问到的一个较多的问题是:谁应该考虑这种技术,谁又不适合?
我们想强调的是,我们并不是推荐普遍都用 Go 开发前端。我们之所以选择这条路线,是基于一系列具体且充分的理由:我们拥有一支出色的 Go 工程师团队;面对一个复杂且难以用 TypeScript/React 扩展的 UI;需要在两个代码库之间实现标准化和代码复用;以及在公司范围内整体提升开发速度的要求。这确实是一个相当特殊的情况。如果你也有类似的情况,那么这种方案当然值得考虑。但如果你的情况有所不同,那么或许应该优先考虑其他更通用的工具和标准。
评论