iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
Software Development

異步程式設計修煉:Kotlin Coroutines 完全指南系列 第 30 篇

Day 29:全系列整合驗收,跑一次完整的多來源下載情境

  • 分享至 

  • xImage
  •  

Day 28:協程常見陷阱總盤點,這些坑我們都踩過 結尾留下一個疑問,一個具備多來源平行下載、資料流回報進度、例外處理這些真實複雜度的完整專案,要怎麼確保自己從頭到尾都沒有不小心踩中任何一個陷阱,光靠逐段檢查程式碼夠不夠。今天正面回答這個問題。

回顧,從逐段檢查到一次端到端驗證

逐段檢查程式碼,看起來已經是很負責任的做法,但檢查終究是一種主觀判斷,讀過一遍程式碼、覺得沒問題,跟這段程式碼實際運行起來確實沒問題,中間還隔著一段距離。真正能拍板定案的,是讓程式碼在一個接近真實的情境裡跑一次,看它的行為是否真的如預期發生。

今天不學習任何新機制,全系列的技術地基在 Day 28 已經全部到齊。今天要做的事情,是對圖片下載工具完整版進行一次端到端的完整驗證,設計一個更貼近真實情境的完整情境,同時涵蓋正常成功、部分來源失敗、使用者中途取消這幾種情況,看看前面二十幾天累積的觀念,能不能在這個完整情境下站得住腳。這有點像畢業考,考的範圍不侷限在單一章節,而是把整個學期學過的東西放進同一份考卷裡,一次考完。

設計這次驗收的完整情境

情境的樣貌並不複雜,卻刻意安排得足夠擁擠。同時向多個來源發出下載請求,其中大多數來源正常成功回應,其中一個來源被刻意設計成回應時間超過逾時限制,觸發逾時失敗。過程中透過 progress 這個 StateFlow,持續回報目前整體下載的進度快照。下載進行到一半時,模擬使用者主動觸發取消整次下載的操作,觀察後續的行為是否符合預期。

三種狀況疊在同一次呼叫裡,成功、失敗、取消同時交織出現,這正是這個情境的用意所在。真實世界裡的下載任務很少乾淨地只落在單一狀況,多半是這樣的複合情境。選擇這個設計,是因為它同時涵蓋了系列前四階段建立的核心能力,平行下載與結構化收斂、例外隔離、資料流進度回報、取消機制,這四件事沒有一件能被單獨拆開驗證就算數,只有把它們放進同一次呼叫裡一起檢驗,才稱得上是一次完整的整合驗收,而不是分別測試四個互不相干的小功能。

不過同一個情境裡,「取消」與「等到最終回傳值」這兩件事,本質上互相排斥。協程一旦被取消,它原本該回傳的結果就不會再產生,這正是 Cooperative Cancellation 的基本邏輯。所以這次驗收拆成兩個測試案例,各自鎖定一個切面,一個驗證正常跑到底時回傳值是否正確反映部分失敗、部分成功,另一個驗證中途取消時協程樹是否真的收斂,兩者共用同一組多來源情境設定,合起來才是這次端到端驗收的完整樣貌。

用 runTest 驗證回傳值,部分失敗不該波及聚合結果

先看第一個測試案例,驗證的是這次情境裡最基本的一環,slow.jpg 逾時失敗時,downloadAll 最終回傳的聚合結果,是不是依然正確地把這個失敗獨立標記出來,同時保留另外三個成功結果,而不是讓一次局部失敗拖垮整批呼叫。

@Test
fun `其中一個來源逾時時最終回傳值應正確標記失敗並保留其他成功結果`() = runTest {
    val downloader = ImageDownloader(
        ioDispatcher = StandardTestDispatcher(testScheduler),
    )
    val urls = listOf(
        "https://example.com/one.jpg",
        "https://example.com/two.jpg",
        "https://example.com/slow.jpg",
        "https://example.com/three.jpg",
    )

    val results = downloader.downloadAll(urls)

    val failures = results.filterIsInstance<DownloadResult.Failure>()
    val successes = results.filterIsInstance<DownloadResult.Success>()

    assertEquals(1, failures.size)
    assertEquals(3, successes.size)
    assertEquals("https://example.com/slow.jpg", failures.single().url)
}

