深度分析:前端中的后端-实现篇

2021-02-26 15:44:12 浏览数 (1)

当我有一个想法,并且这个想法很有意思,正好戳中我技能的盲区时,我便有一种强大的要将其实验一番的冲动。自从上周做一个「前端中的后端」的想法出炉后,这周我几乎寝食难安,随时随地都在想这件事,所以后来干脆撸起袖子开干,毕竟 Linus 大神告诫我们:

一旦开干,就有些搂不住了,每日正常工作开会带娃做饭之余,我几乎是 7-12-7 地将其一点点折腾出来,为了优化每一分时间,我甚至把哄小贝睡觉的时间从平均一个小时缩减到 25 分钟(诀窍是:唱摇篮曲的时候不断地假装打哈欠 —— 哈欠是会传染的)。

这种沉浸式的,集中精神全力以赴做一件事的感觉让我很快乐。在这个过程中,我第一次正式写 swift,就被迫在 DataUsafeRawBufferPoinerUnsafePointer<UInt8> 之间游蹿,不得不深入到 xcodebuild / swift package / xcframework 的细节去把一切东西自动化地在 CI 中完整地串联起来。当你真正深入去做一件事情的时候,你会发现,你的认知和实际情况相差很大 —— 比如:和我花在 swift package 上编译 static library 所花的巨大精力相比,在Rust 上构建 FFI 代码的过程简直就像闲庭信步,真是大大出乎了我的意料。

当我最终在 xcode 里测试通过 swift 和 rust 交互的整个流程,并且将其运行在 github action(使用 ubuntu 而不是 osx)做了一个相对完整的 CI 后,可想而知,我有多么兴奋:

更令人兴奋的是,在整个过程中,我学到了:

  1. 如何更好地定制化 prost build,让生成的 rust 的 protobuf 代码能够完美兼容不够严谨的 JSON 数据。
  2. 如何生成 rust 代码的 flamegraph,来更好地剖析代码中的低效的部分,然后结合 citerion 做 benchmark,来优化和提升代码运行的效率 —— 通过这个过程,我把一个不起眼的函数的效率提升了几乎一倍。
  1. 如何使用 Mozilla 提供的 ffi-support,让跨语言调用时即便 Rust 侧 panic,整个应用程序也不会崩溃。
  2. 如何更好地拆分 rust crate,让 unit test 变得更加简单。
  3. 如何更合理地使用 unsafe。这是我第一个真正使用 unsafe Rust 的项目。嗯,不少心得。
  4. 如何使用 tokio/future runtime,使其可以把任务从调用的线程(swift 线程)转交给一组 Rust 的线程,并通过 callback 返回。这个其实很简单的工作,由于我一开始思路错了,导致走了很多弯路。
  5. 如何写包含 unit test,formatter,linter 的严肃的 swift 代码(嗯,我之前为了学语言写过 playground 代码和 swift UI,但没有正经写过包含单元测试的 Swift 代码)。
  6. 如何使用 swift protobuf 和在 swift 上做 performance benchmark。
  7. 如何使用 swift package manager,以及如何在 xcode 里链接静态库。
  8. 如何把静态库打包成 xcframework(很遗憾,arm 的静态库目前还无法成功打包进去)。
  9. 如何优雅地撰写复杂的 Makefile。

这些学到的内容也许值得写好几篇文章,就看我有没有时间,以及有没有心情了。在做这个 POC 的时候,我纠结过,是用一套公开的 API 来撰写一个开源的 POC 项目,还是特定对于 Tubi 的业务做一个更贴近生产环境的闭源 POC 项目。几经思考之后,我决定还是做成一个闭源 POC 项目,因为这样可以更好地通过已有的业务来更好地评估「前端中的后端」这件事情的难度以及意义。等一切坑都趟平后,我会在做 quenya client 端代码自动生成时,将这个流程及代码生成结合起来,做一套通过 OpenAPI spec 生成 Rust 代码,用于 FFI 的 protobuf 定义,以及对应的 swift/kotlin/typescript 的 binding 的代码。这将是另外一个故事了。

