iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

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

Day 06 -「普羅米修斯之火」-五步驟 SOP

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260805/20182564kxMdr6sP73.png

在神話中,宙斯把火藏了起來,在沒有火之前,人類只能生吃食物、在黑暗中生活。

普羅米修斯是泰坦族,也是人類的守護者。

他有次上奧林帕斯山,偷了一點火種,藏在空心的茴香莖裡帶回人間,人類從此有了火,也有了技藝,學會烹飪、建造與鍛造。

而懂得越多,也開始意識到自己還有多少不懂、多少事情可能出錯。

宙斯發現之後,把普羅米修斯鎖在高加索山的岩石上,每天派一隻老鷹啄食他的肝臟,肝臟入夜後重新長回來,隔天老鷹又再次飛來。他在那裡受苦多年,直到赫拉克勒斯射殺老鷹,才終於獲救。

據說普羅米修斯早就知道自己會受到懲罰,但他還是把火帶給了人類。

接手 Legacy Code 也有點像拿到火。它讓我們有能力改變系統,卻也可能一不小心燒掉原本還能運作的東西...既然不能因為害怕就永遠不改,那至少在下手之前,我們得先知道要注意些什麼。


動刀前的五步驟儀式

昨天我們寫過測試,也練習過拆依賴,但真的收到需求時,還是很容易站在一大坨 Legacy Code 前面發呆:

現在到底該先做什麼?

這時候別急著憑感覺挑一段 code 開始改,我們可以先把工作拆成五個步驟:

  1. Identify change points(找出要改哪裡)
  2. Find test points(找出要在哪裡測)
  3. Break dependencies(拆掉擋路的依賴)
  4. Write tests(寫測試)
  5. Make changes and refactor(修改並重構)

這五步驟不是只能套用在某一種架構上的標準答案,而是一種在「不知道從哪裡開始」時很實用的路線,熟練之後,我們未必會刻意在腦中照著做,但思考順序大概不會離得太遠。

今日範例 就用一個 CheckoutService 的結帳流程,從需求進來一路走到功能完成。


步驟一:找出要改哪裡

先看看目前的結帳流程:

public class CheckoutService
{
    public decimal Checkout(int customerId, List<OrderItem> items)
    {
        var conn = new SqlConnection(
            ConfigurationManager.ConnectionStrings["DB"].ToString());

        var record = new InventoryRecord(items);
        var subtotal = record.CalculateTotalPrice();

        var memberLevel = conn.QueryFirst<int>(
            "SELECT Level FROM Members WHERE Id = @id",
            new { id = customerId });

        var discountedSubtotal =
            memberLevel >= 3 ? subtotal * 0.9m : subtotal;

        var tax = record.CalculateTax(discountedSubtotal);

        var customer = conn.QueryFirst<Customer>(
            "SELECT Id, Address, IsVip FROM Customer WHERE Id = @id",
            new { id = customerId });

        var fee =
            new PriceCalculator().CalculateBaseFee(discountedSubtotal);

        if (customer.IsVip)
            fee = 0m;
        else if (IsRemoteArea(customer.Address))
            fee += 50m;

        return discountedSubtotal + tax + fee;
    }

    private bool IsRemoteArea(string address)
    {
        return address.Contains("金門")
            || address.Contains("澎湖")
            || address.Contains("馬祖");
    }
}
public class InventoryRecord
{
    private readonly IReadOnlyCollection<OrderItem> _items;

    public InventoryRecord(IEnumerable<OrderItem> items)
    {
        _items = items.ToList();
    }

    public decimal CalculateTotalPrice()
        => _items.Sum(x => x.UnitPrice * x.Quantity);

    public decimal CalculateTax(decimal amount)
        => Math.Round(amount * 0.05m, 0);
}

這時 PM 帶來一個新需求:

結帳滿千免運,明天上線。

看到「滿千」,第一眼很容易先停在 InventoryRecord.CalculateTotalPrice,而進一步查看 InventoryRecord 後,會發現它似乎負責計算商品總額,好像只要在這裡回傳一個「是否滿千」就能解決。

但我們這時候要先忍住,再繼續把 Checkout 讀完。

我們會發現後面有一段在算 fee 的邏輯,變數名只告訴我們這裡在處理某種費用,因此還得藉由幾個線索,試著拼湊出它真正代表的意思:

撈資料庫取得客戶的 AddressIsVip
如果客戶是 VIP,則費用為 0
如果客戶地址是偏遠地區,則費用 + 50

如果把 費用 換成 運費,這幾條線索似乎都說得通,接著可以查看相關文件、詢問 PM,甚至自行操作系統,確認這裡確實在計算運費,這樣一來,改動點就不在 InventoryRecord,而是 Checkout 後段這個區塊。

