0%

【翻译】URL Loading System

原文:URL Loading System

前言

南峰子大佬之前有翻译过这个专题:URL加载系统之一:基本结构,但是随着官方文档的更新,这篇编程指南的原文也从苹果的文档中删除了,因此为复习一下网络框架,就顺手把这篇翻译了,以下开始正文。

URL 加载系统

使用标准的网络协议通过与 URLs 交互,来与服务器进行交流。

概述

URL 加载系统通过使用标准的协议如 https 或者创建的自定义协议提供被 URLs 标识的特定资源的访问。加载是异步执行的,因此你的 app 可以保持响应并且在即将到来的数据或错误信息抵达时做出处理。

使用一个 NSURLSession 实例来创建一个或多个 NSURLSessionTask 实例,它可以用于给你的 app 获取和返回数据,下载文件,或者上传数据和文件到远程地址。你需要使用一个可以控制诸如如何使用 caches 和 cookies 或者是否允许使用蜂窝网络连接等行为的 NSURLSessionConfiguration 对象来配置这个 session 实例。

你可以使用一个 session 重复的创建多个任务,举个例子:一个网页浏览器可能会有不同的 session 用于正常访问和隐私访问,在隐私访问的 session 中并不缓存数据。图 1 展示了使用这些配置的两个 session 如何创建多个任务。

图 1 使用 URL session 创建任务6789dd96-afdc-4c18-b8eb-01f9012dc04d

每个 session 都会与一个代理关联起来用于接收周期性的更新(或者 errors)。默认的代理调用你提供的处理任务完成 completion block;如果你创建了自定义的代理,就不会调用这个 block 了。

你可以配置一个运行在 background 的 session,这样当你的 app 挂起时,系统会代表你下载数据并且唤醒你的 app 来传递结果。

First Steps

从网站获取数据到内存

通过从 URL session 中创建一个 data task 直接接收数据到内存。

概述

对于与远程服务器的简单交互,你可以使用 NSURLSessionDataTask 类将数据接收到内存中(使用NSURLSessionDownloadTask 则不同,它会将数据直接保存到文件系统)。一个 data task 对于访问 web 服务端点来说是很完美的。

使用一个 URL session 来创建 task。如果你的需求很简单,就可以直接使用 NSURLSession 类的 sharedSession 单例对象。如果你想通过代理回调与传输过程交互的话,需要创建一个 session 而不是使用 sharedSession 单例。创建一个 session 时需要一个 NSURLSessionConfiguration 实例,同时还需传入一个实现了 NSURLSessionDelegate 或其子协议的类。Session 可以被重用于创建多个 task,因此可以对每一个独特的配置创建一个 session 并将其作为属性存储到 session 实例中。

注意
注意不要创建超出需求的 session,举例来说,如果你的 app 中几个部分需要差不多配置的 session,只需要创建一个并且共享它就可以了。

一旦有了 session,就可以使用dataTask()的相关方法中的一个来创建 data task 了,新创建的 task 是处于 suspended 状态的,需要通过调用 resume 方法来启动它。

使用 Completion Handler 接收结果

获取数据最简单的方法是使用创建一个使用 completion handler 的 data task。这样 task 会传递服务器端的 response,data 也可能是 error 给你提供的 completion handler。下图展示了 session 和 task 之间的关系,以及结果如何传递到 completion handler 中。

bf4501ff-82b2-4dd4-9ec3-243ef0e70d21

要创建一个使用 completion handler 的 data task,需要调用 URLSession 的dataTaskWithURL:方法,在 completion handler 中要做 3 件事情:

  1. 验证 error 参数是否为空,如果不是则说明传输发生错误;处理错误并退出。
  2. 检查 response 参数,验证代表成功的状态码及 MIME 类型是否是预期值,若不是,则处理服务器错误并退出。
  3. 按需求使用 data 实例。

