尤川豪   ·  4年前
445 貼文  ·  275 留言

Laravel、Ruby on Rails、Django ... 使用 Active Record Pattern 框架的最大問題:到底該把 save 寫在哪裡?

許多 web 框架為了開發速度,預設使用 Active Record Pattern 作為 domain modeling 的方式。

根據 Martin Fowler 的定義:

An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on that data.

也就是同時將 1. 與資料庫互動的邏輯 2. 應用程式商業邏輯 放在一起。

這樣做雖然很方便,但會對架構設計帶來一些滿嚴重的問題。

其中最核心的關鍵問題就是:到底該把 save 寫在哪裡?

Fat Controller, Thin Model(save 寫在 controller)

使用這種框架的新手,第一個容易犯的錯誤就是寫出「fat controller, thin model」。

以電商功能,顧客送出訂單為例,他們會把幾乎所有程式碼寫在 controller:

// OrderController.php
function submit(Request $request)
{
    // blah blah ...
    $order = new Order();
    $order->price = $price;
    $order->status = 2;
    $order->save();
    // blah blah ...
}

而在 model 內幾乎什麼也沒有:

// Order.php
class Order extends Eloquent
{
    // nothing here
}

這些新手很快就會發現,不同 controller 與 function 有時候會需要用到同樣的功能。

controller 內的程式碼無法重複使用,於是在不同地方重複出現,很快就會造成維護困難。

Fat Model, Thin Controller(save 寫在 model)

這時候新手容易進入下一個極端:寫出「fat model, thin controller 」。

也就是盡量把 code 都寫在 model:

// Order.php
class Order extends Eloquent
{
    function checkout($price)
    {
        $this->price = $price;
        $this->status = 2;
        $this->save();
    }
}

不留太多 code 在 controller:

// OrderController.php
function submit(Request $request)
{
    // blah blah ...
    $order = new Order();
    $order->checkout($price);
    // blah blah ...
}

這種作法只比 fat controller 好一點而已,還是非常糟糕。

系統 domain modeling 如果用到了 N 個 model,共需要 X 行程式碼,那麼平均起來一個 model 就有 X/N 行程式碼。如果資料庫 table 數量沒增加,而系統功能持續增加,那麼每個 model 程式碼就會動輒上千行,光是找一個函式就要找滿久。

既然是 OOP 程式設計開發,你勢必需要使用更多的 class 來描述你的系統。只使用 active record pattern 相關的 class 是不行的。

Service Objects(save 寫在 service)

幾乎各個框架社群之後都開始提倡 service objects 的使用。

很多人使用 service objects 的方式很含糊,只是把本來放在 fat model 的程式碼照搬到另一個同名的 service object 而已。也就是本來只有 Order.php 一個檔案,改成 Order.php 還有 OrderService.php 兩個檔案。函式憑感覺放到兩者其一的地方。這樣使得程式碼不再是 X/N 這般肥胖,但只是少一半變成 X/2N 而已,實在也沒好到哪去。

沒關係,為了方便,我們姑且就先用這個方式進行 domain modeling。

// OrderService.php
function checkout($order, $price)
{
    $order->price = $price;
    $order->status = 2;
    $order->save();
}

如此一來,同樣的邏輯就能在不同 controller 重複使用了:

    $orderService->checkout($order, $price);

save 到底要寫在哪裡?

到目前為止,我們可以發現不論哪種作法,save 都是緊跟在商業邏輯後面直接出現。

也就是說,每次有一段商業邏輯執行完,就順便執行 SQL 存進資料庫。

這很自然,畢竟 active record pattern 就是設計來做這兩件事情的。

但是!問題這時候就出現了。當你設計了多個可以重複使用的函式,並且在某個 controller 內依序使用的時候:

// OrderController.php
function submit(Request $request)
{
    // blah blah ...
    $orderService->checkout($order, $price); // 這函式會更新訂單的某些狀態並存進資料庫
    $orderService->anotherTask1($order); // 這函式也會更新訂單的某些狀態並存進資料庫
    $orderService->anotherTask2($order); // 這函式也會更新訂單的某些狀態並存進資料庫
    $orderService->anotherTask3($order); // 這函式也會更新訂單的某些狀態並存進資料庫
    // blah blah ...
}