另外,「滿千」到底是原價滿千,還是折扣後滿千?偏遠地區滿千之後還要不要加價?這些不是看 code 就能猜出來的事情,跟主事人確認後,我們就可以把運費規則稍微整理一下:

  • 會員折扣後的商品金額判斷是否滿一千元。
  • VIP 與滿千都免運,偏遠地區加價不再另外收取。
  • 稅金不列入免運門檻。

這一來一回就能體現出「觀察」的重要性,在還沒熟到能感覺出改動位置之前,我們就先讀完,也問清楚,不要急著動


步驟二:找出要在哪裡測

知道要改哪裡之後,下一步我們要知道:

測試要從哪裡進去,才能知道這次沒有改壞?

Checkout 這個 method 的流程邊界還算乾淨,也許我們可以直接測它!

各位可能覺得很奇怪,改哪裡,不就是測哪裡嗎?方向沒錯,但如果要改的 method 有幾千、幾萬行,難道要從頭測到尾嗎?這次只是剛好遇到相對可愛、好處理的 Checkout,真正的戰場通常不會這麼簡單。後面的章節會再示範其他做法,今天先記住一件事:

處理 Legacy Code 時,仍然要刻意思考測試該從哪個入口切入

所以我們決定了!這次的改動點與測試點都是 Checkout,不過光用肉眼就能看見幾個直接依賴,這個 method 會讀設定檔、連資料庫,既然我們只想確認金額,就需要用到昨天學會的拆依賴技術了。


步驟三:拆掉擋路的依賴

先照昨天的覆寫技巧試一次

昨天已經學過如何透過覆寫技巧解開直接依賴,現在就先照同樣的方式試一次:

public class CheckoutService
{

    public decimal Checkout(int customerId, List<OrderItem> items)
    {
        var conn = GetDbConnection();

        var record = new InventoryRecord(items);
        var subtotal = record.CalculateTotalPrice();

        var memberLevel = GetMemberLevel(customerId, conn);
        var discountedSubtotal =
            memberLevel >= 3 ? subtotal * 0.9m : subtotal;

        var tax = record.CalculateTax(discountedSubtotal);
        var customer = GetCustomerById(customerId, conn);

        var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);

        if (customer.IsVip)
            fee = 0m;
        else if (IsRemoteArea(customer.Address))
            fee += 50m;

        return discountedSubtotal + tax + fee;
    }


    protected virtual IDbConnection GetDbConnection()
    {
        return new SqlConnection(ConfigurationManager.ConnectionStrings["DB"].ToString());
    }

    protected virtual int GetMemberLevel(int customerId, IDbConnection conn)
    {
        return conn.QueryFirst<int>(
            "SELECT Level FROM Members WHERE Id = @id",
            new { id = customerId });
    }

    protected virtual Customer GetCustomerById(int customerId, IDbConnection conn)
    {
        return conn.QueryFirst<Customer>(
            "SELECT Id, Address, IsVip FROM Customer WHERE Id = @id",
            new { id = customerId });
    }
}

換個角度去想:Checkout 到底要什麼資料?

做到這裡其實已經能開始測試了,只是得覆寫三個資料庫相關的 method,設定成本並不低。

趁還沒動手寫測試,我們先停下來想一件事:

Checkout 到底從資料庫拿了什麼?

這兩個:

  • 客戶會員等級
  • 客戶資料

Checkout 根本不在乎這兩筆資料是用什麼 DB 查出來的、用 Dapper 還是其他套件,它只在乎「拿不拿得到」。

順著這個念頭,我們先借用 Repository 的想法,替資料存取建立一道邊界。

從 Repository 的想法建立資料存取邊界

「我只需要知道我要什麼,不需要知道怎麼找到它。」這個念頭,和 Repository 想處理的問題很接近。

Repository 被收錄在 Martin Fowler 的《Patterns of Enterprise Application Architecture》中,隔年的 Eric Evans《Domain-Driven Design》也將 Repository 納入 DDD 的戰術設計,兩者的實作細節雖有不同,核心方向都在於:

讓使用端以領域需要表達資料存取,不必直接依賴資料庫結構與查詢細節。

如果嚴格按照定義,Repository 通常會用接近「集合」的介面取得與保存領域物件,這次範例的介面除了取得 Customer,也直接查詢 memberLevel 這種單一資料,所以借用 Repository 的核心思想,先把業務流程和 SQL 查詢隔開。

說來抽象,看看下面這張餐巾紙,是一位母語人士隨手寫下的英文縮寫:

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

before 寫成 B4please 寫成 plsyou 寫成 u,可以看到單字的形式全都變了,不過只要對方讀得懂,真正重要的「意思」從沒改變。

系統也一樣,我們公司以前的開發模式大致是這樣,從拿到需求開始:

  1. 先開資料表
  2. 建一組跟欄位一對一的 Class 並放進 Model 專案
  3. 讓它一路穿過業務邏輯層(BLL)、資料存取層(DAL)、API、再到前端

