Simply Patrick

Rust FFI 完整指南:從手寫綁定到 AI 輔助的 Safe Wrapper

Rust 的 FFI(Foreign Function Interface)讓我們可以呼叫 C 函式庫,但正確實作並不容易。手寫綁定容易出錯,維護成本高;bindgen 自動產生綁定但仍需要 unsafe;safe wrapper 提供安全的 API 但設計繁瑣。

本文以綁定 FFmpeg 的 libavformat 為例,完整介紹:

  1. 三種 FFI 方式的優缺點比較
  2. 手寫綁定的常見陷阱:結構體大小、欄位順序、對齊、生命週期…
  3. Bindgen 的工作原理:如何用 libclang 解析 C 標頭檔
  4. Safe Wrapper 設計原則:RAII、類型狀態、錯誤處理、thiserror
  5. 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 慣例,可以用 filtermap 等方法鏈。

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 輔助

  1. 模式明確:bindgen 設定、RAII wrapper、錯誤處理都有標準模式
  2. 正確性可驗證:能編譯、能執行、測試通過
  3. 重複性高:每個函式的 wrapper 結構類似
  4. 文件密集:需要大量的 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


用 Rust 和 WebAssembly 打造即時 Markdown 編輯器

這個專案是我「52 個 Rust 專案」學習計畫的一部分。在完成了 16 個專案後,我發現 WebAssembly 是一個重要的學習缺口,於是決定用 Rust 來打造一個即時 Markdown 編輯器。

專案原始碼:wasm-markdown-editor

為什麼選擇這個專案?

在分析了之前完成的專案後,我發現幾個學習上的空白:

  • 已掌握的領域:Async/await、網路程式設計(TCP/UDP/HTTP/WebSockets)、解析器、CLI 工具、錯誤處理
  • 待加強的領域:資料庫 ORM、程序宏、FFI、WebAssembly、進階測試、GUI/圖形

選擇 Markdown 編輯器的原因:

  1. 填補關鍵缺口:之前沒有任何 WASM 專案
  2. 善用既有技能:運用之前在計算機、shell、EBML 等專案中學到的解析技巧
  3. 互動性強:能立即看到成果,滿足感高
  4. 實用價值:這是真正能用的工具,不只是 demo
  5. 現代技術棧: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. 釋放記憶體

字串傳遞流程

  1. JS 字串寫入 WASM 線性記憶體
  2. 傳遞指標和長度給 Rust 函式
  3. Rust 處理資料
  4. Rust 將結果寫入記憶體
  5. 回傳結果的指標給 JS
  6. JS 從記憶體讀取結果字串
  7. 釋放不再需要的記憶體

型別轉換對照表

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 特定技能

  1. ✅ 理解 cdylib crate 類型及其作用
  2. ✅ 使用 #[wasm_bindgen] 屬性匯出給 JS
  3. ✅ 管理 Rust/JavaScript 邊界的記憶體
  4. ✅ 型別轉換(簡單型別 vs. 複雜結構)
  5. ✅ WASM 模組初始化模式
  6. ✅ 在瀏覽器 DevTools 中除錯 WASM
  7. ✅ 套件大小優化技術
  8. ✅ 建置工具(wasm-packwasm-bindgen

什麼時候該用 WASM?

適合的場景

  • ✅ 計算密集型操作(解析、圖像處理)
  • ✅ 想重用現有的 Rust 函式庫
  • ✅ 效能關鍵路徑
  • ✅ 套件大小可接受時

不太適合的場景

  • ❌ 大量 DOM 操作(用 JS)
  • ❌ 微小的工具函式(開銷不值得)
  • ❌ 簡單的 CRUD 操作
  • ❌ 套件大小是關鍵考量時

與純 JavaScript 方案的比較

效能

  • Markdown 解析:比 JS 替代方案快約 2-3 倍
  • 打字時沒有 GC 暫停
  • 可預測的記憶體使用

套件大小

  • 與流行的 JS 函式庫相當或更小
  • markdown-it.js:約 320 KB
  • 我們的方案:235 KB(包含解析器 + 統計)

反思

順利的部分

  1. 流暢的建置過程:wasm-pack「直接就能用」
  2. 優秀的文件:Rust WASM book 非常有價值
  3. 型別安全:編譯時就能捕捉錯誤
  4. 效能:解析明顯流暢
  5. 工具鏈:自動產生的 TypeScript 定義很有幫助

克服的挑戰

  1. 路徑解析:404 錯誤需要理解 WASM 服務方式
  2. 記憶體模型:理解線性記憶體花了一些時間
  3. 型別轉換:學習何時用 JsValue vs. 簡單型別
  4. 非同步初始化:理解 init() 的必要性

驚喜的發現

  1. 套件大小:比預期小(222 KB 含完整解析器!)
  2. 建置速度:增量建置很快(8 秒)
  3. 瀏覽器支援:所有現代瀏覽器都能用,不需要 polyfill
  4. 開發體驗:在 DevTools 中除錯 WASM 相當不錯

結論

這個專案成功填補了我 rust-52-projects 學習旅程中的一個主要缺口。它證明了 Rust + WASM 已經可以用於生產環境的互動式 Web 應用程式,尤其是像解析這樣的計算密集型任務。

Rust 的效能和安全性與 JavaScript 的普及性結合,創造了一個強大的開發模式。工具鏈(wasm-packwasm-bindgen)已經成熟到體驗流暢且高效的程度。

這是一個很好的「第一個 WASM 專案」,在學習核心概念的同時建構出真正實用的東西。

參考資源


我的 VSCode Peacock 配色收藏

什麼是 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 實作一個生產級的非同步任務佇列系統。這個專案展示了多個重要的 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&lt;(), String&gt;;
}

