Rust 的 FFI(Foreign Function Interface)讓我們可以呼叫 C 函式庫,但正確實作並不容易。手寫綁定容易出錯,維護成本高;bindgen 自動產生綁定但仍需要 unsafe;safe wrapper 提供安全的 API 但設計繁瑣。
本文以綁定 FFmpeg 的 libavformat 為例,完整介紹:
- 三種 FFI 方式的優缺點比較
- 手寫綁定的常見陷阱:結構體大小、欄位順序、對齊、生命週期…
- Bindgen 的工作原理:如何用 libclang 解析 C 標頭檔
- Safe Wrapper 設計原則:RAII、類型狀態、錯誤處理、thiserror
- AI Coding Agent 如何加速開發:讓最佳實踐不再繁瑣
如果你正在考慮綁定一個 C 函式庫,或是想了解為什麼「先用 unsafe 頂著」是個壞主意,這篇文章應該能幫到你。
三種 FFI 方式
1. 手寫 FFI(Manual)
最傳統的方式:手動撰寫 extern "C" 宣告。
#[link(name = "avformat")]
extern "C" {
pub fn avformat_open_input(
ps: *mut *mut AVFormatContext,
url: *const c_char,
fmt: *const c_void,
options: *mut *mut c_void,
) -> c_int;
}
優點:
缺點:
- 容易出錯:必須精確對應 C 的結構體佈局
- 維護負擔:函式庫更新時需手動同步
- ABI 細節容易遺漏
2. Bindgen 自動生成
使用 bindgen 在編譯時從 C 標頭檔自動產生綁定:
// build.rs
let bindings = bindgen::Builder::default()
.header_contents("wrapper.h", r#"
#include <libavformat/avformat.h>
"#)
.allowlist_function("avformat_open_input")
.generate()
.expect("Failed to generate bindings");
優點:
- 準確:直接從 C 標頭檔產生
- 完整:包含所有型別、函式、常數
- 可維護:標頭檔更新時自動同步
缺點:
- 編譯時需要 libclang
- 產生的程式碼冗長
- 可能包含不需要的內容
3. 安全封裝(Safe Wrapper)
在 bindgen 綁定之上,建立符合 Rust 慣例的 API:
pub struct FormatContext {
ptr: *mut bindgen::AVFormatContext,
}
impl FormatContext {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
// 安全的 Rust API,內部處理所有 unsafe
}
}
impl Drop for FormatContext {
fn drop(&mut self) {
unsafe { bindgen::avformat_close_input(&mut self.ptr); }
}
}
優點:
- 使用者不需要寫
unsafe
- RAII 自動管理資源
- 編譯器協助防止錯誤
缺點:
- 需要額外的抽象層
- 可能有輕微的效能開銷
- 設計 API 需要深思熟慮
手寫 FFI 的常見陷阱
手寫 FFI 看似簡單,但有許多隱藏的坑。以下是實際可能發生的錯誤:
1. 結構體大小不匹配
// 你的定義(40 bytes)
#[repr(C)]
pub struct AVPacket {
pub pts: i64,
pub dts: i64,
pub data: *mut u8,
pub size: i32,
}
// 實際 C 結構體(104 bytes)
// 還有很多你沒定義的欄位
後果: 當 FFmpeg 寫入結構體時,會覆寫超出你定義範圍的記憶體 → 程式崩潰或資料損壞。
2. 欄位順序錯誤
// 錯誤的順序
pub struct AVRational {
pub den: c_int, // 你把分母放前面
pub num: c_int,
}
// C 標頭檔的定義
struct AVRational {
int num; // 分子在前!
int den;
};
後果: 你的 num 讀到分母,den 讀到分子 → 計算錯誤、除以零。
3. 對齊問題
#[repr(C)]
pub struct Misaligned {
pub flag: u8,
pub value: u64, // 期望 8-byte 對齊
}
後果: 在某些平台上,C 會在 flag 後加 7 bytes 的 padding。如果 Rust 沒有,value 欄位就會讀到垃圾值。
4. 列舉值不匹配
// 你的猜測
pub enum AVMediaType {
Video = 0,
Audio = 1,
}
// 實際 FFmpeg(不同版本)
// FFmpeg 4.x: Video = 0, Audio = 1
// FFmpeg 6.x: Unknown = -1, Video = 0, Audio = 1
後果: 檢查 if type == Video 可能失敗,因為數值已經偏移。
5. 指標生命週期
// C 函式內部儲存了這個指標
let path = CString::new("/path/to/file").unwrap();
avformat_open_input(&mut ctx, path.as_ptr(), ...);
drop(path); // CString 被釋放!
// ctx 現在持有一個指向已釋放記憶體的懸空指標
後果: Use-after-free → 崩潰或安全漏洞。
6. 呼叫慣例錯誤
extern "C" fn callback(x: i32) -> i32 // 假設是 cdecl
// 但 FFmpeg 在 Windows 上可能期望 stdcall
後果: 堆疊損壞、錯誤的返回值。
7. 版本相依的欄位
// 在 FFmpeg 5.0 上正常運作
pub struct AVCodecParameters {
pub codec_type: AVMediaType,
pub codec_id: u32,
pub bit_rate: i64,
}
// FFmpeg 6.0 在 bit_rate 之前新增了一個欄位
後果: bit_rate 現在從錯誤的偏移量讀取 → 得到無意義的值。
這就是為什麼 bindgen 是更安全的選擇。
Bindgen 如何避免這些陷阱
Bindgen 的核心原理是:在編譯時讀取實際的 C 標頭檔,使用 libclang 解析 AST,然後產生對應的 Rust 程式碼。
工作流程
flowchart TD
A["C 標頭檔 (avformat.h)"] --> B["libclang 解析"]
B --> C["AST(抽象語法樹)"]
C --> D["bindgen 轉換"]
D --> E["Rust 綁定 (bindings.rs)"]
為什麼能避免手寫的陷阱
| 陷阱 |
Bindgen 如何解決 |
| 結構體大小 |
從 AST 讀取精確的 sizeof,產生正確大小的 Rust struct |
| 欄位順序 |
按照 C 標頭檔中的宣告順序產生欄位 |
| 對齊問題 |
讀取每個欄位的 alignof,自動加入正確的 #[repr(C)] 和 padding |
| 列舉值 |
直接從標頭檔讀取每個列舉的數值 |
| 版本相依 |
每次編譯時重新產生,自動適應安裝的函式庫版本 |
實際產生的程式碼
當 bindgen 處理 AVRational 時:
// bindgen 產生的程式碼(自動)
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct AVRational {
pub num: ::std::os::raw::c_int, // 順序正確
pub den: ::std::os::raw::c_int,
}
對於複雜的結構體如 AVFormatContext:
// bindgen 產生數百行,包含所有欄位
#[repr(C)]
pub struct AVFormatContext {
pub av_class: *const AVClass,
pub iformat: *const AVInputFormat,
pub oformat: *const AVOutputFormat,
pub priv_data: *mut ::std::os::raw::c_void,
pub pb: *mut AVIOContext,
pub ctx_flags: ::std::os::raw::c_int,
pub nb_streams: ::std::os::raw::c_uint,
pub streams: *mut *mut AVStream,
// ... 還有幾十個欄位 ...
}
Bindgen 的限制
Bindgen 不是萬能的,它無法解決:
1. 跨版本相容性
編譯時:FFmpeg 6.0 安裝 → bindings.rs 對應 FFmpeg 6.0
執行時:FFmpeg 7.0 安裝 → ABI 不相容 → 未定義行為
解決方案: 重新編譯,或使用版本檢查。
2. 指標生命週期
Bindgen 只產生型別定義,不會幫你管理生命週期:
// bindgen 產生的函式簽名
pub fn avformat_open_input(
ps: *mut *mut AVFormatContext,
url: *const ::std::os::raw::c_char, // 誰負責這個指標的生命週期?
// ...
) -> ::std::os::raw::c_int;
解決方案: 在 safe wrapper 層處理。
3. 語意正確性
Bindgen 確保 ABI 正確,但不保證你正確使用 API:
// 這段程式碼 ABI 正確,但語意錯誤
let ctx = avformat_open_input(...);
// 忘記呼叫 avformat_find_stream_info()
let streams = (*ctx).nb_streams; // 可能是 0 或垃圾值
解決方案: 閱讀文件,或使用 safe wrapper 強制正確的呼叫順序。
結論:三層防護
理想的 FFI 架構是三層:
block-beta
columns 1
block:layer1
A["Safe Wrapper(語意正確性)← 人類設計"]
end
block:layer2
B["Bindgen 綁定(ABI 正確性)← 工具產生"]
end
block:layer3
C["C 函式庫(實際實作)← 外部依賴"]
end
- Bindgen 確保 Rust 和 C 之間的 ABI 匹配
- Safe Wrapper 確保 API 被正確使用
- 人類 負責設計 wrapper 的 API 和處理邊界情況
手寫 FFI 適合學習底層原理,但在生產環境中,bindgen + safe wrapper 的組合才是正確的選擇。
Safe Wrapper 的設計原則
Safe wrapper 是 FFI 的最後一道防線,設計得好可以讓使用者完全不需要接觸 unsafe。以下是幾個關鍵原則:
1. RAII:資源獲取即初始化
原則: 用 Rust 的所有權系統管理 C 資源的生命週期。
pub struct FormatContext {
ptr: *mut AVFormatContext, // C 資源
}
impl FormatContext {
pub fn open(path: &str) -> Result<Self> {
let mut ptr = std::ptr::null_mut();
let ret = unsafe { avformat_open_input(&mut ptr, ...) };
if ret < 0 {
return Err(AvError::from_code(ret));
}
Ok(FormatContext { ptr }) // 獲取資源
}
}
impl Drop for FormatContext {
fn drop(&mut self) {
unsafe { avformat_close_input(&mut self.ptr); } // 自動釋放
}
}
效果: 使用者不可能忘記釋放資源,編譯器保證。
2. 類型狀態模式:編譯時強制正確順序
原則: 用不同的類型表示不同的狀態,讓編譯器阻止錯誤的呼叫順序。
// 未初始化的 context
pub struct UninitializedContext { ptr: *mut AVFormatContext }
// 已讀取 stream info 的 context
pub struct ReadyContext { ptr: *mut AVFormatContext }
impl UninitializedContext {
pub fn find_stream_info(self) -> Result<ReadyContext> {
let ret = unsafe { avformat_find_stream_info(self.ptr, ...) };
if ret < 0 {
return Err(...);
}
Ok(ReadyContext { ptr: self.ptr })
}
}
impl ReadyContext {
// 只有 ReadyContext 才能讀取 packets
pub fn read_packet(&mut self) -> Result<Packet> { ... }
}
效果: 不可能在 find_stream_info() 之前呼叫 read_packet(),編譯器會報錯。
3. 借用而非擁有:避免不必要的複製
原則: 對於 C 結構體內的資料,返回借用而非複製。
impl Packet {
// 返回借用,避免複製整個 packet 資料
pub fn data(&self) -> Option<&[u8]> {
unsafe {
let ptr = (*self.ptr).data;
let size = (*self.ptr).size;
if ptr.is_null() || size <= 0 {
None
} else {
Some(std::slice::from_raw_parts(ptr, size as usize))
}
}
}
}
效果: 零複製存取,效能與直接使用 C API 相同。
4. 錯誤類型化:用 Rust 的 Result 取代錯誤碼
原則: 把 C 的錯誤碼轉換成有意義的 Rust 錯誤類型。
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error("End of file")]
Eof,
#[error("Failed to open input: {0}")]
OpenInput(String),
#[error("FFmpeg error ({code}): {message}")]
Ffmpeg { code: i32, message: String },
}
impl AvError {
pub fn from_code(code: i32) -> Self {
if code == AVERROR_EOF {
return AvError::Eof;
}
let message = get_error_string(code);
AvError::Ffmpeg { code, message }
}
}
效果: 使用者可以用 match 處理特定錯誤,IDE 有自動完成。
為什麼用 thiserror?
thiserror 是定義錯誤類型的最佳選擇,原因如下:
1. 自動實作 std::error::Error
// 手寫需要這些
impl std::fmt::Display for AvError { ... }
impl std::error::Error for AvError { ... }
// thiserror 一行搞定
#[derive(thiserror::Error)]
2. 錯誤訊息內嵌在類型定義中
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error("Failed to open '{path}': {reason}")]
OpenInput { path: String, reason: String },
#[error("Codec not found: {0}")]
CodecNotFound(String),
}
程式碼和文件在同一處,不會不同步。
3. 支援錯誤鏈(Error Source)
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error("IO error")]
Io(#[from] std::io::Error), // 自動實作 From<std::io::Error>
#[error("Invalid path: {0}")]
InvalidPath(#[source] std::ffi::NulError), // 保留原始錯誤
}
使用者可以用 .source() 追溯錯誤來源。
4. 與 anyhow 完美搭配
// 函式庫用 thiserror 定義具體錯誤
#[derive(thiserror::Error)]
pub enum AvError { ... }
// 應用程式用 anyhow 統一處理
fn main() -> anyhow::Result<()> {
let ctx = FormatContext::open("video.mp4")?; // AvError 自動轉換
Ok(())
}
5. 零執行時開銷
thiserror 是純粹的 proc-macro,所有程式碼在編譯時產生,執行時沒有任何額外成本。
比較:不同錯誤處理方式
| 方式 |
優點 |
缺點 |
返回 i32 錯誤碼 |
與 C API 一致 |
無類型安全、難以理解 |
手寫 Error trait |
完全控制 |
樣板程式碼多 |
thiserror |
簡潔、類型安全 |
需要依賴(但很輕量) |
anyhow::Error |
最簡單 |
丟失具體類型,不適合函式庫 |
結論: 函式庫應該用 thiserror 定義具體錯誤類型,讓使用者可以精確處理每種錯誤情況。
5. 隱藏指標:不暴露原始指標給使用者
原則: 所有原始指標都應該是 struct 的私有欄位。
pub struct FormatContext {
ptr: *mut AVFormatContext, // 私有!
}
impl FormatContext {
// 提供安全的存取方法
pub fn nb_streams(&self) -> usize {
unsafe { (*self.ptr).nb_streams as usize }
}
// 如果真的需要原始指標(進階使用),標記為 unsafe
pub unsafe fn as_ptr(&self) -> *mut AVFormatContext {
self.ptr
}
}
效果: 一般使用者完全不需要寫 unsafe。
6. 迭代器模式:讓集合存取符合 Rust 慣例
原則: 用迭代器取代 C 風格的索引存取。
impl FormatContext {
pub fn streams(&self) -> impl Iterator<Item = StreamInfo> + '_ {
(0..self.nb_streams()).map(move |i| self.stream_info(i).unwrap())
}
}
// 使用方式
for stream in ctx.streams() {
println!("Stream {}: {:?}", stream.index, stream.media_type);
}
效果: 符合 Rust 慣例,可以用 filter、map 等方法鏈。
7. 文件化不變量:清楚說明 Safety 條件
原則: 即使是 safe 函式,也要說明前提條件和可能的 panic。
impl FormatContext {
/// Read the next packet from the container.
///
/// # Returns
/// - `Ok(true)` if a packet was read
/// - `Ok(false)` if end of file reached
/// - `Err(...)` if an error occurred
///
/// # Panics
/// Never panics. All errors are returned as `Err`.
pub fn read_packet(&mut self, packet: &mut Packet) -> Result<bool> {
// ...
}
}
設計檢查清單
設計 safe wrapper 時,問自己這些問題:
| 問題 |
如果答案是「否」 |
使用者需要寫 unsafe 嗎? |
提供更高層的 API |
| 使用者可能忘記釋放資源嗎? |
實作 Drop |
| 使用者可能呼叫順序錯誤嗎? |
使用類型狀態模式 |
| 使用者可能傳入無效參數嗎? |
在建構時驗證 |
| 錯誤訊息有意義嗎? |
定義專屬的錯誤類型 |
| API 符合 Rust 慣例嗎? |
參考標準庫的設計 |
好的 safe wrapper 讓使用者感覺像在用純 Rust 函式庫,完全不知道底下是 C。
AI Coding Agent 與 FFI 開發
Bindgen + Safe Wrapper 的組合是最佳實踐,但過去有個問題:太繁瑣了。
設定 build.rs、處理 pkg-config、設計 wrapper API、實作 Drop、寫文件、寫測試… 這些工作加起來可能比實際的業務邏輯還多。這也是為什麼很多人選擇「先用 unsafe 頂著,以後再說」。
AI coding agent 改變了這個權衡。
AI 擅長的 FFI 任務
1. Build Script 設定
告訴 AI:「用 bindgen 綁定 libavformat,只需要這幾個函式…」
AI 會產生完整的 build.rs:
// AI 產生,包含 pkg-config 偵測、bindgen 設定、錯誤處理
fn main() {
let lib = pkg_config::probe_library("libavformat")
.expect("libavformat not found");
let bindings = bindgen::Builder::default()
.header_contents("wrapper.h", r#"#include <libavformat/avformat.h>"#)
.allowlist_function("avformat_open_input")
.allowlist_function("avformat_find_stream_info")
// ... 完整設定
.generate()
.expect("Failed to generate bindings");
// 輸出到正確位置
bindings.write_to_file(out_path.join("bindings.rs")).unwrap();
}
手寫這個需要查文件、試錯、處理邊界情況。AI 幾秒鐘搞定。
2. Safe Wrapper 骨架
告訴 AI:「為 AVFormatContext 建立 safe wrapper,實作 RAII」
AI 會產生符合所有設計原則的程式碼:
pub struct FormatContext {
ptr: *mut AVFormatContext,
}
impl FormatContext {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> { ... }
pub fn nb_streams(&self) -> usize { ... }
pub fn streams(&self) -> impl Iterator<Item = StreamInfo> + '_ { ... }
}
impl Drop for FormatContext {
fn drop(&mut self) { ... }
}
包含:
- 正確的生命週期標註
Result 錯誤處理
- 迭代器 API
Drop 實作
3. 錯誤類型定義
告訴 AI:「用 thiserror 定義錯誤類型,涵蓋 FFmpeg 的錯誤碼」
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error("End of file")]
Eof,
#[error("Failed to open input: {0}")]
OpenInput(String),
#[error("FFmpeg error ({code}): {message}")]
Ffmpeg { code: i32, message: String },
}
4. 文件和測試
AI 自動產生:
/// # Safety 段落
- 參數和返回值說明
- 基本的單元測試
- 使用範例
這些在手動開發時往往被省略,但對使用者很重要。
人類的角色
AI 不是萬能的。人類仍然負責:
| 任務 |
為什麼需要人類 |
| 決定要綁定哪些函式 |
需要理解業務需求 |
| 審查 API 設計 |
確保符合 Rust 慣例和使用者期望 |
| 驗證 ABI 正確性 |
編譯成功不代表執行時正確 |
| 處理邊界情況 |
AI 可能遺漏特殊情況 |
| 效能調校 |
需要實際測量和分析 |
實際工作流程
flowchart TD
A["1. 人類:「綁定 libavformat 的 open/read/close」"] --> B["2. AI:產生 Cargo.toml、build.rs、bindgen 設定"]
B --> C["3. 人類:cargo build,確認編譯成功"]
C --> D["4. AI:產生 safe wrapper(FormatContext, Packet...)"]
D --> E["5. 人類:審查 API,要求修改(「加上迭代器」)"]
E --> F["6. AI:修改 + 補充文件和測試"]
F --> G["7. 人類:用真實檔案測試,確認功能正確"]
整個過程可能只需要 30 分鐘,而不是以前的一整天。
為什麼 FFI 特別適合 AI 輔助
- 模式明確:bindgen 設定、RAII wrapper、錯誤處理都有標準模式
- 正確性可驗證:能編譯、能執行、測試通過
- 重複性高:每個函式的 wrapper 結構類似
- 文件密集:需要大量的 Safety 說明和使用範例
AI 處理這些機械性工作,人類專注於設計決策和驗證。
結論
FFI 開發的最佳實踐是 bindgen + safe wrapper:
- Bindgen 確保 ABI 正確性(結構體大小、欄位順序、對齊)
- Safe Wrapper 確保語意正確性(資源管理、呼叫順序、錯誤處理)
過去這個組合太繁瑣,很多人選擇捷徑。現在有了 AI coding agent,完整實作的成本大幅降低。
不要再「先用 unsafe 頂著」了。讓 AI 幫你產生正確的 bindgen + safe wrapper,你只需要審查和測試。
這就是 AI 時代的 FFI 開發:人類做設計決策,AI 做繁瑣實作,最終得到安全、符合慣例的 Rust 綁定。
專案原始碼:rust-52-projects/libavformat-ffi
這個專案是我「52 個 Rust 專案」學習計畫的一部分。在完成了 16 個專案後,我發現 WebAssembly 是一個重要的學習缺口,於是決定用 Rust 來打造一個即時 Markdown 編輯器。
專案原始碼:wasm-markdown-editor
為什麼選擇這個專案?
在分析了之前完成的專案後,我發現幾個學習上的空白:
- 已掌握的領域:Async/await、網路程式設計(TCP/UDP/HTTP/WebSockets)、解析器、CLI 工具、錯誤處理
- 待加強的領域:資料庫 ORM、程序宏、FFI、WebAssembly、進階測試、GUI/圖形
選擇 Markdown 編輯器的原因:
- 填補關鍵缺口:之前沒有任何 WASM 專案
- 善用既有技能:運用之前在計算機、shell、EBML 等專案中學到的解析技巧
- 互動性強:能立即看到成果,滿足感高
- 實用價值:這是真正能用的工具,不只是 demo
- 現代技術棧:WASM 在 Rust 生態系中越來越重要
技術架構
Rust 依賴項
[dependencies]
wasm-bindgen = "0.2" # JavaScript 互操作層
pulldown-cmark = "0.12" # 經過實戰驗證的 Markdown 解析器
web-sys = "0.3" # Web API 綁定
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6" # Rust/JS 之間的資料序列化
console_error_panic_hook = "0.1" # 瀏覽器中更好的錯誤訊息
專案結構
wasm-markdown-editor/
├── Cargo.toml # Rust 專案設定
├── index.html # 主進入點
├── src/
│ ├── lib.rs # WASM 進入點
│ ├── parser.rs # Markdown 解析 + 統計邏輯
│ └── utils.rs # Panic hook 和工具函式
├── www/
│ ├── index.js # JavaScript 應用邏輯
│ └── styles.css # 樣式
└── pkg/ # 建置輸出
├── wasm_markdown_editor.js
└── wasm_markdown_editor_bg.wasm
核心實作
將函式匯出到 JavaScript
use wasm_bindgen::prelude::*;
// WASM 模組載入時自動執行
#[wasm_bindgen(start)]
pub fn init() {
utils::set_panic_hook(); // 更好的錯誤訊息
}
// 簡單的字串轉換
#[wasm_bindgen]
pub fn markdown_to_html(markdown: &str) -> String {
parser::parse_markdown(markdown)
}
// 複雜結構 → JavaScript 物件
#[wasm_bindgen]
pub fn get_statistics(text: &str) -> JsValue {
let stats = parser::calculate_stats(text);
serde_wasm_bindgen::to_value(&stats).unwrap()
}
關鍵模式:
#[wasm_bindgen] 標記要匯出給 JS 的函式
#[wasm_bindgen(start)] 在模組初始化時自動執行
- 簡單型別(str、數字、布林)自動轉換
- 複雜型別需要
serde-wasm-bindgen 進行序列化
Markdown 解析
use pulldown_cmark::{html, Options, Parser};
pub fn parse_markdown(markdown: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(markdown, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}
啟用的功能:刪除線、表格、註腳、任務清單、標題屬性。
統計資訊計算
#[derive(Serialize)]
pub struct Statistics {
pub characters: usize,
pub characters_no_spaces: usize,
pub words: usize,
pub lines: usize,
pub paragraphs: usize,
pub reading_time_minutes: f64,
}
pub fn calculate_stats(text: &str) -> Statistics {
let words = text.split_whitespace().count();
let paragraphs = text
.split("\n\n")
.filter(|s| !s.trim().is_empty())
.count();
// 平均閱讀速度:每分鐘 200 字
let reading_time_minutes = (words as f64 / 200.0).ceil();
// ...
}
JavaScript 整合
import init, {
markdown_to_html,
get_statistics
} from '../pkg/wasm_markdown_editor.js';
async function run() {
// 初始化 WASM 模組
await init();
// 現在可以使用 Rust 函式了
const html = markdown_to_html(markdown);
const stats = get_statistics(text);
}
// 效能優化:防抖動
let debounceTimer = null;
const DEBOUNCE_DELAY = 300;
function handleInput() {
if (debounceTimer) clearTimeout(debounceTimer);
updateStatistics(); // 立即更新(快速)
debounceTimer = setTimeout(() => {
updatePreview(); // 防抖動(較重)
saveToStorage();
}, DEBOUNCE_DELAY);
}
關鍵重點:
- ES6 模組匯入 WASM
- 呼叫 Rust 函式前必須先執行非同步的
init()
- 防抖動避免過度渲染
- 統計立即更新(便宜),預覽防抖動(昂貴)
WASM 編譯深入解析
編譯流程
1. Rust 原始碼 (lib.rs, parser.rs, utils.rs)
↓
2. rustc --target wasm32-unknown-unknown
↓
3. 原始 .wasm 二進位檔(WebAssembly 位元組碼)
↓
4. wasm-bindgen(產生 JS 膠水程式碼)
↓
5. wasm-opt(Binaryen 優化)
↓
6. 最終輸出:.wasm + .js + .d.ts
記憶體模型
WASM 使用線性記憶體(單一連續區塊),JavaScript 和 Rust 透過這塊共享記憶體交換資料:
sequenceDiagram
participant JS as JavaScript
participant Mem as WASM 線性記憶體
participant Rust as Rust
JS->>Mem: 1. 寫入字串
JS->>Rust: 2. 傳遞指標 + 長度
Rust->>Rust: 3. 處理資料
Rust->>Mem: 4. 寫入結果
Rust->>JS: 5. 回傳結果指標
JS->>Mem: 6. 讀取結果
JS->>Mem: 7. 釋放記憶體
字串傳遞流程:
- JS 字串寫入 WASM 線性記憶體
- 傳遞指標和長度給 Rust 函式
- Rust 處理資料
- Rust 將結果寫入記憶體
- 回傳結果的指標給 JS
- JS 從記憶體讀取結果字串
- 釋放不再需要的記憶體
型別轉換對照表
| Rust 型別 |
WASM 型別 |
JavaScript 型別 |
&str, String |
i32 (ptr) + i32 (len) |
string |
i32, u32 |
i32 |
number |
f64 |
f64 |
number |
bool |
i32 (0 或 1) |
boolean |
JsValue |
externref |
any |
| 可序列化 struct |
externref |
object |
建置與效能
建置指令
# 安裝 wasm-pack(只需一次)
cargo install wasm-pack
# 建置 WASM 模組
wasm-pack build --target web
# 執行 Rust 單元測試
cargo test
# 在瀏覽器中執行 WASM 測試
wasm-pack test --headless --firefox
建置輸出
- WASM 套件:222 KB(已優化)
- JS 膠水程式碼:13 KB
- 總下載量:235 KB
- 建置時間:約 8 秒(增量)、約 13 秒(完整)
Cargo.toml 關鍵設定
[lib]
crate-type = ["cdylib", "rlib"] # WASM 編譯必需
[profile.release]
opt-level = "s" # 優化檔案大小(而非速度)
lto = true # 連結時優化,產生更小的套件
為什麼這些設定很重要:
cdylib = C 動態函式庫類型,WASM 必需
opt-level = "s" 產生的二進位檔比 “3” 小約 30%
- LTO 消除整個依賴樹中的死碼
遇到的問題與解決方案
問題:404 錯誤
從 www/ 目錄提供服務時,瀏覽器無法存取 ../pkg/(在提供的目錄之外)。
初始嘗試:從 www/ 目錄提供服務
http://localhost:8080/ → www/index.html
http://localhost:8080/pkg/... → 404 錯誤
解決方案:在專案根目錄建立 index.html
# 從專案根目錄提供服務,而非 www/
cd wasm-markdown-editor
python -m http.server 8080
# 存取 http://localhost:8080
實作的功能
- ✅ 帶防抖動的即時預覽
- ✅ 即時統計(字數、字元數、閱讀時間)
- ✅ LocalStorage 自動儲存
- ✅ 帶內嵌 CSS 的 HTML 匯出
- ✅ 範例 Markdown 載入器
- ✅ 鍵盤快捷鍵(Ctrl+S、Ctrl+K)
- ✅ 響應式分割視窗佈局
學習成果
WASM 特定技能
- ✅ 理解
cdylib crate 類型及其作用
- ✅ 使用
#[wasm_bindgen] 屬性匯出給 JS
- ✅ 管理 Rust/JavaScript 邊界的記憶體
- ✅ 型別轉換(簡單型別 vs. 複雜結構)
- ✅ WASM 模組初始化模式
- ✅ 在瀏覽器 DevTools 中除錯 WASM
- ✅ 套件大小優化技術
- ✅ 建置工具(
wasm-pack、wasm-bindgen)
什麼時候該用 WASM?
適合的場景:
- ✅ 計算密集型操作(解析、圖像處理)
- ✅ 想重用現有的 Rust 函式庫
- ✅ 效能關鍵路徑
- ✅ 套件大小可接受時
不太適合的場景:
- ❌ 大量 DOM 操作(用 JS)
- ❌ 微小的工具函式(開銷不值得)
- ❌ 簡單的 CRUD 操作
- ❌ 套件大小是關鍵考量時
與純 JavaScript 方案的比較
效能:
- Markdown 解析:比 JS 替代方案快約 2-3 倍
- 打字時沒有 GC 暫停
- 可預測的記憶體使用
套件大小:
- 與流行的 JS 函式庫相當或更小
- markdown-it.js:約 320 KB
- 我們的方案:235 KB(包含解析器 + 統計)
反思
順利的部分
- 流暢的建置過程:wasm-pack「直接就能用」
- 優秀的文件:Rust WASM book 非常有價值
- 型別安全:編譯時就能捕捉錯誤
- 效能:解析明顯流暢
- 工具鏈:自動產生的 TypeScript 定義很有幫助
克服的挑戰
- 路徑解析:404 錯誤需要理解 WASM 服務方式
- 記憶體模型:理解線性記憶體花了一些時間
- 型別轉換:學習何時用 JsValue vs. 簡單型別
- 非同步初始化:理解 init() 的必要性
驚喜的發現
- 套件大小:比預期小(222 KB 含完整解析器!)
- 建置速度:增量建置很快(8 秒)
- 瀏覽器支援:所有現代瀏覽器都能用,不需要 polyfill
- 開發體驗:在 DevTools 中除錯 WASM 相當不錯
結論
這個專案成功填補了我 rust-52-projects 學習旅程中的一個主要缺口。它證明了 Rust + WASM 已經可以用於生產環境的互動式 Web 應用程式,尤其是像解析這樣的計算密集型任務。
Rust 的效能和安全性與 JavaScript 的普及性結合,創造了一個強大的開發模式。工具鏈(wasm-pack、wasm-bindgen)已經成熟到體驗流暢且高效的程度。
這是一個很好的「第一個 WASM 專案」,在學習核心概念的同時建構出真正實用的東西。
參考資源
什麼是 Peacock?
Peacock 是一個非常實用的 VSCode 擴充套件,由 John Papa 開發。它的主要功能是讓你可以為不同的 VSCode 工作區設定不同的顏色主題,透過改變工作區的視窗顏色(包括狀態列、標題列、活動列等),讓你在同時開啟多個專案時能夠快速辨識當前正在工作的專案。
為什麼需要 Peacock?
如果你跟我一樣,經常需要同時處理多個專案,你一定遇過這些困擾:
- 🔀 在多個 VSCode 視窗間切換時,常常搞不清楚哪個視窗對應哪個專案
- 💥 不小心在錯誤的專案中編輯或執行程式碼
- 🎯 想要快速找到特定專案的視窗,但需要逐一檢視
Peacock 透過視覺化的顏色標記,完美解決了這些問題。每個專案都有自己獨特的顏色,一眼就能辨識。
主要功能
- 工作區顏色化:自訂狀態列、標題列、活動列等 UI 元素的顏色
- 預設配色庫:內建多種精心挑選的顏色主題
- 收藏顏色:可以建立自己的配色收藏清單
- 快速切換:透過指令面板快速更改工作區顏色
- 專案記憶:每個工作區的顏色設定會被記錄,重新開啟時自動套用
我的 Peacock 收藏配色
我用 AI 幫我產生一套以品牌和技術堆疊為主題的配色方案。這些顏色不僅視覺上容易辨識,也與對應的技術或品牌有直接關聯,讓我能更直覺地記憶和使用。
品牌主題色系
這些是知名科技品牌的代表色,當我在處理與這些平台相關的專案時特別好用:
| 顏色名稱 |
色碼 |
適用場景 |
| Airbnb Pink |
#ff385c |
Airbnb 相關專案 |
| Amazon Orange |
#ff9900 |
AWS 或 Amazon 服務整合 |
| Azure Blue |
#007fff |
Azure 雲端專案 |
| Facebook Blue |
#1877f2 |
Meta/Facebook 專案 |
| Google Blue |
#4285f4 |
Google Cloud 或 Firebase 專案 |
| LinkedIn Blue |
#0a66c2 |
LinkedIn 整合專案 |
| Netflix Red |
#e50914 |
串流媒體相關專案 |
| Spotify Green |
#1db954 |
音樂或音訊相關專案 |
| Tesla Red |
#e82127 |
IoT 或電動車相關專案 |
| Twitter Blue |
#1da1f2 |
社群媒體專案 |
程式語言與框架色系
這是我最常用的部分!根據專案使用的主要技術堆疊選擇對應顏色:
前端技術
- React Blue (
#61dafb) - React 專案
- Vue Green (
#42b883) - Vue.js 專案
- Angular Red (
#dd0531) - Angular 專案
- Svelte Orange (
#ff3d00) - Svelte 專案
- Nuxt Green (
#00dc82) - Nuxt.js 專案
- Tailwind Cyan (
#06b6d4) - 使用 Tailwind CSS 的專案
後端技術
- Node Green (
#215732) - Node.js 後端專案
- Node.js Green (
#68a063) - Node.js 全端專案
- Deno Green (
#00a853) - Deno 專案
- Python Blue (
#3776ab) - Python 專案
- Go Cyan (
#00add8) - Go 專案
- Rust Orange (
#ce422b) - Rust 專案
- PHP Purple (
#777bb4) - PHP 專案
- Ruby Red (
#cc342d) - Ruby 專案
- Java Orange (
#007396) - Java 專案
- Kotlin Purple (
#7f52ff) - Kotlin 專案
- Elixir Purple (
#6f42be) - Elixir 專案
- Scala Red (
#dc322f) - Scala 專案
- C# Purple (
#239120) - C# 專案
- C++ Blue (
#00599c) - C++ 專案
框架與工具
- Django Green (
#092e20) - Django 專案
- Laravel Red (
#ff2d20) - Laravel 專案
- NestJS Red (
#ea2845) - NestJS 專案
- Spring Green (
#6db33f) - Spring Framework 專案
- Fastify Blue (
#000000) - Fastify 專案
JavaScript 生態系
- JavaScript Yellow (
#f9e64f) - 純 JavaScript 專案
- TypeScript Blue (
#3178c6) - TypeScript 專案
- Babel Yellow (
#f9dc3e) - Babel 設定專案
- Webpack Blue (
#8dd6f9) - Webpack 相關專案
- ESLint Purple (
#4b32c3) - ESLint 設定專案
資料庫與基礎設施
- MongoDB Green (
#13aa52) - MongoDB 專案
- PostgreSQL Blue (
#336791) - PostgreSQL 專案
- Redis Red (
#dc382d) - Redis 快取專案
- Docker Blue (
#2496ed) - Docker 容器化專案
- Kubernetes Blue (
#326ce5) - K8s 部署專案
- Firebase Orange (
#ffa400) - Firebase 專案
- GraphQL Pink (
#e10098) - GraphQL API 專案
其他科技品牌
- GitHub Green (
#08872b) - GitHub 相關專案或 Actions
- npm Red (
#cb3837) - npm 套件開發
- Yarn Blue (
#2c8ebb) - 使用 Yarn 的專案
- AMD Red (
#ed1c24) - AMD 相關專案
- Intel Blue (
#0071c5) - Intel 相關專案
- Nvidia Green (
#76b900) - GPU 運算或機器學習專案
- Electron Blue (
#47848f) - Electron 桌面應用程式
特殊用途
- Jest Red (
#c21325) - 測試專案
- Bootstrap Purple (
#7952b3) - Bootstrap 前端專案
- Mandalorian Blue (
#1857a4) - 星際大戰粉絲的選擇 😄
- Something Different (
#832561) - 當你想要與眾不同的時候
如何使用這些配色
1. 安裝 Peacock
在 VSCode 擴充套件市場搜尋 “Peacock” 並安裝,或直接使用指令:
code --install-extension johnpapa.vscode-peacock
2. 設定收藏配色
將以下完整配色清單加入你的 VSCode 設定檔(settings.json):
{
"peacock.favoriteColors": [
{"name":"Airbnb Pink","value":"#ff385c"},
{"name":"Amazon Orange","value":"#ff9900"},
{"name":"AMD Red","value":"#ed1c24"},
{"name":"Angular Red","value":"#dd0531"},
{"name":"Apple Gray","value":"#555555"},
{"name":"AWS Orange","value":"#ff9900"},
{"name":"Azure Blue","value":"#007fff"},
{"name":"Babel Yellow","value":"#f9dc3e"},
{"name":"Bootstrap Purple","value":"#7952b3"},
{"name":"C++ Blue","value":"#00599c"},
{"name":"C# Purple","value":"#239120"},
{"name":"Clojure Green","value":"#5881d8"},
{"name":"Deno Green","value":"#00a853"},
{"name":"Django Green","value":"#092e20"},
{"name":"Docker Blue","value":"#2496ed"},
{"name":"Electron Blue","value":"#47848f"},
{"name":"Elixir Purple","value":"#6f42be"},
{"name":"ESLint Purple","value":"#4b32c3"},
{"name":"Facebook Blue","value":"#1877f2"},
{"name":"Fastify Blue","value":"#000000"},
{"name":"Firebase Orange","value":"#ffa400"},
{"name":"GitHub Green","value":"#08872b"},
{"name":"Go Cyan","value":"#00add8"},
{"name":"Google Blue","value":"#4285f4"},
{"name":"GraphQL Pink","value":"#e10098"},
{"name":"Haskell Purple","value":"#5e5086"},
{"name":"Instagram Pink","value":"#e4405f"},
{"name":"Intel Blue","value":"#0071c5"},
{"name":"Java Orange","value":"#007396"},
{"name":"JavaScript Yellow","value":"#f9e64f"},
{"name":"Jest Red","value":"#c21325"},
{"name":"Kotlin Purple","value":"#7f52ff"},
{"name":"Kubernetes Blue","value":"#326ce5"},
{"name":"Laravel Red","value":"#ff2d20"},
{"name":"LinkedIn Blue","value":"#0a66c2"},
{"name":"Lua Blue","value":"#000080"},
{"name":"Mandalorian Blue","value":"#1857a4"},
{"name":"MATLAB Orange","value":"#0071c5"},
{"name":"Meta Blue","value":"#1877f2"},
{"name":"Microsoft Blue","value":"#0078d4"},
{"name":"MongoDB Green","value":"#13aa52"},
{"name":"NestJS Red","value":"#ea2845"},
{"name":"Netflix Red","value":"#e50914"},
{"name":"Node Green","value":"#215732"},
{"name":"Node.js Green","value":"#68a063"},
{"name":"npm Red","value":"#cb3837"},
{"name":"Nuxt Green","value":"#00dc82"},
{"name":"Nvidia Green","value":"#76b900"},
{"name":"Perl Blue","value":"#0073a1"},
{"name":"PHP Purple","value":"#777bb4"},
{"name":"PostgreSQL Blue","value":"#336791"},
{"name":"Python Blue","value":"#3776ab"},
{"name":"R Blue","value":"#276dc3"},
{"name":"React Blue","value":"#61dafb"},
{"name":"Redis Red","value":"#dc382d"},
{"name":"Ruby Red","value":"#cc342d"},
{"name":"Rust Orange","value":"#ce422b"},
{"name":"Scala Red","value":"#dc322f"},
{"name":"Slack Purple","value":"#e01e5a"},
{"name":"Something Different","value":"#832561"},
{"name":"Spotify Green","value":"#1db954"},
{"name":"Spring Green","value":"#6db33f"},
{"name":"Svelte Orange","value":"#ff3d00"},
{"name":"Swift Orange","value":"#fa7343"},
{"name":"Tailwind Cyan","value":"#06b6d4"},
{"name":"Tesla Red","value":"#e82127"},
{"name":"Twitter Blue","value":"#1da1f2"},
{"name":"TypeScript Blue","value":"#3178c6"},
{"name":"Uber Black","value":"#000000"},
{"name":"Vue Green","value":"#42b883"},
{"name":"Webpack Blue","value":"#8dd6f9"},
{"name":"WhatsApp Green","value":"#25d366"},
{"name":"Yarn Blue","value":"#2c8ebb"}
]
}
這份清單包含了 74 種配色,涵蓋主流的程式語言、框架、工具和科技品牌。你可以直接複製整段 JSON 貼到你的設定檔中。
3. 使用配色
開啟專案後,按下 Ctrl+Shift+P(Mac: Cmd+Shift+P)開啟指令面板,輸入 “Peacock”,你會看到以下選項:
- Peacock: Change to a Favorite Color - 從收藏清單選擇顏色
- Peacock: Enter a Color - 輸入自訂顏色
- Peacock: Surprise Me with a Random Color - 隨機選擇顏色
- Peacock: Reset Colors - 重設為預設顏色
結語
Peacock 是一個看似簡單,但能大幅提升開發效率的工具。透過視覺化的顏色標記,我再也不會在錯誤的專案視窗中執行不對的指令,也能更快速地在多個專案間切換。
相關連結
專案概述
本文將探討如何使用 Rust 實作一個生產級的非同步任務佇列系統。這個專案展示了多個重要的 Rust 概念和系統設計原則,包括:
- 模組化設計與封裝
- Trait 系統的應用
- 並發安全與資料競爭處理
- 非同步程式設計 (Tokio)
- SQLite 資料持久化
- CLI 工具設計
專案原始碼: https://github.com/p47t/rust-52-projects/tree/master/async-job-queue
系統架構
核心組件
我們的任務佇列系統由三個主要模組組成:
async-job-queue/
├── src/
│ ├── job.rs # 任務定義與狀態管理
│ ├── storage.rs # SQLite 儲存層
│ ├── worker.rs # 工作池與任務處理
│ ├── lib.rs # 公開 API
│ └── bin/
│ ├── producer.rs # 生產者 CLI
│ └── worker.rs # 消費者 CLI
資料流程
Producer (CLI) → SQLite Database ← Worker Pool (多個 workers)
↓
[Pending Jobs]
↓
[Running Jobs]
↓
[Completed/Failed/Dead Letter]
Rust 核心概念解析
1. 模組可見性:mod vs pub mod
問題: 如何設計乾淨的公開 API?
在 lib.rs 中,我們使用私有模組配合公開重匯出:
// 私有模組 - 隱藏內部實作細節
mod job;
mod storage;
mod worker;
// 公開重匯出 - 只暴露需要的類型
pub use job::{Job, JobHandler, JobStatus, Priority};
pub use storage::{Storage, StorageError};
pub use worker::WorkerPool;
優點:
- ✅ 使用者只能透過
use async_job_queue::{Job, Storage} 匯入
- ✅ 內部模組結構可以自由重構而不影響公開 API
- ✅ 更好的封裝性,隱藏實作細節
替代方案(不推薦):
pub mod job; // 暴露整個模組,使用者可存取所有公開項目
2. Trait:定義行為抽象
JobHandler trait 定義了任務處理的介面:
pub trait JobHandler: Send + Sync {
fn handle(&self, payload: &[u8]) -> Result<(), String>;
}
設計考量:
Send + Sync:確保可以在執行緒間安全傳遞和共享
- 接受
&[u8] 而非具體類型:保持通用性,支援任何序列化格式
- 回傳
Result<(), String>:簡單的錯誤處理
使用範例:
struct EchoHandler;
impl JobHandler for EchoHandler {
fn handle(&self, payload: &[u8]) -> Result<(), String> {
let message = String::from_utf8_lossy(payload);
println!("處理任務:{}", message);
Ok(())
}
}
3. Newtype 模式的應用場景
討論: 應該使用 Vec<u8> 還是 JobPayload 新類型?
目前實作(Vec<u8>):
pub struct Job {
pub payload: Vec<u8>,
// ...
}
Newtype 模式:
pub struct JobPayload(Vec<u8>);
impl JobPayload {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl From<String> for JobPayload {
fn from(s: String) -> Self {
Self(s.into_bytes())
}
}
何時使用 Newtype:
- ✅ 提供型別安全(避免混淆不同的
Vec<u8>)
- ✅ 零成本抽象(編譯時展開,無執行時開銷)
- ✅ API 更清晰(
Job::new(JobPayload, ...) vs Job::new(Vec<u8>, ...))
- ✅ 未來擴充性(可加入驗證、壓縮等功能)
目前專案的選擇:
保持 Vec<u8> 是合理的,因為:
- 系統設計為通用型,不關心具體格式
- API 介面簡單,不易混淆
- 遵循 YAGNI 原則(You Aren’t Gonna Need It)
4. 列舉型別與 Display Trait
問題: 如何為列舉實作 Display?
手動實作(目前方式):
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Priority::Low => write!(f, "low"),
Priority::Normal => write!(f, "normal"),
Priority::High => write!(f, "high"),
Priority::Critical => write!(f, "critical"),
}
}
}
使用第三方 crate(strum):
use strum_macros::Display;
#[derive(Display)]
#[strum(serialize_all = "lowercase")]
pub enum Priority {
Low,
Normal,
High,
Critical,
}
建議: 對於簡單列舉,手動實作更清晰,無需額外依賴。
5. 非同步程式設計:Tokio 的角色
Tokio 在專案中的使用:
#[tokio::main] - 設定非同步執行環境
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ...
}
tokio::spawn - 並發執行多個 worker
for worker_id in 0..self.num_workers {
let handle = tokio::spawn(async move {
worker_loop(worker_id, storage, handler, poll_interval).await;
});
handles.push(handle);
}
tokio::time::sleep - 非阻塞延遲
sleep(poll_interval).await; // 輪詢間隔
有趣的觀察:
這個專案實際上是「假非同步」!核心操作都是同步的:
- SQLite 操作使用
rusqlite(同步 API)
- 任務處理是同步函式
- 使用
std::sync::Mutex 而非 tokio::sync::Mutex
為什麼還要用 Tokio?
- 方便建立並發 worker(比
std::thread 更輕量)
- 非阻塞的 sleep(不會凍結整個執行緒)
- 為未來的非同步擴充做準備
6. std::sync::Mutex vs tokio::sync::Mutex
關鍵差異:
| 特性 |
std::sync::Mutex |
tokio::sync::Mutex |
| 等待鎖時 |
阻塞整個執行緒 |
讓出執行權給其他 task |
| 使用方式 |
mutex.lock().unwrap() |
mutex.lock().await |
| 適用場景 |
短時間持鎖、同步操作 |
長時間持鎖、跨 await |
| 效能 |
更快(作業系統級) |
稍慢(需協調排程) |
目前專案的正確選擇:
pub struct Storage {
conn: Arc<Mutex<Connection>>, // ✅ std::sync::Mutex
}
pub fn get_next_pending(&self) -> Result<Option<Job>, StorageError> {
let conn = self.conn.lock().unwrap();
// 執行快速的同步資料庫操作
// 沒有在持鎖期間 await
Ok(job)
}
何時必須使用 tokio::sync::Mutex:
// ❌ 錯誤:在持有 std::sync::Mutex 時 await
let guard = std_mutex.lock().unwrap();
async_operation().await; // 會阻塞整個執行緒!
// ✅ 正確:使用 tokio::sync::Mutex
let guard = tokio_mutex.lock().await;
async_operation().await; // OK,會讓出執行權
並發安全與資料競爭
發現的競爭條件問題
原始設計的漏洞:
// ❌ 有競爭條件的程式碼
pub fn get_next_pending(&self) -> Result<Option<Job>, StorageError> {
// 步驟 1:讀取待處理任務
let job = SELECT ... WHERE status = Pending LIMIT 1;
// ⚠️ 問題:Worker 2 可能在這裡也讀取到相同任務!
Ok(job)
}
// Worker 處理邏輯
let job = storage.get_next_pending()?; // 取得任務
job.mark_running(); // 標記為執行中
storage.update(&job)?; // 更新資料庫
時序圖展示問題:
| 時間 |
Worker 1 |
Worker 2 |
資料庫狀態 |
| T1 |
get_next_pending() |
|
Job A: Pending |
| T2 |
讀取 Job A |
|
|
| T3 |
|
get_next_pending() |
Job A: Pending |
| T4 |
|
讀取 Job A (相同!) |
|
| T5 |
mark_running() |
|
|
| T6 |
update(Job A) |
|
Job A: Running |
| T7 |
|
mark_running() |
|
| T8 |
|
update(Job A) |
Job A: Running |
| T9 |
處理 Job A |
處理 Job A |
❌ 重複處理! |
解決方案:原子性操作
修正後的實作:
pub fn get_next_pending(&self) -> Result<Option<Job>, StorageError> {
let conn = self.conn.lock().unwrap();
// ✅ 原子性的 UPDATE + RETURNING(一個 SQL 語句完成)
let mut stmt = conn.prepare(
"UPDATE jobs
SET status = ?1, updated_at = ?2
WHERE id = (
SELECT id FROM jobs
WHERE status = ?3
ORDER BY priority DESC, created_at ASC
LIMIT 1
)
RETURNING id, payload, priority, status, retry_count, max_retries,
created_at, updated_at, error_message",
)?;
let now = Utc::now().to_rfc3339();
let job = stmt
.query_row(
params![JobStatus::Running as i32, now, JobStatus::Pending as i32],
|row| Ok(self.row_to_job(row)?),
)
.optional()?;
Ok(job)
}
為什麼這樣可以解決問題:
- 原子性保證:
UPDATE ... RETURNING 是單一 SQL 語句
- Mutex 保護: 同一時間只有一個 worker 能執行此查詢
- 狀態立即改變: 任務在被回傳前就已標記為 Running
- 資料庫層級鎖定: SQLite 的 EXCLUSIVE 鎖確保寫入安全
修正後的時序:
| 時間 |
Worker 1 |
Worker 2 |
資料庫狀態 |
| T1 |
取得 Mutex |
|
|
| T2 |
UPDATE Job A → Running |
等待 Mutex |
Job A: Running |
| T3 |
釋放 Mutex,回傳 Job A |
|
|
| T4 |
|
取得 Mutex |
|
| T5 |
|
UPDATE (找不到 Pending) |
Job A: Running |
| T6 |
|
回傳 None |
|
| T7 |
處理 Job A |
等待下一輪 |
✅ 無重複處理 |
Worker 程式碼簡化
因為 get_next_pending() 已經原子性地標記任務,worker 程式碼更簡潔:
// ✅ 修正後
match storage.get_next_pending() {
Ok(Some(mut job)) => {
// 任務已經是 Running 狀態,直接處理
match handler.handle(&job.payload) {
Ok(()) => job.mark_completed(),
Err(e) => job.mark_failed(e),
}
storage.update(&job)?;
}
Ok(None) => {
sleep(poll_interval).await; // 無任務,等待
}
Err(e) => { /* 處理錯誤 */ }
}
日誌與除錯
tracing vs println!
何時使用 println!:
// ✅ 使用者導向的輸出
println!("Job ID: {}", job.id);
println!("Status: {}", job.status);
何時使用 tracing:
// ✅ 操作性日誌、除錯資訊
tracing::info!(job_id = %job.id, "Processing job");
tracing::error!("Database error: {}", e);
在 CLI 工具中:
producer 使用 println! 顯示命令結果(使用者期望的輸出)
worker 使用 tracing 記錄操作日誌(方便監控和除錯)
初始化 tracing
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 設定日誌格式與過濾級別
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
// ...
}
使用方式:
# 預設 INFO 級別
./worker
# 設定為 DEBUG 級別
RUST_LOG=debug ./worker
資料持久化設計
SQLite Schema
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
payload BLOB NOT NULL,
priority INTEGER NOT NULL,
status INTEGER NOT NULL,
retry_count INTEGER NOT NULL,
max_retries INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
error_message TEXT
);
CREATE INDEX idx_status_priority
ON jobs(status, priority DESC, created_at ASC);
索引設計考量:
status 在前:快速過濾待處理任務
priority DESC:高優先順序優先
created_at ASC:相同優先順序時,先進先出 (FIFO)
狀態轉換
┌─────────────┐
│ Pending │
└──────┬──────┘
│
↓
┌──────────────┐
│ Running │
└──────┬───────┘
│
┌───────┴────────┐
│ │
↓ (成功) ↓ (失敗)
┌───────────┐ ┌─────────┐
│ Completed │ │ Failed │
└───────────┘ └────┬────┘
│
┌───────┴────────┐
│ │
↓ (可重試) ↓ (超過最大重試)
[Pending] ┌──────────────┐
(retry_count++) │ DeadLetter │
└──────────────┘
失敗處理:
pub fn mark_failed(&mut self, error: String) {
self.status = if self.can_retry() {
self.retry_count += 1;
JobStatus::Pending // 重新排程
} else {
JobStatus::DeadLetter // 移至死信佇列
};
self.error_message = Some(error);
self.updated_at = Utc::now();
}
CLI 設計
Producer(生產者)
# 提交任務
producer submit -p "Hello, World!" -r high -m 3
# 查詢狀態
producer status --job-id <uuid>
# 統計資料
producer stats
使用 Clap 實作:
#[derive(Parser)]
#[command(name = "producer")]
#[command(about = "Job queue producer - submit and manage jobs")]
struct Cli {
#[arg(short, long, default_value = "jobs.db")]
database: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Submit {
#[arg(short, long)]
payload: String,
#[arg(short = 'r', long, default_value = "normal")]
priority: String,
#[arg(short, long, default_value = "3")]
max_retries: u32,
},
Status { /* ... */ },
Stats,
}
Worker(消費者)
# 啟動 4 個 workers
worker -w 4
# 使用不同的資料庫
worker --database /path/to/jobs.db --workers 8
效能考量
輪詢 vs 事件驅動
目前實作(輪詢):
loop {
match storage.get_next_pending() {
Ok(Some(job)) => { /* 處理 */ }
Ok(None) => sleep(poll_interval).await, // 等待 1 秒
Err(e) => { /* 錯誤處理 */ }
}
}
優點:
- ✅ 實作簡單
- ✅ 可靠性高(無需額外機制)
- ✅ 易於理解和維護
缺點:
- ❌ 延遲:最多
poll_interval 的延遲
- ❌ 資源浪費:無任務時仍在輪詢
改進方案(事件驅動):
可以使用 tokio::sync::Notify 或資料庫的 NOTIFY/LISTEN:
// 生產者插入任務後通知
notifier.notify_waiters();
// Worker 等待通知
notifier.notified().await;
指數退避(Exponential Backoff)
if job.retry_count > 0 {
let backoff = Duration::from_secs(2_u64.pow(job.retry_count.min(5)));
sleep(backoff).await;
}
退避時間:
- 第 1 次重試:2^1 = 2 秒
- 第 2 次重試:2^2 = 4 秒
- 第 3 次重試:2^3 = 8 秒
- 第 4 次重試:2^4 = 16 秒
- 第 5 次以上:2^5 = 32 秒(最大值)
目的: 避免快速重試造成系統負擔,給下游服務恢復時間。
最佳實踐總結
1. 模組設計
- 使用私有模組 + 公開重匯出控制 API
- 清晰的關注點分離
2. 型別安全
- 考慮使用 newtype 模式增加型別安全
- 平衡抽象與簡潔性
3. 並發處理
- 識別臨界區(critical section)
- 使用原子操作避免競爭條件
- 選擇適當的同步原語(Mutex、RwLock 等)
4. 錯誤處理
- 使用
thiserror 定義清晰的錯誤類型
- 區分可恢復與不可恢復的錯誤
5. 日誌策略
- CLI 輸出用
println!
- 操作日誌用
tracing
- 使用環境變數控制日誌級別
6. 非同步選擇
- 評估是否真的需要 async/await
- 混合使用同步與非同步程式碼時要小心
- 理解
std::sync 與 tokio::sync 的差異
擴充方向
-
真正的非同步化:
- 使用
sqlx 或 tokio-rusqlite
- 非同步的
JobHandler trait
-
分散式支援:
- 使用 PostgreSQL 的
SELECT FOR UPDATE SKIP LOCKED
- Redis 作為訊息佇列
-
監控與觀測:
-
進階功能:
- 延遲任務(scheduled jobs)
- 任務優先順序調整
- 動態 worker 擴縮容
結論
這個專案展示了如何使用 Rust 構建可靠的系統軟體。關鍵要點:
- 型別系統讓我們在編譯期捕獲許多錯誤
- 所有權模型確保記憶體安全與執行緒安全
- Trait 系統提供零成本抽象
- 並發原語幫助我們正確處理競爭條件
Rust 的「如果編譯通過,通常就能正確運行」的特性,讓我們能夠自信地構建複雜的並發系統。