diff --git a/i18n/zh-TW/GLOSSARY.md b/i18n/zh-TW/GLOSSARY.md index d51e2ce..d5e3e8f 100644 --- a/i18n/zh-TW/GLOSSARY.md +++ b/i18n/zh-TW/GLOSSARY.md @@ -10,7 +10,9 @@ - 程式碼區塊內的程式碼不翻譯,只翻譯註解。 - 中文與英文、數字、inline code 之間加一個半形空格;中文句子使用全形標點。 - 下表標示「保留」的詞在中文句子中直接使用英文。 -- 章節錨點(`#authenticated-endpoints` 等)由英文標題產生;連到英文文件時沿用英文錨點。 +- 章節錨點沿用英文:每個翻譯後的標題都以 `{#id}` 指定與英文頁面相同的錨點,例如 `## 快取鍵 {#cache-keys}`,讓兩種語言的連結可以互換。 +- 中文段落不要在句中換行:換行在 HTML 中會變成空格,出現在兩個中文字之間。一個段落或清單項目寫在同一行。 +- 尚未翻譯的頁面以英文網址連結(`https://fastapi-cachex.readthedocs.io/en/latest/...`),並在連結後加上「(英文)」。 ## 詞彙 @@ -20,9 +22,14 @@ | cache(動詞) | 快取、存入快取 | | | cache hit / miss | 快取命中/未命中 | | | cache key | 快取鍵 | | +| (cache) entry | 項目、快取項目 | | +| key builder | 保留 | | | backend | 後端 | | | header | 標頭 | | | request / response | 請求/回應 | | +| (response) body | 本文 | | +| render(回應) | 產生、重新產生 | | +| replay | 重播 | 重播快取的回應;replay attack:重送攻擊 | | handler | 保留 | 指路由處理函式 | | route / endpoint | 路由/端點 | | | decorator | 裝飾器 | | @@ -38,12 +45,20 @@ | atomic | 原子性、原子操作 | | | counter | 計數器 | | | lock | 鎖 | | +| one-shot | 一次性 | | +| glob | 萬用字元(glob) | | +| no-op | 保留 | | +| stampede | 保留(cache stampede) | | | token | 權杖 | | | signature / sign | 簽章/簽署 | | | session | 保留(Session) | | | claim | 保留 | JWT claim | | state(OAuth) | 保留 | | | serialize / deserialize | 序列化/反序列化 | | +| process / multi-process | 行程/多行程 | | +| worker | 保留 | 工作執行緒(worker thread)除外 | +| production | 正式環境 | | +| callback(OAuth) | 回呼(callback) | | | extra | 保留 | 套件的選用依賴,例:`redis` extra | | deprecated | 已棄用 | | | breaking change | 破壞性變更 | | diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md new file mode 100644 index 0000000..e60e145 --- /dev/null +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -0,0 +1,50 @@ +# 應用層快取 {#application-cache} + +除了透過 `@cache` 快取 HTTP 回應,你也可以用 `CacheManager` 在商業邏輯中直接快取任意可 JSON 序列化的 Python 值。它是一層輕量、具命名空間的包裝,底層使用透過 `BackendProxy` 設定的後端。 + +```python +from fastapi_cachex import AppCache, CacheManager + + +@app.get("/expensive") +async def expensive_operation(cache: AppCache): + result = await cache.get("expensive:result") + if result is None: + result = perform_expensive_calculation() + await cache.set("expensive:result", result, ttl=300) + return result + + +# 也可以直接建立實例,例如在請求之外使用: +manager = CacheManager(key_prefix="myapp:", default_ttl=60) +await manager.set("user:42", {"name": "Alice"}) +user = await manager.get("user:42") # {"name": "Alice"} +await manager.delete("user:42") +await manager.clear_prefix() # 清除 "myapp:" 底下的所有項目 + +# 未命中時才計算:只有在鍵不存在、已過期或無法解碼時才會執行 `factory`。 +# 它可以是同步或非同步函式。 +profile = await manager.get_or_set("user:42", lambda: load_user(42), ttl=300) + +# 只在鍵尚未被占用時寫入:同時呼叫的呼叫者中恰好只有一個會得到 True。 +if await manager.add(f"webhook:{event_id}", True, ttl=86400): + await deliver_webhook(event_id) + +# 在此 manager 的命名空間內做萬用字元(glob)比對,使用後端原生的模式比對 +# 支援(Redis SCAN),而不是列舉所有鍵。 +await manager.clear_pattern("user:*") # 比對 "myapp:user:*" +``` + +## 行為 {#behavior} + +- `get()` 在快取未命中時回傳 `None`(或你提供的 `default=`),遇到不存在或損毀的項目也絕不會拋出例外。 +- `set()` 遇到無法 JSON 序列化的值時,會讓 `TypeError` 直接往外拋出。 +- `get_or_set()` 不提供 cache stampede 保護:同一個鍵同時發生多次未命中時,每一次都會執行 `factory`。 +- `add()` 只在鍵尚未被占用時寫入值,並回傳是否有寫入。檢查與寫入是同一個後端原子操作(`set_if_absent`),因此適合「每個鍵只做一次」的工作,例如 webhook 或電子郵件的去重。已過期的鍵視為未被占用;存放無法解碼之值的鍵則不算,即使 `get()` 會把它當成未命中。 +- 鍵預設位於獨立、以 `cache:` 為前綴的命名空間,與 HTTP 路由快取及 OAuth state 分開,因此 `clear()`/`clear_prefix()` 絕不會動到無關的快取項目。 +- `AppCache` 依賴項在第一次使用時會建立並註冊一個預設的 `CacheManager`;`CacheManagerProxy.set()` 則可改為註冊你自己的實例。 + +> [!NOTE] +> `clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是一次批次 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `clear_pattern()` 在 Memcached 後端上不會有任何作用;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。 + +完整的方法清單請見 [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/cache-manager/)(英文)。 diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md new file mode 100644 index 0000000..3ef8931 --- /dev/null +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -0,0 +1,135 @@ +# 後端 {#backends} + +所有快取,包括 HTTP 回應、`CacheManager` 的值、Session 與 OAuth state,都存放在同一個後端,並在啟動時以 `BackendProxy.set()` 註冊一次。 + +## 選擇後端 {#choosing-a-backend} + +| 情境 | 建議後端 | 原因 | +|----------|---------------------|-----| +| 開發與測試 | MemoryBackend | 快速,無外部依賴 | +| 分散式系統 | Redis | 非同步、有效率,支援依模式清除 | +| 簡單快取 | Memcached | 穩定、成熟(但無法列舉鍵,因此不支援依模式/路徑清除,也無法監控) | +| 多行程部署 | Redis | 共用快取、一致性 | + +所有後端都會以前綴為鍵建立命名空間(預設為 `fastapi_cachex:`,可用 `key_prefix=` 變更),以避免與其他應用程式衝突。 + +## 記憶體(預設) {#in-memory-default} + +若未指定後端,FastAPI-CacheX 預設會使用記憶體快取。這適合開發與測試用途。此後端會自動執行清理工作,每 60 秒移除一次已過期的項目(`MemoryBackend(cleanup_interval=60)`)。 + +```python +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex import BackendProxy + +backend = MemoryBackend() +BackendProxy.set(backend) +``` + +> [!NOTE] +> 記憶體快取不適合用於多行程的正式環境。每個行程都各自維護獨立的快取。 + +## Redis {#redis} + +以 `uv add "fastapi-cachex[redis]"` 安裝此 extra。 + +```python +from fastapi_cachex.backends import AsyncRedisCacheBackend +from fastapi_cachex import BackendProxy + +backend = AsyncRedisCacheBackend(host="127.0.0.1", port=6379, db=0) +BackendProxy.set(backend) +``` + +- 完全非同步的實作 +- 支援依模式清除鍵 +- 使用 SCAN 而非 KEYS,可安全用於正式環境(不會阻塞) +- 預設以 `fastapi_cachex:` 前綴建立命名空間;多租戶情境可傳入 `key_prefix="myapp:cache:"` +- 只有傳給 `clear_pattern()` 的模式是萬用字元(glob)模式。鍵前綴與傳給 `clear_path()` 的路徑都以字面值比對,因此其中的 `*`、`?`、`[` 或 `]` 不會觸及前綴以外的鍵,也不會漏掉該路徑 + +**從模型設定**:`RedisConfig` 是具有相同設定項與驗證的 pydantic 模型,當設定來自環境變數或設定檔時很方便: + +```python +from fastapi_cachex.backends import AsyncRedisCacheBackend +from fastapi_cachex.backends.config import RedisConfig + +config = RedisConfig( + host="127.0.0.1", + port=6379, + password=None, # SecretStr | None + db=0, + encoding="utf-8", # 用戶端解碼伺服器回應的方式 + socket_timeout=1.0, # 秒;適用於讀取/寫入 + socket_connect_timeout=1.0, + key_prefix="fastapi_cachex:", + protocol=2, # RESP 版本,2 或 3 +) +backend = AsyncRedisCacheBackend.load_from_config(config) +BackendProxy.set(backend) +``` + +除非你需要 RESP3 的功能,*而且*你的 `hiredis` 建置支援它(RESP3 需要 hiredis >= 3.0),否則請保留 `protocol=2`。Redis 8.0 支援 RESP3,但較舊的 hiredis 會無法協商使用它。 + +## Memcached {#memcached} + +以 `uv add "fastapi-cachex[memcache]"` 安裝此 extra(注意是 `memcache`,不是 `memcached`)。 + +```python +from fastapi_cachex.backends import MemcachedBackend +from fastapi_cachex import BackendProxy + +backend = MemcachedBackend(servers=["localhost:11211"]) +BackendProxy.set(backend) +``` + +**限制**: + +- Memcached 協定不支援依模式清除鍵(`clear_pattern`) +- 無法列舉鍵:`get_all_keys()`/`get_cache_data()` 會回傳空結果(並發出 `RuntimeWarning`),因此監控路由不會顯示任何內容 +- `clear_path()` 只會刪除完全相符的那個鍵;`include_params` 沒有作用 +- `clear()` 會發出 `flush_all`,清空整台 Memcached 伺服器,而不只是這個命名空間 +- Memcached 會拒絕的鍵(超過 250 位元組、含空白字元或非 ASCII 字元)會改以其 SHA-256 摘要儲存 +- 若需要依模式清除快取,請考慮使用 Redis 後端 + +同步的 pymemcache 用戶端在工作執行緒中執行,並使用連線池,因此並行的請求絕不會共用同一個 socket。寫入會等待伺服器確認(`default_noreply=False`),因此只要 `set()` 返回,就能從連線池中的任何連線讀到該值。 + +## 後端的原子操作 {#atomic-backend-primitives} + +每個後端都在 `get`/`set`/`delete` 之上提供原子操作,供會被許多並行請求讀寫的值使用: + +```python +import secrets + +from fastapi_cachex import BackendProxy +from fastapi_cachex.types import CacheEntry + +backend = BackendProxy.get() + +# 固定時間窗計數器:首次使用時建立,`ttl` 只在那時套用。 +hits = await backend.increment(f"resend:{user_id}", ttl=86400) +if hits > 3: + raise TooManyRequests() + +# 一次性的值:多個並行呼叫者中恰好只有一個會取得該項目。 +grant = await backend.get_and_delete(f"grant:{token}") + +# 鎖/名額:只在未被占用時取得,且只在仍屬於你時釋放。 +owner = CacheEntry(fingerprint="lock", content=secrets.token_bytes(16)) +if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): + try: + ... + finally: + await backend.delete_if_equals(f"stream:{user_id}", owner) +``` + +- `increment(key, delta=1, ttl=None) -> int`:記憶體後端在鎖內執行讀取—修改—寫入,Redis 執行 Lua 腳本(`EXISTS` + `INCRBY` + `EXPIRE`),Memcached 則使用 `ADD` + `INCR`/`DECR`(Memcached 的計數器最低停在 0)。計數器可透過 `get()` 讀到,形式為 fingerprint 為 `COUNTER_FINGERPRINT`、內容為十進位數值的 `CacheEntry`,因此 `delete`/`clear*` 與監控路由都會把它當成一般項目處理。對存放快取回應的鍵執行 increment 會拋出 `CacheXError`。 +- `get_and_delete(key) -> CacheEntry | None`:記憶體後端在鎖內 pop,Redis 使用 `GETDEL`(伺服器 6.2 以上),Memcached 則只在自己的 `DELETE` 勝出時才回傳該值。`StateManager.consume_state`、`StateManager.delete_state`、`CacheManager.delete` 與 `invalidate()` 都建立在它之上。 +- `set_if_absent(key, value, ttl=None) -> bool`:只在 `key` 不存在時儲存 `value`(已過期的鍵視為不存在),並回報是否有寫入。記憶體後端在鎖內檢查,Redis 使用 `SET NX EX`,Memcached 使用 `ADD`。 +- `delete_if_equals(key, expected) -> bool`:只在 `key` 仍存放 `expected` 時才移除它,因此項目已過期的持有者無法釋放已被他人取得的鎖。請在你儲存的項目中放入唯一的權杖,並以同一個項目釋放。記憶體後端在鎖內比較,Redis 透過 Lua 腳本刪除,並在腳本中重新檢查先前比較過的值,Memcached 則使用 `GETS` + 一個讓項目立即過期的 `CAS` 寫入(傳統協定的 `DELETE` 不接受 CAS 權杖)。 + +這四個方法在 `BaseCacheBackend` 上都有非原子性的後備實作,因此只實作抽象方法的第三方後端仍可正常運作;覆寫它們才能得到真正的原子性。 + +## TTL 值 {#ttl-values} + +每個 `ttl` 參數(`set`、`set_if_absent`、`increment`,以及建立在它們之上的 `CacheManager` 與 `StateManager` 方法和預設值)只能是 `None`(表示項目永不過期),或正數秒數。零與負值會拋出 `ValueError`。底層儲存對這些值的解讀各不相同:Memcached 把 exptime `0` 視為「永不過期」,Redis 拒絕 `EX 0`,而行程內的 dict 則會立即讓項目過期。第三方後端應在其 `set` 中呼叫 `fastapi_cachex.backends.base.validate_ttl(ttl)`,以遵循相同規則。(`@cache(ttl=0)` 是另一回事:它會送出 `max-age=0`,且絕不會把 `0` 傳給後端;見 [HTTP 快取](HTTP_CACHING.md)。) + +各後端如何儲存項目,請見[快取流程](CACHE_FLOW.md#backend-storage-formats);類別本身請見 [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/backends/)(英文)。 diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md new file mode 100644 index 0000000..fe88af9 --- /dev/null +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -0,0 +1,384 @@ +# FastAPI-CacheX 快取流程 {#fastapi-cachex-cache-flow} + +本文件詳細說明 FastAPI-CacheX 如何將快取邏輯套用到 HTTP 請求上。除非另有說明,這裡描述的所有行為都位於 [`fastapi_cachex/cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/cache.py)。 + +## 整體流程 {#overall-flow} + +``` +HTTP 請求抵達 + ↓ +@cache 裝飾器攔截請求(只有 GET 會經過快取;其他所有 +方法直接執行 handler,也不會帶上 Cache-Control 標頭) + ↓ +建立快取鍵:method|||host|||path|||query_params + ↓ +no-store? ── 是 → 執行 handler,既不讀取也不寫入快取, + │ 回應帶上 Cache-Control: no-store + ↓ 否 +private? ── 是 → 執行 handler;比對 If-None-Match 決定回傳 304 或 200 + │ (共用後端既不讀取也不寫入) + ↓ 否 +讀取後端項目 + ↓ +請求帶有 If-None-Match? + ├─ 且為 no-cache → 先執行 handler 計算目前的 ETag;相符 → 304 + ├─ 其他情況 → 與快取項目的 ETag 比對;相符 → 304 + └─ 不相符/沒有此標頭 → 繼續 + ↓ +快取項目存在、已設定 ttl,且未啟用 no-cache? + ├─ 是 → 以快取內容回應(包含儲存的狀態碼 + │ 與標頭;handler **不會**執行) + └─ 否 → 執行 handler + ├─ 非 2xx(或 206)→ 原樣回傳且**不寫入** + │ (不會覆寫既有的正常項目) + ├─ 串流/檔案回應 → 無法計算 ETag;原樣回傳,不寫入 + └─ 一般回應 → 設定 ETag;只有與既有項目的 ETag + 不同時才寫入後端 + ↓ +在回應中附加 Cache-Control(非 2xx 回應回傳時不帶此標頭) +``` + +## 詳細步驟 {#detailed-steps} + +### 1. 請求攔截與快取鍵產生 {#1-request-interception-and-key-generation} + +請求抵達時,`@cache` 裝飾器會執行以下步驟: + +```python +from fastapi_cachex.types import CACHE_KEY_SEPARATOR # "|||" + +# 快取鍵格式(fastapi_cachex/cache.py 中的 default_key_builder) +cache_key = CACHE_KEY_SEPARATOR.join( + [request.method, request.headers.get("host", "unknown"), request.url.path, query] +) + +# 例如: +# GET|||example.com|||/api/users|||page=1&limit=10 +# GET|||api.example.com|||/api/users/123||| +``` + +分隔符號使用 `|||` 而不是冒號,是因為 host 本身可能包含連接埠(`127.0.0.1:8000`);若使用冒號,快取鍵就無法可靠地拆分,而 `clear_path()` 需要從快取鍵中取回路徑。 + +查詢參數依請求送出的順序串接(`str(request.query_params)`),**不會排序**,因此 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩個不同的快取項目。若希望兩者視為同一個,請傳入自訂的 `key_builder` 將查詢字串正規化。 + +這個快取鍵格式讓每個維度各自獨立快取: + +- **方法隔離**:GET 與 POST 不共用快取(而且目前只有 GET 會進入快取流程) +- **Host 隔離**:`example.com` 與 `api.example.com` 分開快取 +- **路徑隔離**:每個端點有各自的項目 +- **查詢參數隔離**:同一端點上不同的查詢參數分開快取 + +### 2. Cache-Control 指令 {#2-cache-control-directives} + +裝飾器參數同時控制伺服器端的行為,以及送給用戶端的 `Cache-Control` 標頭: + +```python +# 改變伺服器端快取的使用方式 +@cache(no_cache=True) # 每次都重新執行 handler(重新驗證);項目仍會寫入 +@cache(no_store=True) # 永不讀取或寫入快取 + +# 一般快取行為 +@cache(ttl=3600) # 快取 1 小時(也作為 max-age 的值) +@cache(public=True) # 允許共用快取 +@cache(private=True) # 僅限私有;永遠不接觸共用後端 +@cache(immutable=True) # 內容永不改變 + +# 只影響標頭的指令(不會改變伺服器端行為) +@cache(ttl=60, must_revalidate=True) # must-revalidate +@cache(ttl=60, stale="revalidate", stale_ttl=30) # stale-while-revalidate=30 +@cache(ttl=60, stale="error", stale_ttl=300) # stale-if-error=300 +``` + +參數會在套用裝飾器時驗證;若同時設定 `public` 與 `private`,或只提供 `stale`/`stale_ttl` 其中之一,會拋出 `CacheXError`。 + +標頭值在每個被裝飾的路由上只建立一次: + +| 參數 | 送出的 `Cache-Control` | +|------|------------------------| +| `no_store=True` | `no-store`(覆蓋其他所有設定) | +| `no_cache=True` | `no-cache`,若有要求則加上 `must-revalidate`;省略 `public`/`private`/`max-age`/`stale-*`/`immutable` | +| 其他情況 | 依序為:`public` 或 `private`、`max-age=`、`must-revalidate`、`stale-while-revalidate=` 或 `stale-if-error=`、`immutable` | + +> [!NOTE] +> 沒有設定 `ttl`(或設定 `ttl=0`,此時會送出 `max-age=0`)時,項目仍會寫入(不設過期時間),但永遠不會直接拿來回應:它只用於以 `304` 回應相符的 `If-None-Match`。請設定正數的 `ttl`,讓伺服器重播快取的回應。 + +> [!WARNING] +> **預設的快取鍵不包含使用者身分**,而且後端由所有 worker 與所有使用者共用。直接在需要驗證的端點上加上 `@cache(ttl=...)`,會把使用者 A 的回應提供給下一個請求相同路徑的使用者 B。 +> +> 對於回應內容取決於呼叫者的端點,請擇一處理: +> +> 1. `private=True`:永遠不讀取或寫入共用後端。它仍會送出 `Cache-Control: private`,讓使用者自己的瀏覽器可以快取回應,而且 `If-None-Match` 仍會與新產生的內容比對。 +> 2. 包含身分的自訂 `key_builder`:當你確實需要以使用者為單位的伺服器端快取時使用。 +> +> 身分請取自可信任的來源(已驗證的權杖 claim、透過依賴注入取得的使用者物件);不要信任未經檢查的用戶端標頭。 + +### 3. 快取查詢 {#3-cache-lookup} + +以快取鍵查詢後端,後端會回傳 `CacheEntry`(過期的項目由後端自行略過): + +```python +from fastapi_cachex.types import CacheEntry + +entry = CacheEntry( + fingerprint='W/"9f86d081..."', # ETag,內容的弱驗證器 + content=b'{"data": "response"}', # 原始回應位元組 + media_type="application/json", + status_code=200, # 以原本的狀態碼重播 + headers={"Vary": "Accept-Encoding"}, # 重播時送回的標頭 +) +``` + +TTL 不儲存在 `CacheEntry` 中:過期由後端負責(`MemoryBackend` 將它存在 `CacheItem.expiry`,Redis 使用 `SET ... EX`,Memcached 使用 exptime)。 + +若尚未以 `BackendProxy.set()` 設定後端,裝飾器會在第一個請求時建立 `MemoryBackend` 並註冊它。 + +**判斷邏輯**(`cache.py` 的包裝函式,依序執行): + +```python +if request.method != "GET": + return await handler() # 不快取,不帶 Cache-Control + +if no_store: + return await render() # 不讀取,不寫入 + +if private: + response, etag = await render() # 共用後端既不讀取也不寫入 + return not_modified(...) if etag_matches(client_etag, etag) else response + +entry = await backend.get(cache_key) # 過期的項目已在此略過 + +if client_etag and no_cache: + fresh = await render() # no-cache:一律先重新產生 + if etag_matches(client_etag, fresh.etag): + return not_modified(...) # 304 +elif client_etag and entry and etag_matches(client_etag, entry.fingerprint): + return not_modified(...) # 304,handler 不執行 + +if entry and not no_cache and ttl is not None: + return Response( # 200,handler 不執行 + content=entry.content, + status_code=entry.status_code, + media_type=entry.media_type, + headers={**(entry.headers or {}), "ETag": entry.fingerprint, ...}, + ) + +response, body, etag = await render() # 未命中(若 no-cache 已產生過則直接沿用) +if not is_cacheable_status(response.status_code): + return response # 非 2xx:原樣回傳,不寫入 +if etag is None: + return response # 串流/檔案:沒有 ETag,不寫入 +if not entry or entry.fingerprint != etag: + await backend.set(cache_key, CacheEntry(...), ttl=ttl) +return response +``` + +> [!NOTE] +> 「非 2xx 不寫入」是刻意的設計:暫時性的錯誤不應抹除最後一次正常的快取回應,也不應在之後被當成 200 重播。`206 Partial Content` 同樣不會快取,因為它的內容只對產生它的那個 `Range` 請求有意義。非 2xx 回應也永遠不會以 `304` 回應,且回傳時不帶裝飾器的 `Cache-Control` 標頭(只有 `no_store=True` 會在每個回應加上 `no-store`)。 + +### 4. ETag 產生與驗證 {#4-etag-generation-and-validation} + +ETag 由回應內容計算而來,用於偵測內容是否已改變: + +```python +# 產生:MD5,標記為弱驗證器 +def _etag_for(body: bytes) -> str: + return f'W/"{hashlib.md5(body).hexdigest()}"' +``` + +`If-None-Match` 依 RFC 9110 §8.8.3.2 規定以**弱比較**判斷,因此: + +``` +If-None-Match: W/"abc" → 與 "abc" 相符(兩邊的 W/ 前綴都會忽略) +If-None-Match: "abc", W/"def" → 多個值逐一比對;任一相符 → 304 +If-None-Match: * → 只要資源存在就相符 → 304 +``` + +304 回應會帶有與 200 相同的 `Cache-Control` 與 `ETag`,以及影響快取行為的標頭 `Vary`、`Content-Location` 與 `Expires`;否則中介快取在重新驗證後會遺失這些欄位(RFC 9110 §15.4.5)。 + +## 後端儲存格式 {#backend-storage-formats} + +### MemoryBackend {#memorybackend} + +```python +# dict[str, CacheItem];CacheItem 包裝 CacheEntry 並記錄其過期時間 +{ + "GET|||example.com|||/api/users|||": CacheItem( + value=CacheEntry( + fingerprint='W/"abc123"', + content=b"...", + media_type="application/json", + status_code=200, + headers=None, + ), + expiry=1702650600.5, # epoch 秒數;None 表示永不過期 + ), +} + +# 特性: +# - 儲存在行程記憶體中,不在行程之間共用 +# - 背景清理任務每 cleanup_interval 秒(預設 60)清除一次 +# 過期的項目 +# - 清理任務會在第一次呼叫 get/set/set_if_absent/increment/ +# get_and_delete 時才延遲啟動 +# - get() 會當場刪除過期的項目並回報未命中,不必等待 +# 清理任務 +# - 快取鍵不加前綴 +``` + +### 網路後端共用的序列化([`backends/codec.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/backends/codec.py)) {#serialization-shared-by-the-network-backends-backendscodecpy} + +Redis 與 Memcached 共用同一套 JSON 編解碼器;若已安裝 `orjson` 就使用它,否則使用標準函式庫的 `json`: + +```json +{ + "fingerprint": "W/\"abc123\"", + "content": "", + "media_type": "application/json", + "status_code": 200, + "headers": {"Vary": "Accept-Encoding"} +} +``` + +- `content` 使用 **latin-1 來回轉換**,而不是 base64:latin-1 與位元組一一對應,因此任何位元組序列都能放進 JSON 文字中,並原封不動地還原。 +- 舊版本寫入、沒有 `status_code`/`headers` 欄位的項目仍可讀取,解碼後為 `200` 且沒有額外標頭。 +- 任何解碼失敗(損壞的 JSON、缺少欄位、型別錯誤)都視為**快取未命中**,回傳 `None` 而不是拋出例外。 +- `increment()` 會留下一個**單純的整數**(由 Redis/Memcached 的 INCR 系列指令寫入);它會解碼成 fingerprint 為 `counter` 的 `CacheEntry`。 + +### MemcachedBackend {#memcachedbackend} + +``` +key: "fastapi_cachex:GET|||example.com|||/api/users|||" +value: 上述的 JSON 文件 + +# 特性: +# - 若加上命名空間後的快取鍵包含空白、控制字元或非 ASCII +# 位元組,或超過 250 位元組,會改以其 SHA-256 十六進位摘要儲存 +# (`fastapi_cachex:`);否則 Memcached 會拒絕它, +# 請求會以 500 失敗 +# - 超過 30 天的 TTL 會以絕對的 epoch 時間戳記送出;否則 +# Memcached 會把它解讀為 1970 年的某個時刻,使項目立即過期 +# - 協定無法列舉快取鍵,因此 clear_pattern()/get_all_keys()/ +# get_cache_data() 都是 no-op,回傳 0/[]/{} 並發出 RuntimeWarning; +# 因此 CacheManager.clear()/clear_prefix() 在此後端上不會有任何作用 +# - clear_path() 只會刪除與指定路徑完全相同的快取鍵,因此 +# 無法清除 HTTP 路由的項目 +# - clear() 會送出 flush_all,清空「整個」Memcached 伺服器(不只是 +# 這個快取鍵前綴)並發出 RuntimeWarning +# - 同步的 pymemcache 用戶端在 worker 執行緒中執行,並使用連線 +# 池與 default_noreply=False +``` + +### AsyncRedisCacheBackend {#asyncrediscachebackend} + +``` +key: "fastapi_cachex:GET|||example.com|||/api/users|||" +value: 上述的 JSON 文件 + +# 特性: +# - 以 SET ... EX 設定過期時間(ttl 為 None 時使用一般的 SET) +# - 模式操作以 SCAN(COUNT=100)分頁走訪快取鍵,而不是使用 KEYS, +# 因此永遠不會阻塞伺服器 +# - clear() 只移除此後端快取鍵前綴下的快取鍵 +# - get_and_delete() 使用 GETDEL(需要 Redis 6.2 以上);刪除操作以 +# 每批最多 100 個快取鍵的 DEL 送出 +# - increment() 執行已註冊的 Lua 腳本,因此遞增與設定 TTL +# 是單一的原子操作 +``` + +> [!NOTE] +> `add_routes()` 掛載的 `/cached-hits` 與 `/cached-records` 路由從 `get_cache_data()` 讀取過期時間。記憶體後端直接追蹤過期時間,Redis 回報每個快取鍵的 `PTTL`;在無法列舉快取鍵的 Memcached 上,這些端點完全不會回傳任何項目。 + +## 快取清除策略 {#cache-clearing-strategies} + +### 自動清除 {#automatic-clearing} + +```python +# MemoryBackend:每 cleanup_interval 秒(預設 60)清除一次 +async def cleanup_task(): + while True: + await asyncio.sleep(self.cleanup_interval) + # 移除所有 CacheItem.expiry 已過的項目 + + +# 此任務只會在第一次呼叫 get/set/set_if_absent/increment/ +# get_and_delete 時延遲啟動(它需要執行中的事件迴圈),因此只寫入的 +# 用法(例如 StateManager.create_state)也會啟動它。 + +# Redis/Memcached:TTL 機制 +# 使用後端內建的 TTL(SET ... EX、exptime) +# 項目會自行過期;不需要清理任務 +``` + +### 手動清除 {#manual-clearing} + +`clear_path()`、`clear_pattern()`、`clear()` 與 `invalidate()` 的說明請見 [HTTP 快取](HTTP_CACHING.md#clearing-the-cache)。 + +## 效能 {#performance} + +快取命中時,端點的 handler 完全不會執行:成本只有一次後端查詢。該選擇哪個後端,請見[後端](BACKENDS.md#choosing-a-backend)。 + +## 快取失效情境 {#cache-invalidation-scenarios} + +| 情境 | 行為 | +|------|------| +| `no_store=True` | 既不讀取也不寫入快取;端點每次都會執行 | +| `no_cache=True` | 端點每次都會執行以重新計算 ETag;與用戶端的 `If-None-Match` 相符時仍回傳 304,ETag 改變時會更新快取 | +| `private=True` | **共用後端**既不讀取也不寫入;仍會送出 `Cache-Control: private`,並以新產生的內容比對 ETag | +| 沒有 `ttl` | 項目寫入時不設過期時間,但只用於 `If-None-Match` 重新驗證;沒有相符驗證器的請求每次都會執行 handler | +| 快取過期(TTL 已到) | 端點會再次執行;`MemoryBackend` 讀取到過期項目時會當場刪除 | +| 非 2xx 或 206 回應 | 原樣回傳、不寫入,既有的項目不受影響 | +| 串流/檔案回應 | 無法計算 ETag;原樣回傳且不寫入 | +| 手動呼叫 `invalidate()` | 刪除該路由的快取鍵 | +| 手動呼叫 `clear_path()`/`clear_pattern()`/`clear()` | 依範圍清除(在 Memcached 上,`clear_path()` 只刪除完全相符的快取鍵,`clear_pattern()` 是 no-op,`clear()` 會清空整個伺服器) | + +## 實作細節 {#implementation-details} + +### 快取項目結構 {#cache-entry-structure} + +實際的型別是定義在 [`fastapi_cachex/types.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/types.py) 的 dataclass: + +```python +@dataclass +class CacheEntry: + fingerprint: str # ETag,格式為 W/"" + content: bytes # 原始回應位元組 + media_type: str | None = None + status_code: int = 200 # 原樣重播 + headers: dict[str, str] | None = None # 重播時送回 + + +@dataclass +class CacheItem: + value: CacheEntry + expiry: float | None = None # epoch 秒數;僅 MemoryBackend 使用 +``` + +`headers` 儲存 handler 自行設定的標頭,但排除每次回應都必須重新計算或不得重播的欄位:`Set-Cookie`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Date`、`ETag`、`Cache-Control` 與 `Content-Type`(`Content-Type` 會由 `media_type` 還原;兩者都儲存會使該標頭送出兩次)。 + +計數器(`backend.increment()`)同樣以 `CacheEntry` 表示:fingerprint 一律為 `counter`,`content` 為十進位數值的位元組,因此刪除、清除與監控都以相同方式處理它們。 + +### 請求流程程式碼範例 {#request-flow-code-example} + +裝飾器內部的執行順序請見上方「3. 快取查詢」中的判斷邏輯;在使用端,你只需要: + +```python +@app.get("/expensive") +@cache(ttl=3600) +async def expensive_endpoint(): + # 此函式只會在快取未命中(或需要重新驗證)時執行 + return await perform_calculation() +``` + +handler 不必自行宣告 `Request`:`@cache` 會在函式簽名中注入一個名為 `__cachex_request` 的 keyword-only 參數(若 handler 有 `**kwargs`,則放在它之前)。若 handler **已經**宣告了 `Request`(包括字串註記、`Annotated[...]` 或 `Request` 子類別),就會沿用該參數,不會注入任何東西。 + +## 常見問題 {#faq} + +**Q:為什麼快取命中不一定回傳 200?** A:視情況而定。若請求帶有 `If-None-Match` 標頭且其 ETag 相符,會回傳 304 以節省頻寬。沒有此標頭時,則回傳帶有內容的 200。 + +**Q:為什麼 POST/PUT 的回應不會被快取?** A:`@cache` 只適用於 GET。其他所有方法都直接執行 handler,不讀取或寫入快取,也不會加上 `Cache-Control` 標頭。 + +**Q:為什麼同一個端點有好幾個快取項目?** A:因為快取鍵包含查詢參數,而且查詢參數**不會排序**。`/users?page=1` 與 `/users?page=2` 是不同的項目,`?a=1&b=2` 與 `?b=2&a=1` 也是。 + +**Q:MemoryBackend 在多個行程下如何運作?** A:無法運作。每個行程都有自己的快取;正式環境請使用 Redis。 + +**Q:清除快取是同步還是非同步?** A:非同步:`await cache.clear_path(...)` 或 `await cache.clear_pattern(...)`。請注意,`clear_pattern()`(以及 `get_all_keys()` 與 `CacheManager.clear()`)在 Memcached 後端上是 no-op,因為 Memcached 協定無法列舉快取鍵。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md new file mode 100644 index 0000000..ca1f5b3 --- /dev/null +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -0,0 +1,239 @@ +# HTTP 快取 {#http-caching} + +`@cache` 裝飾器會快取 FastAPI GET 路由的回應,並替你處理 `Cache-Control`、`ETag` 與 `If-None-Match`。本頁說明如何使用它;[快取流程](CACHE_FLOW.md)則說明請求內部發生了什麼。 + +## `@cache` 裝飾器 {#the-cache-decorator} + +```python +from fastapi import FastAPI +from fastapi_cachex import cache + +app = FastAPI() + + +@app.get("/") +@cache(ttl=60) # 快取 60 秒 +async def read_root(): + return {"Hello": "World"} + + +@app.get("/no-cache") +@cache(no_cache=True) # 一律重新驗證:每個請求都會執行 handler +async def non_cache_endpoint(): + return {"Hello": "World"} + + +@app.get("/no-store") +@cache(no_store=True) # 任何地方都不儲存這個回應 +async def non_store_endpoint(): + return {"Hello": "World"} +``` + +只有 GET 請求會被快取;其他方法照常執行 handler。handler 不需要宣告 `Request` 參數:缺少時裝飾器會自動加上。如果尚未設定任何後端,`@cache` 會改用 `MemoryBackend`(見[後端](BACKENDS.md))。 + +## Cache-Control 指令 {#cache-control-directives} + +`@cache` 有兩個角色:替瀏覽器與中間層(CDN、反向代理)寫出 `Cache-Control` 標頭,以及在後端維護自己的伺服器端快取。大部分指令只影響前者:它們會寫進標頭,但不論有沒有設定,伺服器端快取的行為都一樣。 + +| 指令 | 設定方式 | 寫入標頭 | 對伺服器端快取的影響 | +|--------------------------|------------------------------------------|--------------------|----------------------------------------------------------------------------------------------------------------| +| `max-age` | `ttl=N` | :white_check_mark: | `N` 秒內直接回傳已儲存的回應,不執行 handler(`ttl=0` 或未設定:不直接回傳)。 | +| `no-cache` | `no_cache=True` | :white_check_mark: | 每個請求都執行 handler;回應仍會儲存,`If-None-Match` 相符時回 304。 | +| `no-store` | `no_store=True` | :white_check_mark: | 不讀取也不儲存,也不設定 ETag。 | +| `private` | `private=True` | :white_check_mark: | 完全不經過後端;每個請求都執行 handler,ETag 重新驗證仍有效。 | +| `public` | `public=True` | :white_check_mark: | 無(僅寫入標頭)。 | +| `immutable` | `immutable=True` | :white_check_mark: | 無(僅寫入標頭)。 | +| `must-revalidate` | `must_revalidate=True` | :white_check_mark: | 無(僅寫入標頭)。 | +| `stale-while-revalidate` | `stale="revalidate", stale_ttl=N` | :white_check_mark: | 無(僅寫入標頭):伺服器端快取不會回傳過期內容。 | +| `stale-if-error` | `stale="error", stale_ttl=N` | :white_check_mark: | 無(僅寫入標頭):handler 失敗時不會改用快取回應。 | +| `s-maxage` | — | :x: | — | +| `proxy-revalidate` | — | :x: | — | +| `no-transform` | — | :x: | — | +| `must-understand` | — | :x: | — | + +`no_cache=True` 與 `no_store=True` 會取代標頭的其餘內容:使用 `no_cache` 時只送出 `no-cache`(若有設定,再加上 `must-revalidate`),使用 `no_store` 時只送出 `no-store`。其他參數如何組合,請見[快取流程](CACHE_FLOW.md#2-cache-control-directives)。 + +### 請求的 `Cache-Control` 會被忽略 {#the-requests-cache-control-is-ignored} + +用戶端自己送出的 `Cache-Control` 請求標頭(例如瀏覽器強制重新整理時送出的 `no-cache`、`max-age=0` 等)不會改變 `@cache` 的行為。這是刻意的設計:若請求標頭能繞過快取,任何用戶端都能讓每個請求直接打到你的 handler。條件式請求仍會處理:`If-None-Match` 相符時回 304。 + +## 快取命中時的行為 {#cache-hit-behavior} + +當快取項目仍有效(在 TTL 內)時: + +- **預設行為**:直接回傳快取的內容,連同 handler 當初產生的狀態碼與標頭,不會重新執行端點的 handler +- **帶有 `If-None-Match` 標頭**:ETag 相符時回傳 HTTP 304 Not Modified +- **使用 `no-cache` 指令**:先以新產生的內容強制重新驗證,再決定是否回 304 +- **使用 `private=True`**:不從共用後端讀取,也不寫入;每次都執行 handler,只有 `If-None-Match` 重新驗證有效 +- **未設定 `ttl`**(`ttl=None`):快取的回應本文永遠不會直接回傳;每個請求都會執行 handler,唯一的例外是 `If-None-Match` 與已儲存 ETag 相符的請求,會得到 304 +- **使用 `ttl=0`**:送出 `max-age=0`,其餘行為與 `ttl=None` 相同。負數的 `ttl` 會在套用裝飾器時以 `CacheXError` 拒絕 + +只有成功的回應會被儲存。handler *回傳* 非 2xx 狀態的回應(例如 `Response(..., status_code=404)`)會原樣傳出、永不快取,因此暫時性的錯誤不會取代或污染上一筆正常的項目。`206 Partial Content` 同樣排除在外,因為它的本文只對產生它的那個 `Range` 請求有意義。`Set-Cookie` 永遠不會被儲存或重播。 + +handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cache` 時相同:回傳值會經過路由的 response model 驗證與過濾(明確宣告的,或由回傳型別註記推斷,並套用 `response_model_*` 選項),套用路由的 `status_code`,而在注入的 `response: Response` 參數上設定的狀態碼與標頭也會保留。 + +## 快取鍵 {#cache-keys} + +快取鍵以下列格式產生,以避免衝突: + +``` +{method}|||{host}|||{path}|||{query_params} +``` + +這可確保: + +- 不同的 HTTP 方法(GET、POST 等)不共用快取 +- 不同的主機不共用快取(適用於多租戶情境) +- 不同的查詢參數各有獨立的快取項目 +- 同一個端點搭配不同參數時可以各自快取 + +查詢參數依用戶端送出的順序取用,不會排序,因此 `?a=1&b=2` 與 `?b=2&a=1` 對同一個邏輯上的請求而言是兩筆不同的快取項目。 + +所有後端都會自動替鍵加上前綴(例如 `fastapi_cachex:`)作為命名空間,以避免與其他應用程式衝突。`CacheManager`(見[應用層快取](APP_CACHE.md))則使用另一個較簡單、以 `cache:` 為前綴的鍵命名空間,而不是這種以 `|||` 分隔的格式,因為它的鍵與 HTTP 請求無關。 + +### 需驗證身分的端點 {#authenticated-endpoints} + +> [!WARNING] +> **預設的快取鍵不包含使用者身分。** 後端由所有 worker 與所有呼叫者共用,因此以預設的 key builder 快取需驗證身分的端點,會把某位使用者的回應提供給下一位存取相同路徑的使用者。 +> +> 回應內容取決於請求者身分的端點,請擇一處理: +> +> 1. **`private=True`**:回應永遠不會從共用後端讀取,也不會寫入。`Cache-Control: private` 仍允許使用者自己的瀏覽器快取它,而 `If-None-Match` 重新驗證仍會對新產生的內容運作。 +> 2. **包含呼叫者身分的 key builder**:確實需要依使用者區分的伺服器端快取時使用。不要設定 `private`:`private=True` 會繞過後端,key builder 就永遠不會被使用。 + +```python +from fastapi import Request, Response + +from fastapi_cachex import cache +from fastapi_cachex.types import CACHE_KEY_SEPARATOR + + +# 1. 完全不放進共用快取。 +@app.get("/me/profile") +@cache(ttl=60, private=True) +async def my_profile(user: CurrentUser): + return user.profile + + +# 2. 或讓每位使用者擁有自己的項目。 +def per_user_key(request: Request) -> str: + # `request.state.user_id` 由你的驗證層在確認呼叫者身分後填入; + # 絕對不要直接從未經驗證的請求標頭讀取身分(見下方說明)。 + user_id = getattr(request.state, "user_id", "anonymous") + return ( + f"{request.method}{CACHE_KEY_SEPARATOR}" + f"{request.headers.get('host', 'unknown')}{CACHE_KEY_SEPARATOR}" + f"{request.url.path}{CACHE_KEY_SEPARATOR}" + f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}" + ) + + +@app.get("/me/dashboard") +@cache(ttl=60, key_builder=per_user_key) +async def my_dashboard(user: CurrentUser, response: Response): + # 沒有 `private` 時,回應會帶著 `Cache-Control: max-age=60` 送出, + # 共用快取(CDN、反向代理)可能會儲存它。對承載身分的標頭設定 Vary, + # 讓這類快取為每位使用者各保留一份。 + response.headers["Vary"] = "Authorization" + return build_dashboard(user) +``` + +依使用者區分的項目要不被應用程式前方的共用快取交給其他使用者,前提是這些快取會遵守該標頭的 `Vary`。若它們不遵守,或身分來自共用快取看不到的地方,請改用做法 1。 + +> [!CAUTION] +> key builder 決定了誰能看到誰的資料,因此它讀取的身分必須來自已經驗證過的來源:已檢查權杖中的 claim、你的依賴項解析出的使用者,或驗證中介軟體寫入 `request.state` 的值。 +> +> ```python +> # ❌ 絕對不要這樣做:任何人都能送出這個標頭。 +> user_id = request.headers.get("x-user-id", "anonymous") +> ``` +> +> 以原始請求標頭組成的鍵等同於水平權限提升:送出 `X-User-Id: ` 就會拿到該使用者的快取回應。 + +## 清除快取 {#clearing-the-cache} + +### 依路徑或模式 {#by-path-or-pattern} + +清除用的方法位於後端上,可以透過 `CacheBackend` 依賴項注入,或以 `BackendProxy.get()` 取得: + +```python +from fastapi_cachex import CacheBackend + + +@app.post("/admin/clear") +async def clear(cache: CacheBackend) -> None: + # 清除特定路徑:只清除「沒有」查詢參數的項目…… + await cache.clear_path("/api/users") + # ……或連同所有查詢參數的變體一起清除 + await cache.clear_path("/api/users", include_params=True) + + # 依模式清除:比對整個鍵 method|||host|||path|||query + await cache.clear_pattern("GET|||*|||/api/users/*") + # 你自己組成的鍵(例如 CacheManager 的鍵)可以直接比對 + await cache.clear_pattern("cache:user:*") + + # 清除全部 + await cache.clear() # 移除所有快取項目 +``` + +`clear_path()` 會比對該路徑在所有方法與主機下的項目。只寫成路徑的模式(例如 `clear_pattern("/api/users/*")`)無法比對到 HTTP 鍵;這類呼叫沒有清除任何項目時,會發出 `RuntimeWarning`,提示你改用 `clear_path()`。 + +各後端支援的功能列於[後端](BACKENDS.md)。 + +### 使單一快取路由失效 {#invalidating-a-single-cached-route} + +`clear_path`/`clear_pattern` 作用於一整批鍵。若只想刪除某個 `@cache` 裝飾路由會用到的那一筆項目(通常是在資料變更之後),請呼叫 `invalidate()`,它會以相同的 key builder 重建該路由的鍵並刪除: + +```python +from fastapi import Request +from starlette.requests import Request as StarletteRequest + +from fastapi_cachex import cache, invalidate + + +@app.get("/items/{item_id}") +@cache(ttl=300) +async def read_item(item_id: int): + return await load(item_id) + + +@app.post("/items/{item_id}") +async def update_item(item_id: int, request: Request): + await save(item_id) + # 組出快取的 GET 會使用的鍵:相同的主機與標頭、 + # GET 方法、快取的路徑、沒有查詢字串。 + scope = dict(request.scope) + scope["method"] = "GET" + scope["path"] = f"/items/{item_id}" + scope["query_string"] = b"" + return {"invalidated": await invalidate(StarletteRequest(scope))} +``` + +`invalidate(request, key_builder=None)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`(包括尚未設定後端的情況;它永遠不會拋出例外)。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder`,這裡也要傳入同一個,否則鍵不會相符。 + +## 監控路由 {#monitoring-routes} + +`add_routes()` 會掛載兩個唯讀端點,回報後端目前的內容: + +```python +from fastapi import Depends, FastAPI +from fastapi_cachex import add_routes + +app = FastAPI() +add_routes( + app, + prefix="/admin/cache", # 預設 "" -> /cached-hits、/cached-records + include_in_schema=False, # 預設:不出現在 OpenAPI 中 + dependencies=[Depends(verify_admin)], + include_content_preview=False, # 預設 True:顯示前 100 個位元組 +) +``` + +- `GET {prefix}/cached-hits`:列出每筆快取項目,拆分為方法、主機、路徑與查詢,附上 ETag 與到期時間,另外統計有效與已過期的項目數,以及不重複的快取路徑。它不會計算命中次數。 +- `GET {prefix}/cached-records`:列出每筆快取紀錄的大小、到期時間,以及快取內容前 100 個位元組的預覽。設定 `include_content_preview=False` 時,`content_preview` 為 `null`,不會有任何回應本文離開伺服器;鍵、大小與到期時間仍會回報。 + +> [!WARNING] +> **這些路由本身沒有任何身分驗證。** `include_in_schema=False` 只是讓它們不出現在 OpenAPI 文件中;任何猜到路徑的人都能讀取。`/cached-records` 含有快取內容的預覽(除非設定 `include_content_preview=False`),並會暴露整個路由結構。正式環境中請務必傳入 `dependencies=[Depends(your_auth)]`,或將它們掛載在僅供內部使用的應用程式上。 + +> [!NOTE] +> Memcached 無法列舉鍵,因此在 Memcached 上這兩個路由都不會回傳任何內容。 diff --git a/i18n/zh-TW/docs/STATE.md b/i18n/zh-TW/docs/STATE.md new file mode 100644 index 0000000..b631cc6 --- /dev/null +++ b/i18n/zh-TW/docs/STATE.md @@ -0,0 +1,133 @@ +# State 管理擴充 {#state-management-extension} + +`fastapi_cachex.state` 提供**一次性 state 權杖**,保護 OAuth / OIDC 授權流程免於 CSRF 攻擊。開始授權之前,先產生一個隨機 state 並存入快取後端;回呼(callback)回來時,再將它**消耗**掉。已消耗的 state 無法再使用第二次。 + +State 與 HTTP 快取存放在同一個後端,但使用自己的鍵前綴(預設為 `oauth_state:`),因此像 `CacheManager.clear_prefix()` 這類依命名空間的操作不會動到它們。不過後端層級的 `clear()`(例如 `BackendProxy.get().clear()`)會移除它們,因為它會清除後端命名空間底下的所有內容。 + +本指南中的所有內容也都可以從頂層的 `fastapi_cachex` 套件匯入。 + +## 快速開始 {#quick-start} + +```python +from fastapi import FastAPI, HTTPException +from fastapi.responses import RedirectResponse + +from fastapi_cachex import BackendProxy +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.state import StateError, StateManagerDep + +app = FastAPI() +BackendProxy.set(MemoryBackend()) + + +@app.get("/login") +async def login(states: StateManagerDep): + state = await states.create_state(metadata={"next": "/dashboard"}) + return RedirectResponse( + f"https://provider.example.com/authorize?state={state}&client_id=..." + ) + + +@app.get("/callback") +async def callback(state: str, code: str, states: StateManagerDep): + try: + data = await states.consume_state(state) # 一次性:取出時即刪除 + except StateError as e: # 未知、已過期或格式錯誤的 state + raise HTTPException(status_code=400, detail="Invalid state") from e + + # 以 code 換取權杖、建立 Session…… + return {"next": data.metadata.get("next", "/")} +``` + +## StateManager {#statemanager} + +```python +from fastapi_cachex.state import StateManager + +states = StateManager( + backend=None, # None 表示使用 BackendProxy.get() + key_prefix="oauth_state:", # 鍵前綴 + default_ttl=600, # 預設:10 分鐘 +) +``` + +當 `backend=None` 時,後端是在**建構 `StateManager` 時**解析,而不是每次呼叫時才解析。若尚未呼叫 `BackendProxy.set(...)`,建構子會拋出 `BackendNotFoundError`。請先設定後端。 + +### `create_state(ttl=None, metadata=None) -> str` {#create_statettlnone-metadatanone-str} + +以 `secrets.token_urlsafe(32)`(256 位元熵)產生 state 字串,存入後端並回傳。`metadata` 是與 state 一併儲存的任意可 JSON 序列化 dict(例如授權完成後要重新導向的路徑)。省略 `ttl` 時使用 `default_ttl`。同一個 TTL 會同時作為後端 TTL 與 state 的 `expires_at`。 + +### `consume_state(state) -> StateData` {#consume_statestate-statedata} + +**一次性消耗。** 項目透過後端的原子操作 `get_and_delete()` 取出並移除,因此當多個並行呼叫提交同一個 state 時,**只有一個**會取得它。重送的回呼無法通過第二次。 + +| 情況 | 行為 | +|------|------| +| 不存在、已被消耗,或已因後端 TTL 而被淘汰 | `InvalidStateError` | +| 已取出但超過其 `expires_at` | `StateExpiredError`(項目也已被刪除,不會殘留) | +| 已取出但內容不是有效的 `StateData` JSON | `StateDataError`(項目也已被刪除) | +| 其他情況 | 回傳 `StateData` | + +一般情況下,後端 TTL 會先移除已過期的 state,因此過期的 state 通常會以 `InvalidStateError` 而非 `StateExpiredError` 呈現;兩者都請捕捉。在 Redis 與 Memcached 上,完全無法解碼成快取項目的儲存值會被後端視為未命中,同樣會以 `InvalidStateError` 呈現。 + +### `validate_state(state) -> bool` {#validate_statestate-bool} + +不會消耗 state 的唯讀檢查:state 存在、可解析且尚未過期時回傳 `True`,否則回傳 `False`。它不會拋出 state 相關例外。 + +> [!WARNING] +> `validate_state()` **不會**消耗 state,因此單獨使用時無法防止重送攻擊。真正的防護是 `consume_state()`。`validate_state()` 只應用於與安全無關的判斷,例如「先探測,再決定要顯示哪個 UI」。 + +### `get_state_metadata(state) -> dict | None` {#get_state_metadatastate-dict-none} + +同樣不會消耗 state。回傳建立時儲存的 `metadata`;若 state 不存在、已過期或無法解析,則回傳 `None`。 + +### `delete_state(state) -> bool` {#delete_statestate-bool} + +手動刪除 state(例如使用者取消授權時)。回傳該 state 是否存在。它使用相同的原子操作 `get_and_delete()`,因此即使與 `consume_state()` 競爭,也最多只有一個呼叫者會得到 `True`。 + +## StateData {#statedata} + +```python +class StateData(BaseModel): + state: str # state 字串本身 + created_at: datetime # 建立時間(UTC) + expires_at: datetime # 過期時間(UTC) + metadata: dict[str, Any] # 建立時附加的 metadata +``` + +`expires_at` 是儲存在資料內的邏輯過期時間,與後端 TTL 無關。後端 TTL 到期時,項目就會消失;`expires_at` 則確保後端仍保留、但在邏輯上已過期的項目同樣會被拒絕。 + +## 依賴注入與 proxy {#dependency-injection-and-proxy} + +```python +from fastapi_cachex.state import StateManagerDep, StateManagerProxy, get_state_manager + + +# 1. 直接使用型別註記(最常見) +@app.get("/login") +async def login(states: StateManagerDep): ... + + +# 2. 自訂實例(例如不同的前綴或 TTL):在啟動時註冊, +# 依賴注入就會回傳它 +StateManagerProxy.set(StateManager(key_prefix="csrf:", default_ttl=300)) +``` + +尚未註冊任何實例時,`get_state_manager()`(`StateManagerDep` 背後的依賴項)會在第一次使用時延遲建立一個以 `BackendProxy` 的後端為基礎的預設 `StateManager`,並將它註冊。它不會退回使用 `MemoryBackend`:若尚未設定後端,請求會以 `BackendNotFoundError` 失敗。 + +## 例外 {#exceptions} + +``` +CacheXError +└── StateError + ├── InvalidStateError # 不存在或已被消耗 + ├── StateExpiredError # 已過期 + └── StateDataError # 內容格式錯誤 +``` + +## 注意事項 {#notes} + +- **後端必須在多個行程之間共用。** 多 worker 部署請使用 Redis 或 Memcached。使用 `MemoryBackend` 時,state 只存在於建立它的行程中,因此落到其他 worker 的授權回呼會失敗。 +- **一次性保證來自後端的原子操作。** `get_and_delete()` 在 Redis 上是 `GETDEL`(需要 Redis 伺服器 6.2 或更新版本);在 Memcached 上是先 get 再 `delete(noreply=False)`,只有 delete 成功的呼叫者勝出;在記憶體後端上則是在鎖內 `pop`。只實作抽象方法的自訂後端會退回使用 `BaseCacheBackend` 的非原子性版本,因此並行的重送可能兩邊都成功。這種情況請覆寫 `get_and_delete()`。 +- 不要在 state 中存放敏感資料。`metadata` 會以明文 JSON 存放在快取後端中。 +- **日誌中絕不會出現 state 本身。** 來自 `fastapi_cachex.state.manager` 的日誌以 `state_ref` 識別 state,也就是其 SHA-256 的前 12 個十六進位字元;你可以從已知的 state 計算出它來比對。未知或已過期的 state 以 INFO 等級記錄,因為那是常見的用戶端輸入;格式錯誤的儲存資料則以 WARNING 等級記錄一次。 diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index c08da61..03275a9 100644 --- a/i18n/zh-TW/docs/index.md +++ b/i18n/zh-TW/docs/index.md @@ -16,17 +16,14 @@ FastAPI-CacheX 是 FastAPI 的高效能快取擴充套件:提供支援 `Cache-Control` 與 `ETag` 的伺服器端回應快取、應用層快取,以及可選的 Session 管理。 -**文件:** (尚未翻譯的指南與完整 API 參考請見[英文文件](https://fastapi-cachex.readthedocs.io/en/latest/)) +**文件:** (尚未翻譯的頁面與完整 API 參考請見[英文文件](https://fastapi-cachex.readthedocs.io/en/latest/)) ## 功能特點 -- **HTTP 快取**:GET 路由專用的 `@cache` 裝飾器,支援 `Cache-Control`、 - `ETag` / `If-None-Match`(304)與單一路由的快取失效。 -- **應用層快取**:`CacheManager` 可在自己的程式碼中快取任意 JSON 值,提供 - 未命中時才計算的 `get_or_set()` 與原子性的「不存在才寫入」`add()`。 +- **HTTP 快取**:GET 路由專用的 `@cache` 裝飾器,支援 `Cache-Control`、`ETag` / `If-None-Match`(304)與單一路由的快取失效。 +- **應用層快取**:`CacheManager` 可在自己的程式碼中快取任意 JSON 值,提供未命中時才計算的 `get_or_set()` 與原子性的「不存在才寫入」`add()`。 - **後端**:記憶體、Redis 與 Memcached,支援原子操作的計數器、一次性取值與鎖。 -- **Session(可選)**:以 HMAC 簽章或 JWT 發行的 Session 權杖,可經由標頭、 - Bearer 權杖或 Cookie 傳遞,支援滑動過期與 IP / User-Agent 綁定。 +- **Session(可選)**:以 HMAC 簽章或 JWT 發行的 Session 權杖,可經由標頭、Bearer 權杖或 Cookie 傳遞,支援滑動過期與 IP / User-Agent 綁定。 - **OAuth state**:OAuth / OIDC 流程中用於防範 CSRF 的一次性 state 權杖。 ## 安裝 @@ -74,23 +71,19 @@ async def report(cache: AppCache): ``` > [!WARNING] -> 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True` -> 或依使用者區分的 key builder,詳見 -> [需驗證身分的端點](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints)(英文)。 +> 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True` 或依使用者區分的 key builder,詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 ## 文件 -以下指南尚未翻譯,連結指向英文文件: - -- [HTTP 快取](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/):`@cache` 裝飾器、Cache-Control 指令、快取鍵、快取失效與監控路由 -- [快取流程](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/):快取請求內部的處理流程 -- [應用層快取](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/):`CacheManager` -- [後端](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/):選擇與設定後端、原子操作的基本功能 -- [Session 管理](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/)與 [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/) -- [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/):一次性的 OAuth / CSRF state 權杖 -- [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/) -- [開發指南](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/)與[貢獻指南](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/) -- [變更紀錄](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [已知限制與規劃中的工作](https://github.com/allen0099/FastAPI-CacheX/issues) +- [HTTP 快取](HTTP_CACHING.md):`@cache` 裝飾器、Cache-Control 指令、快取鍵、快取失效與監控路由 +- [快取流程](CACHE_FLOW.md):快取請求內部的處理流程 +- [應用層快取](APP_CACHE.md):`CacheManager` +- [後端](BACKENDS.md):選擇與設定後端、原子操作的基本功能 +- [Session 管理](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/)與 [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)(英文) +- [OAuth state](STATE.md):一次性的 OAuth / CSRF state 權杖 +- [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)(英文) +- [開發指南](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/)與[貢獻指南](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)(英文) +- [變更紀錄](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md)(英文) · [已知限制與規劃中的工作](https://github.com/allen0099/FastAPI-CacheX/issues) ## 授權 diff --git a/zensical.zh-TW.toml b/zensical.zh-TW.toml index c230a0c..edf8cd9 100644 --- a/zensical.zh-TW.toml +++ b/zensical.zh-TW.toml @@ -23,13 +23,13 @@ site_dir = "site-zh-TW" nav = [ { "首頁" = "index.md" }, { "指南" = [ - { "HTTP 快取(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/" }, - { "快取流程(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/" }, - { "應用層快取(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/" }, - { "後端(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/" }, + { "HTTP 快取" = "HTTP_CACHING.md" }, + { "快取流程" = "CACHE_FLOW.md" }, + { "應用層快取" = "APP_CACHE.md" }, + { "後端" = "BACKENDS.md" }, { "Session 管理(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/SESSION/" }, { "JWT claims(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/" }, - { "OAuth state(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/STATE/" }, + { "OAuth state" = "STATE.md" }, ] }, { "API 參考(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/" }, { "開發(英文)" = [