這蠻接近一種叫做 Transaction Script 的開發方式,系統按照一個個業務操作,把查資料、判斷、計算與寫回資料庫集中成一段流程。

實務上它也常搭配資料庫導向的設計,資料表長怎樣,Entity 就長怎樣,再一路當成 DTO(Data Transfer Object) 傳到前端。這樣做最大的優點就是,需求一進來,開好表、用工具產生 Class,再把 CRUD 流程像寫流水帳一樣,很快就能交付功能。

Transaction Script 本身並不是錯誤,對規則單純的 CRUD 系統來說,它其實相當務實。問題通常發生在規則逐漸變複雜之後,如果團隊建立了看似領域模型的物件,卻仍把計算、判斷與規則全放在外層 Service,模型只剩資料,就可能逐漸形成 貧血模型(Anemic Domain Model),如果設計又長期由資料表主導,團隊就容易把「資料表欄位」直接當成「業務語言」。

久了之後,真正聽得懂規則的人越來越少,程式裡只有少數人知道來龍去脈的區域,也就慢慢變成團隊裡誰都不敢動的 Legacy,我接手過不少專案都有這樣的跡象。

Repository 所提供的核心價值,就是在業務物件與資料來源之間劃出界線。

業務這一側透過符合自身語言的介面取得或保存物件;另一側再負責怎麼查、怎麼存,以及如何對應資料欄位:

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

這道邊界也讓部分儲存決策可以延後,只要介面所表達的語意維持不變,業務邏輯就有機會先寫、先測,再決定具體資料結構,中途更換儲存方式時,改動也比較可能集中在資料存取實作,不過交易一致性、查詢能力與效能特性仍可能影響上層,並不是換掉實作就保證主流程永遠不用調整。

釐清我們要建立的資料存取邊界後,接下來就把 Checkout 裡那幾段直接查 SQL 的程式,收斂成只描述「我需要什麼資料」的介面。

用 Extract Class 抽出 Repository

剛才用 Extract Method 抽出的幾個方法並沒有白費,現在可以繼續透過重構工具,把它們收進 Repository,操作如下:

先選取剛才抽出的 method,開啟重構工具,再選擇 Extract Class

https://ithelp.ithome.com.tw/upload/images/20260805/20182564RnGQ5yIDoi.png

接著替 Repository 命名,勾選要移入新類別的 method。Reference to extracted 欄位則用來設定 IDE 在 CheckoutService 中自動建立的 Repository 欄位名稱:

https://ithelp.ithome.com.tw/upload/images/20260805/201825640EcuphJKOJ.png

截圖中先命名為 CustomerRepository;由於這個實作會直接查 SQL,接著再用 Rename 把具體類別改成 SqlCustomerRepository,讓名稱說出它的資料來源。按下確認後,程式碼如下:

public class CheckoutService
{
    private readonly SqlCustomerRepository _sqlCustomerRepository = new SqlCustomerRepository();

    public decimal Checkout(int customerId, List<OrderItem> items)
    {
        var conn = GetDbConnection();

        var record = new InventoryRecord(items);
        var subtotal = record.CalculateTotalPrice();

        var memberLevel = _sqlCustomerRepository.GetMemberLevel(customerId, conn);

        var discountedSubtotal = memberLevel >= 3 ? subtotal * 0.9m : subtotal;

        var tax = record.CalculateTax(discountedSubtotal);

        var customer = _sqlCustomerRepository.GetCustomerById(customerId, conn);

        var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);

        if (customer.IsVip)
            fee = 0m;
        else if (IsRemoteArea(customer.Address))
            fee += 50m;

        return discountedSubtotal + tax + fee;
    }
    // ... 略
}

用 Extract Interface 提取介面

接著替 Repository 提取介面。選取 SqlCustomerRepository,再次打開重構工具,再選擇 Extract Interface

https://ithelp.ithome.com.tw/upload/images/20260805/20182564Qb0NrHpv7q.png

一樣填入想要的名稱,並選取要放進介面的 method:

https://ithelp.ithome.com.tw/upload/images/20260805/201825644pvuFYifVV.png

如此一來,CheckoutService 就可以改用介面描述它需要的資料存取能力。此時物件內部仍然會直接建立 SqlCustomerRepository,依賴還沒有完全拆開;不過不用著急,現在的重點是先不要改變對外介面。

把連線收進 Repository

不過現在仍然直接依賴具體類別,所以先稍微調整程式碼,把 Repository 欄位 Inline 回 Checkout

https://ithelp.ithome.com.tw/upload/images/20260805/201825643Ah4JAytsZ.png

接著把 conn 注入到 Repository,這時候 IDE 會先出現紅色蚯蚓:

var conn = GetDbConnection();
CustomerRepositoryGateway customerRepository = new SqlCustomerRepository(conn);

點擊這一行,用快捷鍵或左側圖示打開 Context Action,再選擇 Create Primary Constructor