好,废话不多说。我们来具体讲讲实现过程中我关于架构,设计,以及具体编码过程中的一些思考。我写的项目名字叫 olorin:olorin 是 Gandalf 的另外一个名字,就像 Gandalf 联合起护戒小分队一样,我希望这个项目可以将 iOS/android/web/osx/windows 很好地联合起来。

架构和设计

如果你看过上一篇文章,那么你还大概记得这样一个架构:

以及一个设想中的 API 的实现流程:

olorin 的实现几乎完全按照这个架构完成:

  • Swift 和 Rust 之间使用 protobuf 序列化出来的字节流进行通讯,这让两端之间的主要接口就是一个根据 protobuf 反序列化结果的 dispatch 函数。
  • Rust 侧有一组 Tokio 管理的线程池,用来处理异步 HTTP 请求。
  • Tokio 线程池的 runtime,HTTP client 连接池,以及运行中所保持的状态,都由一个 ConcurrentHandleMap 管理,在 Swift 侧,看到的是一个 u64 的句柄。Swift 代码只需要提供对应的句柄访问 FFI 接口,就可以调用 Rust 侧代码进行工作。
  • Swift 侧把所有 FFI 代码封装成一个类,使用 Swift Package Manager 提供给具体的客户端平台的 APP 使用。

更为具体的流程见下图:

这里面,FFI 接口是至关重要的,它包括下面几个函数:

service_init

Rust 侧的初始化。Swift 代码提供一个用于初始化的 protobuf 字节流的指针和长度,Rust 侧创建对应的运行时,然后返回给 Swift 一个句柄,供以后的请求使用。这个请求一般是 app 启动时调用。Swift 可以提供一些基本的服务器请求参数,比如设备 ID,平台,用户 ID,要请求的服务器域名(prod/staging/dev)等信息。Rust 代码会利用设备 ID 和用户 ID(如果存在)在本地存储里查找是否有之前储存的用户状态,如果有,就加载到 State 中;如果没有,就创建新的 State。

service_dispatch/service_dispatch_block

这两个函数一个用于异步请求,一个用于同步请求。同步请求会阻塞 Swift 代码所在的线程;而异步请求则在不同的线程执行,完成之后调用 Swift 侧提供的 callback,提交结果。

请求的时候会提供之前获取的句柄,来找到对应的 Rust 运行时及状态。此外,还要提供请求所包含的 protobuf 字节流的指针和长度。因为所有的请求都走这一个接口,所以它被封装成为 protobuf 的一个 oneof message,如下所示(有删减):

这种通过使用 oneof 来统一调用接口的方法,我是跟 Tendermint 的 ABCI 学的,非常好用。这样,我们在处理请求的时候,就可以根据其类型进行相应的 dispatch 了:

之所以提供一个同步和一个异步的接口,完全是为了客户端灵活而设置的。我自己没有做过生产环境的客户端,不知道哪种方式最适合客户端使用,所以干脆都提供了。好在对于 Tokio 来说,不过是 spawnblock_on 的区别而已。

我看了 Firefox sync 的部分代码,它只提供了同步调用的接口,所以整体上的设计比我这里所列的要简单。其实同步调用挺好的,不容易出错。

service_dispatch 接口具体在 Rust 中的实现并不困难。我们只需要了解如何做 Rust C FFI 即可。其实没什么神秘的,只需要注意三点:

  • 使用 #[no_mangle],这样 Rust 编译器生成的 symbol 不会使用内部的混淆后的名字。
  • 使用 extern "C" 声明 C FFI。
  • 使用 C 认识的数据结构。如果是 struct,需要添加 #[repr(C)] 宏。

一个完整流程

我们看一个从 Swift 到 Rust 的完整的 Ping/Pong 的代码,看看具体是怎么运作的。

首先在 Swift 侧,我们先初始化 service 结构。初始化的时候会调用 Rust 侧的初始化,生成上文我们所说的 runtime/state。

当我们在 Swift 里调用 service.ping 时,会先生成一个 AbiRequestPing。这是我用 Apple 官方的 swift protobuf 库,基于我定义的 protobuf 生成的结构。由于 Swift import 一个库之后,所有的结构就无需 namespace 可以直接访问,所以我加了一个前缀(在 protobuf 定义:option swift_prefix="Abi"),一来好找,二来避免和其它数据结构冲突。

