iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

1
Modern Web

讓 TypeScript 成為你全端開發的 ACE!系列 第 41

Day 41. 戰線擴張・模擬戰 — UBike 地圖 X 外觀模式 - Façade Pattern in TypeScript

https://ithelp.ithome.com.tw/upload/images/20191007/20120614m00GZ4JxWW.png

閱讀本篇文章前,仔細想想看

還記得單例模式 Singleton Pattern嗎?今天會用到喔!

本篇文承接上一篇文,因此如果是跳到這篇的話可以先從上一篇或者是 UBike 地圖範例的一開始看起喔~

貼心小提示

想要本範例的程式碼 —— 可以參見 Maxwell-Alexius/Iron-Man-Competition 這個 Repo 喔!

筆者在這個系列講過的模式有:

事實上在講抽象工廠模式時,順便有提到的 Factory Method Pattern 也是設計模式的一種。

所以嚴格來說,今天要講述第五種模式 —— 外觀模式 Façade Pattern 的應用。(有些資源會翻譯成門面模式,就只是名稱不同罷了)

本篇也要順便對前一篇的 UBike 地圖範例進行重構的部分,所以請筆者務必要把前幾篇內容稍微理解喔~

模擬戰 — UBike 地圖應用(四)

LeafletJS 地圖邏輯的重構

對於本 UBike 地圖範例來說,光是 LeafletJS 部分的邏輯就很複雜,筆者就把程式分成幾個部分。

1. Leaflet 地圖的個體建構

使用 L.map 建構地圖,並且還摻雜 markerLayer 的參數宣告;對於讀者可能感到不起眼,但是筆者認為地圖(Map)與圖層(Marker Layer)本身是兩個不ㄧ樣的東西。

https://ithelp.ithome.com.tw/upload/images/20191007/20120614VgoTU06Tig.png

2. 主程式 —— 繪製 UBike 站點

這裡就非常複雜,除了將資料從網路上抓出來過濾外,裡面做了幾件事情:

  1. 將每筆 UBike 站點資料轉換成 Marker 物件
  2. Marker 物件會綁定對應的 Tooltip 以顯示站點資訊
  3. 將一系列的 Marker 丟到圖層裡並顯示到地圖上

https://ithelp.ithome.com.tw/upload/images/20191007/20120614XK3aAs14lQ.png

筆者認為這邊有三個主角在互動:

  • Map 個體本身
  • Marker Layer 圖層
  • Marker 站點的記號

3. 更新地圖點位

這裡就是對 <select> 這個標籤註冊一個事件,並且每一次更新時除了會移除 Marker Layer 外,會呼叫 updateUBikeMap 重新更新地圖。

https://ithelp.ithome.com.tw/upload/images/20191007/20120614UTjVMnJN67.png

這裡只有牽扯到 Marker Layer 這個主角,但事實上因為有呼叫 updateUBikeMap 所以其實其他的 Map 相關的角色都有被牽扯到。

重構 Leaflet Map 的前置作業

UBike Map 地圖的主角以及介面的宣告

反正回過頭來,筆者腦袋中想像的 Leaflet 地圖部分有四大主角:

  • map 物件 —— 使用 L.map 產出來的地圖的個體(Instance)
  • Initializer —— 負責初始化地圖的邏輯,必須填入 MapConfig 相關的值
  • MarkerLayer —— 負責將所有的點位(Marker)匯集後,渲染到地圖上
  • Marker —— 站點的點位

本篇會善用介面與類別 —— 就像是筆者在《機動藍圖》篇章探討策略模式一樣,帶領讀者善用介面與類別實作出還算不錯的程式碼。

但筆者不保證這會是最完美的解法,程式碼沒有完美不完美,就只有可能比較好或比較不好,層面又分很多種,可讀性(Readability)亦或者是效率(Performance);況且需求一定持續性地變更,因此今天程式碼重構結果 —— 有可能因為需求改變,而有被修改的可能。

首先,筆者新增一個檔案資料夾名為 /ubike-map 放在 /src 裡面。並且新增 map.d.ts 檔案,宣告 InitializerMarkerLayerMarker 的介面,並且安妥地被 CustomMap 這個命名空間包覆:

https://ithelp.ithome.com.tw/upload/images/20191008/20120614AICnclIuOV.png

介面 CustomMap.Initializer 只有三個成員:

  • map 代表 Leaflet Map 本身的個體,唯讀模式
  • config 代表筆者自己定義的 MapConfig 這個型別的設定,唯讀模式
  • initialize 為一個方法,專門初始化地圖