設計考量:

  • Send + Sync:確保可以在執行緒間安全傳遞和共享
  • 接受 &[u8] 而非具體類型:保持通用性,支援任何序列化格式
  • 回傳 Result<(), String>:簡單的錯誤處理

使用範例:

struct EchoHandler;

impl JobHandler for EchoHandler {
    fn handle(&self, payload: &[u8]) -> Result&lt;(), String&gt; {
        let message = String::from_utf8_lossy(payload);
        println!("處理任務:{}", message);
        Ok(())
    }
}

3. Newtype 模式的應用場景

討論: 應該使用 Vec<u8> 還是 JobPayload 新類型?

目前實作(Vec<u8>):

pub struct Job {
    pub payload: Vec&lt;u8&gt;,
    // ...
}

Newtype 模式:

pub struct JobPayload(Vec&lt;u8&gt;);

impl JobPayload {
    pub fn new(bytes: Vec&lt;u8&gt;) -> Self {
        Self(bytes)
    }
    
    pub fn as_bytes(&self) -> &[u8] {
        &self.0
    }
}

impl From&lt;String&gt; 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&lt;'_&gt;) -> 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 在專案中的使用:

  1. #[tokio::main] - 設定非同步執行環境
#[tokio::main]
async fn main() -> Result&lt;(), Box&lt;dyn std::error::Error&gt;&gt; {
    // ...
}
  1. 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);
}
  1. 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&lt;Mutex&lt;Connection&gt;&gt;,  // ✅ std::sync::Mutex
}

pub fn get_next_pending(&self) -&gt; Result&lt;Option&lt;Job&gt;, StorageError&gt; {
    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) -&gt; Result&lt;Option&lt;Job&gt;, StorageError&gt; {
    // 步驟 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) -&gt; Result&lt;Option&lt;Job&gt;, StorageError&gt; {
    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)
}

