24 July 2006

你的設計模型裡有多少 null column?我的很多!

先來看看 schema 吧

      customer_order              CustomerOrder
     =================          ----------------------
      id                          constructor(buyer)
      buyer_id                    ship()
      total_cost                  arrival()
      create_date                 cancel()
      ship_date                   getStatus():String
      arrival_date                ...rest of getter()
      status                      

這是個迷你的訂單管理系統,是我在兩年前寫的,customer_order 裡面記著 "誰買的","總價多少","建立的日期",還有"出貨日"、"到貨日",最後還有一個 "訂單狀態" (含 READY, SHIPPED, ARRIVED, CANCEL 等狀態。這個 table 足夠提供使用者進行管理及追蹤訂單,查詢的 SQL 也很好寫。它所 mapping 到的物件 CustomerOrder,有幾個主要的 business method:constructor()/ship()/arrival()/cancel(),分別對應不同的流程,裡面有各自的邏輯,好好的封裝在 CustomerOrder 的物件裡。

我心中一直對這樣的設計耿耿於懷。第一,這裡有兩個 null column: ship_date 和 arrival_date,這兩個 column 一開始的時候是 null,等到流程到了,才會填值進去。也就是說這個 table 裡的資料零零碎碎的,完整性很差,我最近摸了一些 DB 的書,也說 null column 違反 relational 的理論。第二,ship(), arrival(), cancel() 等 method 乍看之下好像封裝的不錯,但這幾個 method 互相 depend,例如執行了 cancel() 後,就不能再執行 ship() 或是 arrival();執行了 ship() 就不能再做 cancel() 了。換句話說,是個蠻爛,甚至很可笑的 API。再者,ship()/arrival()/cancel() 是 business 的流程,依我這樣子設計,等於是把流程寫死了,如果未來要改變流程的話,怪怪,每個牽連到的 method 都要動到,更何況還不見得改得動 (因為這個流程也一起綁死在 customer_order 這個 table 裡了)

這個系統用了兩年,也沒啥大問題... 而這兩年來我也一直都是這樣 modeling 各式各樣的需求。當遇到一些需求變動時,仗著自己有寫 test,而且一直都是自己 maintain,就咬著牙硬改... 但... 難道真的沒有更好的 model 了嗎?書東看西看的,很多都講的很玄,什麼 prefer interface over class、loose couple、high cohesive、immutable object、side-effect free... 之類的 。頂多只能領會一成吧,真正在設計的時候還是綁手綁腳的,還是只會用上面那樣的老招。

這幾天看了 The art of SQL,老實說這本書是給進階的人看的,我這個 SQL 白痴看的可是頭昏眼花。不過在第一章裡面就提到一個道理:"當出現 Null Column 時,代表你的 model 有問題"(我只能翻個大概)。哪泥?!腦中頓時一陣閃光,一堆想法突然通通冒出來了。Null Column, Null Column,原來一切都是你!

先不管 object 要怎麼 model,從去除 Null Column 開始,重新來過:

      customer_order       shipment          arrival
     =================    ===============   ==============
      id                   id                id
      buyer_id             order_id(fk)      ship_id(fk)
      total_cost           ship_date         arrival_date
      create_date          address           receiver_id
      canceled

耶?Null Column 去除後,好像變得清爽不少,把 shipment 和 arrival 都升級變成獨立的 table,各自只存自己的資料,而且 customer_order 不知道 shipment 和 arrival 等這些後續的事了,換句話說 "流程" 不再綁在 customer_order 上了。同理,shipment 之於 arrival 也是一樣。我們再回到物件上,看看會成什麼樣子:

      CustomerOrder      Shipment           Arrival
     ----------------   ----------------   ----------------------------
      construct(buyer)   construct(order)   construct(ship, receiver)
      cancel()           ...getters()       ...getters()
      ...getters()       

耶?可笑的 ship(), arrival() 都不見啦,CustomerOrder 現在只管自己怎麼創建,以及取消,而 Shipment 和 Arrival 都只剩創建而已。這次的 API 就 "防呆" 的多啦,而且也很直覺。再者,每個 class 所管的事... 幾乎只剩一樣,這不就是 high cohesive 了嗎?CustomerOrder 不 depend Shipment,而 Shipment 不 depend on Arrival... 哎呀!這...這... 這是 loose couple 啊!而除了 CustomerOrder.cancel() 之外,其他物件只剩 getters + constructor,物件一旦 create 就不能再修改了,這種 immutability 是物件中最理想的啊!嘿嘿... 如果把 Shipment 的 constructor 改一改,變成:

    public class CustomerOrder implements Shippable {} 

    public class Shipment {
        public Shipment(Shippable shippable) {
           //....
        }
    } 

Shipment 現在只看 Shippable,即只要是 "可以出貨的"物件就能處理。它不再只限於 CustomerOrder,可以開始重覆使用了。啊咧?不小心又符合了 prefer interface over class 這個至理名言。上述的種種變化和影響真是讓我吃驚不已!

話說回來,上面新設計也不是沒缺點的... 首先是本來只有一個物件/table,現在暴增為三個。第二,要產生報表的 SQL query 可不好寫啊,要開始 join table 或是 sub-query 了,而且還會變慢。第三,真的需要這麼高的彈性嗎?以我自己的案子為例,就兩年來需求都沒變過... 如果當初這樣搞不就是 over design 了嗎?

因此,我想追求所謂一定最好的設計方式可能是無解的,什麼樣的 model 適用,拿捏還是得靠自己的經驗和 domain 的知識。經過這一次的腦力激盪,又多學了一招 -- "觀察有無 Null Column 對 model 的改變" -- 多了這一招,以後 modeling 時又多了幾個候選可以挑囉 :-)