下面的代码展示了获取 URL 内容的startLoad()方法。通过使用 NSURLSession 类的共享单例创建一个 data task 用于传递结果给 completion handler。在检查了本地和服务端错误后,在 handler 中将数据转化为一个字符串,并用于插入代码到 WKWebView 中。当然,你的 app 可能会将数据用于其他用途,例如解析到数据模型中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func startLoad() {
let url = URL(string: "https://www.example.com/")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
self.handleClientError(error)
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
self.handleServerError(response)
return
}
if let mimeType = httpResponse.mimeType, mimeType == "text/html",
let data = data,
let string = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
self.webView.loadHTMLString(string, baseURL: url)
}
}
}
task.resume()
}

重要
completion handler 是在与创建 task 不同的 GCD queue 中被调用的。因此任何使用 data 或 error 信息来更新 UI ,像更新 webView 的操作,应该像上例中被显式的放到 main queue 中去。

使用代理接收传输详情及结果

为了更进一步随着 task 的运行获取它的状态,你可以在创建一个 data task 时给 session 设置一个代理,而不是提供一个 completion handler。下图展示了这种方案:

730c8e1b-654f-4eb9-9c63-d439a69ac5d2

通过这种方式,一份份的数据随着他们的抵达会提供到 NSURLSessionDataDelegate URLSession:dataTask:didReceiveData:方法,直到传输完成或者失败。随着数据的传输过程代理对象还接收其他的事件。

当你使用这种方式时,你需要创建一个自己的 URLSession 实例,而不是使用共享的单例。创建一个新的 session 允许你设置自己的类作为其代理。正如下面的代码中展示的。

声明你的类实现了一个或多个代理协议(NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDataDelegate, 和 NSURLSessionDownloadDelegate)。然后使用初始化方法 sessionWithConfiguration:delegate:delegateQueue: 来创建 URL session 的实例。可以在初始化方法中设置配置实例。例如,设置 waitsForConnectivityYES是个好主意,这样 session 在所要求的连接不可用时会等待适当的连接,而不是直接失败。

1
2
3
4
5
6
private lazy var session: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.waitsForConnectivity = true
return URLSession(configuration: configuration,
delegate: self, delegateQueue: nil)
}()

下面的代码展示了使用这个 session 启动一个 data task 的startLoad()方法,并使用代理回调来处理接收的 data 和 error。这段代码实现了 3 个代理回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var receivedData: Data?

func startLoad() {
loadButton.isEnabled = false
let url = URL(string: "https://www.example.com/")!
receivedData = Data()
let task = session.dataTask(with: url)
task.resume()
}

// delegate methods
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
guard let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode),
let mimeType = response.mimeType,
mimeType == "text/html" else {
completionHandler(.cancel)
return
}
completionHandler(.allow)
}

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
self.receivedData?.append(data)
}

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
DispatchQueue.main.async {
self.loadButton.isEnabled = true
if let error = error {
handleClientError(error)
} else if let receivedData = self.receivedData,
let string = String(data: receivedData, encoding: .utf8) {
self.webView.loadHTMLString(string, baseURL: task.currentRequest?.url)
}
}
}

各种各样的代理协议提供了超出上面代码展示的方法。对于处理认证,重定向及其他的情况,在 URLSession 文档的Using a URL Session部分讨论了传输过程中的众多回调方法。

上传数据到网站

从 app 中 post 数据到服务器。

概述

大多数 app 都与能够接收上传诸如图片或文件,或者使用接收如 JSON 这样结构化数据的网络服务 API 端点的服务器协同工作。想要从 app 中上传数据,要使用一个 NSURLSession 实例创建一个 NSURLSessionUploadTask 实例,upload task 使用 URLRequest 实例来描述了上传是如何被执行的。

准备好用于上传的数据

用于上传的数据可以是文件的内容,一个 stream,或者像下面代码中的数据。

很多网站服务端点接收 JSON 格式的数据,可以通过在如数组和字典这样的 Encodable 类型上使用 JSONEncoder类来创建。如下代码所示, 你可以声明一个遵循 Codable 协议的结构体,创建该类型的实例,然后使用JSONEncoder来将实例编码成用于上传的 JSON 数据。

1
2
3
4
5
6
7
8
9
10
11
12
struct Order: Codable {
let customerId: String
let items: [String]
}

// ...

