← Answer Me · 範例:由 answer-me v0.1.3 產生 · 2026-10-05 · 經人工核對 · 素材:ticket_api

ticket_api · 呼叫順序解說

TicketService 先查自己的 cache,沒有才問 FakeRepo

呼叫端只跟 TicketService 說話。TicketService 一律先檢查自己的 cache;只有 cache 沒有這張票時,才呼叫 FakeRepo.find。FakeRepo 從不主動呼叫 TicketService,只回傳結果。

三種情況一句話

  • 首次讀取:cache 未命中 → 呼叫 FakeRepo.find(reads 加 1)→ 把結果複製進 cache → 回 200。
  • 再次讀取同一張票:cache 命中 → 直接回 200,FakeRepo 完全沒被呼叫。
  • 找不到票:FakeRepo.find 回 None → get_ticket 拋出 NotFound → handle_get 接住並回 (404, {"error": "not found"})。

所有判斷都在 get_ticket 這八行

呼叫端的入口是 handle_get,它把工作交給 get_ticket,再把結果或例外轉成「狀態碼+內容」的 tuple。FakeRepo 是測試用的假資料來源:find 每被呼叫一次,計數器 reads 就加 1,並用 dict.get 查表,查不到時回 None。

 9    def find(self, ticket_id):            # FakeRepo
10        self.reads += 1
11        return self.rows.get(ticket_id)
 ⋮
18    def get_ticket(self, ticket_id):      # TicketService
19        if ticket_id in self.cache:
20            return self.cache[ticket_id]
21        row = self.repo.find(ticket_id)
22        if row is None:
23            raise NotFound(ticket_id)
24        self.cache[ticket_id] = dict(row)
25        return self.cache[ticket_id]
26
27    def handle_get(self, ticket_id):
28        try:
29            return 200, self.get_ticket(ticket_id)
30        except NotFound:
31            return 404, {"error": "not found"}
來源:ticket_api/service.py 第 9–11、18–31 行(節錄,未改動程式)。
  • 實線:呼叫
  • 虛線:回傳
  • 方框:該參與者內部的動作
  • 「呼叫端」在測試中是 test_service.py;圖中的票號沿用測試資料

首次讀取:TicketService → FakeRepo,一來一回

第一次要 "T1" 時,cache 是空的(建構時設為 {},第 16 行),所以 TicketService 呼叫 FakeRepo.find("T1")。FakeRepo 把 reads 加 1 並回傳那一列資料;TicketService 用 dict(row) 複製一份放進 cache,再回傳 cache 裡的那份。

← 圖較寬,可在框內左右捲動 → 呼叫端 例:測試程式 TicketService service.py FakeRepo service.py 1. handle_get("T1") 2. get_ticket:cache 沒有 "T1" 3. find("T1") 4. reads += 1(0 → 1) 5. row = {"title": "hello"} 6. cache["T1"] = dict(row) 7. (200, cache["T1"])
依據:service.py 第 19、21、24–25、29 行;FakeRepo 第 10–11 行。

再次讀取:停在 TicketService,不碰 FakeRepo

第二次要 "T1" 時,第 19 行的 if ticket_id in self.cache 成立,第 20 行直接回傳,第 21 行的 self.repo.find 根本不會執行。因此 FakeRepo.reads 停在 1。這正是測試 test_repeat_hit 要確認的事。

← 圖較寬,可在框內左右捲動 → 呼叫端 例:測試程式 TicketService service.py FakeRepo service.py 1. handle_get("T1") 2. get_ticket:cache 已有 "T1" 3. (200, cache["T1"]) 4. 未被呼叫,reads 仍為 1
依據:service.py 第 19–20 行;test_service.py 第 5–10 行斷言兩次都是 200,且 repo.reads == 1。

首次與再次讀取對照

同一個 TicketService 實例,連續兩次 handle_get("T1")
面向首次讀取再次讀取
cache 檢查(第 19 行)未命中命中
是否呼叫 FakeRepo.find是,呼叫 1 次否
reads 之後的值1仍為 1
cache 變化寫入 cache["T1"] = dict(row)不變
回傳(200, cache["T1"])(200, cache["T1"]),與首次是同一個 dict 物件

程式推論,已小型實測兩次回傳的是 cache 裡同一個 dict(第 20、25 行都回 self.cache[ticket_id]),不是各自的複本;而它與 FakeRepo.rows 裡的原始資料是不同物件(第 24 行做了 dict(row))。所以若呼叫端改動拿到的 dict,下一次讀取會看到改動後的內容。我以一段臨時 Python 指令確認:兩次回傳 is 比較為 True,與 rows["T1"] 比較為 False。現有測試沒有涵蓋這一點。

找不到票:例外在 TicketService 內部被轉成 404

找不到票時,FakeRepo 仍然被呼叫,只是 rows.get 回 None。get_ticket 看到 None 就拋出 NotFound,不寫入 cache;外層 handle_get 的 except NotFound 接住它,回傳 (404, {"error": "not found"})。例外不會傳到呼叫端,呼叫端收到的是一般的 tuple。

← 圖較寬,可在框內左右捲動 → 呼叫端 例:測試程式 TicketService service.py FakeRepo service.py 1. handle_get("missing") 2. get_ticket:cache 沒有 "missing" 3. find("missing") 4. reads += 1 5. None(rows 沒有這個 key) 6. raise NotFound("missing") 7. handle_get 的 except 接住 8. (404, {"error": "not found"})
依據:service.py 第 11、22–23、28–31 行;test_service.py 第 12–14 行斷言完整回傳值等於 (404, {"error": "not found"})。

程式推論,已小型實測因為找不到時不寫入 cache,對同一個不存在的票號重複查詢,每次都會再呼叫一次 FakeRepo.find。臨時指令中連查兩次 "X",reads 依序加 1,cache 裡也沒有 "X"。現有測試沒有涵蓋這一點。

程式推論,未測試handle_get 只接 NotFound。若 find 拋出其他例外,它會直接傳到呼叫端,不會變成 404 或其他狀態碼。

證據與限制

  • 讀過的來源:README.md、service.py、test_service.py,共三個檔案。
  • 測試實際執行:2026-10-05 在 ticket_api 目錄以 Python 3.14.7 執行 python3 -m unittest -v,test_repeat_hit 與 test_missing 兩項皆 ok(Ran 2 tests,OK)。
  • 來源衝突:60 秒過期。README 寫「Cached values expire after 60 seconds」,但 service.py 的 cache 只是普通 dict(第 16、24 行),沒有任何時間或過期邏輯。依程式,只要是同一個 TicketService 實例,再次讀取不論隔多久都會命中 cache。兩者哪個代表預期行為,現有材料無法判斷;測試也沒有測過期。
  • 「process-local」的範圍:README 說 cache 屬於行程;依程式,cache 是每個 TicketService 實例各自的 self.cache,兩個實例不共用。這是從第 16 行推論,未另行測試。
  • 範圍:FakeRepo 是記憶體中的假資料來源;材料中沒有真實資料庫或 HTTP 層,200/404 只是 handle_get 回傳 tuple 的第一個元素。

自我檢查(可略過)

先查 handle_get("T9") 得到 404,之後有人把 "T9" 加進 FakeRepo.rows,再查一次會得到什麼?

會得到 200 和那張票。找不到時沒有寫入 cache,所以第二次仍會呼叫 FakeRepo.find,這次查得到。這是依第 22–24 行的推論,現有測試沒有涵蓋。