後端知識 - MapGet 不是在執行,是在登記。

image

這是 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++ 這個看起來只有一步的操作。它其實是三步:

  1. 讀出 count 現在的值
  2. 加一
  3. 寫回去

兩個 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,都是建立在同一件事上。

啟動期先登記好,請求期只是照表執行。

(來自一個想把一千五百行讀完、讀到中途就迷路的人)