iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 28

Day 28 -「泰坦之王克洛諾斯」誰說改版一定要全部砍掉重寫?聊聊 Strangler Fig Pattern

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260827/20182564BrRTokWiZ3.png

克洛諾斯曾經推翻自己的父親烏拉諾斯,成為新一代的統治者,母親蓋亞與烏拉諾斯曾下過預言:

「總有一天,自己也會被親生孩子推翻。」

這件事他可不敢當成耳邊風,畢竟自己就是這樣上位的啊!於是妻子瑞亞每生下一個孩子,他就直接吞進肚子裡,前面五個孩子都沒能逃過,既然不想讓下一代取代自己,那就連長大的機會都不給。

瑞亞看著孩子一個個被吞掉,悲痛不已,等到第六個孩子宙斯快出生時,她前往克里特島生下宙斯,再由蓋亞把孩子藏進山洞裡,接著把一塊包在襁褓裡的石頭交到克洛諾斯手上 ,結果他真的吞了下去,完全沒發現孩子已經被藏起來了。

宙斯在克里特島長大,後來在蓋亞的計策與宙斯的力量下,克洛諾斯吐出了他吃下的孩子,被救出來的兄弟姊妹成了宙斯的盟友,之後眾神與泰坦展開長年的戰爭,宙斯這一方才終於獲勝,取代父親的統治。

克洛諾斯連自己的孩子都吞了,還是沒能保住王位,而取代他的下一代,早就在他看不見的地方慢慢長大了。

剛接手 Legacy Code 的時候,我們經常會冒出一個想法:

「是不是整個重寫會比較快啊?」

老實說,我以前常常這樣想,舊系統到處都是不好的設計,技術老、效能差,想改個東西還得先看半天,如果重寫,我們就能重新選架構、重新切邊界,連那些看不懂的命名都能一起換掉,光想就覺得舒服。

但問題是,客戶不會停在原地等我們再開發幾年,舊系統每天都還是要運作,照樣接需求、修 Bug,新系統就算從今天開始寫,也得追上這些變化,這到底要追到哪一天啊!

所以今天要聊 Strangler Fig Pattern(絞殺者無花果模式),也替這一系列的技術篇收個尾,前面我們大多在討論 Legacy Code,這次把視角放大到整個 Legacy System,看看除了重寫這條路,有沒有什麼辦法可以把舊系統換掉。


砍掉重練,聽起來最乾淨

所謂 Big Bang Rewrite,就是另外做一套完整的新系統,把準備取代的功能、資料與外部整合都處理好,再挑一個切換日整套換上去,期待舊系統從那天開始就可以退休了。

聽起來很合理吧?舊系統踩過的坑我們都看到了,這次避開就好,再把最強的人調去新團隊,理論上應該能寫出一套乾乾淨淨的系統,總不可能再爛一次吧?

偏偏舊系統不會因為我們開始重寫,就乖乖停止變動,客戶今天要的新功能,舊系統得做,新系統也得跟上,不過麻煩的是,有些行為根本沒寫在規格文件裡,而是藏在某個看起來很蠢的 if 裡,我們以為那是多餘的判斷,但它也可能正好支撐某個客戶的特殊流程,重寫時漏掉,等上線才知道就麻煩了。

最後很容易變成新團隊忙著追進度,舊團隊繼續替線上需求救火,兩邊都忙得要死,新版卻還不能交出去,原本想一次解決技術債,結果還要多養一套系統,錢燒起來可不是開玩笑的。


慢慢來比較快 Strangler Fig Pattern

那如果不要等整套新系統都寫完呢?先做出一塊能用的,讓它真的接手工作,剩下的繼續交給舊系統,至少不用等到遙遙無期的切換日,才知道前面花的時間有沒有價值。

https://ithelp.ithome.com.tw/upload/images/20260827/20182564Iunh8TLmnQ.jpg
圖片擷取自網路

Strangler Fig 的名字來自一類無花果,它們能在其他樹木上發芽,根逐漸往地面延伸,慢慢包圍原本的宿主,隨著無花果越長越大,即使原本的樹最後死亡、腐朽,它仍然能靠自己的結構繼續生長,甚至留下中央中空、外圍卻像樹幹一樣完整的形態。

Martin Fowler 就是藉由這樣的過程,來形容逐步替換舊系統的做法,並提出 絞殺者無花果模式 (Strangler Fig Pattern)

在軟體,就是先挑一塊能獨立交付的功能,在舊系統旁邊做出新版,確認能用後,把這塊功能的請求交給它處理,還沒搬走的部分則繼續照常運作,新舊系統會共存一段時間,每完成一次切換,舊系統需要負責的事情就少一點。

所以這裡不是完全不重寫,而是不要把所有功能綁在同一次重寫、同一天切換,我們還是得寫新程式、釐清舊規則,只是可以一塊一塊交付,也有機會在前面幾次搬遷時,就發現自己到底低估了什麼。

