diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a1b25..3260aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to NetworkingKit are documented in this file. +## 2.3.7 - 2026-07-19 + +### Changed + +- Expand the English and Chinese cache documentation with cache selection, policy behavior, HTTP cache semantics, backend coordination, offline behavior, lifecycle operations, and production scenarios. +- Update the Demo to use a bounded persistent disk cache with an explicit fallback TTL. + ## 2.3.6 - 2026-07-19 ### Changed diff --git a/Examples/NetworkingKitDemo/DemoViewModel.swift b/Examples/NetworkingKitDemo/DemoViewModel.swift index c461cbc..19d10c3 100644 --- a/Examples/NetworkingKitDemo/DemoViewModel.swift +++ b/Examples/NetworkingKitDemo/DemoViewModel.swift @@ -59,6 +59,20 @@ private enum DemoConstants { static let requestTimeout: TimeInterval = 15 } +/// Defines the HTTP cache behavior used by the Demo client. +/// +/// A disk cache makes successful GET responses available after the app relaunches. +/// The server can override the fallback TTL with `Cache-Control` or `Expires`. +private enum DemoCacheConfiguration { + static let maximumDiskSize = 20 * 1_024 * 1_024 + static let fallbackTTL: TimeInterval = 5 * 60 + + static var directory: URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("NetworkingKitDemoHTTPResponses", isDirectory: true) + } +} + /// Demonstrates the actor-backed token store required by refreshing authentication. /// /// This public API does not require credentials, so the demo returns `nil` values. @@ -87,7 +101,10 @@ final class AppNetworkClient: SharedNetworkClient, @unchecked Sendable { let session: URLSession let configuration = AppNetworkConfiguration.default private let refreshingAuthentication = RefreshingAuthInterceptor(provider: DemoTokenProvider.shared) - private let responseCache = InMemoryResponseCache(capacity: 50) + private let responseCache = DiskResponseCache( + directory: DemoCacheConfiguration.directory, + maximumSize: DemoCacheConfiguration.maximumDiskSize + ) private let circuitBreakers = CircuitBreakerRegistry(failureThreshold: 3, resetTimeout: 20) private let networkMetrics = NetworkMetrics() @@ -98,7 +115,8 @@ final class AppNetworkClient: SharedNetworkClient, @unchecked Sendable { registry: circuitBreakers ), cache: responseCache, - policy: .returnCacheElseLoad + policy: .returnCacheElseLoad, + defaultTTL: DemoCacheConfiguration.fallbackTTL ) } diff --git a/README.md b/README.md index b0530ff..c0df971 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [简体中文](README.zh-Hans.md) -NetworkingKit is a lightweight, native Swift networking library for iOS and macOS apps. It supports REST, GraphQL, `async/await`, Combine, Swift 6 concurrency, configurable client defaults, error localization, and request interceptors. +NetworkingKit is a lightweight, native Swift networking library for iOS, macOS, tvOS, and watchOS apps. It supports REST, GraphQL, `async/await`, Combine, Swift 6 concurrency, configurable client defaults, error localization, and request interceptors. ## Features @@ -30,7 +30,7 @@ Add the package in Xcode through **File > Add Package Dependencies**, or declare ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.6") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.7") ] ``` @@ -261,31 +261,115 @@ actor AppNetworkObserver: NetworkObserving { Use `RequestConcurrencyLimiter` as the client’s `executionController` to cap simultaneous transport attempts. Retries and one-time authentication replays each count as an attempt, which prevents failure storms from exhausting device or backend resources. -### Caching, offline mode, and transport security +### HTTP caching, offline mode, and transport security -Wrap the default transport to cache successful `GET` responses. `returnCacheElseLoad` is cache-first; `returnCacheDontLoad` provides deterministic offline behavior and throws `CacheMissError` when no entry exists. +Caching is a user-experience feature, not merely a performance optimization: it can make a catalog or profile appear immediately, reduce radio and server work, and keep previously viewed data available without a connection. `CachingTransport` caches only successful `GET` responses. It deliberately leaves writes (`POST`, `PUT`, `PATCH`, and `DELETE`) on the network so an App does not mistake an old write result for a completed mutation. + +#### Choose a cache implementation + +| Implementation | Lifetime | Best for | Capacity behavior | +| --- | --- | --- | --- | +| `InMemoryResponseCache` | The current process only | Small, non-sensitive screen data where a cold launch is acceptable | Bounded number of request keys; least-recently-used keys are evicted | +| `DiskResponseCache` | Survives App relaunches | Catalogs, articles, reference data, and offline-friendly reads | JSON files in an App-private directory; least-recently-accessed files are removed after `maximumSize` is exceeded | +| `NetworkResponseCaching` | Your implementation | Encrypted storage, a database, or an App-specific eviction policy | Defined by your implementation | + +For a production cache that should survive relaunches, create one cache owned by the client. Do not create a new cache inside `transport`: doing so would discard the cache each time the property is evaluated. ```swift -let cache = InMemoryResponseCache(capacity: 200) -let transport = CachingTransport( +final class CatalogAPIClient: SharedNetworkClient, @unchecked Sendable { + static let shared = CatalogAPIClient() + + let baseURL = URL(string: "https://api.example.com")! + let session: URLSession + + private let responseCache = DiskResponseCache( + directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("CatalogHTTPResponses", isDirectory: true), + maximumSize: 50 * 1_024 * 1_024 + ) + + var transport: any NetworkTransport { + CachingTransport( + upstream: URLSessionTransport(session: session), + cache: responseCache, + policy: .returnCacheElseLoad, + defaultTTL: 5 * 60 + ) + } + + private init() { + session = URLSession(configuration: .default) + } +} +``` + +#### Choose a read policy + +`NetworkCachePolicy` decides whether an existing cached response may satisfy a `GET`. A successful network response is still eligible to refresh the cache for `networkOnly` and `returnCacheElseLoad`. + +| Policy | Behavior | Typical use | +| --- | --- | --- | +| `.networkOnly` | Always sends the request upstream; bypasses a cache read, then stores an eligible successful response | Pull to refresh, a screen that must show current server state, or debugging | +| `.returnCacheElseLoad` | Returns a fresh cached response immediately. Missing, expired, or `no-cache` entries go upstream and are revalidated when possible | Default for catalogs, read-only profile data, configuration, and article detail | +| `.returnCacheDontLoad` | Never contacts the network. Returns a matching cached response even if it is expired; otherwise throws `CacheMissError` | An explicit offline mode or an offline-only screen | + +For example, an offline download area can use a second transport composition with the same `DiskResponseCache` and `.returnCacheDontLoad`. Handle `CacheMissError` by explaining that the item has not been downloaded yet. + +```swift +let offlineTransport = CachingTransport( upstream: URLSessionTransport(session: session), - cache: cache, - policy: .returnCacheElseLoad + cache: responseCache, + policy: .returnCacheDontLoad ) ``` -For cache survival across launches and standards-based revalidation, use `DiskResponseCache`. Expired entries automatically send `If-None-Match` when an ETag is available; a `304 Not Modified` response reuses the local body and refreshes its TTL. +#### Work with the backend's HTTP cache rules + +The App chooses *where* responses are stored and the read policy; the backend should decide *how long* a representation is valid. NetworkingKit uses these standard response headers: + +| Server header | What NetworkingKit does | Recommended backend use | +| --- | --- | --- | +| `Cache-Control: max-age=300` | Treats the response as fresh for 300 seconds | Public or user-safe read data that may be reused briefly | +| `Cache-Control: no-cache` | Stores the response but requires a network revalidation before reusing it | Data that may be stored locally but must be checked before each reuse | +| `Cache-Control: no-store` | Does not store the response | Tokens, one-time secrets, highly sensitive account or payment data | +| `Expires` | Uses it when `max-age` is absent | Legacy backends; prefer `Cache-Control: max-age` for new APIs | +| `ETag: "version"` | Adds `If-None-Match` for an expired matching entry. On `304 Not Modified`, reuses the local body and refreshes metadata/TTL | Any response where the backend can cheaply determine whether its representation changed | +| `Vary: Accept-Language` | Stores a separate variant for each relevant request-header value | Localized content, content negotiation, or other header-dependent representations | + +When neither `Cache-Control` nor `Expires` is present, `defaultTTL` is used (five minutes by default). If the request itself includes `Cache-Control: no-store`, NetworkingKit neither reads nor writes a cache entry for that request. Responses declaring `Vary: *` are never stored because they cannot be matched safely. + +A typical backend flow looks like this: -The cache honors request and response `Cache-Control: no-store`, `no-cache`, and `Expires`; it maintains separate response variants for `Vary` headers and never stores `Vary: *` responses. `304 Not Modified` merges its metadata with the cached response before refreshing TTL. The in-memory cache keeps entries in least-recently-used order. +```text +1. GET /articles/42 → 200 + Cache-Control: max-age=300 + ETag: "article-42-v7" + +2. After five minutes, the App sends: + GET /articles/42 + If-None-Match: "article-42-v7" + +3. If unchanged, the backend replies 304 Not Modified. + NetworkingKit keeps the local response body, merges returned headers, and refreshes its expiry. +``` + +This avoids downloading the same JSON body again while still allowing the backend to control freshness. If a stale entry cannot be revalidated because the network fails, `returnCacheElseLoad` surfaces that failure rather than silently presenting stale data; use `.returnCacheDontLoad` only when the product intentionally supports offline data. + +#### Operate and invalidate the cache + +Inspect a disk cache for diagnostics or storage reporting, and clear user-scoped cached data when the user signs out or switches account. A cache directory must remain App-private; never use a shared location for authenticated responses. ```swift -let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - .appendingPathComponent("NetworkingKitCache") -let cache = DiskResponseCache(directory: directory, maximumSize: 50 * 1_024 * 1_024) -let transport = CachingTransport(upstream: URLSessionTransport(session: session), cache: cache) +let statistics = await responseCache.statistics() +print("Cache files: \(statistics.entryCount), bytes: \(statistics.totalSize)") + +func signOut() async { + await responseCache.removeAll() + // Clear credentials and App state after the cache is removed. +} ``` -Call `await cache.statistics()` for its file count and total size, and `await cache.removeAll()` to clear it on logout or when the app needs to reclaim storage. Files are pruned by least-recent access when the configured size limit is exceeded. +For data changed by a write, prefer the backend's versioning/ETag rules or clear the relevant cache namespace through a custom `NetworkResponseCaching` implementation. The built-in caches intentionally expose `removeAll()` rather than URL-specific deletion, keeping the default behavior simple and safe. Use `CertificatePinningEvaluator` and `ServerTrustSessionDelegate` to pin leaf-certificate DER data per host. Keep at least two pins during certificate rotation, and retain normal system trust evaluation before accepting a pin. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 62335d4..181ab58 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -2,7 +2,7 @@ [English](README.md) -NetworkingKit 是一个面向 iOS 与 macOS App 的轻量级原生 Swift 网络库,支持 REST、GraphQL、`async/await`、Combine、Swift 6 并发、Client 级配置、错误本地化和拦截器。 +NetworkingKit 是一个面向 iOS、macOS、tvOS 与 watchOS App 的轻量级原生 Swift 网络库,支持 REST、GraphQL、`async/await`、Combine、Swift 6 并发、Client 级配置、错误本地化和拦截器。 ## 特性 @@ -28,7 +28,7 @@ NetworkingKit 是一个面向 iOS 与 macOS App 的轻量级原生 Swift 网络 ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.6") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.7") ] ``` @@ -244,31 +244,115 @@ actor AppNetworkObserver: NetworkObserving { 将 `RequestConcurrencyLimiter` 配置为 Client 的 `executionController` 可限制同时进行的传输尝试数。重试和一次认证重放均会计入尝试次数,避免故障风暴耗尽设备或服务端资源。 -### 缓存、离线模式与传输安全 +### HTTP 缓存、离线模式与传输安全 -通过包装默认 Transport 可缓存成功的 `GET` 响应。`returnCacheElseLoad` 为缓存优先;`returnCacheDontLoad` 提供确定性的离线行为,找不到缓存时会抛出 `CacheMissError`。 +缓存不只是性能优化,更直接影响用户体验:商品列表或个人资料可以更快显示,网络和服务端压力更小,已经看过的内容在无网时仍有机会可用。`CachingTransport` 只缓存成功的 `GET` 响应;`POST`、`PUT`、`PATCH` 和 `DELETE` 等写操作始终走网络,避免 App 把旧的写入结果误当成一次已经完成的业务操作。 + +#### 选择缓存实现 + +| 实现 | 存活时间 | 适合场景 | 容量行为 | +| --- | --- | --- | --- | +| `InMemoryResponseCache` | 仅当前进程 | 可接受冷启动的少量、非敏感页面数据 | 以请求 key 数量为上限,按最近最少使用顺序淘汰 | +| `DiskResponseCache` | 跨 App 启动保留 | 商品目录、文章、基础资料和支持离线的读取接口 | 保存在 App 私有目录的 JSON 文件;超过 `maximumSize` 后按最近最少访问顺序清理 | +| `NetworkResponseCaching` | 由 App 自行实现 | 加密存储、数据库或特定淘汰规则 | 由实现决定 | + +生产环境中如果希望跨启动保留缓存,应由 Client 持有一个缓存实例。不要在 `transport` 属性里每次临时创建缓存,否则属性每次计算都会丢失已有内容。 ```swift -let cache = InMemoryResponseCache(capacity: 200) -let transport = CachingTransport( +final class CatalogAPIClient: SharedNetworkClient, @unchecked Sendable { + static let shared = CatalogAPIClient() + + let baseURL = URL(string: "https://api.example.com")! + let session: URLSession + + private let responseCache = DiskResponseCache( + directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("CatalogHTTPResponses", isDirectory: true), + maximumSize: 50 * 1_024 * 1_024 + ) + + var transport: any NetworkTransport { + CachingTransport( + upstream: URLSessionTransport(session: session), + cache: responseCache, + policy: .returnCacheElseLoad, + defaultTTL: 5 * 60 + ) + } + + private init() { + session = URLSession(configuration: .default) + } +} +``` + +#### 选择读取策略 + +`NetworkCachePolicy` 决定已有缓存能否满足一次 `GET` 请求。对于 `.networkOnly` 和 `.returnCacheElseLoad`,后续成功的网络响应仍可能更新缓存。 + +| 策略 | 行为 | 常见场景 | +| --- | --- | --- | +| `.networkOnly` | 始终请求上游;跳过缓存读取,但会保存符合条件的成功响应 | 下拉刷新、必须展示服务器最新状态的页面、排查问题 | +| `.returnCacheElseLoad` | 立即返回仍新鲜的缓存;缓存缺失、已过期或带有 `no-cache` 时请求网络,并在可能时重新验证 | 商品目录、只读资料、配置、文章详情等默认选择 | +| `.returnCacheDontLoad` | 完全不访问网络;即使缓存已过期也返回匹配内容,没有条目则抛出 `CacheMissError` | 明确的离线模式或仅离线可用的页面 | + +例如,离线下载页可通过同一个 `DiskResponseCache` 组合出 `.returnCacheDontLoad` 的 Transport。捕获 `CacheMissError` 后提示用户该内容尚未下载。 + +```swift +let offlineTransport = CachingTransport( upstream: URLSessionTransport(session: session), - cache: cache, - policy: .returnCacheElseLoad + cache: responseCache, + policy: .returnCacheDontLoad ) ``` -需要跨 App 启动保留缓存并遵循 HTTP 重新验证语义时,请使用 `DiskResponseCache`。过期条目存在 ETag 时会自动发送 `If-None-Match`;服务端返回 `304 Not Modified` 后会复用本地 body 并刷新 TTL。 +#### 与后端的 HTTP 缓存规则协作 + +App 决定“缓存保存在哪里”和“采用何种读取策略”;后端应决定“一份数据在多长时间内有效”。NetworkingKit 会处理以下标准响应 Header: + +| 服务端 Header | NetworkingKit 的行为 | 推荐的后端用法 | +| --- | --- | --- | +| `Cache-Control: max-age=300` | 将响应视为 300 秒内新鲜 | 可以短时间安全复用的公开数据或当前用户可见的读取数据 | +| `Cache-Control: no-cache` | 保存响应,但每次复用前都要求走网络重新验证 | 可以本地保存,但每次展示前都必须确认是否变化的数据 | +| `Cache-Control: no-store` | 不保存响应 | Token、一次性密钥、高敏感账户或支付数据 | +| `Expires` | 在没有 `max-age` 时使用 | 兼容旧后端;新接口优先使用 `Cache-Control: max-age` | +| `ETag: "version"` | 匹配的缓存过期后添加 `If-None-Match`;收到 `304 Not Modified` 时复用本地 body,并刷新元数据和 TTL | 后端能够低成本判断资源是否变化的读取接口 | +| `Vary: Accept-Language` | 针对相关请求 Header 的不同值保存独立变体 | 多语言内容、内容协商或其他依赖 Header 的响应 | + +当响应既没有 `Cache-Control` 也没有 `Expires` 时,会使用 `defaultTTL`(默认五分钟)。如果请求本身带有 `Cache-Control: no-store`,NetworkingKit 不会读取或写入对应缓存。带有 `Vary: *` 的响应无法安全匹配,因此永远不会被保存。 + +典型的前后端协作流程如下: -缓存遵守请求和响应中的 `Cache-Control: no-store`、`no-cache`,并支持 `Expires`;会为 `Vary` Header 保留独立响应变体,且永不缓存 `Vary: *` 响应。`304 Not Modified` 会先与旧缓存 Header 合并再刷新 TTL,内存缓存则采用最近最少使用(LRU)淘汰顺序。 +```text +1. GET /articles/42 → 200 + Cache-Control: max-age=300 + ETag: "article-42-v7" + +2. 五分钟后,App 发送: + GET /articles/42 + If-None-Match: "article-42-v7" + +3. 若内容未变,后端返回 304 Not Modified。 + NetworkingKit 保留本地响应 body,合并服务端返回的 Header,并刷新过期时间。 +``` + +这样无需重复下载同一份 JSON body,同时仍由后端控制新鲜度。如果缓存过期后网络重新验证失败,`.returnCacheElseLoad` 会把错误交给 App,而不会悄悄展示旧数据;只有产品明确支持离线旧数据时才应使用 `.returnCacheDontLoad`。 + +#### 缓存的运维与失效 + +可读取磁盘缓存统计信息用于诊断或存储展示;用户退出登录、切换账号时,应清理与用户相关的缓存。缓存目录必须是 App 私有目录,认证后的响应不要放在共享位置。 ```swift -let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - .appendingPathComponent("NetworkingKitCache") -let cache = DiskResponseCache(directory: directory, maximumSize: 50 * 1_024 * 1_024) -let transport = CachingTransport(upstream: URLSessionTransport(session: session), cache: cache) +let statistics = await responseCache.statistics() +print("Cache files: \(statistics.entryCount), bytes: \(statistics.totalSize)") + +func signOut() async { + await responseCache.removeAll() + // Clear credentials and App state after the cache is removed. +} ``` -可通过 `await cache.statistics()` 查看文件数量和总大小;用户退出登录或 App 需要回收空间时调用 `await cache.removeAll()` 清空。超过配置容量后,会按最近最少访问顺序清理文件。 +对于写操作改变的数据,优先使用后端的版本控制或 ETag 规则;如需按 URL 等精确清理,可实现自定义 `NetworkResponseCaching`。内置缓存只提供 `removeAll()`,以保持默认行为简单、安全。 使用 `CertificatePinningEvaluator` 和 `ServerTrustSessionDelegate` 可按 Host 固定叶子证书的 DER 数据。证书轮换期间至少保留两个 pin,并在接受 pin 前继续使用系统信任校验。