生成好 AbiRequestPing 后,需要将其进一步封装到 AbiNativeRequest(见上文的 protobuf 定义),然后将其序列化成字节流。因为接下来要将这个字节流传给 Rust,所以我们需要将其转换成 UnsafeByte<UInt8>。之后调用 service_dispatch_block,同步返回结果 —— 为了简单起见,我们先不看异步的流程。这个结果是一个 ByteBuffer 结构。这是 Rust 传给 Swift 的指针,所以我们需要将其处理成一个 UnsafeRawBufferPointer,封装成 Data,再反序列化成 AbiResponsePong

这里面的核心是 rustCall 函数,它负责处理和内存安全相关的代码,我们先放下不表。

Rust 侧的 service_dispatch_block,会把传入的指针转换成 Vec<u8>,然后再反序列化成 NativeRequest,就可以正常使用了。

内存管理

这时候,你可能会想到:数据在 Swift 和 Rust 间传来传去,究竟谁应该负责清理内存?

答案是:谁原本拥有的内存,谁负责释放。

Swift 侧是调用方,其传递给 Rust 的内存都在 withUnsafeBytes 闭包中,Rust 函数调用栈结束后,对该内存的引用消失,所以没有内存泄漏的危险,不需要手工处理。

Rust 是被调方,内存传递给 Swift 后,并不知道 Swift 会何时何地结束引用,所以 Rust 自己的所有权模型被略过(因为使用了 unsafe),需要手工「释放」。释放的原则:

  1. 任何 Rust 传给 Swift 的 buffer,包括各种指针和字符串(字符串也是指针,但往往会被人忽略),都需要手工释放。
  2. 所谓的「释放」,只不过是把原来的指针再还给 Rust,并由 Rust 代码从指针中构建数据结构来重新「拥有」这块内存,这样 Rust 的所有权模型会接管并在合适的时候进行释放。
  3. 当「拥有」这块内存的 Rust 函数结束后,内存被回收。

这也就意味着 Rust 代码需要为自己传出去的内存提供回收的方法,供 Swift 使用。上文中提到的 FFI 接口,有两个函数:rust_bytebuffer_freerust_str_free 是负责做这个事情的。因为我们两个语言之间交互的主要接口就几个,而涉及的指针,只有以下两种,所以我们只需要相应地处理:

  • ByteBuffer *:Rust 返回给 Swift 的 protobuf 字节流,Swift 做 defer { rust_bytebuffer_free(ptr) } 即可。这个函数会在 rustCall 调用栈结束时自动执行。而此时我们已经从 UnsafeRawBufferPointer 中把数据复制一份生成了 Data,所以「归还」这个 指针给 Rust 是安全的。
  • char *:Rust 调用出现异常时返给 Swift 的 ExternalError 里的错误消息字符串。同样道理,在我们做 String() 初始化时,该内存被复制,所以释放也是安全的。

我们看刚才被忽略的 rustCall 代码:

如果你仔细看这段 Swift 代码,你可能会非常疑惑,这里没有调用 rust_str_free 的代码释放包含错误消息的字符串啊?

这里用了 Swift 的一个很有用的模式:使用参数标签来扩展已有的功能。Swift 有着非常强大的 extension 能力[2],辅以参数标签,能力爆表:

这段代码里我只需扩展 String,为其 init 函数增加一个我自己的会「归还」Rust 指针并初始化字符串的实现即可。

说句题外话,初学 Swift 的时候,我觉得函数的参数标签是个非常鸡肋的功能,边写边吐槽它的繁琐(对于一个不太使用 xcode,大部分时候在 vscode 写代码的人来说,需要额外敲很多键),后来发现参数标签可以用作重载,卧槽,对我这个 Swift 小白来说,简直就是如获至宝。现在我已经离不开参数标签,并且开始吐槽:为啥 Rust 不支持参数标签(及重载)?

错误处理