為什麼這樣可以解決問題:

  1. 原子性保證: UPDATE ... RETURNING 是單一 SQL 語句
  2. Mutex 保護: 同一時間只有一個 worker 能執行此查詢
  3. 狀態立即改變: 任務在被回傳前就已標記為 Running
  4. 資料庫層級鎖定: 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() -&gt; Result&lt;(), Box&lt;dyn std::error::Error&gt;&gt; {
    // 設定日誌格式與過濾級別
    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 &lt;uuid&gt;

# 統計資料
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::synctokio::sync 的差異

擴充方向

  1. 真正的非同步化:

    • 使用 sqlxtokio-rusqlite
    • 非同步的 JobHandler trait
  2. 分散式支援:

    • 使用 PostgreSQL 的 SELECT FOR UPDATE SKIP LOCKED
    • Redis 作為訊息佇列
  3. 監控與觀測:

    • Prometheus metrics
    • 分散式追蹤
  4. 進階功能:

    • 延遲任務(scheduled jobs)
    • 任務優先順序調整
    • 動態 worker 擴縮容

結論

這個專案展示了如何使用 Rust 構建可靠的系統軟體。關鍵要點:

  • 型別系統讓我們在編譯期捕獲許多錯誤
  • 所有權模型確保記憶體安全與執行緒安全
  • Trait 系統提供零成本抽象
  • 並發原語幫助我們正確處理競爭條件

Rust 的「如果編譯通過,通常就能正確運行」的特性,讓我們能夠自信地構建複雜的並發系統。


The future of AI coding

這兩篇文章確實形成了一個有趣且互補的觀點組合:

核心互補性

Jason Gorman 的文章(Codemanship)強調:

  • AI 不會取代程式設計師
  • 程式設計的難點在於將人類思維轉化為精確的計算思維
  • LLM 只是模式匹配,不真正理解代碼

Steve Krenzel 的文章(Bits of Logic)則說明:

  • AI 代理需要高品質的代碼環境才能有效工作
  • 良好的實踐(測試、類型、文檔)從「可選」變成「必需」
  • AI 會「放大代碼庫最糟糕的傾向」

三個層次的互補

1. 戰略層面的一致性

兩位作者都認同:人類程式設計師仍然是核心

  • Gorman:「當事情重要時,軟體開發人員會掌舵」
  • Krenzel:「代理只有在你為它們創造的環境中才有效」

這不是「AI vs 人類」的零和遊戲,而是人類定義規則,AI 在規則內執行

2. 技術層面的呼應

Gorman 指出 LLM 的根本限制:

  • 不理解代碼,只做統計預測
  • 相同提示產生不同結果
  • 需要人類驗證輸出

Krenzel 的解決方案正好針對這些限制:

  • 100% 代碼覆蓋率:強制 AI 用可執行範例證明每一行
  • 強類型系統:縮小 AI 的搜索空間,消除非法狀態
  • 快速反饋循環:保持 AI「在短繩上」,小改動→檢查→修復

這實際上是在說:既然 AI 不理解代碼,我們就用機械化的護欄來約束它

3. 實踐層面的轉化

Gorman 的悲觀預測:

「LLM 實際上讓大多數團隊變慢,軟體更不可靠、更難維護」

Krenzel 提供了避免這種情況的路徑:

  • 不是讓 AI 自由發揮,而是建立嚴格的自動化護欄
  • 不是期待 AI 理解業務邏輯,而是通過類型和測試外化邏輯
  • 不是一次性生成大量代碼,而是快速迭代、持續驗證

深層洞察:AI 作為「放大器」

兩篇文章共同揭示了一個關鍵真理:

AI 不是替代品,而是放大器

  • 如果你的代碼庫混亂、測試不足、類型鬆散 → AI 會放大這些問題(Gorman 的警告)
  • 如果你的代碼庫結構良好、測試完整、類型嚴格 → AI 會放大生產力(Krenzel 的承諾)

這解釋了為什麼:

  1. Gorman 看到的失敗案例:團隊試圖用 AI 彌補技術債,結果債務加倍
  2. Krenzel 看到的成功案例:團隊先支付「技術稅」,然後用 AI 收穫紅利

對實踐的啟示

這兩篇文章合起來給出了一個完整的策略:

短期(現在)

  1. 不要裁員程式設計師(Gorman 的建議)
  2. 投資於代碼品質基礎設施(Krenzel 的路線圖)
    • 建立 100% 測試覆蓋率
    • 遷移到強類型語言
    • 自動化所有可自動化的檢查

中期(1-3 年)

  1. 將 AI 視為「受監督的實習生」
    • 可以處理重複性任務
    • 需要明確的規則和即時反饋
    • 不能做架構決策

長期(3+ 年)

  1. 重新定義「程式設計師」的角色
    • 從「寫代碼」轉向「設計系統和護欄」
    • 從「實現功能」轉向「定義不變量和約束」
    • 更像「系統架構師 + 品質工程師」的混合體

一個有趣的悖論

Krenzel 的文章標題是「AI 正在迫使我們寫好代碼」,但實際上:

AI 沒有迫使任何人做任何事

真正發生的是:

  • 那些已經想寫好代碼的團隊,發現 AI 給了他們一個絕佳的理由去說服管理層投資
  • 那些不想投資品質的團隊,會發現 AI 讓情況變得更糟

這與 Gorman 的觀察完全一致:AI 不會改變基本面,只會加速既有趨勢

結論

這兩篇文章不是矛盾的,而是同一枚硬幣的兩面:

  • Gorman 告訴我們「為什麼」:為什麼 AI 不會取代程式設計師(因為難點不在語法)
  • Krenzel 告訴我們「如何」:如何讓 AI 成為有效的工具(通過建立嚴格的環境)

合在一起,它們描繪了一個清晰的未來:更多的程式設計師,寫更好的代碼,用 AI 處理繁瑣的部分

但這個未來不會自動到來——它需要有意識的投資和紀律。那些理解這一點的團隊會繁榮,那些不理解的會掙扎。

您的團隊目前在這個光譜的哪個位置?您認為哪些「技術稅」最值得優先支付?