0%

Alamofire 源码阅读(一)

阅读 Alamofire 5.9.1 版本的源代码,有些收获,记录于此。

创建 URLSession

AF 在初始化 URLSession 时,使用到三个队列,分别是:rootQueue、requestQueue 和 serializationQueue。rootQueue 默认为 DispatchQueue(label: “org.alamofire.session.rootQueue”)。

而 requestQueue 和 serializationQueue 默认均是以 rootQueue 为 target 的串行队列。也就是说,提交到 requestQueue 上的 task A 是会在 rootQueue 上执行的。

假设 iOS 会给 rootQueue 分配一个线程 T1,那么提交到 requestQueue 和 rootQueue 上的任务均会在线程 T1 上执行。

如果 requestQueue 自己是个单独的串行队列呢?iOS 会专门给 requestQueue 分配另一个线程 T2,这样 App 就可能使用了不必要的线程资源。

当存在过多线程时,不仅会增加内存压力,更重要的是,这些线程在有限的 CPU 核心上运行,导致频繁的线程上下文切换。有时候,与实际执行需要的指令相比,这些上下文切换所消耗的资源和时间反而占据了主要部分。这种情况下,由于线程被阻塞的同时,又不断有新的任务以 async 的方式提交到并行队列,导致过多的新线程被创建,我们将其称为线程爆炸

Apple 也在 DispatchQueue 文档 中提示我们避免创建过多线程,限制线程数量可以避免资源的过度占用和性能下降。

AF 创建 Request 的流程

AF 处理 Response 的流程

AF 中是如何解决数据竞争的?

AF 中是利用范型类 Protected<Value> 来实现线程安全的读和写。

范型类 Protected<Value> 的核心思路是利用锁的机制来实现线程同步。除此以外,还利用到@dynamicMemberLookupKeyPath 的特性,以方便地访问/修改 Value 的成员变量(属性)。

我也借鉴它的思路,解决了工作中的一个数据竞争导致的崩溃(EXC_BAD_ACCESS KERN_INVALID_ADDRESS)。在读取硬件设备的数据时,业务层会以并发的方式发送蓝牙指令,而蓝牙发送工具类中使用一个结构体 FIFO 队列来控制蓝牙指令的执行顺序。

1
2
3
4
5
6
7
8
9
10
public protocol Queue {
associatedtype Element
mutating func enqueue(_ element: Element)
mutating func dequeue() -> Element?
}

public struct FIFOQueue<Element>: Queue {
...
...
}

该结构体 FIFO 队列的入队出队方法均是 mutating 方法,结构体的 mutating 方法本质上是把当前的值拷贝一份,作出修改后,重新赋值给 self

假设要发出的两个蓝牙指令分别在不同的线程中发送,那么这两个线程就有可能同时执行 FIFO 队列的入队方法,即出现数据竞争的情况。

在定位到这个崩溃的根本原因后,利用范型类 Protected<Value> 就能很方便地解决问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
extension FIFOQueue {
/// Return a thread-safe reference of a `FIFOQueue`.
public static func makeThreadSafeReference(_ elements: [Element] = []) -> Protected<Self> {
Protected(Self.init(elements))
}
}

extension Protected: Queue where Value: Queue {
public typealias Element = Value.Element

public func enqueue(_ element: Value.Element) {
write { queue in queue.enqueue(element) }
}

public func dequeue() -> Value.Element? {
write { queue in queue.dequeue() }
}
}

在添加上述代码之后,只需改动蓝牙发送工具类中初始化 FIFO 队列 一行代码。

1
2
3
4
5
// Before
var queue = FIFOQueue<Value>()

// After
var queue = FIFOQueue<Value>.makeThreadSafeReference()

这一刻,我觉得 Swift 是真的优雅!

SSL Pinning

AF 也内置了常见的 SSL pinning 方式,比如通过固定证书的方式和使用公钥的方式等。

For projects deploying to iOS 14, tvOS 14, watchOS 7, or macOS 11 or later, Apple now provides built in pinning capabilities configurable in your app’s Info.plist. Please use that capability before implementing your own using Alamofire.

但如果 iOS 项目是最低支持 iOS 14 的,AF 优先推荐使用 iOS 系统内置的方案来实现 SSL Pinning。

什么是 Pining?

把主机名(Host)与预设好的 X509 证书或者公钥关联起来的过程叫做 pinning。iOS App 中可内置关联好的证书或公钥来防止中间人攻击(Man-in-the-middle attack)。

什么是中间人攻击(Man-in-the-middle attack)

在使用非对称加密中,Alice 若想与 Bob 进行安全的通信,Alice 需要拿到 Bob 的公钥,然后 Alice 使用 Bob 的公钥加密通信内容。而加密后的内容只能以 Bob 的私钥解密,这似乎听起来很安全。那么设想这样一个场景:

  1. Alice 向 Bob 发送:嗨!Bob 请给我你的公钥。
  2. Jack 窃取到了这条信息,且转发给了 Bob。
  3. Bob 把自己的公钥发送给 Alice。
  4. Jack 收到了 Bob 的公钥,但却把他自己的公钥匙发给 Alice。
  5. Alice 认为自己收到了 Bob 的公钥,其实是 Jack 的公钥。
  6. Alice 向 Bob 发送:今晚 7 点去吃麦当劳吧,并用(她认为的) Bob 的公钥匙加密。
  7. Jack 收到 Alice 用自己的公钥加密的信息后,用自己的私钥解密,并把信息篡改为“今晚 6 点去吃食其家吧”,再用 Bob 的公钥加密,发给 Bob。
  8. Bob 收到信息,用自己的私钥解密后,就去准备晚上 6 点和 Alice 在食其家碰头了。

在上述场景中,Alice 需要一种方式来确保自己收到的公钥是 Bob 的公钥,而不是攻击者 Jack 的公钥。Bob 可以向权威机构申请一个证书,这个证书会包含 Bob 的公钥,由权威机构来给 Bob 的公钥作背书。

所以 iOS app 中常见的防止中间人攻击的方法之一就是预先把与公司主机名关联好的公钥内置在 app 中。在建立 HTTPS 安全连接时,读取证书链中的叶子节点证书,先通过证书的签名和所使用的加密算法来验证证书的完整性。完整性验证通过后,再对比公钥是否一致。

Cache

AF 使用 URLSession 的系统实现来缓存网络请求的响应的,即默认存入 URLCache.shared 中。

URLSession 的缓存策略(requestCachePolicy)有 6 种:

  1. useProtocolCachePolicy
    1. 解释:遵循 HTTP 中的缓存机制,详见 Caching in HTTP
  2. reloadIgnoringLocalCacheData
    1. 解释:不使用任何缓存数据。
  3. reloadIgnoringLocalAndRemoteCacheData
  4. returnCacheDataElseLoad
    1. 解释:优先使用缓存数据,若没有缓存数据,则开始网络请求。
  5. returnCacheDataDontLoad
    1. 解释:使用缓存数据,若没有缓存数据,则直接网络请求失败,这种行为有点像离线模式
  6. reloadRevalidatingCacheData

我们还可以通过实现这个代理方法,来实现更细节的控制,比如阻止对某个特定 URL 的响应进行缓存。

1
2
3
4
5
6
open func urlSession(_ session: URLSession,
dataTask: URLSessionDataTask,
willCacheResponse proposedResponse: CachedURLResponse,
completionHandler: @escaping (CachedURLResponse?) -> Void) {
// Add your implementation
}

但需要注意的是,这个代理方法被调用的条件有些苛刻,得同时满足以下 7 个条件才行!