跨语言的错误处理是一个很有意思的技术活。我们需要回答一个核心问题:如何把 Rust 代码的错误 Resut<T, E>,优雅地转化成 Swift 里的 Exception

一种思路是,把 Result<T, E> 中的 E ,也就是 Error,转化成一个 C 的结构体,包含错误码 (enum)和错误消息(char *),然后在 Swift 侧,利用这个信息重组并抛出异常。

另一种思路是,Rust 代码中返回的 protobuf 中包含错误信息,然后在 Swift 侧,查看这一信息并在需要的时候抛出异常。

因为我已经在使用 protobuf 来传递数据,所以我更加喜欢第二种思路的处理方式:简洁且没有额外的内存需要释放,然而,我使用的库 ffi-support 在其封装的 FFI 调用接口上,强行安置了 ExternalError 这个参数,使得我只能使用第一种思路。

如果你再看一眼 service_dispatch_block 的实现,会对下面这个闭包式的调用感到困惑:call_with_result 为什么要设计成这样的形式?

这是因为其它语言调用 Rust 的时候,Rust 代码有可能 panic(比如 unwrap() 失败),这将会直接导致调用的线程崩溃,从而可能让整个应用崩溃。从开发的角度,我们应该避免任何代码主动产生 panic,而是要把所有错误封装到 Result 中,但因为我们的代码会调用第三方库,我们无法保证所有第三方库都严格这样处理。对于 Swift 代码来说,Rust 代码所提供的库是一个黑盒,它理应保证不会出现任何会导致崩溃的行为。所以,我们需要一旦遇到 panic 时,能够进行栈展开(stack unwinding)。

我们知道,当函数正常调用结束后,其调用栈会返回到调用之前的状态 —— 你可以写一段简单的 C 代码,编译成 .o,然后用 objdump 来查看编译器自动插入的栈展开代码。然而,当一层层调用,栈不断累积的时候,如果内层的函数抛出了异常,而很外面的函数才捕获这个异常,那么,(支持异常处理的)编译器会插入回溯代码,一路把栈回溯到捕获异常的位置。在这个过程中,涉及到的上下文中所有的栈对象和用智能指针管理的堆对象都会并回收,不会有内存泄漏(对于 C 来说,非智能指针分配出的对象会泄漏)。对于 Rust 来说,栈展开是内存安全的,不会有任何内存泄漏。下图是我在 google image 里找到的关于栈展开不错的实例[3](我自己就懒得画了):

所以 call_with_result 就是为了保证在 FFI 这一层,所有调用的代码都有合适的栈展开代码来把任何潜在的 panic 捕获到并回溯堆栈,让 Swift(或者其他语言)的代码就像经历了一次异常。只要 Swift 代码捕获这个异常,那么程序依旧能够正常处理。call_with_result 的具体实现如下,感兴趣的可以深入了解:

单元测试

我们讲了跨语言调用的解决方案,实现方法,以及内存管理和异常处理这些在实际开发中非常重要的部分。接下来,我们讲讲同样非常重要却往往被人忽视的部分:单元测试。

Rust FFI 接口之外的单元测试自不必说,该怎么搞就怎么搞,我们用单元测试(以及 property testing)保证纯粹的 Rust 代码在逻辑上的正确性。

Rust 提供给其它语言的 C FFI,需要妥善测试。这里有几个挑战:

  1. 我们要为测试环境提供一个贴近于 Swift 调用 Rust 的运行环境,比如:所有的测试使用同一个 service_init 产生的 handle。这个,可以通过 std::sync::Once 来完成。
  2. 对于 service_dispatch,模拟 Swift callback 函数。
  3. 因为 service_dispatch 在其他线程中执行,因此测试结果出错需要能够被测试线程捕获。

2 和 3 的实现方法可以参考以下实例:

可以看到,assert_eq!on_result 回调中调用,而这个回调运行在 tokio 管理的若干个线程中的某个,因而有可能测试线程结束时,该线程还没有结束。所以这里我们需要不那么优雅地通过 sleep 阻塞一下测试线程。这里因为回调是一个 C 函数,无法做成 Rust 的闭包,因此,使用 channel 同步两个线程的思路行不通。如果大家有比 sleep 更好的方法,欢迎跟我探讨。我个人非常讨厌在 test 中显式地 sleep 来进行同步。