https://ithelp.ithome.com.tw/upload/images/20260805/20182564X0mFaIQD1w.png

因為這時候連線物件已經注入 Repository,所以不必再傳連線給每個 method,我們就可以對目標 method 使用 Change Signature,直接移除連線參數:

https://ithelp.ithome.com.tw/upload/images/20260805/20182564A9Ra1IIqOU.png

調整完成後,介面就只描述業務邏輯真正需要的兩個查詢,DB 連線則在建構時注入:

public interface CustomerRepositoryGateway
{
    int GetMemberLevel(int customerId);
    Customer GetCustomerById(int customerId);
}

public class SqlCustomerRepository(IDbConnection conn)
    : CustomerRepositoryGateway
{
    public int GetMemberLevel(int customerId)
        => conn.QueryFirst<int>(
            "SELECT Level FROM Members WHERE Id = @id",
            new { id = customerId });

    public Customer GetCustomerById(int customerId)
        => conn.QueryFirst<Customer>(
            "SELECT Id, Address, IsVip FROM Customer WHERE Id = @id",
            new { id = customerId });
}

快結束了!最後回到 CheckoutService,再做一次 Extract Method

protected virtual CustomerRepositoryGateway GetCustomerRepository()
{
    var conn = GetDbConnection();
    CustomerRepositoryGateway customerRepository =
        new SqlCustomerRepository(conn);
    return customerRepository;
}

收尾:Checkout 終於乾淨了

最後,主程式會變成這樣:

public decimal Checkout(int customerId, List<OrderItem> items)
{
    var customerRepository = GetCustomerRepository();

    var record = new InventoryRecord(items);
    var subtotal = record.CalculateTotalPrice();

    var memberLevel = customerRepository.GetMemberLevel(customerId);

    var discountedSubtotal = memberLevel >= 3 ? subtotal * 0.9m : subtotal;

    var tax = record.CalculateTax(discountedSubtotal);

    var customer = customerRepository.GetCustomerById(customerId);

    var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);

    if (customer.IsVip)
        fee = 0m;
    else if (IsRemoteArea(customer.Address))
        fee += 50m;

    return discountedSubtotal + tax + fee;
}

到這裡,我們已經有條件開始測試了。這個過程看似落落長,但熟悉工具之後其實很快;我們藉由 IDE 的自動化重構降低了在沒有測試時搬動程式碼的風險,更重要的是:

CheckoutService 的對外介面完全沒變

人類有了火之後,總算不用把時間全耗在生食和取暖上,才有餘力蓋房子、鍛工具、學習其他技藝,重構工具給我的感覺差不多:

省下手動搬動程式碼的時間,我們才有餘力處理真正想做的事,下一步,就開始寫測試啦!


步驟四:寫測試

依賴拆開之後,我們終於可以在測試裡放入自己準備的客戶資料,不必真的連上資料庫。

還記得前面認識過的**測試替身(Test Double)**家族嗎?

這次我們要用的是家族裡的 Stub,它會提供測試預先安排好的答案,像這裡直接實作 CustomerRepositoryGateway 就可以了,因此不管收到哪個 customerId,它都會回傳我們指定的會員等級與客戶資料。

public class StubCustomerRepository : CustomerRepositoryGateway
{
    private readonly int _memberLevel;
    private readonly Customer _customer;

    public StubCustomerRepository(
        int memberLevel,
        Customer customer)
    {
        _memberLevel = memberLevel;
        _customer = customer;
    }

    public Customer GetCustomerById(int customerId) => _customer;

    public int GetMemberLevel(int customerId) => _memberLevel;
}

接著用測試專用的 FakeCheckoutService 子類別覆寫,讓正式流程取得這個 Stub

public class FakeCheckoutService(CustomerRepositoryGateway repo) : CheckoutService
{
    protected override CustomerRepositoryGateway GetCustomerRepository() => repo;
}

目前系統的既有規則是基本運費 60 元。我們先從最單純的一般客戶開始,寫下第一支測試:

[Fact]
public void Checkout_一般客戶購買800元_應付900元()
{
    var repository = new StubCustomerRepository(
        memberLevel: 1,
        customer: new Customer { Address = "台北市", IsVip = false });
    var sut = new FakeCheckoutService(repository);

    var total = sut.Checkout(
        customerId: 1,
        items: [new OrderItem { UnitPrice = 800m, Quantity = 1 }]);

    Assert.Equal(900m, total);
}

跑起來是綠燈,很好,接著把原本沒被保護到的組合一起補進來:

