如何用 Rust 開發一個 FinClip 小程式沙箱 SDK 原生擴充套件

語言: CN / TW / HK

如何用 Rust 開發一個 FinClip 小程式沙箱 SDK 原生擴充套件

無限增強FinClip小程式安全執行沙箱

FinClip小程式安全執行沙箱,以SDK的方式供App開發者嵌入,讓自己的App秒變能執行小程式的超級App。在這裡,“App”還不僅僅是指iOS或者Android的應用,宿主可以是強大至配備多核CPU和較多記憶體的PC,也可以是運算能力比較有限的嵌入式裝置(embedded devices),例如一個帶觸控式螢幕的Raspberry PI。

宿主硬體環境和軟體環境都各有不同,需要暴露給小程式利用的功能不一樣;此外,不同的商業環境下,應用所整合的原生能力(例如地圖、支付、加密、音影片、甚至AR/XR等)也不同。FinClip支援開發者自定義各種API介面,注入至FinClip SDK中,從而以JavaScript的方式供小程式開發者呼叫。

通過這種方式,任何小程式理論上可利用所在宿主環境的任何技術能力,而不僅受限於FinClip SDK所提供的標準介面。

官方支援的自定義介面擴充套件方式

當前FinClip官方支援的自定義介面,需要用所在宿主環境的原生技術實現,例如在iOS上需要用Objective-C或Swift開發,在Android上則是採用Java/Kotlin等。這個做法就需要起碼兩個平臺的工程師分別開發 - 當然過去以來這也不是問題,任何手機端App都不得不維持兩隊人馬搞兩個版本。但是當你有更多型別的終端要支援的時候,就很麻煩了。

正如在《FinClip小程式+Rust》這個系列所提到,對於純邏輯類、演算法類的功能例如音影片編碼的處理、加解密等等,完全沒有人機互動的部分,採用一個跨平臺的通用語言來實現,更加便利。但是我們又都不想去折騰C的程式碼,Rust是一個很好的選擇。作為一種新興的、記憶體安全、執行緒安全的語言,Rust可跨平臺編譯,高效能、體積小,尤其適合於裝置端的程式設計,包括在低算力、低功耗、低記憶體的IoT裝置上開發Heapless程式碼;並且,Rust已經是Android官方支援的系統語言。

那麼,FinClip能否支援開發者用Rust提供對其安全執行沙箱的自定義擴充套件呢?

先看一下FinClip環境下的技能分工與協同

回答上述問題前,在這裡我們先想象一下,假如在一個端到端的應用軟體生產鏈路上,以FinClip技術為各環節的“粘合劑”,一個新型的技術小組的角色(技能)組成是怎樣的:

  • iOS/Android/其他終端的“宿主”開發工程師:負責宿主“殼”應用的開發,以及FinClip SDK整合。需要懂ObjC/Swift、Java/Kotlin、乃至Electron/Qt/C++,視乎所要支援的目標作業系統
  • FinClip小程式開發工程師:負責各種業務功能的前端開發,懂HTML/JavaScript以及一些前端框架即可
  • FinClip SDK Extension開發工程師:負責實現一些通用的邏輯演算法、底層的基礎設施,供各目標終端的“宿主”應用整合。需要掌握Rust程式設計
  • DevOps工程師:如果打算自行執行維護自己的FinClip小程式中心、管理自己的小程式開發者和開發生態,那麼就需要工程師去部署執行FinClip伺服器端

這是一條起碼的“流水線”,各個崗位用不同的語言技能,分工越清晰越好,哪怕這些事情都是同一個人“包打天下”了,也需要自己明確在不同角色下做不同的事,有助於梳理出合理的架構,在一個端到端的技術鏈路上,界定好每一個環節的功能範疇。

Glue code和生態

FinClip這門技術,單純從軟體工程角度看,它扮演的是glue code(粘合劑)的角色,把一個涉及多種語言、多類技術的軟體系統粘合起來。Glue code往往是最繁瑣、也最容易出錯的地方,把這個層面的技術解決好了,能大大釋放開發者的生產力和創造力。相當於流水線搭建好了,每個環節都可以獨立潤滑、豐富、加強。而且每個環節都變得更加專業,甚至形成自己的零部件“供應鏈”。

