Skip to content

Commit fa3b9ca

Browse files
committed
重构: 拆分代理与下载链路职责
- 抽出代理请求解析、响应校验、缓存写入和 HLS 处理组件 - 拆分 URLSession 下载配置、重试、校验和流式代理逻辑 - 调整缓存淘汰为最近访问顺序并补充文档与测试
1 parent be33456 commit fa3b9ca

23 files changed

Lines changed: 1186 additions & 902 deletions

Documentation/architecture.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# HTTPMediaCache 架构维护说明
2+
3+
HTTPMediaCache 是一个 Swift-only 的本地 HTTP 媒体缓存库。核心路径分为代理播放、主动预加载、HLS playlist 改写、区间缓存存储和 URLSession 回源下载。
4+
5+
## 模块边界
6+
7+
- `API`:暴露 `HTTPMediaCache``CacheRequest``PreloadTask`、配置和查询入口。
8+
- `Core`:放运行时装配、Range、代理 URL 编解码等基础能力。
9+
- `Proxy`:承接 NIO HTTP 请求,解析代理 URL,按资源类型分发到缓存响应、回源响应或 HLS playlist 改写。
10+
- `Pipeline`:负责把请求区间规划为本地缓存段和远端下载段。
11+
- `Storage`:维护 URL 到缓存单元的索引、元数据、数据文件和缓存空间限制。
12+
- `Network`:定义下载抽象和默认 `URLSessionDownloader` 实现。
13+
- `HLS`:解析、选择、改写 playlist,并为 HLS 预加载生成资源计划。
14+
15+
## 代理播放链路
16+
17+
1. `NIOProxyServer` 接收播放器请求并进入 `HTTPRequestRouter`
18+
2. `ProxyRequestParser` 解码原始 URL,过滤不能透传的请求头,并规范化 Range。
19+
3. `ProxyRequestClassifier` 区分 HLS playlist、HLS 子资源和普通媒体文件。
20+
4. 普通媒体优先通过 `ProxyCacheResponder` 命中完整缓存或请求区间缓存。
21+
5. 缓存未命中时,router 选择 HEAD、流式回源或拼接式回源路径。
22+
6. 回源数据通过 `ProxyCacheWriter` 写入缓存,响应长度和 Range 由 `ProxyResponseValidator` 校验。
23+
7. `HTTPResponseWriter+Proxy` 负责把缓存响应、数据响应和 HEAD 响应转换为 NIO 输出。
24+
25+
`HTTPRequestRouter` 应只保留请求生命周期、路径选择和 NIO 上下文管理;新增代理行为时优先放到 responder、writer、validator 或 parser 中。
26+
27+
## HLS 链路
28+
29+
HLS playlist 请求由 `HLSProxyResponder` 处理。它缓存原始 playlist,再在返回播放器前通过 `HLSPlaylistRewriter` 改写 URI。改写后的 variant、rendition、segment、key 和 init map URI 会重新进入代理链路。
30+
31+
HLS 下载请求头由 `HLSDownloadPolicy` 统一生成。playlist、key 和完整 segment 默认不继承播放器 Range;init map 与 byte-range segment 使用 playlist 中声明的 `BYTERANGE`
32+
33+
## 预加载链路
34+
35+
`HTTPMediaCache.preload` 创建 `PreloadTask` 并进入 `PreloadCoordinator`。普通文件由 `FilePreloadExecutor` 规划和下载目标 Range;HLS 由 `HLSPreloadExecutor` 读取 playlist,递归选择子 playlist,并按 segment 数、字节数或时长限制预加载依赖资源。
36+
37+
预加载和代理播放共用 `CacheIndex``CacheUnit`、下载器、HLS 下载策略和缓存 identity provider,因此行为变更要同时验证代理和预加载测试。
38+
39+
## 缓存与淘汰
40+
41+
`CacheIndex` 管理 URL 到 `CacheUnit` 的映射。`CacheUnit` 负责 `data.bin``metadata.json`、已缓存区间和完整文件路径。
42+
43+
当前空间限制策略是 LRU 淘汰:写入前如果空间不足,`CacheIndex.prepareForWrite` 会按缓存单元的最近访问时间删除未使用中的旧缓存,并跳过正在读取或写入的缓存单元。成功写入、读取缓存数据和获取完整缓存文件路径会刷新最近访问时间;单纯查询 `cacheItem` 或缓存列表不会改变淘汰顺序。
44+
45+
## 下载器拆分
46+
47+
默认下载器由多个小组件组成:
48+
49+
- `URLSessionDownloader`:下载入口和编排。
50+
- `URLSessionStreamingDelegate`:URLSession delegate、响应 continuation、body stream 和取消处理。
51+
- `URLSessionDownloadValidator`:内容类型、内容长度和 Range 响应校验。
52+
- `URLSessionDownloadRetryPolicy`:瞬时网络错误重试。
53+
- `URLSessionDownloaderSettings`:超时、请求头、内容类型和 metrics 配置。
54+
- `URLSessionDownloaderBackgroundTaskCoordinator`:iOS 后台下载任务生命周期。
55+
56+
新增下载行为时优先扩展对应组件,避免把策略重新堆回 `URLSessionDownloader`
57+
58+
## 验证建议
59+
60+
- 网络层改动:运行 `swift test --scratch-path /tmp/HTTPMediaCache-swiftpm-scratch --filter URLSessionDownloaderTests`
61+
- 代理层改动:运行 `swift test --scratch-path /tmp/HTTPMediaCache-swiftpm-scratch --filter ProxyRangeResponseTests`
62+
- HLS 或预加载改动:运行 `swift test --scratch-path /tmp/HTTPMediaCache-swiftpm-scratch --filter PreloadTests``--filter HLSPlaylistRewriterTests`
63+
- 收尾验证:运行完整 `swift test --scratch-path /tmp/HTTPMediaCache-swiftpm-scratch`

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ await HTTPMediaCache.stop()
7575

