一个拥有数百万行代码的 Ruby on Rails 应用,其开发和生产部署的瓶颈常常首先出现在前端资源的构建和分发上。在我们的项目中,Webpacker 的编译等待时间已经成为团队日常开发流程中一个无法忽视的痛点,而在生产环境中,通过 Ruby 进程处理静态资源请求在高并发下也显得力不从心。我们需要一个外科手术式的解决方案:在不重写核心业务逻辑的前提下,彻底替换掉缓慢的资源管道。
最初的构想是引入 Vite 或直接使用 esbuild,通过 jsbundling-rails
这类工具来解决构建速度问题。但这只解决了一半的问题——资源服务。在生产环境中,我们依然依赖 Nginx 或 CDN,但在开发环境,依赖 Node.js 的开发服务器引入了另一个技术栈和进程管理的复杂性。我们追求的是一个更内聚、更高效的方案:一个单一的、高性能的二进制文件来负责资源的即时编译和高速伺服,并且能与现有的 Rails 应用无缝集成。
技术选型最终落在了一个非典型的组合上:esbuild
用于极速构建,Actix-web
(Rust) 用于构建一个内存占用极低、并发性能极强的资源服务器,而与 Rails 的粘合则通过 Rust 的 FFI (Foreign Function Interface) 实现。这个方案的目标是创建一个由 Rails 进程生命周期管理的“资源边车(Asset Sidecar)”,它在物理上是一个独立的 Rust 组件,但在逻辑上却是 Rails 应用的一部分。
第一步:构建 Rust 核心服务
这个 Rust 服务需要承担两个核心职责:
- 接收一个源目录和一个目标目录,调用
esbuild
命令行工具将 TypeScript, JSX, SASS 等资源打包到目标目录。 - 启动一个
Actix-web
服务器,将目标目录作为静态文件根目录对外提供服务。
为了让这个 Rust 组件可以被 Ruby 调用,我们需要将其编译为一个动态链接库 (.so
或 .dylib
)。
Cargo.toml
依赖配置:
[package]
name = "asset_server"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # 关键:编译为 C 动态库
[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
env_logger = "0.10"
log = "0.4"
nix = { version = "0.27", features = ["signal"] } # 用于进程管理
lazy_static = "1.4.0"
crossbeam-channel = "0.5"
once_cell = "1.18"
[build-dependencies]
# 可以用于在构建时下载 esbuild 二进制文件,这里为了简化,我们假设 esbuild 已在 PATH 中
src/lib.rs
核心实现:
这里的代码结构设计必须考虑 FFI 的限制和多线程环境的挑战。一个常见的错误是直接在 FFI 函数中启动一个阻塞的 tokio
运行时,这会锁死调用它的 Ruby 线程。正确的做法是在一个独立的线程中管理 tokio
运行时和 Actix-web
服务器,并通过通道(channel)进行通信。
use actix_web::{web, App, HttpServer, middleware::Logger};
use actix_files::Files;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use tokio::runtime::Runtime;
use tokio::sync::oneshot;
use log::{info, error};
use serde::Deserialize;
use once_cell::sync::Lazy;
use crossbeam_channel::{unbounded, Sender, Receiver};
// 定义传递给 Rust 服务的配置结构
#[derive(Deserialize, Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
source_path: String,
dest_path: String,
esbuild_path: String,
}
// 全局状态管理,用于控制服务器的启停
enum ServerCommand {
Start(ServerConfig, oneshot::Sender<()>),
Stop(oneshot::Sender<()>),
}
// 使用 Lazy 和 Mutex 来安全地管理全局的 Tokio 运行时和服务器句柄
static SERVER_CMD_SENDER: Lazy<Mutex<Option<Sender<ServerCommand>>>> = Lazy::new(|| Mutex::new(None));
// 初始化函数,用于创建管理线程和通道
fn init_server_manager() -> Sender<ServerCommand> {
let (tx, rx): (Sender<ServerCommand>, Receiver<ServerCommand>) = unbounded();
thread::spawn(move || {
let runtime = match Runtime::new() {
Ok(rt) => rt,
Err(e) => {
error!("Failed to create Tokio runtime: {}", e);
return;
}
};
let mut server_handle = None;
for cmd in rx {
match cmd {
ServerCommand::Start(config, notifier) => {
if server_handle.is_some() {
error!("Server is already running.");
let _ = notifier.send(()); // 通知调用者操作完成
continue;
}
info!("Received start command with config: {:?}", config);
if let Err(e) = run_esbuild(&config) {
error!("esbuild failed: {}", e);
let _ = notifier.send(());
continue;
}
let config_clone = config.clone();
let (tx_shutdown, rx_shutdown) = oneshot::channel();
let server = runtime.spawn(async move {
let server = HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.service(Files::new("/", &config_clone.dest_path).index_file("index.html"))
})
.bind((config_clone.host, config_clone.port))
.unwrap()
.run();
let server_handle = server.handle();
tokio::spawn(async move {
rx_shutdown.await.ok();
server_handle.stop(true).await;
});
if let Err(e) = server.await {
error!("Server error: {}", e);
}
});
server_handle = Some((server, tx_shutdown));
info!("Asset server started on http://{}:{}", config.host, config.port);
let _ = notifier.send(());
}
ServerCommand::Stop(notifier) => {
if let Some((_server, tx_shutdown)) = server_handle.take() {
info!("Sending stop signal to server...");
let _ = tx_shutdown.send(());
info!("Server stopped.");
} else {
info!("Server was not running.");
}
let _ = notifier.send(());
}
}
}
});
tx
}
// FFI 暴露的启动函数
#[no_mangle]
pub extern "C" fn start_asset_server(config_json: *const c_char) -> i32 {
let _ = env_logger::try_init();
let config_str = unsafe {
if config_json.is_null() {
error!("Config JSON is null");
return -1;
}
CStr::from_ptr(config_json).to_str().unwrap_or("")
};
let config: ServerConfig = match serde_json::from_str(config_str) {
Ok(c) => c,
Err(e) => {
error!("Failed to parse config JSON: {}", e);
return -2;
}
};
let mut sender_guard = SERVER_CMD_SENDER.lock().unwrap();
if sender_guard.is_none() {
*sender_guard = Some(init_server_manager());
}
if let Some(sender) = sender_guard.as_ref() {
let (tx, rx) = oneshot::channel();
if sender.send(ServerCommand::Start(config, tx)).is_ok() {
// 等待服务器线程完成启动操作
let _ = rx.blocking_recv();
0
} else {
error!("Failed to send start command to server manager thread.");
-3
}
} else {
-4 // Should be unreachable
}
}
// FFI 暴露的停止函数
#[no_mangle]
pub extern "C" fn stop_asset_server() -> i32 {
let sender_guard = SERVER_CMD_SENDER.lock().unwrap();
if let Some(sender) = sender_guard.as_ref() {
let (tx, rx) = oneshot::channel();
if sender.send(ServerCommand::Stop(tx)).is_ok() {
// 等待服务器线程完成停止操作
let _ = rx.blocking_recv();
info!("Stop command processed.");
0
} else {
error!("Failed to send stop command to server manager thread.");
-1
}
} else {
info!("Server manager not initialized, nothing to stop.");
0
}
}
// 辅助函数:执行 esbuild
fn run_esbuild(config: &ServerConfig) -> anyhow::Result<()> {
info!("Running esbuild...");
let status = Command::new(&config.esbuild_path)
.arg(format!("{}/application.js", config.source_path)) // 入口文件
.arg("--bundle")
.arg(format!("--outfile={}/application.js", config.dest_path))
.arg("--log-level=info")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
if !status.success() {
return Err(anyhow::anyhow!("esbuild process failed with status: {}", status));
}
info!("esbuild finished successfully.");
Ok(())
}
// 提供一个释放 C 字符串的函数,供 Ruby 调用
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
if s.is_null() { return }
unsafe {
let _ = CString::from_raw(s);
}
}
这段 Rust 代码的设计有几个关键考量:
- Crate Type
cdylib
: 明确告诉rustc
我们的目标是创建一个 C 兼容的动态库。 - 线程化运行时:
tokio
的运行时被封装在一个独立的、长生命周期的管理线程中。这避免了 FFI 调用阻塞,也确保了actix-web
服务器可以在后台持续运行。 - 通道通信: 使用
crossbeam-channel
向管理线程发送命令(Start
,Stop
),这是线程安全的。使用tokio::sync::oneshot
作为一次性的通知机制,让 FFI 函数可以同步等待后台操作完成,从而给调用者(Ruby)一个确定的反馈。 - 全局状态:
SERVER_CMD_SENDER
作为全局的、懒加载的、线程安全的句柄,确保了整个应用生命周期内只有一个服务器管理实例。 - 错误处理: FFI 接口通过返回整型状态码来传递错误信息,这是 C ABI 的标准实践。内部使用
anyhow
和log
库来处理和记录详细错误。
使用 cargo build --release
编译后,会在 target/release
目录下生成 libasset_server.so
(Linux) 或 libasset_server.dylib
(macOS)。
第二步:Ruby FFI 集成
在 Rails 应用中,我们需要 ffi
gem 来加载和调用 Rust 库中的函数。我们将创建一个封装类来管理这个资源服务器的生命周期。
Gemfile
:
gem 'ffi'
lib/asset_server.rb
:
require 'ffi'
require 'json'
require 'pathname'
module AssetServer
extend FFI::Library
# 加载 Rust 动态库
# 路径需要根据实际部署情况调整
LIB_PATH = Pathname.new(__FILE__).join("../..", "rust_target/release/libasset_server.so").realpath.to_s
ffi_lib LIB_PATH
# 附加 Rust 导出的函数
attach_function :start_asset_server, [:string], :int32
attach_function :stop_asset_server, [], :int32
attach_function :free_string, [:pointer], :void
class Manager
def initialize(config)
@config = config
@running = false
end
def start
return if @running
puts "[AssetServer] Starting Rust asset server..."
config_json = @config.to_json
# 调用 Rust FFI 函数
status = AssetServer.start_asset_server(config_json)
if status == 0
@running = true
puts "[AssetServer] Started successfully."
# 注册一个 at_exit 钩子以确保进程退出时能优雅关闭
at_exit { stop }
else
# 这里的错误处理应该更健壮,例如抛出异常
raise "Failed to start Rust asset server. Status code: #{status}"
end
end
def stop
return unless @running
puts "[AssetServer] Stopping Rust asset server..."
AssetServer.stop_asset_server
@running = false
puts "[AssetServer] Stopped."
end
end
end
这里的 Manager
类非常直观。它加载动态库,然后将 start
和 stop
方法映射到 Rust 的 FFI 函数。一个关键点是 at_exit
钩子,它保证了即使 Rails 进程因为其他原因退出,我们也能尝试去关闭后台的 Rust 服务器,避免产生僵尸进程。
第三步:融入 Rails 生命周期
现在,我们需要在 Rails 应用启动时启动我们的 Rust 服务器,并配置路由将资源请求代理过去。
config/initializers/rust_asset_server.rb
:
if Rails.env.development?
# 仅在开发环境启动
# 生产环境通常由 Nginx/CDN 直接提供静态资源
config = {
host: '127.0.0.1',
port: 3030, # 选择一个不冲突的端口
source_path: Rails.root.join('app', 'javascript').to_s,
dest_path: Rails.root.join('public', 'assets-rust').to_s,
esbuild_path: `which esbuild`.strip # 假设 esbuild 在 PATH 中
}
# 确保目标目录存在
FileUtils.mkdir_p(config[:dest_path])
# 实例化并启动
# 我们将其挂载到全局变量,以便在控制台中也能操作
$asset_server_manager = AssetServer::Manager.new(config)
$asset_server_manager.start
end
config/routes.rb
:
在开发环境中,我们不再需要 Rails 自己来处理资源请求了。但为了让页面上的 <script>
和 <link>
标签能正确工作,我们需要将 /assets-rust
路径的请求代理到 Rust 服务器上。一个常见的错误是直接修改 asset host,但更简单的做法是利用 Rails 的路由转发能力,虽然这会增加一点开销,但在开发环境中完全可以接受。对于生产环境,Nginx 配置会直接指向 public/assets-rust
目录。
Rails.application.routes.draw do
# ... 你的其他路由
if Rails.env.development?
# 这个只是一个标记,实际我们期望前端直接请求 3030 端口
# 或者通过反向代理设置
# 为了简化,我们假设前端 helper 会生成指向 3030 的 URL
end
end
更实际的开发环境集成,是修改 config/environments/development.rb
:
# config/environments/development.rb
Rails.application.configure do
# ...
# 将 asset host 指向我们的 Rust 服务器
config.action_controller.asset_host = 'http://localhost:3030'
config.assets.prefix = "" # Rust 服务器直接在根目录提供服务
end
这样,javascript_include_tag 'application'
就会生成 <script src="http://localhost:3030/application.js"></script>
,浏览器会直接请求 Rust 服务器。
架构图谱
以下是整个系统在开发环境下的工作流程图。
graph TD A[Rails App] -- 启动 --> B(Initializer: asset_server.rb); B -- 创建 Manager --> C{AssetServer::Manager}; C -- 调用 start() --> D[FFI Interface]; D -- start_asset_server(json) --> E[Rust libasset_server.so]; subgraph Rust Process Thread E -- 发送 Start 命令 --> F((Channel)); F --> G[Manager Thread]; G -- 启动 --> H[Tokio Runtime]; H -- 运行 --> I[Actix-web Server on :3030]; G -- 调用 --> J[esbuild Process]; end J -- 打包 app/javascript --> K[public/assets-rust]; I -- 提供静态文件服务 --> K; L[Browser] -- 请求 http://localhost:3030/application.js --> I; M[Developer] -- 修改 JS/CSS 文件 --> J;
局限性与未来迭代方向
这套基于 FFI 的异构集成方案在性能和开发体验上带来了显著提升,但它并非没有权衡。
首先,FFI 接口是脆弱的。任何 Rust 函数签名的改变都需要在 Ruby 端同步更新,缺乏编译时的类型检查。这要求团队在修改 FFI 边界时有严格的沟通和测试纪律。
其次,跨语言的错误传递相对原始。当前通过返回状态码的方式虽然可行,但丢失了丰富的错误上下文。一个改进方向可以是设计一套更复杂的错误处理机制,例如提供一个 get_last_error()
的 FFI 函数,返回详细的错误信息字符串。
再者,当前的进程管理模型——在 Ruby 进程内的一个线程中运行 Rust 服务器——在开发环境中很方便,但在使用像 Puma 这样的多进程 Web 服务器的生产环境中会产生问题。每个 Puma worker 都会尝试启动一个自己的 Rust 服务器实例,导致端口冲突和资源浪费。一个更健壮的生产架构,应该是将 Rust 服务器作为一个完全独立的守护进程运行,由 Systemd 或类似的工具管理。Rails 应用可以通过 Unix Socket 或本地 TCP 连接与其通信,发送重建资源的指令,而不是通过 FFI 直接控制其生命周期。这种解耦会增加部署的复杂性,但换来的是架构上的清晰和稳定性。
最后,文件监听和热更新(HMR)功能没有实现。当前的方案只是在启动时构建一次。要实现开发时的即时反馈,需要在 Rust 端集成文件监听库(如 notify-rs
),并在文件变更时重新触发 esbuild
并可能通过 WebSocket 通知前端刷新。这会显著增加 Rust 端的复杂度,但也是向现代前端开发体验靠拢的必经之路。