let order = Order(customerId: "12345",
items: ["Cheese pizza", "Diet soda"])
guard let uploadData = try? JSONEncoder().encode(order) else {
return
}

有很多种方式来创建一个数据实例,例如讲一个图片编码成 JPEG 或者 PNG 数据,或者将一个字符串使用 UTF-8 编码转换成数据。

配置一个上传请求

Upload task 需要一个 URLRequest 实例,如下代码所示,根据服务器的支持及预期设置请求的 httpMethod 属性为“POST”或者“PUT”,使用 setValue(_:forHTTPHeaderField:) 方法设置任何你想提供的除Content-Length之外的 HTTP header 值。因为 session 会从数据大小中自动计算出内容长度。

1
2
3
4
let url = URL(string: "https://example.com/post")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

创建并启动一个上传任务

要开始上传,需要调用 NSURLSession 类的 uploadTaskWithRequest:fromData:completionHandler: 方法创建一个用于上传的 NSURLSessionTask 实例,传入之前设置好的 request 实例与 data 实例。鉴于 task 是以 suspend 状态开始的,你需要通过对 task 调用resume方法来开启一个网络加载过程。下面的代码使用了共享的 URLSession 实例,通过一个 completion handler 接收结果。在 handler 中在使用任何返回数据之前检查传输和服务器端的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let task = URLSession.shared.uploadTask(with: request, from: uploadData) { data, response, error in
if let error = error {
print ("error: \(error)")
return
}
guard let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode) else {
print ("server error")
return
}
if let mimeType = response.mimeType,
mimeType == "application/json",
let data = data,
let dataString = String(data: data, encoding: .utf8) {
print ("got data: \(dataString)")
}
}
task.resume()

另一种选择,通过设置代理上传

作为 completion handler 方式的另一种选择,你可以给你配置的 session 设置一个代理,然后使用 uploadTaskWithRequest:fromData: 方法创建一个 upload task。在这种方案下,你要实现NSURLSessionDelegateNSURLSessionTaskDelegate协议中的方法。这些方法会接收服务器的 response 及任何数据或传输错误。

在后台下载文件

创建一个 task 在 app 处于非活跃状态时下载文件。
###概述
对应长时间的不紧急的传输任务,你可以创建运行在后台的 task,即使你的 app 已经被 suspended 了,这些任务依然可以运行,并且允许你的 app 恢复运行时访问这些已下载的文件

注意
并不是所有的后台网络活动都必须按照这篇文章中描述的那样使用 background session 来完成,那些声明了合适的 background mode 的 app 可以使用默认的 URLSession 和 data task,就跟在前台运行一样。

配置 Background Session

按照如下步骤创建一个 background URL session,下面的代码展示了这个过程:

  1. 提供一个在 app 中唯一的 session 标识符,使用 NSURLSession 的类方法 backgroundSessionConfigurationWithIdentifier: 来创建一个 background NSURLSessionConfiguration 对象。因为大部分 app 只需要少数 background session(通常只需要一个),你可以使用一个固定的字符串作为标识符,而不是动态生成的。这个标识符并不需要全局唯一。
  2. 确保 sessionSendsLaunchEvents 属性被设置为true(默认值),来保证当任务完成且你的 app 在后台时会被系统唤醒。
  3. 对于对时间不敏感的任务,可以设置 discretionary 属性为true,以便于系统在最理想的情况下执行传输任务,例如当设备充电或者连上 Wi-Fi 时。
  4. 使用 NSURLSessionConfiguration 实例来创建一个 NSURLSession 实例,这个 session 必须提供一个代理对象,来接收后台传输的各种事件。
1
2
3
4
5
6
private lazy var urlSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "MySession")
config.isDiscretionary = true
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()

注意
想要获取更多关于系统如何调度及执行后台任务,从 Bug 报告Profiles and Logs下载并安装后台网络资料到你的 iOS 设备中。

创建并安排 Download Task