[Theory]
[InlineData(1, "台北市", false,  900)]  // 一般客戶:商品 800 + 稅 40 + 運費 60
[InlineData(1, "台北市", true,   840)]  // VIP:免運
[InlineData(1, "金門縣", false,  950)]  // 偏遠地區:運費 60 + 加價 50
[InlineData(1, "金門縣", true,   840)]  // VIP 又住偏遠:VIP 優先,仍免運
[InlineData(3, "台北市", false,  816)]  // 三級會員:折扣後 720 + 稅 36 + 運費 60
public void Checkout_既有運費規則(int memberLevel, string address, bool isVip, decimal expected)
{
    var repository = new StubCustomerRepository(
        memberLevel: memberLevel,
        customer: new Customer { Address = address, IsVip = isVip });
    var sut = new FakeCheckoutService(repository);

    var total = sut.Checkout(
        customerId: 1,
        items: [new OrderItem { UnitPrice = 800, Quantity = 1 }]);

    Assert.Equal(expected, total);
}

測試長這樣確實有點醜,而且只用註解標示每筆測資的意圖也不夠可靠。

xUnit v3 對 Theory 的顯示名稱有更多支援,不過並不是每個專案都適合立刻升級,所以這裡先用另一種老派但是簡單的方式做整理。

抽出方法後,用五個具名的 Fact 來做取代:

public class CheckoutTests
{
    [Fact]
    public void 一般客戶_商品800_稅40_運費60() => CheckoutTesting(1, "台北市", false, 800, 900);

    [Fact]
    public void VIP_免運() => CheckoutTesting(1, "台北市", true, 800, 840);

    [Fact]
    public void 偏遠地區_運費60_加價50() => CheckoutTesting(1, "金門縣", false, 800, 950);

    [Fact]
    public void VIP又住偏遠_VIP優先_仍免運() => CheckoutTesting(1, "金門縣", true, 800, 840);

    [Fact]
    public void 三級會員_折扣後720_稅36_運費60() => CheckoutTesting(3, "台北市", false, 800, 816);

    private void CheckoutTesting(int memberLevel, string address, bool isVip, decimal amount, decimal expected)
    {
        var repository = new StubCustomerRepository(
            memberLevel: memberLevel,
            customer: new Customer { Address = address, IsVip = isVip });
        var sut = new FakeCheckoutService(repository);

        var total = sut.Checkout(
            customerId: 1,
            items: [new OrderItem { UnitPrice = amount, Quantity = 1 }]);

        Assert.Equal(expected, total);
    }
}

測試總管中還可以直接看見每條測試到底在驗證什麼:

https://ithelp.ithome.com.tw/upload/images/20260805/20182564uVqPwVWNEN.png

這樣在測試總管中,就能直接辨識每條測試想保護的情境。

但這不是一套完整的測試矩陣,實務上仍應依改動風險與業務規則補上相應的邊界案例,這裡省略它們,是為了讓篇幅集中在 Legacy Code 的改動流程,不代表這些案例不重要。


步驟五:動刀並重構

照理說,現在可以直接加入「滿千免運」,功能確實可以馬上做得出來,但新的 else if 仍然會落在 CheckoutService,下一次再來一條離島活動、超商免運或特殊會員規則,我們還是只能繼續往同一個 Checkout 裡面塞邏輯。

所以這時候先不急著改行為。接下來幾個動作都只做結構上的重構:每完成一小步就跑一次測試。

重構一:把運費規則搬進 ShippingPolicy

先回頭看 CheckoutService 裡在計算運費的這一段:

var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);

if (customer.IsVip)
    fee = 0m;
else if (IsRemoteArea(customer.Address))
    fee += 50m;

運費計算已經是一項能獨立描述的責任,適合收斂到自己的位置。

我們就不改裡面的判斷式了,只用 Extract Class 把這段程式原封不動搬進 ShippingPolicy

public class ShippingPolicy(
    Customer customer,
    decimal discountedSubtotal)
{
    public decimal CalculateFee()
    {
        var fee = new PriceCalculator()
            .CalculateBaseFee(discountedSubtotal);

        if (customer.IsVip)
            fee = 0m;
        else if (IsRemoteArea(customer.Address))
            fee += 50m;

        return fee;
    }

    private bool IsRemoteArea(string address)
    {
        return address.Contains("金門")
            || address.Contains("澎湖")
            || address.Contains("馬祖");
    }
}

Checkout 中則變成這樣:

var fee = new ShippingPolicy(
    customer,
    discountedSubtotal
).CalculateFee();

重新執行五個既有測試,確保全部綠燈。

搬完之後再看 PriceCalculator,會發現 CalculateBaseFee 無論收到多少金額,都只會回傳固定的 60m,而且這個輸入根本沒有用到:

public decimal CalculateBaseFee(decimal amount)
    => 60m;

這個方法沒有封裝任何會變動的計算,卻讓讀的人多繞了一層,因此可以把 60 元基本運費直接放進 ShippingPolicy,同時移除 ShippingPolicy 不再需要的 discountedSubtotal 建構子參數:

public class ShippingPolicy(Customer customer)
{
    public decimal CalculateFee()
    {
        var fee = 60m;
        // ... 略
        return fee;
    }
}