介面 CustomMap.MarkerLayer 有五個成員:

  • map 代表 Leaflet Map 本身的個體,唯讀模式
  • layer 代表 Leaflet 本身的 LayerGroup 這個型別,唯讀模式
  • addMarker 代表將某個點位加進這個 MarkerLayer
  • addMarkers 則是代表加入複數個點位
  • clear 代表清空所有的點位

介面 CustomMap.Marker 有兩個成員:

  • marker 代表點位本身連結到的 Leaflet Marker 物件
  • bindTooltip 代表直接綁定 Tooltip,需要帶入一個字串代表 Tooltip 顯示的提示內容

讀者應該會發現,筆者刻意將 InitializerMarkerLayer 指名必須提供 L.map 的個體,那是因為要維持彈性,不會把地圖個體寫死在實踐該介面的類別。

另外,讀者當然可以在 Initializer 多新增 setMap 這個功能負責換掉地圖的個體或者是 changeConfig 代表換掉地圖的設定後再重新 initialize 也可以,看讀者能夠把想像力發揮到什麼地步都可以 —— 但前提就是,功能儘量不要寫死,寫程式到最後越寫越大,可能會需要一些長遠性解法。

此外,筆者認為由於 UBike 地圖的應用,也僅僅只會出現一張地圖,所以可以把 Leaflet 建立的地圖個體本身做成 MapSingleton 這個單子物件(Singleton)。(參見單例模式篇章

不過筆者認為 MapSingleton 沒有實踐介面的需求,所以就不寫 MapSingleton 的介面。但如果功能越變越大,筆者可能就會積極地採取介面方式去規定功能的架構。

實踐主角介面 Class-Interface Implementation

首先,筆者簡單地把 MapSingleton 生出來,這是對單例模式練習的好時機,本程式碼放置在 /src/ubike-map/MapSingleton.ts 裡:

https://ithelp.ithome.com.tw/upload/images/20191008/20120614HbueFjfSZz.png

MapSingleton 存放的就是 L.Map 這個型別的個體,但要注意的是:TypeScript 編譯器本身會提醒你有可能是 null 的情形,因此筆者刻意在 constructor 裡面放一個 console.warn 提醒使用者 Map 建立的過程很有可能出問題。

所以根據 MapSingleton,你可以使用 MapSingleton.getInstance() 就可以取出唯一的 Leaflet 地圖個體喔~

再來是 MapInitializer 這個類別實踐 CustomMap.Initializer 這個介面。筆者刻意包裹 CustomMap 這個命名空間(Namespace)的原因很簡單:Initializer 這個名詞應該有機率出現在其他地方的應用,因此可能會有命名衝突問題,因此筆者選擇使用命名空間。

然而,避免命名的衝突有很多種方式,你也可以選擇使用 ES6 default exportInitializer 輸出出去後,載入到其他檔案時用其他命名就好也可以,隨便讀者取用,不過筆者在這邊想要順便示範 Namespaces 可以用到的地方在哪裡。

以下是 MapInitializer 的實踐:

https://ithelp.ithome.com.tw/upload/images/20191008/20120614S7qR8h9f1X.png

裡面的 initialize 方法不覺得很像 UBike 地圖模擬戰第一篇的初始化地圖過程嗎?我們可以將初始化地圖的邏輯獨立出來,以後想要調整初始化的過程就可以放在這邊~

當然,初始化的過程可能還可以分好幾種,譬如:你想要初始化不同地區、不同的地圖樣式等等,你甚至可以採取策略模式,宣告出 CustomMap.InitializeStrategy 介面,並且定義不同的 InitializeStrategy 相關類別並連接到 MapInitializer 這個類別裡,善用設計模式的過程中,儘管一開始限制感到重重,但運用起來事實上是非常自由的

第二個要實踐的是 MapMarkerLayer 這個類別,對應的是介面 CustomMap.MarkerLayer 這個介面。

https://ithelp.ithome.com.tw/upload/images/20191008/201206143F2KL7HR7A.png

MapMarkerLayer 的實踐過程應該比 MapInitializer 單純許多,沒有太多花樣,就是控制 Marker 的載入與圖層的清除。

最後是 MapMarker 這個類別,對應實作介面為 CustomMap.Marker 這個介面。

https://ithelp.ithome.com.tw/upload/images/20191008/20120614PcfvgM0aO0.png

讀者應該會發現,筆者刻意對建構子設為 private 模式並額外宣告 create 這個 MapMarker 的靜態方法,用另一種方式建立 Leaflet 的 Marker 物件 —— 筆者選擇 MapMarker.create(...) 這種方式去創建物件。另外,MapMarker 類別提供的 bindTooltip 方法除了對 Marker 進行 Tooltip 的綁定外,還預設了 Tooltip 被觸發的行為,等於是把主程式裡面每一次宣告新的 UBike 點位然後綁定 Tooltip 的邏輯整理進去了。

但不要看到私有建構子以為 MapMarker 是單例模式喔,在這裡並不是單例模式!因為筆者開放了 create 這個方法,反而是可以建立無數個 Marker 個體 —— 這應該算是工廠方法(Factory Method)的一種變體。

此外,有些讀者可能認為,這裡可以用抽象工廠模式去建立 Marker,不過因為產品種類太少(才 UBike 的點位這一種產品),因此筆者認為沒有需要使用 Abstract Factory 模式的必要 —— 倒是可以使用普通的 Factory Method 模式。

貼心小提示

並不是要鼓勵讀者一開始使用設計模式,而是當架構成長到一個地步,需要更好的架構時,使用設計模式就更有效果。

除非,地圖點位可能包含 —— 公車、火車、捷運等等站點的匯集,此時可能就有 Abstract Factory 模式的必要性喔!

四大主角因此完畢了,但是這四個主角只是本篇鋪梗的一部分而已,真正要介紹的幕後大主角是:名為 UBikeMapFacade 這個實踐外觀模式(Façade Pattern)的類別

(就好像天使還有分小天使、大天使還有大麥克天使,不是大麥克雞塊

外觀模式的實踐 Façade Pattern Implementation

首先,筆者誠心建議要特別去找 Façade 這個單字的發音,因為這是法文單字,代表一個人的外觀或建築物的外表 —— 英文的同義字是 Visage。

另外,外觀模式 —— 誠如其名,Client 端(使用者端)看得到的就是一個程式碼包裝功能的外觀 —— 可以比擬為開車時看到的儀表板、打檔位的棒子、然後會轉來轉去、滑溜溜的方向盤(請勿亂想)等等都是操作汽車的外觀,但你看不見內部的機械結構、引擎、電路等等的細節內容,這些都被包裝成你看到的操作介面。

外觀模式就是把複雜的內部結構簡化為單一介面 —— 而且不只是這樣,它還有明確的別種意涵在本篇有呈現到:你可以在複雜的功能下定義一系列的子介面(Subclasses/Sub-interfaces),然後整合子介面再變成單一的介面供 Client 端使用

以本篇 UBike 地圖為範例,原本比較複雜的功能是由 Leaflet 這個套件所提供,但是被筆者定義的子介面簡化了,這些子介面包含:MapInitializerMapMarkerLayerMapMarker

而我們可以將這些子介面與 Leaflet 提供的功能進行整合,創造出一個外觀(Façade)供客戶端程式碼使用,在者裡的客戶端程式碼就是指 UBike 地圖系列範例裡的 index.ts 內部的內容。

所以筆者想要再定義一個專屬於 UBikeMapFacade 這個類別,專門負責渲染 UBike 站點在地圖上。而 UBikeMapFacade 就只有兩個成員,非常簡單:

  • pinStops 代表將 UBike 站點都渲染到地圖上
  • clearStops 代表將渲染過後的 UBike 站點從地圖上清除

這是筆者辛辛苦苦地將 UBike 地圖的應用,以外觀模式的方式呈現的類別、介面與套件的關係圖。(如圖一)

https://ithelp.ithome.com.tw/upload/images/20191008/201206149gU0HFKboW.png
圖一:外觀模式下,UBikeMapFacade 就是一個很單純的介面,整合前面四大主角以及 LeafletJS 的功能

以下的程式碼就是實踐 UBikeMapFacade 的內容,放置在 /src/MapFacade.ts 檔案裡。

https://ithelp.ithome.com.tw/upload/images/20191008/201206145Ffml40GNI.png

看起來是很長串程式碼,但仔細看事實上很單純 —— 引用剛剛宣告過的子類別介面,然後操作這些介面達到將 UBike 點位渲染到地圖上的過程。

這樣的好處是 —— 剛剛宣告過跟 CustomMap 相關的類別內部的功能都是專注在地圖上的運作,而非 UBike 點位的渲染過程,而 UBikeMapFacade 可以專心在操作子類別介面,渲染跟 UBike 點位相關的邏輯,因此不需要再重複處理地圖渲染的過程與邏輯:達到類別各司其職,單一職責原則的實現。

另外,UBikeMapFacade 的建構子裡面還需要提供所謂的 tooltipTemplate 這個方法成員,因為要讓客戶端可以自訂 Tooltip 要呈現的內容為何,而這個方法為一種回呼函式,可以接收到要渲染的 UBike 站點的點位資訊 —— 也就是前幾篇有宣告過的 UBikeInfo 型別。

使用外觀類別 Put Façade in Use

在原來的 index.ts 檔案裡,你可以將地圖的初始化過程簡化為對 UBikeMapFacade 的初始化過程喔!

https://ithelp.ithome.com.tw/upload/images/20191008/20120614qTqfIGTvgr.png

再來是,將資料取出的過程中,本來過濾資料後要開始一連串對地圖的渲染操作,都被 UBikeMapFacade 簡化為只要填入 UBikeInfo[] 型別就可以在地圖裡面進行渲染的動作。

https://ithelp.ithome.com.tw/upload/images/20191008/20120614MIDWmpOdCG.png

省去的程式碼有點多,讀者看到這裡應該也會感到愉悅,好多地圖相關的操作邏輯都被處理掉。

最後就只是把地圖清除點位的邏輯換成是用 UBikeMapFacade 裡提供的 clearStops 成員方法進行站點的清除。

https://ithelp.ithome.com.tw/upload/images/20191008/20120614W18xLGN8DG.png

以下是 index.ts 整頓過後的結果。

https://ithelp.ithome.com.tw/upload/images/20191008/20120614kYcJM9nNqD.png

筆者另外要提醒一下:

外觀模式最大的優點是:它可以隱藏使用的內部使用的套件與機制

仔細觀看上面的程式碼,你不會看到 leaflet 套件出現的蹤跡,你只會看到地圖相關的東西只有設定 MapConfig 以及筆者定義的 UBikeMapFacade

你甚至也沒看到 InitializerMarker 等等子介面,外觀模式在客戶端的程式碼就是那麼的直觀,它是一種高度抽象化的結果 —— 你不會看到實作細節,你只要處理好主程式的行為就夠了

本篇唯一重點. 外觀模式 Façade Pattern

外觀模式著重在於對於複雜系統的一層包裝,使得客戶端可以忽略內部實作細節,專注於高度抽象化層級的行為描述

外觀模式不僅僅是用在包裝複雜系統功能這一用途,也可以選擇將內部的複雜系統進行一層層漸進式抽象化宣告一系列子介面,然後再慢慢收斂到一個外觀類別(Façade)的實踐

外觀模式最大的優點在於,它可以隱藏內部的實作細節跟引用的套件,客戶端也就不會有誤用內部功能而造成程式運行過程可能發生的侵入式破壞導致的錯誤

事實上,外觀模式非常常見,比如:

  • 一個語言的編譯器介面,實際上內部是一連串針對語言的編譯過程,包含:Lexer(語法解析)、Parser(敘述式或表達式的解析)、Code Generator(機械碼的產出)等等,但使用者並不會看到內部的實作長什麼樣子,而是依靠編譯器提供的外來介面進行編譯的動作 —— 編譯器就是一個 Façade
  • CLI(Command-Line Interface)等工具就是一種 Façade
  • 套件的實作提供的 API 就是一種 Façade
  • RESTful API 可以看作是連接資料庫、處理 Request 等等功能,整合出來的外觀,故也可看作是一種 Façade

讀者試試看

由於篇幅關係,筆者想說這個可以讓讀者練習。

讀者可以試著利用 UBikeMapFacade,能不能實踐出這個功能 —— 當使用者選擇不同的行政區,就可以將地圖自動對焦到該行政區。

你可能會需要筆者之前整理的 districtsLatLngMap 以及 L.Map 提供的 flyto 方法。並且可以選擇宣告新的介面,叫做 CustomMap.MapUtility 之類的介面並實踐出子類別後,在 UBikeMapFacade 呼叫該子介面方法讓地圖可以隨時轉換焦點。

小結

今天開開心心地總算可以把這個範例結束掉~

筆者也在此宣布 —— 總算把第三篇章《戰線擴張》解決了~(放鞭炮

第四篇章《通用武裝》篇章應該會比第三篇更精彩,應用不僅僅只是泛用型別而已,筆者還要介紹 ES6 Promise 以及更多東西的應用喔~~~


上一篇
Day 40. 戰線擴張・模擬戰 — UBike 地圖 X 使用 LeafletJS - Using LeafletJS with TypeScript
下一篇
Day 42. 通用武裝・泛用型別 X 型別參數化 - TypeScript Generics Introduction
系列文
讓 TypeScript 成為你全端開發的 ACE!46

尚未有邦友留言

立即登入留言