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.