這裡刻意不牽涉取消,讓 downloadAll 完整跑到底,測試直接對它的回傳值下斷言。虛擬時間排程器在背後接管了 slow.jpg 逾時所需的等待,整段測試依然幾乎瞬間完成,這是 Day 26:幫協程寫測試,runTest 與虛擬時間排程器 定案的 runTest 與 StandardTestDispatcher 搭配運作的效果。downloadAll 內部把四個來源各自包成一個 async,共同隸屬同一個 supervisorScope,這正是 Day 07:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發規則。slow.jpg 逾時失敗時,靠的是 Day 15:一個協程失敗,其他協程要不要一起死,SupervisorJob 登場 定案的 SupervisorJob 例外隔離,這個失敗被限制在它自己身上,其餘三個來源不受牽連,繼續完成各自的下載。

assertEquals(1, failures.size) 與 assertEquals(3, successes.size) 這兩行斷言,具體驗證了聚合結果裡失敗與成功各自的數量,failures.single().url 進一步確認被標記失敗的正是 slow.jpg,而不是隨便哪一個來源。這幾行斷言合起來,才真正證明了「部分失敗不會波及其他來源」這件事,不是只看一個籠統的進度數字就宣稱驗證完畢。

用 runTest 驗證取消後協程樹是否真的收斂

第二個測試案例換一個切面,驗證使用者中途取消時,這次情境涵蓋的行為是不是真的如預期發生。這裡真正要驗證的,不是取消這個動作本身能不能被呼叫,那件事只是表象,關鍵在於取消之後,整個 downloadAll 這棵協程樹是不是真的跟著收斂,而不是只在表面上呼叫了 cancel,實際上還有零星協程繼續在背景跑。

@Test
fun `下載進行到一半觸發取消時協程樹應確實收斂`() = runTest {
    val downloader = ImageDownloader(
        ioDispatcher = StandardTestDispatcher(testScheduler),
    )
    val urls = listOf(
        "https://example.com/one.jpg",
        "https://example.com/two.jpg",
        "https://example.com/slow.jpg",
        "https://example.com/three.jpg",
    )

    val job = launch {
        downloader.downloadAll(urls)
    }

    advanceTimeBy(2_000)
    val midProgress = downloader.progress.value
    job.cancel()
    job.join()

    assertTrue(midProgress.completedCount < urls.size)
    assertTrue(job.isCancelled)
    assertFalse(job.isActive)
}

下載進度透過 progress 這個 StateFlow 持續回報,測試裡呼叫 downloader.progress.value 拿到的正是當下那一刻的最新進度快照,這呼應的是系列資料流階段建立的 Flow 與 StateFlow 觀念。assertTrue(midProgress.completedCount < urls.size) 這一行,具體確認了取消發生的時間點,四個來源確實還沒有全部完成,取消真正打斷了進行中的工作,而不是湊巧四個來源都已經跑完,才讓後面的取消淪為多餘的形式動作。

job.cancel() 之後緊接著 job.join(),等待這個父 Job 真正走到終止狀態,再對它下斷言,這一步是這次修正的關鍵。assertTrue(job.isCancelled) 與 assertFalse(job.isActive) 這兩行,直接驗證了父 Job 確實進入取消後的終止狀態,而不是只驗證取消這個呼叫本身沒有拋出例外。依照 Day 07:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發規則,父 Job 只有在它底下所有子協程都真正收斂完畢後,才會走到這個終止狀態,job.join() 能夠順利返回、isCancelled 為真,正是四個子協程確實一併收到取消訊號並停止的具體證據,而不是表面上呼叫了 cancel、卻還有子協程繼續在背景默默跑完。