你可以提供一个 URL 使用 session 的 downloadTaskWithURL: 方法创建一个 download task,或者提供一个 URLRequest 实例通过 downloadTaskWithRequest: 创建。你可以设置属性来帮助系统优化 task 的行为。

  1. 如下代码所示,使用downloadTaskWithURL创建一个 download task。
  2. 这一步是可选的,设置 earliestBeginDate 属性来安排下载在未来的某个时间点开始,下载并不保证会精确到这个时间进行,但是不会开始的更早。
  3. 为帮助系统更有效的安排网络活动,设置 countOfBytesClientExpectsToSendcountOfBytesClientExpectsToReceive 属性,这些值是期望传输数据的猜测最接近的上限,而且应该把 header 和 body 数据都计算入内。
  4. 调用resume启动这个 task.

在下面的代码中,任务被设置为最早一个小时后,而且配置为发送大约 200 字节的数据,接收大约 500 KB 的数据。

1
2
3
4
5
let backgroundTask = urlSession.downloadTask(with: url)
backgroundTask.earliestBeginDate = Date().addingTimeInterval(60 * 60)
backgroundTask.countOfBytesClientExpectsToSend = 200
backgroundTask.countOfBytesClientExpectsToReceive = 500 * 1024
backgroundTask.resume()

处理 App 挂起

不同的 app 状态会影响到你的后台下载任务如何与 app 交互。在 iOS 中,你的 app 可能处于 foreground,suspended,或者甚至被系统 terminated。查看 Managing Your App’s Life Cycle获取更多关于 app 状态的信息。

如果你的 app 在 background 状态,当其他进程的下载运行时,系统可能会 suspend 你的 app,这种情况下,当下载完成时,系统会恢复(resume)你的 app 并且调用 UIApplicationDelegate 的 application:handleEventsForBackgroundURLSession:completionHandler: 方法。这个方法接收你前面创建的 session 标识符作为第二个参数。

这个代理方法还接受一个 completion handler 作为其最后一个参数。你应该立即将这个 handler 保存在它有意义的地方。或许作为 app delegate 或者实现了NSURLSessionDownloadDelegate的类的属性。在下面代码中,completion handler 被保存在了 app delegate 的名为backgroundCompletionHandler的属性中了。

1
2
3
4
5
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
backgroundCompletionHandler = completionHandler
}

当所有事件传送完成后,系统会调用NSURLSessionDelegateURLSessionDidFinishEventsForBackgroundURLSession: 方法,这时,取出保存在 app delegate 中的backgroundCompletionHandler然后执行它。下面的代码展示了这个过程。

注意URLSessionDidFinishEventsForBackgroundURLSession:方法或许会从其他 queue 中被调用,所有需要显式的切换到 main queue 中执行 handler(这个 handler 是从一个 UIKit 方法中接收的)。

1
2
3
4
5
6
7
8
9
10
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let backgroundCompletionHandler =
appDelegate.backgroundCompletionHandler else {
return
}
backgroundCompletionHandler()
}
}

如果 App 被 terminate 了则会重新创建 Session

如果在 app 处于 suspend 状态时被系统 terminate 了,系统会在后台重启 app。作为启动 的一部分,系统会使用同样的 session 标识符重新创建一个 background session。以便于系统将 download task 与你的 session 重新联系起来。这样不论 app 是被用户还是被系统启动,background session 都已经做好准备了。一旦 app 重新登录,一系列的事件就像上一部分讨论的那样,好像 app 被 suspend 然后 resume 了一样。

注意
假如传输过程是在 app 处于 background 状态时被启动的,session 的配置中discretionary属性会被当做YES

处理下载完成和错误

在 app resume 后(或者在前台了),你的NSURLSessionDownloadDelegate协议方法的实现会接收回调来更新传输的状态。

通过实现 URLSession:downloadTask:didFinishDownloadingToURL: 方法来处理下载完成。检查 download task 的 response 看是否有服务器端的错误如 404 状态码。如果有的话,则并没有得到下载的文件,这时应该退出了。如果下载完成,则最后一个参数是一个存放文件的本地 URL,这个地址只是在这个回调中有效,所以你应该将其转移到其他位置,例如 app 的文档目录下。

下面的代码展示了URLSession:downloadTask:didFinishDownloadingToURL:的实现。这个实现通过检查服务端错误码及移动文件到文档目录下完善了 background download 的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
guard let httpResponse = downloadTask.response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
print ("server error")
return
}
do {
let documentsURL = try
FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false)
let savedURL = documentsURL.appendingPathComponent(
location.lastPathComponent)
try FileManager.default.moveItem(at: location, to: savedURL)
} catch {
print ("file error: \(error)")
}
}

要处理客户端的下载错误,例如连接不到服务器这种,需要实现URLSession:task:didCompleteWithError: 方法。这个方法会在任何 task 完成时被调用;你只需要在最后一个参数不为 nil 的时候处理错误。

遵守后台传输限制

通过 background session 传输,真正的传输过程是被与你的 app 不同的进程所执行的。因为重启你 app 的进程是相当耗费资源的操作,一些特性会变得不可用,导致如下的限制:

  • Session 必须为每一个事件传递提供一个代理(对于上传或下载,代理与进程内传输表现一致)。
  • 仅支持 HTTP 或者 HTTPS 协议(不支持自定义协议)。
  • 总是会执行重定向,因此即使你实现了 URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler: 方法,也不会被调用。
  • 只支持上传文件(上传一个数据实例或者 stream 会在 app 退出后失败)。

Requests 和 Responses

NSURLRequest

NSMutableURLRequest

NSURLResponse

NSHTTPURLResponse

缓存行为

获取缓存数据

控制 URL request 对之前缓存数据的使用。

概述

URL 加载系统会将 response 缓存到内存和磁盘中,以提升性能减少网络阻塞。

NSURLCache 类用于从网络资源中缓存 response,你的 app 可以通过 URLCache 的 sharedURLCache 属性获取到共享的缓存实例,或者也可以,在你的NSURLSessionConfiguration对象上设置不同的 cache 来创建自己的 cache 用于其他用途。

设置 URL Request 的缓存策略

每一个 URLRequest 实例包含一个 URLRequest.CachePolicy 对象来指示是否应该缓存以及缓存应该如何被执行。你可以改变 request 的这个策略来控制它的缓存行为。

方便起见,NSURLSessionConfiguration有一个 requestCachePolicy 的属性,所有通过使用这个配置的 session 创建的 request 都会从配置中继承这个缓存策略。

各种不同的缓存策略的行为描述在下表中。这个表展示了策略对于直接从缓存中加载还是访问数据源即服务器或本地文件系统的偏好。当前,仅有 HTTP 和 HTTPS response 会被缓存,对于 FTP 或者文件 URL,策略的唯一作用就是决定 request 是否被允许访问数据源。

缓存策略 本地缓存 数据源
NSURLRequestReloadIgnoringLocalCacheData 忽略 Accessed exclusively(不好翻译..)
NSURLRequestReturnCacheDataDontLoad Accessed exclusively 忽略
NSURLRequestReturnCacheDataElseLoad 先试一次 仅在需要时访问
NSURLRequestUseProtocolCachePolicy 依据协议 依据协议

查看 NSURLRequestCachePolicy获取更多关于 HTTP 和 HTTPS 中是如何实现useProtocolCachePolicy的说明。useProtocolCachePolicy是 URLRequest 对象的默认值。

注意
useProtocolCachePolicy将 HTTPS response 缓存到磁盘中,这可能对于保护用户数据安全来说不太理想,你可以通过像在Manage Caching Programmatically描述的一样,手动处理缓存行为来修改这种行为。

直接获取缓存

你可以通过 session 的 configuration 对象的 URLCache 属性来获取或者设置 URLSession 对象所使用的缓存对象。

对缓存对象调用 cachedResponseForRequest: 来查找一个给定 request 的 response 缓存。如果这个 request 的缓存数据存在的话就会返回一个 NSCachedURLResponse 对象,否则返回 nil.

你可以检查 cache 对象所使用的资源,currentDiskUsagediskCapacity 属性代表了缓存所使用的文件系统资源。currentMemoryUsagememoryCapacity 属性代表了缓存所使用的内存。

