发布时间:2025 年 1 月 31 日
想象一下在浏览器中运行一个功能齐全的博客——不仅仅是前端,还有后端。无需服务器或云——只有您、您的浏览器以及… WebAssembly!通过允许服务器端框架在本地运行,WebAssembly 模糊了经典 Web 开发的界限,并开辟了令人兴奋的新可能性。在这篇文章中,Vladimir Dementyev(Evil Martians 的后端主管)分享了使 Ruby on Rails 准备好 Wasm 和浏览器的进展
- 如何在 15 分钟内将 Rails 带入浏览器。
- Rails wasmification 背后的故事。
- Rails 和 Wasm 的未来。
Ruby on Rails 著名的“15 分钟博客”现在直接在您的浏览器中运行
Ruby on Rails 是一个专注于开发者生产力和快速交付的 Web 框架。它是行业领导者(如 GitHub 和 Shopify)使用的技术。该框架的流行始于多年前 David Heinemeier Hansson(或 DHH)发布的著名 “如何在 15 分钟内构建博客” 视频的发布。早在 2005 年,在如此短的时间内构建一个功能齐全的 Web 应用程序是不可想象的。感觉就像魔法!
今天,我想通过创建一个完全在您的浏览器中运行的 Rails 应用程序来带回这种神奇的感觉。您的旅程从以通常的方式创建一个基本的 Rails 应用程序开始,然后将其打包用于 Wasm。
背景:命令行上的“15 分钟博客”
假设您的机器上安装了 Ruby 和 Ruby on Rails,您首先创建一个新的 Ruby on Rails 应用程序并搭建一些功能(就像原始的“15 分钟博客”视频中一样)
$ rails new --css=tailwind web_dev_blog
create .ruby-version
...
$ cd web_dev_blog
$ bin/rails generate scaffold Post title:string date:date body:text
create db/migrate/20241217183624_create_posts.rb
create app/models/post.rb
...
$ bin/rails db:migrate
== 20241217183624 CreatePosts: migrating ====================
-- create_table(:posts)
-> 0.0017s
== 20241217183624 CreatePosts: migrated (0.0018s) ===========
即使不接触代码库,您现在也可以运行该应用程序并在实际操作中看到它
$ bin/dev
=> Booting Puma
=> Rails 8.0.1 application starting in development
...
* Listening on http://127.0.0.1:3000
现在,您可以打开 您的 博客,网址为 https://127.0.0.1:3000/posts 并开始撰写帖子!
您有一个非常简陋但功能齐全的博客应用程序,在几分钟内构建完成。这是一个全栈、服务器控制的应用程序:您有一个数据库 (SQLite) 来保存您的数据,一个 Web 服务器来处理 HTTP 请求 (Puma),以及一个 Ruby 程序来维护您的业务逻辑、提供 UI 并处理用户交互。最后,有一个薄薄的 JavaScript 层 (Turbo) 来简化浏览体验。
官方 Rails 演示继续朝着将此应用程序部署到裸机服务器的方向发展,从而使其可以投入生产。您的旅程将朝着相反的方向继续:您不会将应用程序放在遥远的地方,而是将其“部署”在本地。
更高级别:Wasm 中的“15 分钟博客”
自从添加 WebAssembly 以来,浏览器不仅能够运行 JavaScript 代码,还能运行任何可编译为 Wasm 的代码。Ruby 也不例外。当然,Rails 不仅仅是 Ruby,但在深入研究差异之前,让我们继续演示并wasmify(由 wasmify-rails 库创造的动词)Rails 应用程序!
您只需要执行几个命令即可将您的博客应用程序编译为 Wasm 模块并在浏览器中运行。
首先,您使用 Bundler(Ruby 的 npm
)安装 wasmify-rails 库,并使用 Rails CLI 运行其生成器
$ bundle add wasmify-rails
$ bin/rails wasmify:install
create config/wasmify.yml
create config/environments/wasm.rb
...
info ✅ The application is prepared for Wasm-ificaiton!
wasmify:rails
命令配置了一个专用的“wasm”执行环境(除了默认的“development”、“test”和“production”环境之外),并安装所需的依赖项。对于全新的 Rails 应用程序,这足以使其准备好 Wasm。
接下来,构建包含 Ruby 运行时、标准库和所有应用程序依赖项的核心 Wasm 模块
$ bin/rails wasmify:build
==> RubyWasm::BuildSource(3.3) -- Building
...
==> RubyWasm::CrossRubyProduct(ruby-3.3-wasm32-unknown-wasip1-full-4aaed4fbda7afe0bdf4e22167afd101e) -- done in 47.37s
INFO: Packaging gem: rake-13.2.1
...
INFO: Packaging gem: wasmify-rails-0.2.0
INFO: Packaging setup.rb: bundle/setup.rb
INFO: Size: 73.77 MB
此步骤可能需要一些时间:您必须从源代码构建 Ruby,才能正确链接来自第三方库的本机扩展(用 C 编写)。此(暂时的)缺点将在本文后面介绍。
编译后的 Wasm 模块只是您的应用程序的基础。您还必须打包应用程序代码本身和所有资产(例如,图像、CSS、JavaScript)。在执行打包之前,创建一个基本的启动器应用程序,该应用程序可用于在浏览器中运行 wasmified Rails。为此,还有一个生成器命令
$ bin/rails wasmify:pwa
create pwa
create pwa/boot.html
create pwa/boot.js
...
prepend config/wasmify.yml
之前的命令生成了一个使用 Vite 构建的最小 PWA 应用程序,该应用程序可以在本地用于测试编译后的 Rails Wasm 模块,或者静态部署以分发该应用程序。
现在,有了启动器,您只需将整个应用程序打包成一个 Wasm 二进制文件即可
$ bin/rails wasmify:pack
...
Packed the application to pwa/app.wasm
Size: 76.2 MB
就是这样!运行启动器应用程序,看看您的 Rails 博客应用程序是否完全在浏览器中运行
$ cd pwa/
$ yarn dev
VITE v4.5.5 ready in 290 ms
➜ Local: https://127.0.0.1:5173/
转到 https://127.0.0.1:5173,稍等片刻,等待“启动”按钮变为活动状态,然后单击它——享受在浏览器本地运行的 Rails 应用程序!
在浏览器沙箱中运行一个整体式服务器端应用程序,感觉不像魔法吗?对我来说(即使我是“巫师”),这仍然看起来像是一个幻想。但这里没有魔法,只有技术的进步。
演示
您可以体验文章中嵌入的演示,或在独立窗口中启动演示。查看 GitHub 上的源代码。
Rails on Wasm 背后的故事
为了更好地理解将服务器端应用程序打包到 Wasm 模块中的挑战(和解决方案),本文的其余部分解释了构成此架构的组件。
Web 应用程序依赖于许多东西,而不仅仅是用于编写应用程序代码的编程语言。每个组件还必须带到您的_本地部署环境_——浏览器。关于“15 分钟博客”演示的令人兴奋之处在于,这可以在不重写应用程序代码的情况下实现。相同的代码用于在经典的服务器端模式和浏览器中运行应用程序。
像 Ruby on Rails 这样的框架为您提供了一个接口,一种与基础设施组件通信的抽象。以下部分讨论了如何使用框架架构来满足有些深奥的本地服务需求。
基础:ruby.wasm
Ruby 在 2022 年(自 3.2.0 版本起)正式准备好 Wasm,这意味着 C 源代码可以编译为 Wasm,并将 Ruby VM 带到任何您想要的地方。ruby.wasm 项目提供预编译模块和 JavaScript 绑定,以便在浏览器(或任何其他 JavaScript 运行时)中运行 Ruby。ruby:wasm 项目还附带了构建工具,可让您构建具有其他依赖项的自定义 Ruby 版本——这对于依赖于具有 C 扩展的库的项目非常重要。是的,您也可以将本机扩展编译为 Wasm!(好吧,还不是任何扩展,但大多数都可以)。
目前,Ruby 完全支持 WebAssembly 系统接口 WASI 0.1。WASI 0.2(包括 组件模型)已处于 alpha 状态,距离完成仅几步之遥。一旦 WASI 0.2 得到支持,它将消除当前每次需要添加新的本机依赖项时重新编译整个语言的需求:它们可以组件化。
作为副作用,组件模型还应有助于减小捆绑包大小。您可以从 What you can do with Ruby on WebAssembly 演讲中了解有关 ruby.wasm 开发和进度的更多信息。
因此,Wasm 方程的 Ruby 部分已得到解决。但是,作为 Web 框架的 Rails 需要上图中显示的所有组件。请继续阅读以了解如何将其他组件放入浏览器并在 Rails 中将它们链接在一起。
连接到在浏览器中运行的数据库
SQLite3 附带官方 Wasm 发行版 和相应的 JavaScript 包装器,因此已准备好嵌入到浏览器中。PostgreSQL for Wasm 可通过 PGlite 项目获得。因此,您只需要弄清楚如何从 Wasm 应用程序上的 Rails 连接到浏览器内数据库。
Rails 的一个组件或子框架,负责数据建模和数据库交互,称为 Active Record(是的,以 ORM 设计模式 命名)。Active Record 通过数据库适配器从应用程序代码中抽象出实际的 SQL 数据库实现。开箱即用,Rails 为您提供 SQLite3、PostgreSQL 和 MySQL 适配器。但是,它们都假定连接到通过网络可用的真实数据库。为了克服这个问题,您可以编写自己的适配器来连接到本地浏览器内数据库!
这就是作为 Wasmify Rails 项目一部分实现的 SQLite3 Wasm 和 PGlite 适配器的创建方式
- 适配器类继承自相应的内置适配器(例如,
class PGliteAdapter < PostgreSQLAdapter
),因此您可以重用实际的查询准备和结果解析逻辑。 - 您不使用低级数据库连接,而是使用一个外部接口对象,该对象存在于 JavaScript 运行时中——Rails Wasm 模块和数据库之间的桥梁。
例如,这是 SQLite3 Wasm 的桥梁实现
export function registerSQLiteWasmInterface(worker, db, opts = {}) {
const name = opts.name || "sqliteForRails";
worker[name] = {
exec: function (sql) {
let cols = [];
let rows = db.exec(sql, { columnNames: cols, returnValue: "resultRows" });
return {
cols,
rows,
};
},
changes: function () {
return db.changes();
},
};
}
从应用程序的角度来看,从真实数据库到浏览器内数据库的转变只是配置问题
# config/database.yml
development:
adapter: sqlite3
production:
adapter: sqlite3
wasm:
adapter: sqlite3_wasm
js_interface: "sqliteForRails"
使用本地数据库不需要花费太多精力。但是,如果需要与某些中央真实来源进行数据同步,那么您可能会面临更高级别的挑战。这个问题超出了本文的范围(提示:查看 Rails on PGlite and ElectricSQL 演示)。
Service Worker 作为 Web 服务器
任何 Web 应用程序的另一个重要组件是 Web 服务器。用户使用 HTTP 请求与 Web 应用程序交互。因此,您需要一种方法将导航或表单提交触发的 HTTP 请求路由到您的 Wasm 模块。幸运的是,浏览器对此有答案——Service Worker。
Service Worker 是一种特殊的 Web Worker,它充当 JavaScript 应用程序和网络之间的代理。它可以拦截请求并对其进行操作,例如:提供缓存数据、重定向到其他 URL 或… Wasm 模块!这是一个 Service Worker 的草图,它使用在 Wasm 中运行的 Rails 应用程序来服务请求
// The vm variable holds a reference to the Wasm module with a
// Ruby VM initialized
let vm;
// The db variable holds a reference to the in-browser
// database interface
let db;
const initVM = async (progress, opts = {}) => {
if (vm) return vm;
if (!db) {
await initDB(progress);
}
vm = await initRailsVM("/app.wasm");
return vm;
};
const rackHandler = new RackHandler(initVM});
self.addEventListener("fetch", (event) => {
// ...
return event.respondWith(
rackHandler.handle(event.request)
);
});
每次浏览器发出请求时都会触发“fetch”。您可以获取请求信息(URL、HTTP 标头、正文)并构建自己的请求对象。
Rails 与大多数 Ruby Web 应用程序一样,依赖于 Rack 接口 来处理 HTTP 请求。Rack 接口描述了请求和响应对象的格式以及底层 HTTP 处理程序(应用程序)的接口。您可以按如下方式表达这些属性
request = {
"REQUEST_METHOD" => "GET",
"SCRIPT_NAME" => "",
"SERVER_NAME" => "localhost",
"SERVER_PORT" => "3000",
"PATH_INFO" => "/posts"
}
handler = proc do |env|
[
200,
{"Content-Type" => "text/html"},
["<!doctype html><html><body>Hello Web!</body></html>"]
]
end
handler.call(request) #=> [200, {...}, [...]]
如果您发现请求格式很熟悉,那么您可能在过去使用过 CGI。
RackHandler
JavaScript 对象负责在 JavaScript 和 Ruby 领域之间转换请求和响应。鉴于大多数 Ruby Web 应用程序都使用 Rack,因此该实现变得通用,而不是 Rails 特定的。实际实现太长,无法在此处发布。
Service Worker 是浏览器内 Web 应用程序的关键组成部分之一。它不仅是 HTTP 代理,还是缓存层和网络切换器(也就是说,您可以构建本地优先或离线功能应用程序)。这也是一个可以帮助您服务用户上传文件的组件。
将文件上传保留在浏览器中
在您的新博客应用程序中实施的首批附加功能之一很可能是文件上传支持,或者更具体地说,是将图像附加到帖子。为了实现这一点,您需要一种存储和提供文件的方法。
在 Rails 中,负责处理文件上传的框架部分称为 Active Storage。Active Storage 为开发人员提供了抽象和接口来处理文件,而无需考虑低级存储机制。无论您将文件存储在硬盘驱动器还是云端,应用程序代码都不知道它。
与 Active Record 类似,为了支持自定义存储机制,您只需要实现相应的存储服务适配器即可。在浏览器中将文件存储在哪里?
传统的选择是使用数据库。是的,您可以将文件作为 blob 存储在数据库中,无需额外的基础架构组件。Rails 中已经有一个现成的插件为此目的而存在,Active Storage Database。但是,通过在 WebAssembly 中运行的 Rails 应用程序提供存储在数据库中的文件并不理想,因为它涉及不免费的(反)序列化轮次。
更好且更针对浏览器优化的解决方案是使用文件系统 API,并直接从 Service Worker 处理文件上传和服务器上传的文件。对于此类基础架构的完美候选者是 OPFS(原始私有文件系统),这是一个非常新的浏览器 API,它肯定会在未来的浏览器内应用程序中发挥重要作用。
Rails 和 Wasm 可以共同实现什么
我非常确定当您开始阅读本文时,您一直在问自己这个问题:为什么要在浏览器中运行服务器端框架?框架或库是服务器端(或客户端)的想法只是一个标签。好的代码,尤其是好的抽象,可以在任何地方工作。标签不应阻止您探索新的可能性并突破框架(例如,Ruby on Rails)以及运行时(WebAssembly)的界限。两者都可以从这种非常规的用例中受益。
也有很多常规或实用的用例。
首先,将框架引入浏览器开启了巨大的学习和原型设计机会。想象一下能够直接在浏览器中与库、插件和模式一起玩,并与其他人一起玩。Stackblitz 使 JavaScript 框架成为可能。另一个例子是 WordPress Playground,它使无需离开网页即可玩 WordPress 主题成为可能。Wasm 可以为 Ruby 及其生态系统实现类似的功能。
浏览器内编码的一个特殊情况对于开源开发人员尤其有用——分类和调试问题。同样,StackBlitz 使 JavaScript 项目成为现实:您创建一个最小的重现脚本,指向 GitHub Issue 中的链接,并节省维护人员重现您的场景的时间。而且,实际上,这已经开始在 Ruby 中发生,这要归功于 RunRuby.dev 项目(这是一个 示例问题,已通过浏览器内重现解决)。
另一个用例是离线功能(或离线感知)应用程序。通常使用网络工作的离线功能应用程序,但在没有连接时,它们仍然可用。例如,一个电子邮件客户端,可让您在离线时搜索收件箱。或者,一个音乐库应用程序,具有“存储在设备上”功能,因此即使没有网络连接,您最喜欢的音乐也能继续播放。这两个示例都依赖于本地存储的数据,而不仅仅是像经典 PWA 那样使用缓存。
最后,使用 Rails 构建本地(或桌面)应用程序也很有意义,因为框架为您带来的生产力并不取决于运行时。功能齐全的框架非常适合构建个人数据和逻辑密集型应用程序。使用 Wasm 作为便携式分发格式也是一个可行的选择。
这仅仅是 Rails on Wasm 之旅的开始。您可以在 Ruby on Rails on WebAssembly 电子书中了解有关挑战和解决方案的更多信息(顺便说一句,它本身就是一个具有离线功能的 Rails 应用程序)。