如何用Rust开发一个FinClip小程序沙箱SDK原生扩展

363 阅读17分钟

不懂移动端开发,也可以贡献移动端代码。

无限增强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<String, FinClipCall> {
    let mut map: HashMap<String, FinClipCall> = 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<String, FinClipCall> {
    let mut map: HashMap<String, FinClipCall> = 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<String, FinClipCall>) {
    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 <FinApplet/FinApplet.h>
#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<UISceneSession *> *)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<String, FinClipCall>'

仅此而已。那么这个库是怎么被注入到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扩展,最终供小程序开发者使用。

本文的示范代码在 github.com/kornhill/fi… 。