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