而在絞殺舊系統的過程中,我們必須確保使用者毫無感覺,也就是使用者還是像往常一樣在使用系統,但是殊不知其實在背後已經不是原來的那個系統了。


舊系統

概念有了,我們直接做一次看看,假設手上有一套 LegacySystem 提供產品與客戶查詢的 Web API,現在都還是舊系統在處理。

今日範例 以 Minimal API 的方式把端點寫在 Program.cs,先不產生 OpenAPI 文件,也只在本機使用 HTTP,稍微體驗如何簡易的實現絞殺模式中最重要的第一步:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/api/products/{id:int}", (int id) => Results.Ok(new
{
    handledBy = $"legacy-system products id = {id}",
}));

app.MapGet("/api/customers/{id:int}", (int id) => Results.Ok(new
{
    handledBy = $"legacy-system customer id = {id}",
}));

app.Run();

開一個 Terminal ,先讓舊系統在 5101 port 跑起來:

dotnet run --project LegacySystem/LegacySystem.csproj --urls http://localhost:5101

接著在另一個 Terminal 呼叫看看:

curl http://localhost:5101/api/products/42
curl http://localhost:5101/api/customers/7

兩次回應的 handledBy 都應該要是 legacy-system

https://ithelp.ithome.com.tw/upload/images/20260827/20182564RDm8wVMZmV.png


新系統:產品查詢

接下來建立新系統 ModernSystem,一樣是使用 Web API 專案,這是新做的 API,我們可以替它設計新版路徑,所以這次把產品查詢放在 /api/v2/products/{id},執行以下指令建立新系統專案並加入到解決方案中:

dotnet new webapi -n ModernSystem --no-openapi --no-https
dotnet sln Day28.sln add ModernSystem 

ModernSystem/Program.cs 完整替換成:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/api/v2/products/{id:int}", (int id) => Results.Ok(new
{
    handledBy = $"modern-system products id = {id}",
}));

app.Run();

新系統現在只有產品查詢,客戶查詢還沒做,這樣可以嗎?當然可以,舊系統不是還在處理嗎?我們想做的就是讓新系統一塊一塊長出來,不需要為了第一條 API,就先把整套功能複製一遍。

雖然新版換了路徑,這次仍然刻意保留舊產品查詢的輸入與回傳欄位,除了觀察用的 handledBy,先不加入破壞相容性的變更。這樣等等交接時,才能專心看清楚誰接手了工作,不會連回傳格式都一起變掉。

保持 LegacySystem 運作,再開一個 Terminal,在 5102 port 啟動 ModernSystem:

dotnet run --project ModernSystem/ModernSystem.csproj --urls http://localhost:5102

接著呼叫新版:

curl http://localhost:5102/api/v2/products/42

預期會看到如下:

https://ithelp.ithome.com.tw/upload/images/20260827/20182564f41JWWA3q8.png

到這裡,舊系統的 /api/products/42 與新系統的 /api/v2/products/42 都能用了,但舊系統有少做任何事情嗎?還沒有,原本的呼叫端仍然照舊網址進來,我們只是多做出一套能跑的新系統而已。


流量要怎麼切過去?

最快的做法,就是請呼叫端自己改網址,產品查詢改打 5102 的 /api/v2/products/42,客戶查詢繼續打 5101 的 /api/customers/7,但如果每搬一條 API,都得通知前端、App 或其他串接系統改一次,遷移成本會非常的高。

我們能不能先保留一個固定的對外入口,讓呼叫端不用知道後面到底是哪一套系統?等某個功能準備好了,就在入口調整去向,還沒搬的請求繼續交給舊系統。

這時候我們就需要 Reverse Proxy(反向代理) 了,它先接住 Request,再依規則轉送給後面的服務,這樣前端就能夠繼續用原本的 Url,根本不會察覺到系統其實已經換掉了,除非 Bug 一堆啦 XD

至於這層要用什麼做?選擇其實不少


YARP

如果環境裡已經有 Nginx,可以用它的反向代理設定分流,而原本就部署在 IIS,也可以透過 ARR 搭配 URL Rewrite 處理,要是功能較為複雜的,也可以評估 Azure API Management,不一定要從頭自己來。

今天我們選的是 YARP(Yet Another Reverse Proxy),它是一個 Microsoft 開源的 .NET 反向代理工具庫,它提供 HTTP 轉送的核心能力,可以接進應用程式,讓我們用設定檔描述「什麼請求送到哪裡」,需要時也能調整轉送出去的路徑。

為什麼選它?因為這一系列都在用 C# 與 .NET,現在要練的又是依 API 路徑分流,YARP 可以直接沿用熟悉的 Program.cs、DI 與設定檔,在本機把流程跑起來,不用為了這個練習再準備一套代理環境。

