Trang chủ

Gọi Rust từ Swift

Tôi có một dự án mà logic nghiệp vụ sống trong Rust còn ứng dụng iOS là một lớp Swift mỏng đặt lên trên. Động cơ là chia sẻ logic ấy với một máy chủ và một CLI, và ranh giới giữa hai ngôn ngữ thì nhỏ hơn tôi tưởng.

C ABI là cây cầu

Không ngôn ngữ nào nói được ABI của ngôn ngữ kia, nhưng cả hai đều nói được C. Rust phơi ra các hàm tương thích C, Swift nhập chúng vào qua một file header.

// src/lib.rs
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

#[unsafe(no_mangle)]
pub extern "C" fn parse_document(input: *const c_char) -> *mut c_char {
    let input = unsafe {
        match CStr::from_ptr(input).to_str() {
            Ok(string) => string,
            Err(_) => return std::ptr::null_mut(),
        }
    };

    let result = core::parse(input);

    match CString::new(result) {
        Ok(string) => string.into_raw(),
        Err(_) => std::ptr::null_mut(),
    }
}

#[unsafe(no_mangle)]
pub extern "C" fn free_string(pointer: *mut c_char) {
    if pointer.is_null() { return; }
    unsafe { drop(CString::from_raw(pointer)); }
}

extern "C" cho nó quy ước gọi hàm của C, no_mangle giữ nguyên tên ký hiệu, còn CString::into_raw trao quyền sở hữu vùng cấp phát cho bên gọi.

Quy tắc về bộ nhớ

Bên nào cấp phát thì bên đó phải giải phóng. Bộ cấp phát của Rust và của Swift là khác nhau, và gọi free() lên một CString của Rust là hành vi không xác định mà thường vẫn chạy được một thời gian.

Đó là lý do free_string tồn tại, và là lý do mọi hàm Rust trả về con trỏ đều cần một hàm giải phóng đi kèm. Ở phía Swift, cái lớp bọc sở hữu kỷ luật đó:

func parseDocument(_ input: String) -> String? {
    input.withCString { pointer in
        guard let result = parse_document(pointer) else { return nil }
        defer { free_string(result) }
        return String(cString: result)
    }
}

withCString lo vòng đời của đầu vào; defer lo vòng đời của đầu ra. Cái hàm năm dòng đó là toàn bộ khuôn mẫu, lặp lại cho mỗi lời gọi.

Cảnh báo

Một con trỏ từ withCString chỉ hợp lệ bên trong closure. Lưu nó lại rồi dùng về sau là một con trỏ treo lơ lửng, và tình trạng hỏng bộ nhớ sinh ra thì lúc có lúc không — loại bug tệ nhất để thừa kế. Hãy làm việc ngay bên trong closure, luôn luôn.

Build nó thành một xcframework

rustup target add aarch64-apple-ios aarch64-apple-ios-sim

cargo build --release --target aarch64-apple-ios
cargo build --release --target aarch64-apple-ios-sim

xcodebuild -create-xcframework \
  -library target/aarch64-apple-ios/release/libcore.a \
  -headers include/ \
  -library target/aarch64-apple-ios-sim/release/libcore.a \
  -headers include/ \
  -output Core.xcframework

Với crate-type = ["staticlib"] trong Cargo.toml. Liên kết tĩnh là đúng ở đây — một binary duy nhất, không nạp động lúc khởi động.

Hãy sinh file header bằng cbindgen thay vì viết tay; một chỗ lệch giữa header và chữ ký bên Rust sẽ biên dịch ngon lành rồi làm hỏng bộ nhớ lúc chạy.

Giữ ranh giới thật hẹp

Sai lầm đầu tiên của tôi là phơi ra một API phong phú — nhiều hàm, nhiều kiểu con trỏ, các struct vượt qua ranh giới. Mỗi cái là một đoạn code không an toàn phải làm cho đúng.

Phiên bản chạy được thì truyền các chuỗi JSON qua một nhúm hàm:

let response = core.call(command: "parse", payload: jsonRequest)

Việc tuần tự hóa có tốn kém chút ít và nó mua về sự an toàn kiểu ở cả hai phía — Codable bên Swift, serde bên Rust — mà không phải khớp cách bố trí struct bằng tay. Với một ranh giới bị vượt qua hàng trăm lần mỗi giây thì cuộc đổi chác đó là sai; với một ranh giới bị vượt vài lần cho mỗi hành động của người dùng thì nó miễn phí.

Hai hàm thay vì bốn mươi cũng là hai hàm đáng để đi review phần unsafe.

Lỗi

Panic không được phép vượt qua ranh giới — việc tháo ngăn xếp chạy vào Swift là hành vi không xác định. Hãy bắt chúng lại:

#[unsafe(no_mangle)]
pub extern "C" fn parse_document(input: *const c_char) -> *mut c_char {
    let result = std::panic::catch_unwind(|| {
        // …
    });

    match result {
        Ok(value) => value,
        Err(_) => std::ptr::null_mut(),
    }
}

Và hãy đặt panic = "abort" trong profile release nếu bạn thà sập một cách có chủ đích còn hơn chạy tiếp trong một trạng thái không xác định. Cách nào cũng bảo vệ được; để một cú panic tháo ngăn xếp xuyên qua FFI thì không.

Nó có đáng không

Có, với dự án này. Bộ phân tích cú pháp và bộ máy quy tắc là khoảng 8.000 dòng Rust dùng chung giữa một ứng dụng iOS, một máy chủ và một CLI. Viết nó ba lần, hoặc bảo trì ba bản cài đặt buộc phải nhất quán với nhau, đã tệ hơn nhiều.

Không, với một ứng dụng bình thường. Nếu đoạn code chỉ bao giờ chạy trên iOS thì đây là thêm một hệ thống build, một bộ công cụ, một ranh giới FFI và một nhóm lỗi bộ nhớ mà trước đó bạn không có. Swift là một ngôn ngữ tốt và là lựa chọn mặc định đúng đắn.

Phép thử: cái logic này có thật sự sắp chạy ở nơi mà Swift sẽ không chạy được không? Nếu có thì ranh giới đó đáng dựng. Nếu nó chỉ là nguyện vọng — “có thể một ngày nào đó chúng ta sẽ làm bản web” — hãy viết nó bằng Swift rồi chuyển sang sau nếu cái ngày đó tới.