重構二:把價格計算集中回 InventoryRecord

運費搬走後,CheckoutService 還是親自處理很多價格細節:

var record = new InventoryRecord(items);
var subtotal = record.CalculateTotalPrice();

var memberLevel = customerRepository.GetMemberLevel(customerId);
var discountedSubtotal =
    memberLevel >= 3 ? subtotal * 0.9m : subtotal;

var tax = record.CalculateTax(discountedSubtotal);
var fee = new ShippingPolicy(customer).CalculateFee();

return discountedSubtotal + tax + fee;

我們知道 InventoryRecord 目前負責商品小計與稅金,但會員折扣與最後的總額組合卻留在 CheckoutService,先把這些計算集中到 InventoryRecord,讓商品金額相關的職責回到同一個地方:

public class InventoryRecord(
    IEnumerable<OrderItem> items,
    int memberLevel)
{
    private readonly IReadOnlyCollection<OrderItem> _items =
        items.ToList();

    private decimal TotalPrice
        => _items.Sum(x => x.UnitPrice * x.Quantity);

    private decimal TierDiscount
        => memberLevel >= 3
            ? TotalPrice * 0.9m
            : TotalPrice;

    public decimal Tax
        => Math.Round(TierDiscount * 0.05m, 0);

    public decimal CalculateTotalFee(decimal shippingFee)
        => TierDiscount + Tax + shippingFee;
}

CheckoutService 不再自己算小計、折扣與稅金,只需要查出計算所需的資料,再把運費交給價格物件:

var memberLevel = customerRepository.GetMemberLevel(customerId);
var customer = customerRepository.GetCustomerById(customerId);

var record = new InventoryRecord(items, memberLevel);
var shippingFee = new ShippingPolicy(customer).CalculateFee();

return record.CalculateTotalFee(shippingFee);

為什麼不是順便改成更漂亮的公式?因為這一步的目的只有集中原本已存在的計算,折扣仍然是九折、稅率仍然是 5%、運費仍然照舊,確保測試再次維持綠燈後,我們再繼續往下整理。

重構三:讓每個物件只拿它真正需要的東西

現在的 ShippingPolicy 接收整個 Customer,實際上卻只讀取 IsVipAddress,這種「要求的比實際用到的多」,其實是一種隱性的過度耦合。

光看建構子的簽名,讀的人沒辦法一眼判斷運費規則到底和 Customer 的哪些欄位有關,即使大多欄位都跟運費一點關係都沒有,還是只能把整個類別讀過一遍才敢確定,讓閱讀成本增加。

那既然運費規則只需要「是不是 VIP」與「地址」,輸入就只需要這兩個欄位:

public class ShippingPolicy(bool isVip, string address)
{
    public decimal CalculateFee()
    {
        var fee = 60m;

        if (isVip)
            fee = 0m;
        else if (IsRemoteArea(address))
            fee += 50m;

        return fee;
    }

    // ... 略
}

這麼做雖然稍微麻煩一點,卻能讓邊界更清楚,外部呼叫也一目瞭然。

如果這兩項資料經常一起出現,也可以再收斂成一個描述運費判斷條件的 值物件(Value Object)。本例先保留原始型別,避免為單一規則增加太多結構。

傳話筒

接著再看 CheckoutService 中:

var shippingFee = new ShippingPolicy(
    customer.IsVip,
    customer.Address
).CalculateFee();

return record.CalculateTotalFee(shippingFee);

既然最後總額本來就由價格物件組合,價格物件可以直接拿到 ShippingPolicy,需要運費時再請它計算,就不必讓主流程當傳話筒了。

程式碼如下:

public class InventoryRecord(
    IEnumerable<OrderItem> items,
    int memberLevel,
    ShippingPolicy shippingPolicy)
{
    private readonly IReadOnlyCollection<OrderItem> _items =
        items.ToList();

    public decimal CalculateTotalFee()
        => TierDiscount + Tax + shippingPolicy.CalculateFee();

    public decimal Tax
        => Math.Round(TierDiscount * 0.05m, 0);

    private decimal TotalPrice
        => _items.Sum(x => x.UnitPrice * x.Quantity);

    private decimal TierDiscount
        => memberLevel >= 3
            ? TotalPrice * 0.9m
            : TotalPrice;
}

變數飛來飛去的

仔細一看目前這裡還藏著另一個不容易從表面看出的問題。

CalculateTotalFee 先讀取一次 TierDiscount,接著 Tax 又讀取一次 TierDiscount,而每次讀取 TierDiscount,最後都會走到 TotalPrice。也就是說,結帳的計算與命名在幾個成員之間跳來跳去,好亂啊!

既然這些動作是在「計算」,就整理一下用命名好好的表達出來:

