---
# System prepended metadata

title: '架構面試題 #4 - 抽象化設計；折扣規則的設計機制'
tags: [fp]

---

# 架構面試題 #4 - 抽象化設計；折扣規則的設計機制

###### tags: `fp`

不知道大家有沒有讀過 [架構面試題 #4 - 抽象化設計；折扣規則的設計機制][1] 這篇文章。

這是一篇非常好的文章，如果有時間這邊非常推薦大家去閱讀。
其中包含了很多物件導向的概念跟微服務架構的設計。

而接下來我要用的是另一種實作方式，
也就是用 Functional Programming 來達成同樣的任務目標。

## 步驟一：系統架構

在進入到實作之前，我們必須要先釐清問題是什麼，
釐清問題之後，再想辦法把 **大問題拆解成小問題 (Divide and Conquer)**，
當我們能夠把小問題逐個解決，那大問題就跟著被解決了。

所以，我們的第一個大問題是 **如何模擬實際的訂單計算**？
這邊原文其實已經提供給我們訂單計算的流程了。

1. 描述商品資訊 (包含品名、標籤及售價)
1. 進行折扣計算
1. 顯示結帳的收據明細

這邊，其實我們就可以先定好邏輯流程了。

```typescript
function main() {
    // 商品資訊
    // 進行折扣計算
    // 顯示結帳的收據明細
}
```

### 定義商品資料結構

在 functional programming 習慣定義 **純資料**，
純資料不會帶有 **方法 (method)**。

```typescript
interface Product {
  name: string;
  sku: string;
  price: number;
}
```

### 顯示結帳的收據明細

接下來我們要實作 `display` 函式。

我希望 `display` 只需要負責顯示結帳的收據明細，
而不應該包含任何的運算邏輯，
所以 `display` 應該要接收

1. 商品有哪些
1. 結算後的金額

```typescript=
interface Bill {
    products: Product[];
    total: number;
}

function display(bill: Bill): string {
    // 呈現商品有哪些
    // 呈現結算後的金額
}
```

接著我們先來撰寫測試，
用於驗證我們的實作是否符合要求。

```typescript
test("display", () => {
  const testcase = {
    products: [
      {
        name: "乖乖(椰子口味)",
        sku: "K0132",
        price: 20,
      },
      {
        name: "乖乖(椰子口味)",
        sku: "K0132",
        price: 20,
      },
      {
        name: "乖乖(椰子口味)",
        sku: "K0132",
        price: 20,
      },
    ],
    total: 60,
  };

  const expected = `
- 乖乖(椰子口味)      $20.00
- 乖乖(椰子口味)      $20.00
- 乖乖(椰子口味)      $20.00
Total: $60.00
`.trim();

  expect(display(testcase)).toBe(expected);
});
```

接著按照我們拆解出來的小問題，逐一解決。

能解決**呈現一個產品**，透過 `map` 就等同於解決呈現多個產品。

> 這裡我們實踐了一個 FP 的設計概念 [Currying][2]
> 它的概念非常簡單：你可以只傳一部份的參數來執行一個函式，
> 它會回傳一個新的函式來處理剩下的參數。
> 直到你把剩下的參數都填完，他才會執行函式。
```typescript
interface Unary<A, B> {
  (a: A): B;
}
function map<A, B>(fn: Unary<A, B>) {
  return function (list: A[]) {
    let result: B[] = [];
    for (const item of list) {
      result.push(fn(item));
    }
    return result;
  };
}

function displayProduct(product: Product) {
  return `- ${product.name}      $${product.price.toFixed(2)}`;
}

const displayProducts = map(displayProduct)
```

呈現總價。

```typescript
function displayTotal(total: number) {
  return `Total: $${total.toFixed(2)}`;
}
```

透過 `join` 將字串組合成一行一行的格式。

```typescript
const join = (token: string) => (list: unknown[]) => list.join(token);
```

注意到 `displayProducts` 回傳給我們是陣列，
但我不想針對某個函式回傳是否陣列而做個別的處理，
這邊透過 `flatten` 一律攤平成一維陣列。

```typescript
type ListOf<T> = T | ListOf<T>[];
function flatten<T>(list: ListOf<T>[]) {
  let result: T[] = [];
  for (const item of list) {
    if (Array.isArray(item)) {
      result = [...result, ...flatten(item)];
      continue;
    }
    result = [...result, item];
  }
  return result;
}
```

```typescript
function display(bill: Bill): string {
  return box(
    flatten([
      // 呈現商品有哪些
      displayProducts(bill.products),
      // 呈現結算後的金額
      displayTotal(bill.total),
    ])
  )
    .pipe(join("\n"))
    .unwrap();
}
```

這邊使用到的 `box` 工具函式 
為了方便我們寫出 [pipeline][3] 而不會喪失型別檢查的小工具。
以下附上這邊用到的工具函式

```typescript
function box<A>(x: A) {
  function pipe<B>(fn: Unary<A, B>) {
    return box(fn(x));
  }
  function unwrap() {
    return x;
  }
  return { pipe, unwrap };
}
```

## 進行計算

按照原文這邊先進行加總總價即可。

我希望 `checkout` 函式可以回傳給我們 `Bill` 型態的資料結構，
讓我們可以直接把 `checkout` 的運算結果直接 pipe 到 `display`。

```typescript
function checkout(products: Product[]): Bill {
    // 計算總價
    // 回傳帳單 須包含 商品有哪些 跟 總價
}
```

```typescript
test("checkout", () => {
  const products = [
    {
      name: "乖乖(椰子口味)",
      sku: "K0132",
      price: 20,
    },
    {
      name: "乖乖(椰子口味)",
      sku: "K0132",
      price: 20,
    },
    {
      name: "乖乖(椰子口味)",
      sku: "K0132",
      price: 20,
    },
  ];

  const expected = {
    products: [
      {
        name: "乖乖(椰子口味)",
        sku: "K0132",
        price: 20,
      },
      {
        name: "乖乖(椰子口味)",
        sku: "K0132",
        price: 20,
      },
      {
        name: "乖乖(椰子口味)",
        sku: "K0132",
        price: 20,
      },
    ],
    total: 60,
  };

  expect(checkout(products)).toStrictEqual(expected);
});
```

首先要 **如何計算出總價**，這裡我們可以把邏輯再細分成
- 取得所有的商品金額
- 加總所有金額

根據 `Product` 型別，
我們可以透過 `price` 屬性拿到該商品的金額，
當我們可以拿到一個商品的金額，透過 `map` 就等同於拿到全部商品的金額。

```typescript
map(prop("price"))
```

注意到我們透過 `prop` 函式來取得物件屬性，
這樣寫目的是讓讀者的把關注點放在，
我要取得 `price`，而不是我如何取得 `price`。

```typescript
function prop<A, K extends keyof A>(key: K) {
  return function (obj: A) {
    return obj[key];
  };
}
```


接者，我們可以一次加總整個數字陣列來得到總價。

透過 `reduce`，我們可以將陣列兩兩合併成一個結果，
使用 `reduce add` 的組合便可以給我們 `sum` 函式。

```typescript
interface Binary<A, B, C> {
  (a: A, b: B): C;
}

function reduce<A, B>(fn: Binary<A, B, A>) {
  return function (initial: A) {
    return function (list: B[]) {
      for (const item of list) {
        initial = fn(initial, item);
      }
      return initial;
    };
  };
}

function add(a: number, b: number) {
  return a + b;
}

const sum = reduce(add);
```

接著，我們將邏輯串在一起即可。

```typescript
function checkout(products: Product[]): Bill {
  // 計算總價
  const total = box(products)
    .pipe(map(prop("price")))
    .pipe(sum(0))
    .unwrap();
    
  // 回傳帳單 須包含 商品有哪些 跟 總價
  return { products, total };
}
```

### 重構

到這邊我們已經讓測試通過了，
按照 TDD 原則 (test driven development)，
我們開始重構。

注意到我們的 `display`，

```typescript
function display(bill: Bill): string {
  return box(
    flatten([
      // 呈現商品有哪些
      displayProducts(bill.products),
      // 呈現結算後的金額
      displayTotal(bill.total),
    ])
  )
    .pipe(join("\n"))
    .unwrap();
}
```

目前是直接把要呈現什麼定死在函式裡，
我不希望每次增加需要呈現的東西時，
都要去異動這個函式 ([Open–closed principle][4])。
所以我們要來試著將邏輯抽出來。

我們希望使用方式變成像是這樣：

```typescript
test("display", () => {
  // ...

  expect(
    // display(testcase), 改成
    display([
      // 呈現商品有哪些
      displayProducts,
      // 呈現結算後的金額
      displayTotal,
    ])(testcase)
  ).toBe(expected);
});
```


首先，為了讓 `display` 在使用拋進來的函式時，
能直接呼叫而不用考慮其實作細節，
我們要定義一個共用的介面，
必須符合介面的規範才能被傳入 `display` 函式。

```typescript
interface Display {
  (bill: Bill): string | string[];
}
```

將原本的 `display` 改成 Higher Order Function，
讓他可以接收外部傳入的呈現邏輯。

```typescript
const display =
  (fns: Display[]) =>
  (bill: Bill): string =>
    box(
      flatten(
        map(applyTo(bill))(fns)
      )
    )
      .pipe(join("\n"))
      .unwrap();
```

這邊用到了一個工具函式 `applyTo`，用途是讓後面拋入函式採用某個參數。
可以方便我們寫出簡短的邏輯，
像是上面看到的 `map(applyTo(bill))(fns)`。

```typescript
const applyTo =
  <A, B>(x: A) =>
  (fn: Unary<A, B>) =>
    fn(x);
```

接著我們將呈現邏輯函式改成符合 `Display` 介面的規格，

```typescript
function displayProduct(product: Product) {
  return `- ${product.name}      $${product.price.toFixed(2)}`;
}
const displayProducts = (bill: Bill) =>
  box(bill)
    .pipe(prop("products"))
    .pipe(map(displayProduct))
    .unwrap();

function displayTotal({ total }: Bill) {
  return `Total: $${total.toFixed(2)}`;
}
```

最後我們來把主流程也串起來，測試看看。

```typescript
function main(products: Product[]) {
  return (
    // 商品資料
    box(products)
      // 進行計算
      .pipe(checkout)
      // 呈現結果
      .pipe(
        display([
          // 呈現商品有哪些
          displayProducts,
          // 呈現結算後的金額
          displayTotal,
        ])
      )
      .unwrap()
  );
}

test("main", () => {
  const products = [
    {
      name: "乖乖(椰子口味)",
      sku: "K0132",
      price: 20,
    },
    {
      name: "乖乖(椰子口味)",
      sku: "K0132",
      price: 20,
    },
    {
      name: "乖乖(椰子口味)",
      sku: "K0132",
      price: 20,
    },
  ];

  const expected = `
- 乖乖(椰子口味)      $20.00
- 乖乖(椰子口味)      $20.00
- 乖乖(椰子口味)      $20.00
Total: $60.00
`.trim();

  expect(main(products)).toBe(expected);
});
```


## 步驟二：定義折扣規則

接著我們要用程式模擬折扣的設計，
基於原文幫我們歸類的重點：

- 能依據購物車上的所有商品 (input)，
  決定結帳後享有那些折扣 (output)，用統一的方式回報折扣資訊。
- 在符合 (1) 的規範下，每個折扣活動可以有自己的計算邏輯實作。
- 折扣活動彼此之間有先後順序，若商品同時滿足多種折扣活動，則按照順序進行。

這邊我與原文作法不同的是，
我認為 計算折扣 跟 結算 應該要拆開，而並非是同一件事。
所以，我的主流程會變成這樣

```typescript
  function main(products: Product[]) {
    return (
      // 商品資料
      box(products)
        // 進行計算
        .pipe(checkout)
        // 進行折扣計算
        .pipe(
          applyDiscountRules([
              // 不同的折扣規則
          ])
        )
        // 呈現結果
        .pipe(
          display([
            // 呈現商品有哪些
            displayProducts,
            // 呈現有哪些折扣
            displayDiscounts,
            // 呈現結算後的金額
            displayTotal,
          ])
        )
        .unwrap()
    );
  }
```


按照原文，折扣至少要包含以下內容：
- 來自哪個折扣規則
- 購買那些商品
- 折扣的金額

```typescript
interface Discount {
  rule: string;
  products: Product[];
  amount: number;
}
```

且按照原文第一點，
結帳的帳單上會附帶有哪些折扣被套用了。
基於上述，我們可以在 `Bill` 加上 `discounts` 屬性來紀錄有哪些折扣。

```typescript
interface Bill {
  discounts?: Discount[];
}
```

顯示折扣的函式也跟之前的顯示函式差不多。

```typescript
function displayDiscount(discount: Discount) {
  return `- 符合折扣 [${discount.rule}], 折抵 ${discount.amount} 元`;
}

const displayDiscounts = ({ discounts }: Bill) =>
  maybe(discounts)
    //
    .pipe(map(displayDiscount))
    .unwrap();
```


接著我們開始實作折扣。
因為商家不一定只會有一種折扣，
所以我們設計的函式需要可以接收複數的折扣規則。

跟之前一樣，我們只需要設計好一個規則的邏輯，
透過 `reduce` 就可以直接套用複數規則。

```typescript
function applyDiscountRule(bill: Bill, rule: Rule): Bill {
  // 計算折扣
  const discount = rule.check(bill.products);

  // 沒有折扣
  if (!discount) return bill;

  // 總價 = 原價 - 折扣
  const total = bill.total - discount.amount;

  // 回傳帳單 須包含 商品有哪些 跟 總價 跟 折扣有哪些
  const discounts = bill.discounts ? [...bill.discounts, discount] : [discount];
  return { ...bill, total, discounts };
}

const applyDiscountRules = (rules: Rule[]) => (bill: Bill) =>
  reduce(applyDiscountRule)(bill)(rules);
```

而折扣規則，只要符合以下的介面定義就可以，
我們就可以不用理會規則的實作細節，或是是否繼承某個類別。

```typescript
interface Rule {
  name: string;
  check: (products: Product[]) => Discount | undefined;
}
```

正式進入規則設計的環節，
首先根據原文，
我們要設計第一個折扣規則是滿足某個數量後可以打折，
這類 累計數量折扣 的英文是 CumulativeQuantityDiscount。

這邊重點放在檢查的函式 `check`，
大至上可以拆做兩個問題
- 確認商品是否滿足數量要求
- 計算折扣的金額

```typescript
function CumulativeQuantityDiscount() {
  function check(products: Product[]): Discount | undefined {
    // 確認商品是否滿足數量要求

    // 計算折扣金額
  }
  return { name, check };
}
```

我希望這個函式可以只定義計算的的邏輯，
而不是個別折扣的細節，
透過傳入值，我們可以定義各種不同的折扣規則。

```typescript
interface CumulativeQuantityDiscountProps {
  name: string;
  count: number;
  rate: number;
  applyTag: string;
}
```

接著撰寫測試讓我們可以確定程式的邏輯符合預期。

```typescript
test("main with discount", () => {
  function main(products: Product[]) {
    return (
      // 商品資料
      box(products)
        // 進行計算
        .pipe(checkout)
        // 進行折扣計算
        .pipe(
          applyDiscountRules([
            CumulativeQuantityDiscount({
              name: `任 2 箱結帳 88 折!`,
              count: 2,
              rate: 0.12,
              applyTag: "熱銷飲品",
            }),
          ])
        )
        // 呈現結果
        .pipe(
          display([
            // 呈現商品有哪些
            displayProducts,
            // 呈現有哪些折扣
            displayDiscounts,
            // 呈現結算後的金額
            displayTotal,
          ])
        )
        .unwrap()
    );
  }

  const products = [
    {
      name: "[御茶園]特撰冰釀微甜綠茶 550ml(24入)",
      sku: "DRINK-001201",
      price: 400,
      tags: ["熱銷飲品"],
    },
    {
      name: "[御茶園]特撰冰釀微甜綠茶 550ml(24入)",
      sku: "DRINK-001201",
      price: 400,
      tags: ["熱銷飲品"],
    },
    {
      name: "[御茶園]特撰冰釀微甜綠茶 550ml(24入)",
      sku: "DRINK-001201",
      price: 400,
      tags: ["熱銷飲品"],
    },
  ];

  const expected = `
- [御茶園]特撰冰釀微甜綠茶 550ml(24入)      $400.00
- [御茶園]特撰冰釀微甜綠茶 550ml(24入)      $400.00
- [御茶園]特撰冰釀微甜綠茶 550ml(24入)      $400.00
- 符合折扣 [任 2 箱結帳 88 折!], 折抵 96 元
Total: $1,104.00
`.trim();

  expect(main(products)).toBe(expected);
});
```

這邊如何知道這項規則可以套用在哪些商品上呢？
原文在後半段設計了 `tag`， 
透過核對該商品的 `tag` 我們就知道商品是否可以套用相關折扣。

```typescript
const matchTag = (tag: string) => (product: Product) =>
  box(product)
    .pipe(prop("tags"))
    .pipe(includes(tag))
    .unwrap();
```

以下就是我們的計算規則。

```typescript
function CumulativeQuantityDiscount(props: CumulativeQuantityDiscountProps) {
  const { name, count, rate, applyTag } = props;

  const match = matchTag(applyTag);

  function check(products: Product[]): Discount | undefined {
    // 確認留下的商品是否滿足數量要求
    const satisfied = box(products)
      .pipe(filter(match))
      .pipe(length)
      .pipe(gte(count))
      .unwrap();
    if (!satisfied) return;

    // 計算折扣金額
    const price = box(products[0]).pipe(prop("price")).unwrap();

    // 實際有幾項商品可以套用折扣
    const matched = box(products)
      .pipe(filter(match))
      .pipe(splitEvery(count))
      .pipe(filter((group) => group.length >= count))
      .pipe(flatten)
      .unwrap();

    const matchCount = box(matched).pipe(length).unwrap();

    const amount = price * rate * matchCount;

    return { rule: name, amount, products: matched };
  }

  return { name, check };
}
```






[1]: https://columns.chicken-house.net/2020/03/10/interview-abstraction/
[2]: https://hello-kirby.hashnode.dev/chapter-04-currying
[3]: https://en.wikipedia.org/wiki/Pipeline_(software)

[4]: https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle