我有一个用 Swift 编写的自定义服务器,使用 Kitura ( http://www.kitura.io ),在 AWS EC2 服务器上运行(在 Ubuntu 16.04 下)。我使用 CA 签名的 SSL 证书 ( https://letsencrypt.org ) 对其进行保护,因此我可以使用 https 从客户端连接到服务器。客户端在 iOS (9.3) 下本机运行。我在 iOS 上使用 URLSession 连接到服务器。
当我对 iOS 客户端进行多次大型下载时,我遇到了客户端超时问题。超时看起来像:
错误域=NSURLErrorDomain 代码=-1001 “请求超时。” UserInfo={NSErrorFailingURLStringKey=https://, _kCFStreamErrorCodeKey=-2102, NSErrorFailingURLKey=https://, NSLocalizedDescription=请求超时。, _kCFStreamErrorDomainKey=4, NSUnderlyingError=0x7f9f23d0 {Error Domain=kCFErrorDomainCFNetwork Code=-1001 "(null) " UserInfo={_kCFStreamErrorDomainKey=4, _kCFStreamErrorCodeKey=-2102}}}
在服务器上,超时总是发生在代码中的同一个地方——它们会导致特定的服务器请求线程阻塞并且永远不会恢复。超时发生在服务器线程调用 KituraRouterResponse
end
方法时。即,服务器线程在调用此end
方法时会阻塞。鉴于此,客户端应用程序超时也就不足为奇了。此代码是开源的,因此我将链接到服务器阻塞的位置:https ://github.com/crspybits/SyncServerII/blob/master/Server/Sources/Server/ServerSetup.swift#L146
失败的客户端测试是:https ://github.com/crspybits/SyncServerII/blob/master/iOS/Example/Tests/Performance.swift#L53
我不是从 Amazon S3 之类的东西下载的。数据是从另一个 Web 源在服务器上获取的,然后通过 https 从运行在 EC2 上的服务器下载到我的客户端。
例如,下载 1.2 MB 数据需要 3-4 秒,当我尝试连续下载 1.2 MB 数据中的 10 次时,其中 3 次超时。使用 HTTPS GET 请求进行下载。
有趣的是,首先进行这些下载的测试会上传相同数据大小的数据。即,它以 1.2 MB 的大小进行 10 次上传。我没有看到这些上传的超时失败。
我的大多数请求都有效,所以这似乎不仅仅是安装不正确的 SSL 证书的问题(我已经使用https://www.sslshopper.com进行了检查)。iOS 端的 https 设置不当似乎也不是问题,我NSAppTransportSecurity
在我的应用程序 .plist 中使用亚马逊的推荐进行了设置(https://aws.amazon.com/blogs/mobile/preparing-your- ios-9/ 应用程序)。
想法?
更新 1 : 我刚刚在本地 Ubuntu 16.04 系统上运行我的服务器并使用自签名 SSL 证书进行了尝试——其他因素保持不变。我遇到了同样的问题。因此,很明显这与 AWS无关。
更新 2: 服务器在本地 Ubuntu 16.04 系统上运行,并且不使用 SSL(服务器代码中只有一行更改以及在客户端中使用 http 而不是 https),问题不存在。下载成功发生。因此,很明显这个问题确实与 SSL 有关。
Update3:
服务器在本地 Ubuntu 16.04 系统上运行,并再次使用自签名 SSL 证书,我使用了一个简单的curl
客户端。为了尽可能模拟我一直在使用的测试,我中断了现有的 iOS 客户端测试,就像它开始下载一样,并使用我的客户端重新启动curl
——它使用服务器上的下载端点下载相同的 1.2MB 文件 20 次。错误没有复制。我的结论是问题源于 iOS 客户端和 SSL 之间的交互。
Update4:
我现在有一个更简单的 iOS 客户端版本来重现该问题。我将在下面复制它,但总而言之,它使用URLSession
's 并且我看到相同的超时问题(服务器正在使用自签名 SSL 证书在我的本地 Ubuntu 系统上运行)。当我禁用 SSL 使用(http 并且服务器上没有使用 SSL 证书)时,我没有得到问题。
这是更简单的客户端:
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
download(10)
}
func download(_ count:Int) {
if count > 0 {
let masterVersion = 16
let fileUUID = "31BFA360-A09A-4FAA-8B5D-1B2F4BFA5F0A"
let url = URL(string: "http://127.0.0.1:8181/DownloadFile/?fileUUID=\(fileUUID)&fileVersion=0&masterVersion=\(masterVersion)")!
Download.session.downloadFrom(url) {
self.download(count - 1)
}
}
}
}
// 在名为“Download.swift”的文件中:
import Foundation
class Download : NSObject {
static let session = Download()
var authHeaders:[String:String]!
override init() {
super.init()
authHeaders = [
<snip: HTTP headers specific to my server>
]
}
func downloadFrom(_ serverURL: URL, completion:@escaping ()->()) {
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.httpAdditionalHeaders = authHeaders
let session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: nil)
var request = URLRequest(url: serverURL)
request.httpMethod = "GET"
print("downloadFrom: serverURL: \(serverURL)")
var downloadTask:URLSessionDownloadTask!
downloadTask = session.downloadTask(with: request) { (url, urlResponse, error) in
print("downloadFrom completed: url: \(String(describing: url)); error: \(String(describing: error)); status: \(String(describing: (urlResponse as? HTTPURLResponse)?.statusCode))")
completion()
}
downloadTask.resume()
}
}
extension Download : URLSessionDelegate, URLSessionTaskDelegate /*, URLSessionDownloadDelegate */ {
public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) {
completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
}
更新5:
哇!我现在正朝着正确的方向前进!我现在有一个使用 SSL/https 的更简单的 iOS 客户端,并且不会导致此问题。@Ankit Thakur 建议进行更改:我现在使用URLSessionConfiguration.background
而不是URLSessionConfiguration.default
,这似乎是使它起作用的原因。我不知道为什么。这是否代表一个错误URLSessionConfiguration.default
?例如,我的应用程序在我的测试期间没有明确进入后台。另外:我不确定如何或是否能够在我的客户端应用程序中使用这种代码模式 - 似乎这种使用URLSession
's 不允许您httpAdditionalHeaders
在创建 URLSession 后更改。看来 的意图URLSessionConfiguration.background
是URLSession
应该在应用程序的生命周期内存在。这对我来说是个问题,因为我的 HTTP 标头可以在应用程序的单次启动期间更改。
这是我的新 Download.swift 代码。我更简单的示例中的其他代码保持不变:
import Foundation
class Download : NSObject {
static let session = Download()
var sessionConfiguration:URLSessionConfiguration!
var session:URLSession!
var authHeaders:[String:String]!
var downloadCompletion:(()->())!
var downloadTask:URLSessionDownloadTask!
var numberDownloads = 0
override init() {
super.init()
// https://developer.apple.com/reference/foundation/urlsessionconfiguration/1407496-background
sessionConfiguration = URLSessionConfiguration.background(withIdentifier: "MyIdentifier")
authHeaders = [
<snip: my headers>
]
sessionConfiguration.httpAdditionalHeaders = authHeaders
session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: OperationQueue.main)
}
func downloadFrom(_ serverURL: URL, completion:@escaping ()->()) {
downloadCompletion = completion
var request = URLRequest(url: serverURL)
request.httpMethod = "GET"
print("downloadFrom: serverURL: \(serverURL)")
downloadTask = session.downloadTask(with: request)
downloadTask.resume()
}
}
extension Download : URLSessionDelegate, URLSessionTaskDelegate, URLSessionDownloadDelegate {
public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) {
completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
print("download completed: location: \(location); status: \(String(describing: (downloadTask.response as? HTTPURLResponse)?.statusCode))")
let completion = downloadCompletion
downloadCompletion = nil
numberDownloads += 1
print("numberDownloads: \(numberDownloads)")
completion?()
}
// This gets called even when there was no error
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
print("didCompleteWithError: \(String(describing: error)); status: \(String(describing: (task.response as? HTTPURLResponse)?.statusCode))")
print("numberDownloads: \(numberDownloads)")
}
}
Update6:
我现在看到了如何处理 HTTP 标头的情况。我可以只使用allHTTPHeaderFields
URLRequest 的属性。情况应该基本解决了!
Update7: 我可能已经弄清楚为什么后台技术有效:
如果原始请求因超时而失败,后台会话创建的任何上传或下载任务都会自动重试。