public class InventoryRecord(
    IEnumerable<OrderItem> items,
    int memberLevel,
    ShippingPolicy shippingPolicy)
{
    private const decimal TaxRate = 0.05m;
    private const decimal MemberDiscountMultiplier = 0.9m;
    private const int MinimumDiscountMemberLevel = 3;
    private readonly IReadOnlyCollection<OrderItem> _items =
        items.ToList();

    public decimal CalculateTotalFee()
    {
        var discountedSubtotal = CalculateDiscountedSubtotal();

        return discountedSubtotal
            + CalculateTax(discountedSubtotal)
            + shippingPolicy.CalculateFee();
    }

    private decimal CalculateDiscountedSubtotal()
    {
        var subtotal = _items.Sum(
            item => item.UnitPrice * item.Quantity);
        var discountMultiplier =
            memberLevel >= MinimumDiscountMemberLevel
                ? MemberDiscountMultiplier
                : 1m;

        return subtotal * discountMultiplier;
    }

    private static decimal CalculateTax(
        decimal discountedSubtotal)
        => Math.Round(discountedSubtotal * TaxRate, 0);
}

現在 CalculateTotalFee 好懂多了。

回歸本體

看到 ShippingPolicy 之後,也許會讓人想繼續建立 TaxPolicyDiscountPolicy,把每一條規則都抽成物件,但抽象本身也有成本,規則還沒有多種變化時,先建立一整套 Policy 只會增加不必要的複雜度,先這樣就好了。

此時 CheckoutService 後半段如下:

var record = new InventoryRecord(
    items,
    memberLevel,
    new ShippingPolicy(customer.IsVip, customer.Address)
);

return record.CalculateTotalFee();

價格細節都回到對應的物件後,CheckoutService 終於只剩下協調流程的工作。

重構四:名字跟著責任一起改

一開始 InventoryRecord 就承擔了金額計算,經過重構後,它還知道會員折扣、稅金與運費策略,並負責算出訂單的最後金額。

如果名字仍然叫做 InventoryRecord,不過下一個讀到這段程式的人很可能以為它是庫存紀錄,根本看不出它負責結帳金額,因此使用 Rename 重構,把它改成更符合責任的 OrderPriceCalculator

public class OrderPriceCalculator(
    IEnumerable<OrderItem> items,
    int memberLevel,
    ShippingPolicy shippingPolicy)
{
    // ... 略
}

重構五:把暫時的測試接縫收斂成正式依賴

步驟三為了在不大改對外介面的前提下先讓測試覆蓋,所以我們在 CheckoutService 留下了 GetCustomerRepository 作為 Seam,測試程式再用子類別覆寫它。

而經過一連串重構後,它在 CheckoutService 裡顯得非常突兀,因為 CheckoutService 本來就不該知道如何建立 SQL 連線。

現在既有行為已經有測試保護,就可以把它改成建構子注入,之後由外部提供給 CheckoutService,不再自行決定要怎麼建立。

public class CheckoutService(CustomerRepositoryGateway customerRepo)
{
    public decimal Checkout(
        int customerId,
        List<OrderItem> items)
    {
        var memberLevel = customerRepo.GetMemberLevel(customerId);
        var customer = customerRepo.GetCustomerById(customerId);

        var record = new OrderPriceCalculator(
            items,
            memberLevel,
            new ShippingPolicy(
                customer.IsVip,
                customer.Address)
        );

        return record.CalculateTotalFee();
    }
}

測試也不需要 FakeCheckoutService 了,直接把 Stub 傳進正式物件即可:

var repository = new StubCustomerRepository(
    memberLevel: memberLevel,
    customer: new Customer
    {
        Address = address,
        IsVip = isVip
    });

var sut = new CheckoutService(repository);

最後再由 .NET 的 DI 容器負責註冊這些服務。

做到這裡,CheckoutService 不會再讀設定檔、不建立資料庫連線,也不再親自計算價格,它就只負責以下流程:

  1. 取得會員與客戶資料。
  2. 建立價格計算器。
  3. 回傳最後應付金額。

測試仍然全部通過,這一連串動作看起來很多,但熟練之後其實很快,重點是透過反覆練習,讓每次重構都維持在可驗證的步驟。

加入新需求

前面已經和主事人確認過:

  • 免運門檻以會員折扣後的商品金額判斷。
  • 只要符合免運,偏遠地區也不再另外加價。

規則說清楚之後,不要急著先寫判斷,我們先用幾個情境,把「滿千」代表什麼用測試寫下來。

先用測試寫出新需求

經過前面的重構,運費規則已經集中到 ShippingPolicy,這次要改的正是免運門檻,所以我們就針對它做測試:

public class ShippingPolicyTests
{
    [Fact]
    public void 剛好滿千_一般地區免運()
        => ShippingTesting(
            isVip: false,
            address: "台北市",
            discountedSubtotal: 1000,
            expectedFee: 0);

    [Fact]
    public void 滿千_偏遠地區也免運()
        => ShippingTesting(
            isVip: false,
            address: "金門縣",
            discountedSubtotal: 1000,
            expectedFee: 0);