即便我们阻塞了足够多的时间,这里还有另一个问题:assert_eq! 产生的 panic 无法被测试线程捕获到。所以我们在 FFI 代码的测试初始化时,需要添加 panic 处理的 hook。这里,我们让 panic 发生后,做完正常的处理流程,就立刻结束整个进程。这样,在 tokio 运行时某个线程中调用的 assert_eq! 被触发并产生错误时,测试能够正常退出并显示测试错误。

同样的,这个代码也只需执行一次,所以也应该将其包裹在 std::sync::Once 中。

Rust 开发的心得

我认为 Rust 开发的一大好处是你可以不断将代码拆分成新的 crate,让这些小的 crate 可以有自己完整的单元测试。这样非常符合 SRP(Single Responsibility Principle)。在这个 POC 里,我做的 Rust 侧代码:

  • protos:管理所有使用 prost(rust protobuf 处理的库)生成的代码。我会为所有数据结构提供 new 函数,以及类型之间的转换,比如,RequestPingVec<u8> 之间的互转。因为我希望 prost 生成的数据结构还能支持 serde,我还做了一个灵活的编译脚本(这个我打算有空可以分拆出来开源)。
  • config:处理配置相关的内容。
  • errors:所有 error 的定义,以及各种 error 之间的互转,使用了 thiserror 库。
  • fixtures:这是一个专门提供测试所用的 fixtures 的库,所有的测试数据会通过 include_str! 编译到可执行文件中,并提供对应的函数给调用者。为了简单起见,我做了一个简单的宏,来生成对应的代码。这是一个非常好的 Rust 设计模式,它可以让我的 unit test 所需要的数据集中在一个 crate 里来处理。
  • rust-bridge:所有 FFI 接口和处理流程。
  • test-utils:所有单元测试使用的公共函数。
  • utils:所有不知道该往哪里放的非测试使用的公共函数。
  • bridge-examples:示例代码。

你可以看到,我甚至为测试单独创建了两个 crate。我不敢说我的项目结构一定是合理的,但是类似的拆分思路可以让我们很好地应对大型项目的需求,并且让代码很好扩展,很好测试。

我最大的心得还是在 protubuf 的使用上。

自从我在自己的一个实验性质的项目 gitrocks 里使用 protobuf 来做应用程序的主要的数据结构后,这一思想我已经运用得越来越娴熟。对于 Rust 代码来说,一个手工撰写的 struct 和一个由 protobuf 生成出来的 struct,除了后者有一些限制外(比如不能用指针类的数据结构,如 Arc),本质是一样的。而后者可以将数据高效地序列化/反序列化,并且在应用程序的多个版本之间安全无障碍地共享。

因此,现在我做任何一个新的 Rust 项目的流程是:

  1. 先定义项目中的 protos。项目都需要什么数据结构,哪些结构可以用 protobuf 定义。项目中使用的所有 error 都在 protobuf 里定义。
  2. 创建一个 protos crate。使用 prost 生成代码并添加合适的 serde 支持。之后,为每个数据结构定义一些接口,如 new,以及各种 From 转换,以便 into() 可以到处使用。
  3. 创建一个 errors crate。使用 thiserror 进行各种 error 的转换,以及 protobuf 里定义的 error 和 thiserror 定义的 error 的转换(这下连 Error 也可以序列化并发送到其它地方)。
  4. 创建 fixtures crate。集中处理所有测试数据。
  5. 创建其它的项目逻辑,使用 protobuf 生成的数据结构。

Swift:被 apple 耽误的好语言

最后,让我好好吐槽一下 Swift 糟糕的生态。

作为一个 Swift 正式使用时间只能以天来计算的初学者来说,这个标题写得对 apple 极为大不敬。

然而,我的 Swift 初体验真的是可以用糟糕透顶来形容。

别会错意,我不是说 Swift 语言本身。作为一个半吊子 Rust 开发者,当我写了一两百行真正的 Swift 代码后,我便沉迷于这个语言的强大的表现力和简单又优雅的语法。