你可以使用 removeCachedResponseForRequest: 方法来删除单个缓存数据。也可以使用 removeCachedResponsesSinceDate: 来同时删除一个给定时间后的多个缓存数据。或者使用 removeAllCachedResponses 删除整个缓存。

通过写程序来管理缓存过程

你可以使用 storeCachedResponse:forRequest: 方法传入一个新的CachedURLResponse对象和一个URLRequest对象通过编写代码来写入缓存。

尤其是URLSessionTask对象处理的 response 的缓存,要在每一个 response 的基础上控制缓存,需要实现NSURLSessionDataDelegate协议的 URLSession:dataTask:willCacheResponse:completionHandler: 方法。注意这个代理方法只会对 upload task 和 data task 调用,而对于有 background 或 ephemeral(不会翻译了,暂时的?) 配置的 session 并不调用。

这个代理接收两个参数:一个CachedURLResponse对象和一个 completion handler。你的代理实现中必须直接调用这个 completion handler,传入下面的参数之一:

  • 提供的CachedURLResponse对象,来照原样缓存 response
  • nil, 来阻止缓存
  • 一个新创建的CachedURLResponse对象,典型的情况是基于提供的对象,按需求修改一下storagePolicyuserInfo 字典。

下面的代码展示了一个urlSession(_:dataTask:willCacheResponse:completionHandler:)的实现代码,其中拦截了 HTTPS 的请求,并且只允许将其缓存存储在内存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
willCacheResponse proposedResponse: CachedURLResponse,
completionHandler: @escaping (CachedURLResponse?) -> Void) {
if proposedResponse.response.url?.scheme == "https" {
let updatedResponse = CachedURLResponse(response: proposedResponse.response,
data: proposedResponse.data,
userInfo: proposedResponse.userInfo,
storagePolicy: .allowedInMemoryOnly)
completionHandler(updatedResponse)
} else {
completionHandler(proposedResponse)
}
}

认证和证书

处理认证询问

当服务器对一个 URL request 询问认证时进行合适的响应。

概述

当你的 app 使用一个NSURLSessionTask进行请求时,服务器可能会在继续进行前返回一个或多个认证询问。session task 会尝试帮你处理,当处理不了时,会调用你 session 的代理来处理这个问询。

实现这个段落中提到的代理方法来回答与你 app 连接的服务器的询问。如果你不实现这些代理方法,你的 request 可能会被服务器拒绝,你会收到一个包含 HTTP 状态码 401(Forbidden)的 response 而不是你想要的数据。

决定适合的代理方法

依据你接收到的问询的种类,实现下面的一个,或者两个代理认证方法都实现。

  • 实现NSURLSessionDelegate协议的处理整个 session 范围内的认证问询的 URLSession:didReceiveChallenge:completionHandler: 方法。这种问询就与传输层安全(TSL)生效一样,一旦你成功的处理了这种问询,那这个 NSURLSession 创建的所有 task 都会持续生效。
  • 实现NSURLSessionTaskDelegate协议的处理指定 task 的问询的 URLSession:task:didReceiveChallenge:completionHandler: 方法,这种问询是像请求 username/password 认证这种。从一个 session 中创建的每个 task 会提出各自的问询。

注意
查看 NSURLProtectionSpace Authentication Method Constants了解哪个问询方法是作用于整个 session 的,哪个是针对 task 的。

一个简单的例子,考虑一下当你请求一个被 HTTP 基本认证保护的 http URL(如RFC 7617.中定义的),因为是一个指定 task 的认证,你需要实现URLSession:task:didReceiveChallenge:completionHandler:方法来处理它。

注意
如果你是通过 https 连接的,你还会接收到一个服务器信任的认证,查看Performing Manual Server Trust Authentication获取处理这种作用于 session 的认证的信息。

下图展示了回复 HTTP 基本认证的策略759d2099-d938-415f-ac8a-1a0cac9dea4b
下面的章节描述了这个策略的实现

决定认证问询的种类

当你接收到一个认证问询时,使用代理方法来决定认证的种类。代理方法接收一个描述所提出的问询NSURLAuthenticationChallenge实例,这个实例包含一个protectionSpace属性,它的authenticationMethod属性表明了服务器所提出的认证种类(例如请求一个用户名和密码,或者一个客户端证书)。你使用该值来决定是否能处理这个认证问询。