例如,把HTML/JavaScript部分的技術,和裝置端原生技術對接好,就引入了大量的可以專注於小程式開發的工程師提供豐富的應用場景;把裝置端原生技術中跨裝置通用的邏輯解耦出來成為可插拔的“外掛”,又可以進一步促生僅聚焦這一部分工作的人,產出豐富、高品質的外掛。只要標準化,就有機會形成生態。

提供端到端的應用解決方案,變成是“集大成”,在技術鏈路各環節的“供應鏈”中,選取自己需要的零部件,去組裝自己的應用。

外掛開發者:用Rust實現FinClip SDK的“外掛”

正如FinClip小程式的開發者無需懂得任何iOS/ObjC/Swift、Android/Java/Kotlin的技能知識,僅憑對HTML/JavaScript的掌握即可開發出有用的應用一樣,FinClip SDK Extension的開發者,最好也無需瞭解太多作業系統平臺的程式設計知識,甚至無需跟ObjC、Java打交道,即可開發出自己的擴充套件。

我們在技術是可以做到的。按以下的步驟 - 注意下述內容都發生在Rust這側,對ObjC/Java空間的程式碼開發要求為零

準備構建一個靜態庫所需的環境

用cargo建立一個lib型別的專案,例如

cargo new --lib myplugin

然後修訂一下所生成的Cargo.toml:

``` [package] name = "myplugin" version = "0.1.0" edition = "2021"

[lib] name = "myplugin"

this is needed to build for iOS and Android.

crate-type = ["staticlib", "lib"]

[dependencies] serde_json = "1.0.81" # 建議使用這個crate實現json物件序列化

其他你準備封裝或者依賴的crate

```

定義準備注入至FinClip的API

在src/lib.rs,開始定義和封裝你計劃提供給原生宿主應用開發者注入至其FinClip SDK的函式。

首先,定義一個新的型別:

type FinClipCall = fn(&String) -> String;

這個型別名字請命名為'FinClipCall',且這個型別所表示的函式簽名,必須是:

fn(&String) -> String

它實際上是一個函式指標,它能夠指向這樣的函式,例如:

fn invoke(param: &String) -> String {...}

注意這個型別的函式,期望的輸入引數是一個合法的JSON字串,返回的也必須是一個合法的JSON字串。因為FinClip的自定義API,統一用JSON作為入參和出參,便於小程式側JavaScript程式碼的處理。所以在上文推薦引入serde_json這個crate,幫助做一些JSON相關的資料轉換。

其次,開始實現你的函式實現,例如:

``` fn api_drinker(input: &String) -> String {

// 先處理一下入參,把進來的字串檢測為合法JSON物件,
// 再用serde_json把它轉化為某個型別的引數物件,供後續使用
println!("invoked with parameter {}", input);

//中間的邏輯演算法從略,這裡應該是你自己的演算法,產生的結果物件,可以
//用serde_json進行Json serialization
let john = json!({
    "name": "john doe",
    "phones": "1234567"
});

john.to_string()

}

fn api_whisky(input: &String) -> String {

// 先處理一下入參,把進來的字串檢測為合法JSON物件,
// 再用serde_json把它轉化為某個型別的引數物件,供後續使用
println!("invoked with parameter {}", input);

//中間的邏輯演算法從略,這裡應該是你自己的演算法,產生的結果物件,可以
//用serde_json進行Json serialization
let brands = json!({
    "whisky": {
        "jack": "daniel",
        "johny": "walker",
        "henry": "Mckenna",
        "suntory": "toki"
    }
});

brands.to_string()

} ```

以上以此類推,按類似的簽名來實現你的API。

文字命名你的API名稱並造冊登記“花名”

FinClip小程式側,呼叫自定義API的辦法,是通過API的名字。例如你把一個API介面命名為'abc',那麼這個介面被注入到FinClip SDK後,它在JavaScript側的呼叫,就是'ft.abc(...)'。