不過 YARP 只是可以讓我們組出 Proxy 的工具庫,不是一裝好都替我們包好喔,應用程式怎麼部署監控,仍然是我們的工作,如果團隊已經有合適的分流工具,沿用它也完全可以的。


補上共同入口 Gateway

現在我們要來實現絞殺者模式中最重要的門面,我們把專案叫做 Gateway,同樣用以下指令執行:

dotnet new webapi -n Gateway --no-openapi --no-https
dotnet sln Day28.sln add Gateway
dotnet add Gateway/Gateway.csproj package Yarp.ReverseProxy

接著把 Gateway/Program.cs 完整替換成:

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

app.MapReverseProxy();

app.Run();

程式不多,來看一下語法:

  • AddReverseProxy() 負責把需要的服務註冊進 DI Container。
  • LoadFromConfig(...) 讀取 ReverseProxy 區段的設定,使用預設的設定來源時,也支援設定變更後重新載入,因此不必為了調整 Route 就重啟 Gateway。
  • MapReverseProxy() 則把代理路由連接進應用程式。

這也是官方入門範例 使用的接法,Gateway 會負責把請求送到對的地方。


讓原始路徑,直接交給新系統

接著才是今天真正要看的流量切換,假設新版產品查詢已經驗證過,我們希望原本的 /api/products/42 交給 ModernSystem 處理,客戶查詢則繼續留在 LegacySystem。把 Gateway/appsettings.json 完整替換成:

{
  "AllowedHosts": "*",
  "ReverseProxy": {
    "Routes": {
      "products-original": {
        "Order": 0,
        "ClusterId": "modern-system",
        "Match": {
          "Path": "/api/products/{id:int}",
          "Methods": [
            "GET"
          ]
        },
        "Transforms": [
          {
            "PathPattern": "/api/v2/products/{id}"
          }
        ]
      },
      "modern-v2": {
        "Order": 10,
        "ClusterId": "modern-system",
        "Match": {
          "Path": "/api/v2/{**remainder}"
        }
      },
      "legacy-original": {
        "Order": 100,
        "ClusterId": "legacy-system",
        "Match": {
          "Path": "/api/{**remainder}"
        }
      }
    },
    "Clusters": {
      "modern-system": {
        "Destinations": {
          "primary": {
            "Address": "http://localhost:5102/"
          }
        }
      },
      "legacy-system": {
        "Destinations": {
          "primary": {
            "Address": "http://localhost:5101/"
          }
        }
      }
    }
  }
}

來講解一下這個設定檔在幹嘛:

  • Routes:定義請求的匹配規則與轉送目標,決定 Request 要交給哪個 Cluster
  • Order:當多條 Route 都可能匹配 Request 時,用來控制路由優先順序,數字越小優先權越高。
  • Match:定義匹配條件,例如 URL Path、HTTP Method。
  • Transforms:轉送前改寫 Request,例如將 /api/products/1 改成 /api/v2/products/1
  • ClusterId:指定匹配成功後要轉送到哪個 Cluster
  • Clusters:定義後端服務群組,例如新版與舊版系統。
  • Destinations:定義實際的後端服務位址

保持前面兩個服務運作,再開一個 Terminal,在 5100 port 啟動 Gateway:

dotnet run --project Gateway/Gateway.csproj --urls http://localhost:5100

啟動 Gateway 後,我們就可以接著來做驗證,不要只看服務有啟動,我們先從 5100 port 呼叫產品查詢:

curl http://localhost:5100/api/products/42

應該收到:

https://ithelp.ithome.com.tw/upload/images/20260827/2018256445PkfzoGNN.png

再呼叫客戶查詢:

curl http://localhost:5100/api/customers/7

應該收到:

https://ithelp.ithome.com.tw/upload/images/20260827/20182564rj5UNeccn7.png

這樣才代表既有的產品請求真的被成功轉移了,而原本的客戶查詢仍然照常運作,證明 Proxy 的路徑轉換與分流設定正確。

這就是絞殺者模式中最重要的一步,今天以最簡單的方式示範了如何讓呼叫端不改任何設定,後面的系統就悄悄換掉了。


談上線

完成絞殺者模式的第一步之後,這只是個開始,再來我們得把 Proxy 納入監控,監測它的健康程度,這次的範例只按路徑分流,一改就會影響整條路徑,如果正式上線希望先讓內部或特定客戶試用,還得另外設計篩選規則,並不是加了 YARP 就自動有分批放量。

至於什麼時候可以繼續往下一個功能邁進,並不是等上線後大家各憑感覺,而是依照每次替換的錯誤率、延遲、關鍵流程成功率,來決定我們的部署策略。

