# 架構面試題 #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