在此,你需要給每一個自定義函式一個文字命名,並對映它們的關係,這確實有點像程式碼編譯器裡面的virtual table。這裡,是我們開發的這個myplugin專案中另一個需要注意一下的地方:

``` pub unsafe extern "C" fn myplugin_register_apis() -> *mut HashMap { let mut map: HashMap = HashMap::new(); map.insert("get_drinker".to_string(), api_drinker); map.insert("get_whisky".to_string(), api_whisky);

Box::into_raw(Box::new(map))

} ```

這個函式做了什麼事情呢?雖然只有幾行程式碼,有必要解釋一下:

  • 首先,我們初始化了一個HashMap,這個HashMap的Key和Value的型別,分別是String和之前我們自定義的函式指標型別FinClipCall
  • 其次,開始造“花名冊”,也就是直接粗暴的窮舉所有要輸出的函式,把它們塞到HashMap,完成造冊
  • 最後,是比較“技巧性”的地方,就是如何把這個HashMap物件返回出去。記得我們的這些函式,最終必須暴露給iOS、Android等平臺上的宿主應用,以便於這些應用的開發者,把這些函式注入到FinClip SDK,所以這裡是你的Rust程式碼和你的合作伙伴的ObjC或者Java/JNI程式碼的臨界點。此處我們用了一個辦法,就是把HashMap這個只存在於Rust側的collections型別(就像Java裡的collection classes只存在於Java一樣),包裝在一個類似C++的smart pointer這樣的Box裡,返回這一整個資料結構在記憶體裡的地址

至此,我們的工作基本上完成90%,是不是很簡單?

把提供“花名冊”的函式暴露給其他語言使用

到這一步為止,我們都是在Rust世界中折騰。但是最終這些成果必須被外界發現和使用。最後一步,就是把“花名冊”送到異構語言的世界中,我們需要利用Rust FFI(Foreign Function Interface)去讓Rust編譯器編譯上述程式碼時,生成C風格的程式碼庫,所以對上述函式還要做一點補充:

```

[no_mangle]

pub unsafe extern "C" fn myext_register_apis() -> *mut HashMap { let mut map: HashMap = HashMap::new(); map.insert("api_drinker".to_string(), api_drinker); map.insert("api_whisky".to_string(), api_whisky);

Box::into_raw(Box::new(map))

} ```

'no_mangle'告訴Rust編譯器,編譯時不要混淆或改變'myplugin_register_api()'這個函式名字,否則ObjC或者Java/JNI側就無法知道用什麼名字呼叫了。

注意上述函式的聲明裡,有'unsafe'和'extern "C"'的標識,extern好理解,就是標識這個函式是供異構語言以C函式呼叫的方式使用,'unsafe'涉及Rust關於什麼才是記憶體安全、執行緒安全的規則或者說思想,詳情讀者可自行了解。在此,主要是我們用到了'Box::into_raw'這個函式,即我們把一個只在Rust裡面才能解析的資料結構,通過一個原始指標把它丟到C側了,相當於這一片記憶體被異構語言下的程式碼“持有”,其記憶體安全不再受Rust的監控和保障,所以是不安全的。

這裡有一個問題,就是:既然Rust側的HashMap無法被C側解析,把這玩意兒的原始指標丟過去有什麼用呢?有用,因為它實際上相當於一個不透明指標,是一個由宿主應用側“持有”的handle,當宿主需要呼叫Rust的函式時,把這個handle傳回來就是了。

有去有回,記得防止記憶體洩漏

在Rust FFI,每一次產生的'into_raw'操作,最終都必須有一次對應的'from_raw'操作。前者把一片Rust管理的記憶體的控制權轉移出去了,後者是外部的異構語言下的程式碼必須把該記憶體的控制權還給Rust,否則記憶體洩漏就發生了。所以,最後我們還需要增加一個函式,供異構語言在使用完上面的東西后,記得通知Rust回收:

```

[no_mangle]

pub unsafe extern "C" fn myext_release(ptr: *mut HashMap) { if !ptr.is_null() { drop(Box::from_raw(ptr)); } } ```

注意,呼叫這個函式是宿主應用開發者的責任,所以必須在你的plugin的使用說明文件中向他們強調。這個比較醜陋但似乎沒有什麼好辦法,跨語言的呼叫總是有一些小不便。