    private void ShippingTesting(
        bool isVip,
        string address,
        decimal discountedSubtotal,
        decimal expectedFee)
    {
        var policy = new ShippingPolicy(isVip, address);

        var fee = policy.CalculateFee(discountedSubtotal);

        Assert.Equal(expectedFee, fee);
    }
}

此時 CalculateFee 還沒有接收折扣後小計,測試會先因為編譯不過而失敗,先替 method 與既有呼叫端補上 discountedSubtotal 參數後,兩個滿千免運情境仍會因實際運費不是 0 元而失敗,因為接下來我們才要開始實作新邏輯。

實作

CalculateFee 加上參數時,呼叫端也必須一起更新:

public decimal CalculateTotalFee()
{
    var discountedSubtotal = CalculateDiscountedSubtotal();

    return discountedSubtotal
        + CalculateTax(discountedSubtotal)
        + shippingPolicy.CalculateFee(discountedSubtotal);
}

接著讓 ShippingPolicy.CalculateFee 收下折扣後小計,並加入足以讓測試通過的判斷:

public decimal CalculateFee(decimal discountedSubtotal)
{
    var fee = 60m;

    if (isVip)
        fee = 0m;
    else if (discountedSubtotal >= 1000)
        fee = 0m;
    else if (IsRemoteArea(address))
        fee += 50m;

    return fee;
}

本章定義的兩個新增情境都通過後,實作已符合目前明訂的需求,最後再進行一次小小的整理。

重構

計算流程就是 免運判斷偏遠地區 判斷,那就分成兩個 method:

public class ShippingPolicy(bool isVip, string address)
{
    private const decimal BaseFee = 60m;
    private const decimal RemoteAreaSurcharge = 50m;
    private const decimal FreeShippingThreshold = 1000m;

    public decimal CalculateFee(decimal discountedSubtotal)
    {
        if (IsFreeShipping(discountedSubtotal))
            return 0m;

        if (IsRemoteArea())
            return BaseFee + RemoteAreaSurcharge;

        return BaseFee;
    }

    private bool IsFreeShipping(
        decimal discountedSubtotal)
        => isVip || discountedSubtotal >= FreeShippingThreshold;

    private bool IsRemoteArea()
    {
        return address.Contains("金門")
            || address.Contains("澎湖")
            || address.Contains("馬祖");
    }
}

最後再跑一次測試,確保全部維持綠燈。

在上述範圍內,我們完成了這次需求,給螢幕前的自己掌聲鼓勵鼓勵!


總結

今天的內容會有點多,請各位看官見諒!

今天其實只圍繞著一個簡單的新需求,動作卻這麼多,不免讓人感到厭煩,甚至覺得「有必要嗎?」

在本例中,這些步驟換來的是更短的驗證迴圈,實際需要投入多少時間,就要看系統規模、既有測試與環境準備成本,若沒有先確認需求並建立回饋,時間往往仍會花在猜改動點、等待環境、重複手動驗證、追查回歸問題與返工上。

以前剛出社會的時候,光是確認一次改動,可能就得先把後端環境準備好,從前端進入訂單頁面下單,再把 payload 複製到 Postman,每次修改程式碼後都要重新呼叫 API,碰上資料庫裡沒有合適的資料,還得先確認資料、補上假資料,甚至另外建立測試設定檔,一旦漏掉既有規則,後面還要再追查是哪次改動造成回歸,省下的步驟不一定真的省下了時間。

反之我們照著五步驟,會發現下一次改動時會更有安全感,維護性更佳,不然的話 Legacy Code 是怎麼造成的呢,您說是吧?

這兩天的篇幅特別長,但我覺得這會是很重要的部分,當我們熟悉這套處理 Legacy Code 的常用起手式後,面對許多不熟悉的程式碼,就比較知道該從哪裡開始,後面的章節則會繼續介紹特殊狀況下可以採取的策略,讓我們在這個基礎上更加游刃有餘。

至於重構,今天的範例其實還可以繼續整理,但重構本身也有成本,「過頭」了未必更好,什麼時候該停,取決團隊共同的維護經驗,重要的是讓下一次改動更容易理解與驗證,而不是追求某一種真理。

就像我現在其實對 CalculateTotalFee 這個命名又有點不滿意,聽起來像只計算「費用」,實際回傳的是整筆訂單應付總額,所以重構工具按一下就把它再改成 CalculateTotalAmount,就是這麼快速又任性 XD

不過,今天剛好有時間,能從公開入口把這次可能受影響的既有計算路徑先保護起來,但是如果今天客戶已經在發火了,PM 要我們一小時內交出東西,還能堅持住嗎?

明天我們繼續看:客戶已經火大了,還能寫測試嗎?

Reference


上一篇
Day 05 -「阿基里斯之踵」如何感覺到程式碼?
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言