你會發現這段程式碼背後會執行 4 次 SQL 指令!這是相當大的主機效能浪費!應該可以 1 次 SQL 就做完的!

當你把每個任務拆得很細、漂亮的寫好 K 個方便重複使用的小函式,居然更慘,會導致執行 K 次 SQL 指令!

這就是 active record pattern 的最大問題:類別同時負責商業邏輯跟資料庫邏輯,所以每次都應該要一起出現嗎?

聰明的你可能想到了:那不然 save 永遠別寫在商業邏輯後面直接執行。也就是說,在 service 裡面永遠不寫 save:

// OrderService.php
function checkout($order, $price)
{
    $order->price = $price;
    $order->status = 2;
    // $order->save();
}

總是把 save 留在 controller 最後執行:

// OrderController.php
function submit(Request $request)
{
    // blah blah ...
    $orderService->checkout($order, $price);
    $orderService->anotherTask1($order);
    $orderService->anotherTask2($order);
    $orderService->anotherTask3($order);
    $order->save();
    // blah blah ...
}

這樣子就可以確保就算你設計成許多個函式,最終也只有執行 1 次 SQL指令!效能問題解決了!

效能問題是解決了,可是不說別的,這種寫法,你不覺得看起來很奇怪、很醜嗎?

  1. 訂單 物件先作為參數被傳進 service object 裡面去。在裡面被更新一些狀態。

  2. 訂單 物件再自己執行 save 來把自己更新後的狀態存進資料庫。

嗯...好啦,也沒那麼奇怪。前者是 active record pattern 作為商業邏輯被看待的那部份;後者是 active record pattern 作為資料庫邏輯被看待的那部份。

但這樣就出現了一個新的問題:這是在用慣例而非架構解決問題。

也就是架構本身不夠 expressive,導致開發者需要隨時記得一些額外的團隊開發慣例。

新的同事加入開發團隊之後,他很可能出於直覺而把 save 寫在不同於這種隱晦慣例的地方。

因為架構本身沒有說明要在哪裡處理資料庫邏輯。

Repository Pattern(save 寫在 repository)

為了解決前述「有慣例無架構」的問題,各個社群開始提倡 repository pattern 的使用。

他們教導的 repository 類別長成這樣:

class OrderRepository
{
    function findById($id)
    {
        return Order::find($id);
    }

    function save($order)
    {
        return $order->save();    
    }

    function findBySomeCriteria()
    {
        //
    }
}

如此一來就可以在 controller 內改寫成這樣:

// OrderController.php
function submit(Request $request)
{
    // blah blah ...
    $orderService->checkout($order, $price);
    $orderService->anotherTask1($order);
    $orderService->anotherTask2($order);
    $orderService->anotherTask3($order);
    $orderRepository->save($order);
    // blah blah ...
}

controller 看起來漂亮多了!

...真的嗎?這樣有解決「有慣例無架構」的問題嗎?

只能說稍微解決而已。乍看之下架構本身開始有「務必透過 repository 類別跟資料庫互動」的感覺。

但這依然有點「使用慣例」解決問題的感覺。

因為 active record pattern 明明就鼓勵 $order 把自己存起來阿。我為啥不能把 save 直接寫在 service 或 controller 裡面?

再看一次前面描述的問題情境:

新的同事加入開發團隊之後,他很可能出於直覺而把 save 寫在不同於這種隱晦慣例的地方。

你覺得問題算是有解決了嗎?這居然成了一個主觀問題。我是覺得沒解決。

除此之外,看看 OrderRepository 的程式碼,它是如此的可笑。findById 跟 save 居然是傳入物件之後,再呼叫物件自己的函式。這是哪門子抽象化?

不過 findBySomeCriteria 之類的查詢函式,倒是不錯,把常用的條件查詢封裝起來,方便重複使用。

可是話說回來,如果好處僅只於此的話,單純的這樣設計類別是否更好一點:

class OrderFinder
{
    function findBySomeCriteria()
    {
        //
    }

    function findByAnotherCriteria()
    {
        //
    }
}