7676
## 项目架构
7777

78-
完整架构图可查看 [Documentation/architecture.html](Documentation/architecture.html)
78+
完整架构图可查看 [Documentation/architecture.html](Documentation/architecture.html),维护说明可查看 [Documentation/architecture.md](Documentation/architecture.md)
7979

8080
```mermaid
8181
flowchart LR
@@ -320,6 +320,8 @@ await HTTPMediaCache.setMaxCacheLength(500 * 1024 * 1024)
320320
let maxLength = await HTTPMediaCache.maxCacheLength()
321321
```
322322

323+
当写入新数据会超过最大缓存空间时,缓存索引会按最近访问时间淘汰未使用中的旧缓存,并跳过正在读取或写入的缓存单元。成功写入、读取缓存数据和获取完整缓存文件路径都会刷新缓存单元的最近访问时间;单纯查询 `cacheItem` 或缓存列表不会改变淘汰顺序。
324+
323325
## 下载配置
324326

325327
可以配置下载超时、透传请求头、额外请求头和可接受的响应类型:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// HTTPURLResponse+URLSessionHeaders.swift
3+
// HTTPMediaCache
4+
//
5+
// Created by Sun on 2026/5/12.
6+
//
7+
8+
import Foundation
9+
10+
extension HTTPURLResponse {
11+
var urlSessionHeaderDictionary: [String: String] {
12+
allHeaderFields.reduce(into: [String: String]()) { result, header in
13+
guard let name = header.key as? String else {
14+
return
15+
}
16+
result[name] = "\(header.value)"
17+
}
18+
}
19+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// URLSessionDownloadRetryPolicy.swift
3+
// HTTPMediaCache
4+
//
5+
// Created by Sun on 2026/5/12.
6+
//
7+
8+
import Foundation
9+
10+
struct URLSessionDownloadRetryPolicy {
11+
func run<T>(_ operation: () async throws -> T) async throws -> T {
12+
let maxAttemptCount = 3
13+
var lastError: Error?
14+
15+
for attempt in 1 ... maxAttemptCount {
16+
do {
17+
return try await operation()
18+
} catch {
19+
guard attempt < maxAttemptCount, isTransientNetworkError(error) else {
20+
throw error
21+
}
22+
lastError = error
23+
try? await Task.sleep(nanoseconds: UInt64(attempt) * 200_000_000)
24+
}
25+
}
26+
27+
throw lastError ?? CacheError.networkFailure("Transient retry failed without an underlying error.")
28+
}
29+
30+
private func isTransientNetworkError(_ error: Error) -> Bool {
31+
let nsError = error as NSError
32+
guard nsError.domain == NSURLErrorDomain else {
33+
return false
34+
}
35+
36+
let retryableCodes = [
37+
NSURLErrorSecureConnectionFailed,
38+
NSURLErrorNetworkConnectionLost,
39+
NSURLErrorTimedOut,
40+
NSURLErrorCannotConnectToHost,
41+
NSURLErrorDNSLookupFailed,
42+
]
43+
return retryableCodes.contains(nsError.code)
44+
}
45+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// URLSessionDownloadValidator.swift
3+
// HTTPMediaCache
4+
//
5+
// Created by Sun on 2026/5/12.
6+
//
7+
8+
import Foundation
9+
10+
struct URLSessionDownloadValidator {
11+
func validate(response: HTTPURLResponse, cacheRequest: CacheRequest) async throws {
12+
let settings = await URLSessionDownloaderSettings.shared.snapshot()
13+
guard let contentType = response.value(forHTTPHeaderField: "Content-Type"), !contentType.isEmpty else {
14+
throw CacheError.networkFailure("Unacceptable content type for \(cacheRequest.url.absoluteString).")
15+
}
16+
17+
let acceptableContentTypes = settings.acceptableContentTypes ?? []
18+
let isAcceptable = acceptableContentTypes.contains {
19+
contentType.range(of: $0, options: [.caseInsensitive]) != nil
20+
}
21+
if !isAcceptable,
22+
settings.unacceptableContentTypeDisposer?(cacheRequest.url, contentType) != true
23+
{
24+
throw CacheError.networkFailure("Unacceptable content type for \(cacheRequest.url.absoluteString).")
25+
}
26+
27+
let headers = response.urlSessionHeaderDictionary
28+
let contentLength = headers.contentLength ?? 0
29+
guard contentLength > 0 else {
30+
throw CacheError.networkFailure("Invalid Content-Length for \(cacheRequest.url.absoluteString).")
31+
}
32+
33+
guard let range = cacheRequest.range,
34+
!range.isSuffixRange,
35+
let requestedEnd = range.end,
36+
let expectedLength = range.length,
37+
let totalLength = headers.contentRangeTotalLength ?? headers.contentLength,
38+
requestedEnd < totalLength
39+
else {
40+
return
41+
}
42+
43+
guard contentLength == expectedLength else {
44+
throw CacheError.networkFailure("Mismatched Content-Length for \(cacheRequest.url.absoluteString).")
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)