你通过直接调用传入的 completion handler 来回复这个认证询问,向 handler 中传入一个NSURLSessionAuthChallengeDisposition 来表明你对认证问询的回复。你可以使用这个 disposition 参数来提供如下选择中合适的:一个认证,取消请求,或者允许默认的处理来进行。

下面代码检查了authenticationMethod是否是所期待的 HTTP 基本类型。如果authenticationMethod属性指明了其他种类的问询,它会调用 completion handler 并传入NSURLSessionAuthChallengePerformDefaultHandling处理参数。告诉 task 使用满足认证问询的默认处理;否则 task 会继续直到下一个认证问询再次调用这个代理方法。这个过程会继续执行直到等到你要处理的 HTTP 基本认证。

1
2
3
4
5
let authMethod = challenge.protectionSpace.authenticationMethod
guard authMethod == NSURLAuthenticationMethodHTTPBasic else {
completionHandler(.performDefaultHandling, nil)
return
}

创建一个凭证实例

你需要依据接收到的认证问询的种类来提交一个合适的凭证来回复认证。对于 HTTP 基本认证和 HTTP 摘要认证,你可以提供一个 username 和 password。下面的代码展示了一个从用户界面创建 NSURLCredential实例的帮助方法。

1
2
3
4
5
6
7
8
func credentialsFromUI() -> URLCredential? {
guard let username = usernameField.text, !username.isEmpty,
let password = passwordField.text, !password.isEmpty else {
return nil
}
return URLCredential(user: username, password: password,
persistence: .forSession)
}

在这个例子中,返回的NSURLCredential实例有个 NSURLCredentialPersistenceForSession 持久化策略,所以它只会保存在创建这个 task 的NSURLSession实例中,对于其他 session 实例创建的 task 你需要提供新的NSURLCredential实例,对于 app 以后运行时也需要创建新的实例。

调用 Completion Handler

一旦你尝试创建 credential 实例,你必须要调用 completion handler 来回复认证问询。

下面的代码展示了这两种情况

1
2
3
4
5
guard let credential = credentialOrNil else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, credential)

如果你传入了一个服务器接受的凭证,这个 task 会继续执行上传或下载数据。

重要
你可以将 completion handler 传给其他方法或者将其短暂的保存在属性中,用于等待用户完成 username/password 输入。但是你最终还是要调用 completion handler 来完成认证使得 task 继续执行,即使你选择像下面的失败情况处理中那样取消认证。

优雅的处理失败情况

如果凭证被拒绝,系统会再次调用你的代理方法。这种情况下,回调会将你被拒绝的凭证作为NSURLAuthenticationChallenge参数的属性proposedCredential传回给你。这个NSURLAuthenticationChallenge实例中还包含一个previousFailureCount属性,这个属性表示你的凭证被拒绝的次数。你可以使用这个参数来决定接下来怎么做。举个例子,如果previousFailureCount 比 0 大,你可以在使用已提交的凭证的字符串来在用户界面上插入一个重新输入 username/password 的界面。

执行服务器信任鉴定

在你的 app 中进行服务器安全凭证的评估。

概述

当你通过 URL request 使用安全连接时(例如 https),你的 NSURLSessionDelegate会接收到一个 NSURLAuthenticationMethodServerTrust 认证类型的认证问询。与其他的服务器要求你的 app 证明自己身份的问询不同,这里是你来鉴定服务器的凭证的机会。

决定何时进行服务器信任鉴定

大部分情况下,你应该将评估服务器信任度的工作交由 URL 加载系统的默认处理流程去做。当你没有设置代理或者没有处理认证问询时就会执行这些默认的行为。然后在下面这些场景中自己操作评估过程可能会更有用:

  • 你想要接受服务端证书而在默认情况下会被系统拒绝的情况。举个例子,你的 app 想要与使用自签名证书的开发服务器之间建立安全连接,而这个证书一般又不会与系统的信任证书的存储中的内容相匹配。
  • 你想要拒绝这个证书而在默认情况下会被系统接受。举个例子,你想要你的 app 固定在几个由你控制的 key 或证书上,而不是接受任何有效的证书。

