這是 backend-lab 自學紀錄的 Day 1,主題是 ASP.NET Core 的 Minimal API。我原本以為 MapGet 那一行就是「收到請求時執行這段」,結果它其實只是在登記路由。這篇記錄我怎麼從一個健康檢查端點,一路挖到框架的原始碼,最後還親手做出一個 race condition。
為什麼要綁住 AI 不准代寫,上一篇講過了,這篇只講學到的東西。
第一個端點長這樣
十幾行,但每一行都比我以為的多做了事
Day 1 的題目是寫一個健康檢查端點,回傳服務狀態跟時間。這種端點是讓外部確認服務還活著用的,部署平台會定期打它,沒回應就當作服務掛了。
用的寫法叫 Minimal API,是 ASP.NET Core 寫 API 最精簡的方式。不需要另外開 Controller 類別,幾行就能架起一個服務。
補全關掉之後我重寫出來的版本是這樣:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () =>
{
var response = new HealthResponse("ok", DateTime.UtcNow);
return TypedResults.Ok(response);
});
app.Run();
/// <summary>健康檢查回應。</summary>
/// <param name="Status">服務狀態。</param>
/// <param name="Timestamp">回應產生時間(UTC)。</param>
public record HealthResponse(string Status, DateTime Timestamp);最下面那個 record 是 C# 專門用來裝資料的型別,宣告一行就有建構子跟屬性,Day 2 會再細講。TypedResults.Ok(...) 則是回傳 HTTP 200,並把內容一起送出去。
我 JS 寫得比 C# 多,第一眼看這段,腦中浮現的是 Express 的 app.get('/health', handler)。這裡的 handler 指的是處理請求的那個函式。C# 那邊的 () => { ... } 叫做 lambda,其實就是 JS 的箭頭函式。
感覺很熟,所以我很自然地把 MapGet 讀成「有人打 /health 的時候,執行這個 lambda」。
這個讀法不算錯,但漏掉了最重要的一半。
註冊,不是執行
MapGet跑的時候,根本還沒有任何請求
MapGet 這一行是在 app.Run() 之前執行的。那時候伺服器還沒開始聽 port,不可能有請求進來。
所以它做的事情只有一件,就是把「/health 對應到這段 lambda」寫進路由表。
真正執行 lambda 的,是之後每一個打進來的請求。
用餐廳來想會比較清楚。開店之前,老闆把菜單寫好、規定每道菜的做法,這是 MapGet。開店之後客人點一份,廚房就照做一份,這是請求進來。
廚房不會在每個客人點餐的時候,重新討論這道菜要怎麼做。
編譯期、啟動期、請求期
整個 Day 1 最後收斂成這三行
寫 JS 的時候,存檔就能直接跑。C# 不一樣,執行之前要先經過編譯器,把程式碼翻成 .NET 執行環境看得懂的格式,順便把型別全部檢查一遍。這一步就是編譯期。
把「登記」跟「執行」拆開之後,我發現其實有三個時間點,不是兩個:
編譯期 → 編譯器推導出 lambda 的 delegate 型別,記進編譯結果
啟動期 → MapGet 執行,讀這份型別資訊,決定回應策略,寫進路由表
請求期 → 請求進來,照路由表既定策略執行。不猜、不判斷。delegate 是 C# 用來描述「一個函式長什麼樣子」的型別,包括吃什麼參數、回傳什麼。
最前面那一段,是我用一個會失敗的實驗確認的。
我故意寫了一個 lambda,某個分支回傳字串,另一個分支回傳物件。程式根本跑不起來,還沒執行就報錯,說 delegate 的型別推導不出來。
這代表「這個 handler 會回傳什麼」在編譯的時候就要定下來了。回傳 string 就是純文字,回傳物件就序列化成 JSON,框架不會等到請求進來才去看你回了什麼。
跳進原始碼,然後迷路
抓機制,不要啃實作
規則裡有一條是遇到框架的「魔法」,要自己跳進原始碼看,不准問 AI 也不准 Google。
我寫 C# 用的 IDE 是 JetBrains 的 Rider,游標停在方法名稱上按 Cmd+B,就會直接跳到框架裡那個方法的實作。
所以我從 MapGet 一路往裡面跳,最後停在一個叫 RequestDelegateFactory 的類別。
這個檔案大約一千五百行。
我很認真地從頭讀,讀到中途就完全不知道自己在哪裡了 😵(然後默默把檔案往上捲回去,假裝剛剛沒讀過)
後來換了一個讀法。不去理解每一行在做什麼,只找一件事的證據,就是 lambda 是不是在啟動期被加工一次,之後就不再動。
證據是兩個東西。一個是裡面有 .Compile(),把 lambda 編譯成一個 RequestDelegate。另一個是我在啟動期把那個 delegate 的 GetType() 印出來,確認它真的是一個已經做好的東西,而不是每次請求才產生。
讀框架原始碼的時候,你不需要懂一千五百行。
你只需要知道自己要找什麼,然後找到那一行。
放錯位置的 record
啟動期只跑一次,所以放在那裡的東西也只會產生一次
搞懂三個時期之後,我回頭看 Day 1 犯的一個錯,才真的看懂它錯在哪。
我一開始把 HealthResponse 建在 handler 外面,像這樣:
var response = new HealthResponse("ok", DateTime.UtcNow);
app.MapGet("/health", () => TypedResults.Ok(response));程式會動,打 /health 也有回應。但不管打幾次,Timestamp 永遠是同一個時間。
因為 handler 外面那一行屬於啟動期,整個伺服器生命週期只跑一次。之後每個請求拿到的,都是開店那一刻做好的那一盤菜。
這個錯本身沒什麼殺傷力,頂多時間不對。
但同樣的位置,如果放的是一個會被寫入的變數,就是另一回事了。
親手做出一個 race condition
說好的一百萬,最後只剩三十五萬
所以 Day 1 的最後一個實驗,是故意把這個錯放大。
我在 handler 外面放一個共享的 count,然後開 10 個 Task 同時跑,每個都對它加 10 萬次。Task 可以先想成 C# 版的 Promise,Day 2 會講。
照理說,最後的數字應該是一百萬。
實際跑出來是 353,900。
跑之前我有先賭一把,賭結果會「比較少」,這點有賭對。但我沒想到會少這麼多,三分之二的加法直接消失了。
原因在 count++ 這個看起來只有一步的操作。它其實是三步:
- 讀出
count現在的值 - 加一
- 寫回去
兩個 Task 如果同時讀到 100,各自加一變成 101,再先後寫回去,結果是 101 而不是 102。
有一次加法就這樣不見了。十個 Task 一起搶的時候,這種事情會一直發生。
想像十個人同時在改白板上的同一個數字。每個人都是看一眼、心算加一、擦掉重寫,看的那一眼跟寫的那一下中間,別人早就改過了。
解法的方向是讓「讀、改、寫」變成一個不能被插隊的動作,也就是原子操作。具體怎麼寫,課程排在第三週,我還沒學到。
這裡我也學到要分清楚兩種情況。共享狀態如果只有讀,問題是資料舊了,像上面那個永遠不更新的時間。如果有併發在寫,問題就會變成 race condition,資料直接錯掉。
前者很容易發現,後者不會報任何錯,數字就只是靜靜地變少。
Day 1 結束時,我真的會的是什麼
照我自己的定義,只有能憑記憶重寫的才算
backend-lab 有一張掌握度的表,綠燈的定義是「打開空白檔案、不看範例能寫出來」。Day 1 結束時,綠燈的有這幾個:
- Minimal API 的基本結構
MapGet是註冊,不是執行- 編譯期、啟動期、請求期的時間軸
- 回傳型別決定回應格式
- handler 外的共享狀態是陷阱
還有一個是黃燈,就是上一篇講過的,我把 string 寫成 String。
如果你也剛開始碰 ASP.NET Core,我覺得 Day 1 最值得帶走的是那條時間軸。之後要學的依賴注入、middleware,都是建立在同一件事上。
啟動期先登記好,請求期只是照表執行。
(來自一個想把一千五百行讀完、讀到中途就迷路的人)