而且 Gateway 的設計一定要保留快速回切的能力,新系統一旦出問題,必須能在很短的時間內把流量重新導回舊系統。

最重要的,因為 Gateway 現在是共同入口,它如果掛了,即便新舊系統都還活著,使用者還是會完全不能用,所以可用性、容量與設定變更都要兼顧。


最麻煩的 DB

有了 Gateway,我們好像只要多加幾條 Route,就能把舊系統慢慢絞殺了,但別忘了,剛才的範例根本沒有資料庫,麻煩的部分還沒談到呢。

新系統接手產品查詢,舊系統可能仍然負責維護產品資料,兩邊最後得對上同一份產品狀態,所以得要先決定哪一份資料才是依據,也就是 Source of Truth?誰可以寫,誰只能讀?

過渡初期可以先共用舊資料庫,少處理同步的機制,但是代價是新系統仍然依賴舊 Schema,改欄位時還得顧著兩邊。

還有另一種做法是其中一邊負責寫入,再把資料單向同步給另一邊作為查詢來源。如果進一步把寫入與查詢模型分開,這時就可能採用 CQRS 的設計,但也得接受資料同步帶來的延遲與最終一致性問題。

最麻煩的是兩邊都要寫資料,也就是所謂的 Dual Write,這種做法不是完全不能做,但要非常小心,如果第一邊成功、第二邊失敗怎麼辦?重試會不會重複?事件順序亂掉呢?實務上通常會盡量避免直接 Dual Write,或搭配 Outbox、CDC、冪等性與補償機制處理一致性問題,一定要小心,不然到時候補資料可就麻煩了。

所以 Route 能切回去,不代表系統真的能 Rollback,如果新版已經寫入舊版無法理解的資料,或 Migration 刪掉舊版依賴的欄位,即使 Gateway 把流量切回 Legacy System,舊系統一樣可能直接壞掉。

所以在絞殺者模式中,DB 的妥善處理是一件極具挑戰的事情,一定要小心看待!


絞殺到一半也可以

那最後一定要全部都要絞殺嗎?我覺得不一定,假設新系統已經接手經常變動、最需要改善的部分,剩下的功能多年沒改、運作穩定,轉移成本又貴,那先留著也不是不行,又不是有強迫症,一定都要打到白金,當然不能只是為了架構好看,就把還能運作的東西硬拆掉。

不過「先留著」之前,還是得評估一下,這個功能如果遇到外部 API 升版、需求改變,或基礎設施停止支援,到時候還有沒有能力處理?兩套部署、監控、權限與資料整合一直維護下去,累積的成本會不會反而比現在換掉更高?

如果這些都能接受,留下來就是有理由的選擇,否則還沒讓舊系統退休,我們倒是先被同時維護兩套系統搞死了 XD


接下來呢?

在還沒切換流量之前,程式碼的絞殺是最重要的,該怎麼讓原本的邏輯也隨著絞殺者的思路逐漸的遷移到新系統?

我個人認為,絞殺者模式不單單是只能應用在專案的最外層,如果把這個概念再往程式碼層級延伸,我覺得其實也是類似的思路,在本系列中,我們經常談怎麼在舊程式碼裡用 Gateway 隔離外部依賴,讓核心邏輯不直接依賴 HTTP Client 或第三方 SDK,而是讓高階邏輯與低階細節都依賴抽象,這正是 DIP 的精神,透過絞殺者的概念,慢慢將程式碼重構,而不是什麼措施都沒有,就一下子就來個大掃除。

至於之後呢?很多人會直接聯想到微服務,覺得這不就是把系統拆散的過程嗎?方向是沒錯的,但微服務只是結果之一,也不一定是結果,我們遷移舊系統的原因有很多,有可能是技術已經跟不上了,維護成本已經太高了,都有可能,但微服務絕對不會是我們這樣做的目的,它只有可能是隨著我們業務的演變而來的。

有些系統即使絞殺後,新的部分仍然是單體,只是不再是那個讓人頭痛的舊單體了,就算我們的目標仍是維持單體,一樣要讓新舊邏輯能夠共存。


總結

今天把絞殺者模式粗略的介紹,如果團隊正在一個讓人頭痛的 Legacy System 裡撐著,至少記住一件事。

不要整個打掉重寫!

宙斯也是先在克里特島長大,才有能力回頭挑戰克洛諾斯,不過我們的系統不必照著神話打一場大戰,能一次搬一塊、確認一塊,讓客戶繼續做生意,我們也慢慢少一點維護上的痛苦,我覺得就已經是最好的解法了。

明天我們繼續看:痛苦大合集

Reference


上一篇
Day 27 -「柏修斯的神器」對付梅杜莎不能只有一把劍,來聊聊團隊的漸進演化
下一篇
Day 29 -「賽姬的考驗」上線了才知道,原來我們提供的是 SaaS!
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言