自定义URLCache子类搭配URLSession时HTTP缓存逻辑失效问题排查
我开发的App基于URLSession实现网络请求,原本使用URLCache将请求结果存储到磁盘,但发现当磁盘缓存容量达到diskCapacity时,URLCache的回收策略会移除所有缓存条目,不符合业务需求。因此打算编写一个URLCache子类,实现自定义存储和可控的LRU缓存回收策略。
URLCache文档明确支持这种场景的子类化:
URLCache类设计为直接使用,但在有特定需求时可以子类化。例如,你可能需要筛选可缓存的响应,或出于安全等原因重新实现存储机制。
但将自定义子类与URLSession结合使用时遇到了问题:
测试资源的HTTP响应头包含:
- Cache-Control: public, max-age=30
- Etag:
使用标准URLCache时:首次请求从网络加载数据;30秒内的二次请求直接使用缓存;30秒后的请求会携带Etag发起条件GET请求,完全符合预期。
但使用自定义URLCache子类时,所有请求都从网络加载,max-age规则失效,也不会发起条件GET请求。看起来URLCache从内部存储加载CachedURLResponse实例后会执行特殊处理,而我忽略了这部分逻辑,导致影响了URLSession的HTTP缓存行为。
以下是复现问题的极简URLCache子类代码:
class CustomURLCache: URLCache { let cachedResponseFileURL = URL(filePath: NSTemporaryDirectory().appending("entry.data")) // MARK: Internal storage func read() -> CachedURLResponse? { guard let data = try? Data(contentsOf: cachedResponseFileURL) else { return nil } return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! CachedURLResponse } func store(_ cachedResponse: CachedURLResponse) { try! (try! NSKeyedArchiver.archivedData(withRootObject: cachedResponse, requiringSecureCoding: false)).write(to: cachedResponseFileURL) } // MARK: URLCache Overrides override func cachedResponse(for request: URLRequest) -> CachedURLResponse? { read() } override func getCachedResponse(for dataTask: URLSessionDataTask, completionHandler: @escaping (CachedURLResponse?) -> Void) { completionHandler(read()) } override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) { store(cachedResponse) } override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for dataTask: URLSessionDataTask) { store(cachedResponse) } }
测试用例代码:
func test() { let useEvictingCache = false let config = URLSessionConfiguration.default if useEvictingCache { config.urlCache = CustomURLCache() } else { config.urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1024 * 1024 * 100) } self.urlSession = URLSession(configuration: config) let url = URL(string: "https://example.com/my-test-resource")! self.urlSession?.dataTask(with: URLRequest(url: url), completionHandler: { data, response, error in if let data { print("GOT DATA with \(data.count) bytes") } else if let error { print("GOT ERROR \(error)") } }).resume() }
测试环境:iOS 16.2
问题原因与修复方案
核心问题在于你的自定义子类完全绕过了URLCache父类的缓存有效性校验逻辑,URLSession需要依赖这些逻辑来判断是否可以直接使用缓存,或是发起条件请求。标准URLCache内部会自动处理缓存过期校验、条件请求头生成等工作,而你的子类只是简单读写CachedURLResponse,缺失了这些关键步骤。
具体缺失的逻辑
- 缓存过期时间校验:URLSession需要知道缓存的响应时间(
CachedURLResponse.responseDate)和max-age值,来判断缓存是否仍有效。如果你的存储没有正确保存responseDate,或者没有在返回缓存前做过期校验,URLSession会认为缓存不可用,直接走网络请求。 - 条件请求触发:当缓存过期时,标准URLCache会告知URLSession缓存存在但已过期,URLSession会自动提取Etag生成
If-None-Match头发起条件GET请求。你的子类没有传递这层信息,导致URLSession直接发起全新请求。 - 完整元数据的序列化:
NSKeyedArchiver虽然能序列化CachedURLResponse,但要确保所有缓存相关的元数据(比如responseDate、完整响应头)都被正确归档和恢复,否则URLSession无法识别缓存状态。
修复建议
- 保留缓存有效性校验逻辑:在返回缓存前,手动校验缓存是否过期,确保
responseDate和max-age的计算正确:override func cachedResponse(for request: URLRequest) -> CachedURLResponse? { guard let cachedResponse = read() else { return nil } // 校验HTTP响应的缓存有效性 guard let httpResponse = cachedResponse.response as? HTTPURLResponse, let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String else { return cachedResponse } // 解析max-age值 let regex = try! NSRegularExpression(pattern: #"max-age=(\d+)"#, options: []) if let match = regex.firstMatch(in: cacheControl, range: NSRange(cacheControl.startIndex..., in: cacheControl)), let maxAgeStr = Range(match.range(at: 1), in: cacheControl).map(String.init), let maxAge = Int(maxAgeStr) { let expirationDate = cachedResponse.responseDate.addingTimeInterval(TimeInterval(maxAge)) // 如果缓存未过期,直接返回;即使过期也返回缓存,让URLSession发起条件请求 return Date() <= expirationDate ? cachedResponse : cachedResponse } return cachedResponse } - 确保元数据完整保存:检查
NSKeyedArchiver是否完整保存了CachedURLResponse的responseDate属性——这个属性是判断缓存过期的核心,若缺失会导致URLSession无法计算缓存有效期。 - 不要完全绕过父类方法(可选):如果业务允许,可以在自定义存储的基础上,复用父类的部分逻辑,比如在存储时调用
super.storeCachedResponse(_:for:),但这取决于你是否需要完全自定义存储机制。
另外,在实现LRU回收策略时,需要为每个缓存条目维护访问时间戳,当达到存储上限时,移除最久未使用的条目,同时保证每个条目的CachedURLResponse元数据完整。
内容的提问来源于stack exchange,提问作者Nikita Zhuk