Re-cap:用Rust開發一個FinClip SDK擴充套件的步驟

實際上是非常簡單和自由的,沒有什麼特殊庫或者協議需要去繼承實現,就是“徒手”寫一個Rust lib,只要它包含以下要素:

  • 準備提供給FinClip小程式呼叫的函式,以JSON字串為入參和出參。函式遵循'fn(&String)->String'的簽名。這些函式多少個都行,叫什麼名字也無妨,自由選擇
  • 給上面這些函式造一個“花名冊”,花名冊的資料結構必須是以HashMap去儲存“名字”->“函式指標”的對映關係,其中“名字”是你打算讓外面的世界知道和使用的函式名(字串),函式指標則是指向上述函式簽名的型別
  • 產生“花名冊”的函式,需要使用Rust FFI,也就是標記'no_mangle',以及宣告為unsafe。“花名冊”的資料結構(HashMap),包在一個不透明指標(opaque pointer)裡,丟出去給異構語言程式碼(也就是準備使用這個plugin的宿主)持有備用。這個返回“花名冊”的函式,名字叫什麼也無法,你只需要在自己的說明文件裡註明(說明文件你總得有吧?)
  • 提供一個釋放“花名冊”資料結構記憶體的函式,同樣的,函式名字隨意,告訴宿主開發者是什麼即可

交付物是一個靜態庫

正如《FinClip小程式+Rust》這個系列裡所介紹過,Rust程式碼需要進行跨平臺編譯,構建出aarch64-apple-ios、x86_64-apple-ios以及Android相關的目標架構下的二進位制庫。例如生成適合在ios simulator和ios裝置上執行的universal library:

cbindgen src/lib.rs -l c > myplugin.h cargo lipo --release

最終你交付給宿主應用開發者的內容應該包括:

  • 一個'libmyplugin.a'檔案、一個'myplugin.h'的標頭檔案

  • 一個使用說明,包括:

    • 如何獲得你所提供的API的花名錄,例如你提供了一個函式"myplugin_register_apis"
    • 如何通知你釋放花名錄記憶體,例如你提供了一個函式"myplugin_release"
    • 你的API花名錄中,每一個API的“花名”,以及該API期望的JSON入參,返回的JSON出參

至此,作為一個Rust開發者提供FinClip SDK Extension的使命完成。再次明確,'myplugin'的標頭檔案名字、建立花名錄資料結構和釋放花名錄記憶體的函式名字,都是開發者自由決定。

App開發者:如何使用Rust的外掛

接下來,就輪到宿主應用的開發者怎麼使用了。宿主應用,就是執行在iOS、Android或者其他裝置端的應用軟體,它嵌入了FinClip SDK從而獲得執行小程式能力。

編寫程式碼僅需加三行

整合FinClip SDK詳見官網,《FinClip小程式+Rust》也有介紹,不在此贅述。以下以iOS App的整合上述myplugin為例,在AppDelegate.m增加三行程式碼(下面有註解的三行):

```

import "AppDelegate.h"

import

import "FinClipExt.h" //引入一個特殊的庫支援Rust擴充套件

import "myplugin.h" // 引入要安裝供FinClip小程式開發者使用的SDK extension

@interface AppDelegate ()

@end

@implementation AppDelegate

  • (BOOL)application:(UIApplication )application didFinishLaunchingWithOptions:(NSDictionary )launchOptions {

    NSString appKey = @"22LyZEib0gLTQdU3MUauARgvo5OK1UkzIY2eR+LFy28NAKxKlxHnzqdyifD+rGyG"; FATConfig config = [FATConfig configWithAppSecret:@"8fe39ccd4c9862ae" appKey:appKey]; config.apiServer = @"http://127.0.0.1:8000"; [[FATClient sharedClient] initWithConfig:config error:nil]; [[FATClient sharedClient] setEnableLog:NO];

    // 安裝 myplugin 到在這裡初始化的FinClip SDK中 [[FinClipExt singleton] installFor:[FATClient sharedClient] withExt :myplugin_register_apis()];

    return YES; }

pragma mark - UISceneSession lifecycle

  • (UISceneConfiguration )application:(UIApplication )application configurationForConnectingSceneSession:(UISceneSession )connectingSceneSession options:(UISceneConnectionOptions )options { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role]; }

  • (void)application:(UIApplication )application didDiscardSceneSessions:(NSSet )sceneSessions { // Called when the user discards a scene session. // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. }

@end ```