下图展示了你的 app 如何通过提供一个代理方法来处理认证问询来执行手动的证书校验。这样就绕过了系统的默认处理。代理会直接比较服务器证书或公钥与存储在 app bundle 中的 证书或公钥(或哈希值等等)的拷贝。如果代理中判断这个服务器凭证是有效的,他就会信任这个服务器并允许继续连接。
851916ce-5c1c-4c23-b45b-c4632dacf24c

注意
如果你要连接的域名开启了 App Transport Security (ATS) ,NSURLSession 会强制使用。它应用于证书, TSL 版本,连接用的密码等安全要求。你不能放松一个使用 ATS 保护的域名的服务器的信任要求,但是你可以使用文章中面熟的技术收紧要求。查看 Information Property List Key Reference文章中的 NSAppTransportSecurity 获取更详细的信息。

处理服务器信任认证问询

实现NSURLSessionDelegateURLSession:didReceiveChallenge:completionHandler:方法来手动处理服务器信任认证。当这个方法被调用时,你的实现中首先需要检查:

  • 问询种类是服务器信任,而不是其他类型的问询。
  • 问询的主机名与你想要处理证书校验的证书匹配。

下面的代码展示了这些情况,根据传入URLSession:didReceiveChallenge:completionHandler:的 challenge 参数的 protectionSpace 来检查上面列出的两项。首先,从 protection space 中获取 authenticationMethod 检查认证问询的类型是不是 NSURLAuthenticationMethodServerTrust,然后保证 protection space 中的 host匹配期待的主机名 example.com,如果这两个条件有一个没有满足,则会调用 completion handler 并传入 NSURLSessionAuthChallengePerformDefaultHandling来让系统采用默认的方法处理。

1
2
3
4
5
6
7
let protectionSpace = challenge.protectionSpace
guard protectionSpace.authenticationMethod ==
NSURLAuthenticationMethodServerTrust,
protectionSpace.host.contains("example.com") else {
completionHandler(.performDefaultHandling, nil)
return
}

校验 Challenge 中的凭证

获取 protection space 的 serverTrust属性(SecTrustRef类的实例)来访问服务器的凭证。下面的代码展示了如何访问服务器凭证并接受或拒绝它。代码首先尝试从 protection space 中获取serverTrust属性,如果为空的话就回退到默认的处理去。然后将服务器凭证传给一个私有方法checkValidity(of:)来比较服务器凭证中的证书或公钥是否有 app bundle 中保存的有效值匹配。

1
2
3
4
5
6
7
8
9
10
11
12
guard let serverTrust = protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
if checkValidity(of: serverTrust) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
// Show a UI here warning the user the server credentials are
// invalid, and cancel the load.
completionHandler(.cancelAuthenticationChallenge, nil)
}

一旦代码决定了服务器凭证的有效性,它会采取下面的两个动作:

Tip
查看 Certificate, Key, and Trust Services 了解更多关于如何校验SecTrustRef 实例或者从中获取证书或公钥。

创建长期的服务器认证策略

如果你需要在某些情况下手动验证服务器认证信息,安排好你 app 的工作如果你想要修改服务器凭证,要考虑下列准则:

  • 将你的服务器凭证与一个公钥匹配,而不是在 app bundle 中存储一个单独的证书。这允许你对同样的 key 重新发布证书来更新服务器,而不需要更新 app。
  • 比较发布的 certificate authority’s(CA’s) key,而不是比较末端的 key,这样你可以部署包含新 key 的使用同一个 CA 签名的证书。
  • 使用一组 keys 或 CAs ,这样装换服务器凭证时更方便。

NSURLAuthenticationChallenge

NSURLCredential

NSURLCredentialStorage

NSURLProtectionSpace

Cookie

NSHTTPCookie

NSHTTPCookieStorage

Errors

URL Loading System Error Codes

URL Loading System Error Info Keys

遗留版本

Legacy URL Loading Systems

将你的代码从使用这些遗留版本的对象迁移到新的。