這兩個測試案例合起來,才對應到協程取消機制,Cooperative Cancellation,完整的行為預期,取消要求發出後,尚未完成的下載協程會在下一個可取消點停下來,不會有殘留的背景工作繼續佔用資源,而這件事在測試裡是靠 Job 的終止狀態被具體驗證出來的,不是靠一個範圍寬鬆到幾乎涵蓋所有情況的數字區間帶過。

這兩段驗證做的事情,不是重新解釋這幾項機制各自的定義,Day 7、Day 15、Day 19、Day 21 已經各自把定義講清楚了。這裡要確認的是另一件事,這幾項機制擺在同一個完整情境裡,能不能協同運作,而且運作出來的結果,和各自那一天建立起來的行為預期一致。單獨測試某一項機制沒問題,不代表放進同一個情境裡還是沒問題,這正是端到端驗證存在的意義。

對照 Day 28 的陷阱清單,逐一確認沒有踩中

技術行為驗證完,回頭對照 Day 28 盤點的三類陷阱,逐一確認圖片下載工具完整版在這次驗收中沒有落入這些問題。

第一類陷阱是隨手用 GlobalScope 啟動協程。翻遍 ImageDownloader 的實作,找不到任何一處呼叫 GlobalScope.launch,每個下載協程都是透過 supervisorScope 建立的子協程,隸屬於呼叫端傳入的那個明確 Scope 之下,第二個測試案例裡 job.cancel() 之所以能讓 job.join() 順利收斂,前提正是這些協程從一開始就沒有脫離生命週期的管理。這次驗收確認了這一點,沒有任何一個下載協程是掛在等同於沒有邊界的全域範圍下運行的。

第二類陷阱是忘記結構化,讓協程長成一片各自為政的散兵。第二個測試案例刻意設計了 job.cancel() 加 job.join() 這組動作,用意正是要檢驗這件事,如果四個下載協程各自建立在互不相干的 Scope 下,取消要求發出後,理應只有被直接持有 Job 的那一個協程收到訊號,其他三個會繼續在背景默默跑完,父 Job 也未必能單純靠 join() 就等到一個乾淨的終止狀態。測試裡 job.isCancelled 為真、job.isActive 為假,這正是 Day 28 提醒過的散兵問題沒有在這裡發生的具體證據,共同隸屬同一個父 Job,是這次取消能夠一次到位、且能被測試觀察到的關鍵。

第三類陷阱是例外被悄悄吞掉,讓錯誤發生了卻沒人知道。第一個測試案例驗證的正是這件事,slow.jpg 逾時失敗時,這個結果沒有被籠統的 try-catch 默默吞掉,而是明確反映成 DownloadResult.Failure,連同其他三個成功結果一起出現在 downloadAll 的回傳值裡,測試裡 failures.size 與 successes.size 的斷言,直接確認了這個回傳值的具體組成,任何沒有被個別捕捉的例外,也還有 CoroutineExceptionHandler 攔截記錄,不會出現使用者完全不知道有來源失敗、卻只看到聚合結果少了一筆資料的情況。

這次驗收做的事情,不只是驗證功能正確而已。Day 28 盤點出的三類陷阱,原本停留在「概念上知道要避免」的層次,透過今天這兩個測試案例,被實際放進一個交織著成功、失敗、取消的完整情境裡逐一檢驗,變成「在一次真實情境的測試裡被具體驗證確實避免了」。這正是系列後段花力氣建立測試能力的地方真正發揮價值的時刻,沒有這次驗收,陷阱清單終究只是一份提醒事項,有了這次驗收,它變成一份被測試背書過的檢核結果。

結尾,這趟旅程,該回頭看看了

這次端到端驗收,是全系列最後一次技術層面的整合演練。從一個會卡住主執行緒的陽春下載函式開始,走到今天這個經得起完整驗證的多來源下載工具,中間走過的距離,值得回頭看一眼。

下一篇是全系列的完賽總結,該回頭看看協程到底解決了什麼問題,又留下了什麼取捨。


上一篇
Day 28:協程常見陷阱總盤點,這些坑我們都踩過
下一篇
Day 30:完賽總結,協程解決了什麼、又留下什麼取捨
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言