但是,Swift 生态非常地支离破碎,稍微复杂一些的需求,就无法完成或者完成得非常别扭。这和我学习 Rust 的体验非常不一样。

比如,链接一个 C 的静态库。Rust 你即便不知道怎么做,stackoverflow 一下,你就能找到靠谱的答案,十分钟搞定,毫无门槛。

Swift?OMG,让人绝望。

至今我还没有搞定在 Swift Package 里如何使用一个静态库。

按照 apple 官方的说法,我可以创建 xcframework,然后在 Swift Package 里引入 xcframework。

看似很简单的任务。我用 Rust 编译出了 linux / osx / iOS (arm) / iOS (x86_64 simulator) 几个平台的静态库,按照 apple 的官方文档生成 xcframework,结果各种出错。好吧,linux 在 aple 生态外,你不支持,无可厚非,我们暂且将其扔到一边;iOS (arm) / iOS (x86_64) 也出错,这是什么鬼?同样的静态库在 xcode 里就可以正常编译链接运行,为啥生成 xcframework 就报错?难道 xcframework 不是亲儿子?

好吧,osx 能够正常打包,我们就在 xcframework 里(暂时)只支持 osx 吧。

按照 apple 的官方文档,我把 xcframework 放在 Swift Package 里作为一个 binaryTarget,并在 target 中引用,照理来说该大功告成了吧?可 swift build 报错。搜索了半天未果,后来我不得不就着错误消息查看了 Swift Package 的源代码才解决了这个问题:

你敢相信这么业余的代码是 apple 的工程师写的么?我们判断一个库是不是一个 static lib 竟然要靠它的命名是不是以 lib 开始?难道非标的静态库命名方式你就不工作了?好吧,我暂且认了,可是我用的是打包好的 xcframework 啊,我在创建 xcframework 时使用非标的 lib 命名方式,为啥你当时不给报个错,让我纠正过来,或者把 lib 名改成标准的名字呢?

吐槽归吐槽,这不重要,我在 Rust 侧构建时按照你要求改回来还不行么?

这下,编译通过了。然而,一旦我在代码中引用静态库里的函数,还是各种 symbol undefined 错误。我尝试了各种论坛上几乎各种方法,从 module.modulemap 到 bridging header,都无法正常编译通过。

而如果我为这个 Swift package 创建一个 xcode 项目(swift package generate-xcodeproj),在 xcode 里打开,添加 bridging header 就可以成功编译。但是 xcode ... 不支持 linux 啊,你让我如何开心地做 CI?毕竟,github action 等 CI 工具,osx 的价格是 linux 的十倍左右啊。

所以,我现在只能很无奈地本地用 xcodebuild test 做 precommit check,然后 CI 中禁用了 Swift 代码的 build/test。让一个 POC 代码这么消耗钱粮,不值当。

好的工具是很容易上手使用,而很难误用。就我这两天的体验来说,在 WWDC 上大吹特吹的 xcframework 和被寄予希望的 Swift Package module,也许在整个 apple 的生态系统里,工作得很好,然而一旦和更大的开源生态结合起来,还有很多路要走。

贤者时刻

上篇文章我引用了别人做的 JSON parsing 的数据,27M 的 JSON,Swift 花了 3s,而 Rust 花了 0.18s,二者 17 倍的差距。对于这个结果,不但有些读者不相信,我自己也不敢相信。于是我弄了一个大 JSON,然后用 app.quicktype.io 上生成的数据结构,分别用 Rust 的 serde_json 和 Swift 自带的 JSONDecoder() 测试,Rust 3.95ms,Swift 49.2ms,依然有 12 倍的差距。

参考资料

  1. Unsafe Swift: Using Pointers and Interacting With C:https://www.raywenderlich.com/7181017-unsafe-swift-using-pointers-and-interacting-with-c
  2. The power of extensions in Swift: https://www.swiftbysundell.com/articles/the-power-of-extensions-in-swift/
  3. stack unwinding: https://www.bogotobogo.com/cplusplus/stackunwinding.php

0 人点赞