總而言之,Repository Pattern 並無法解決上述提到關於 save 的設計問題。

你可能解決了「不知道 save 該寫在哪裡」的問題。

但沒有解決「不知道同事把 save 寫在哪裡」的問題。

當一個類別嚴重違反了關注點分離原則的時候,就是會有這種結果。

結論

本文無意說服大家停止使用 Active Record Pattern。

只是想提醒大家注意使用這個模式會伴隨而來的架構問題。

設計架構的時候,務必要留意這個會阻礙抽象化設計的思考問題。

除此之外,花些時間研究下使用 Data Mapper Pattern 的 ORM 套件與架構,或許會發現更適合你口味也不一定。

總結來說,Active Record Pattern 可以用一句話來評論:一個實在太好用的 anti-pattern。

  分享   共 7,283 次點閱
按了喜歡:
共有 2 則留言
caesar.minfeng.tsai   ·  4年前
0 貼文  ·  2 留言

是否用 MVC 架構就無法避免文章提到的矛盾呢?
之前研究過一下 golang 該如何布局 專案layout
我只有看看文章, 查詢相關資料
實作還很少, 說的可能不正確
.
總覺得你文章中的 order 類別
有點像是 clean code 中說的
把 資料 跟 物件 混在一起使用 ?
也可能是我搞錯
因為我實際寫 code 經驗少
也不懂 ruby laravel, 不清楚 namespace 如何影響呼叫
我只知道 golang 的限制...
.
我沒寫 java
但查資料有看到 spring 框架
感覺可以激發思考如何處理
.
spring 對於各種 物件的定義
雖然 我覺得有些應該是純粹的資料
不應該都叫物件
下面連結是關於 PO VO DTO DAO 的簡易筆記, 只是自己看的
https://hackmd.io/Mj6-gC_hSrykPojhOJuGFg
深入內容還是看網路吧
感覺 order 類型 似乎混合了 PO 跟 DAO
checkout() 應該算 DAO 還是 BO
我分不太出來
.
若想更確定 save() 的執行順序
php 是否可以把函數當參數, 想如下的形式
// OrderController.php
function submit(Request $request, funXXX tasks )
{
// blah blah ...
tasks()
$orderRepository->save($order);
// blah blah ...
}
或是另外抽出來, 進行類似 template 的設計模式呢?
比較偏物件導向的做法, 但這樣或許要多一個抽象
.
go 的 專案 layout
以下這篇是我想學習的形式, 感覺是通用的想法
https://www.jianshu.com/p/022ba2dd9239
但等級太高, 類似採用 DDD 的方式
我初學者還無法理解太深
暫時記在心中以後學習
以下是我覺得應該符合 DDD 的開園專案
https://github.com/drone/drone
.
轉職初學者的胡言亂語
.
另外補充一個連結
https://teddy-chen-tw.blogspot.com/2014/03/top-downbottom-up.html
所說的 top-down 設計方式, 應該類似 DDD 吧?

 
按了喜歡:
尤川豪   ·  4年前
445 貼文  ·  275 留言
  1. MVC 這個名詞已經沒有定義可言 請參考 http://blog.turn.tw/?p=1539

  2. 這是跟 active record pattern 有關 跟 MVC 倒是無關

  3. 你說的沒錯 這就是把「資料 跟 物件 混在一起使用」 這正是 active record pattern 的一大問題

  4. 因為同時負責太多事情 所以 的確混合了 PO VO DTO DAO 之類的多種身份

  5. DDD 之類的概念也是某些人的經驗談 說不定自己的領域用起來不好消化.

軟體開發沒有萬靈丹 多看是好的 但還是要看當下適不適合. 還有自己做不做得出來.

我還沒看過什麼 Laravel, RoR, Django 專案是依循 DDD 漂亮做 domain modeling 的...

然後你提到的很多名詞 我其實也不熟 所以無從評論.

以上 個人淺見 供您參考

按了喜歡:
您的留言
尤川豪
445 貼文  ·  275 留言

Devs.tw 是讓工程師寫筆記、網誌的平台。隨手紀錄、寫作,方便日後搜尋!

歡迎您一起加入寫作與分享的行列!

查看所有文章