上面的程式碼,AppDelegate.m,在用xcode建立ObjC專案時自動生成,我們在這裡初始化了FinClip SDK(詳情見官網,或《FinClip小程式+Rust》系列。在此基礎上,再安裝myplugin,程式碼非常簡單:

  • 引用myplugin.h
  • 呼叫 FinClipExt 的 installFor 方法,入參為初始化的FinClip SDK handle,以及 myplugin的API“花名錄”物件(由myplugin_register_apis產生)
  • 在宿主應用的生命週期中,找合適的階段(例如應用退出)釋放“花名錄”記憶體(由myplugin_release提供)

這裡的'FinClipExt',負責了把Rust API轉換成ObjC方法再注入到FinClip SDK中。

編譯構建需要連結靜態庫

myplugin這個庫,從上面我們已經可以看到,沒有使用任何與FinClip直接相關的特殊的庫,唯一的約束,就是兩個規範:

  • 自定義一個叫'FinClipCall'的函式指標型別,函式簽名必須是 'fn(&String)->String'
  • 提供一個函式,能生成你計劃提供給FinClip小程式開發者使用的自定義API的“花名錄”,它的資料結構,必須是'HashMap'

僅此而已。那麼這個庫是怎麼被注入到FinClip SDK並能被小程式呼叫的呢?魔術在於,宿主App的開發者,在其專案中引入一個叫libfincliprust.a的靜態庫(這個庫目前尚不是FinClip官方支援的標準工具,只是我個人的專案,且目前僅提供iOS版本。有興趣的朋友可以去優化,歡迎提供Android的版本,原始碼在GitHub上,由ObjC和Rust程式碼組成)。作為使用者,無需關注其中的實現,只要下載這個靜態庫,編譯構建App的時候指定依賴與連結它即可。

最後,當然也必須把所要安裝的myplugin的靜態庫, libmyplugin.a,引入專案,一同構建。

作為宿主應用開發者,引入一個叫myplugin的FinClip SDK Extension供FinClip小程式開發者呼叫的使命,也完成了。

小程式開發者:如何呼叫Rust介面

上述myplugin的“花名錄”裡,有兩個暴露給小程式的API,分別是'api_drinker'和'api_whisky'。這兩個用Rust寫出來的、以JSON字串為入參和出參的函式,經過libfincliprust.a的一些“魔術”操作,被轉換成ObjC的method,並被動態注入到FinClip SDK中。要使用這些API,FinClip小程式開發者需要在自己的小程式專案根目錄下編寫一個FinClipConf.js:

module.exports = { extApi:[ { name: 'get_drinker', sync: true, //同步api params: { //擴充套件api 的引數格式,可以只列必須的屬性 } }, { name: 'get_whisky', sync: true, params: { } } ] }

此後,在JavaScript中對這些API的呼叫,只需要通過'ft'物件即可進行,例如'ft.get_drinker'。

總結

在一個現代的軟體專案中,多語言混合程式設計是難以避免的 - 不同的語言在端到端技術鏈路上適合於解決不同環節的問題,但是也難免導致整合、融合的麻煩,往往是影響開發效率、引起諸多麻煩的。例如跨語言的轉接,涉及API介面的產生和資料結構在異構語言中反反覆覆的“翻譯”,寫glue code非常繁瑣。FinClip更平滑的解決了前端的異構技術對接問題,在本文中,進一步介紹了一個較為“透明”的方法,讓完全不熟悉JavaScript、不懂ObjC、不瞭解終端開發的工程師,能通過Rust這種強大的語言開發出邏輯通用的FinClip SDK擴充套件,最終供小程式開發者使用。

本文的示範程式碼在 https://github.com/kornhill/finclip-rust-ext-demo 。