隨著軟體專案不斷增加新功能,你是否隱約感到不安?覺得Model越來越胖、開發速度越來越慢?
在一元翻譯用Laravel 4開發半年多,我也遇到同樣問題。
上禮拜受PHP 也有 Day的邀請,
跟大家分享了幾個我在工作上找到的改善方法,分別是:
- Presenter
- Repository
- Form
- Service
- Package
投影片是以Laravel 4當範例,但是概念上通用於任何語言、框架。
歡迎在這邊留言和我討論,有問題想問也可以。謝謝各位。
隨著軟體專案不斷增加新功能,你是否隱約感到不安?覺得Model越來越胖、開發速度越來越慢?
在一元翻譯用Laravel 4開發半年多,我也遇到同樣問題。
上禮拜受PHP 也有 Day的邀請,
跟大家分享了幾個我在工作上找到的改善方法,分別是:
投影片是以Laravel 4當範例,但是概念上通用於任何語言、框架。
歡迎在這邊留言和我討論,有問題想問也可以。謝謝各位。
越來越多的framework本身提供Active Record實作。
不管是Rails的Active Record,還是Laravel的Eloquent,
這些Active Record實作的功能越來越豐富而強大。
它讓開發速度更快了,但也因為太多功能塞在一起,容易寫出極度肥胖的萬能類別。
該怎麼使用這種萬能類別,又不至於讓它胖到難以維護呢?
我從工作中整理出了稱之為ARCA的架構。今天來跟各位分享。
註:本文程式碼多以Laravel框架舉例。但是相關概念通用在任何擁有Active Record的語言/框架。
ARCA全名「Active Record Centered Architecture」,
也就是「Active Record中心架構」,以Active Record為主軸的軟體架構。
ARCA試圖在享受Active Record帶來的開發速度的同時,拉出一定的架構。
這套架構適用於中型以上(連續開發3個月以上)專案,不適用於小型專案(像是餐廳官網)。
ARCA架構由幾個元素組成,檔案結構看起來如下(以MVC舉例):
一個元素會是一個資料夾,或是一個檔案(類別)。
/View資料夾
/Controller資料夾
/Model資料夾
…./Entity Model資料夾
……../Entity類別
……../Repository類別
……../Presenter類別
……../Form類別
…./Service資料夾
……../Service類別
…./Operation Model資料夾
擺放與一種entity相關概念的程式碼。
將一種entity相關的程式碼放在同一個資料夾。
entity是公司在乎的商業領域(domain)中,代表現實世界的一種事物。
常見的entity包含訂單、商品、顧客、禮券、文章。
通常會代表現實生活中的一種事物、需要具備ID。
在ARCA中負責直接繼承Active Record的類別。
這是ARCA架構的重點所在。遵照少數的類別命名、資料表命名慣例(convention),
直接賦予這些entity類別多種能力:
而開發者所要做的,只是寫好類別定義與幾個函式而已。
class User extends Eloquent { public function phone() { return $this->hasOne('Phone'); } public function comments() { return $this->hasMany('Comment'); } }
也正是Active Record的多功能,讓entity的建立如此迅速。
公司domain所需的商業行為,與entity相關的動作都是寫在此處。
class User extends Eloquent { // ... public function addComment() { // ... } }
封裝query logic,提供取得entity(一或多個)的不同方法。
不論是Rails的Active Record還是Laravel的Eloquent,都有提供查詢(query)的功能。
舉例來說,想要拿出新舊排序過的精選文章,可以這樣寫:
Article::where('featured', true) ->where('status', '=', Article::STATUS_PUBLISHED) ->orderBy('published_at', 'desc') ->get();
但是這幾行程式碼很可能常常用到,會到處重複。
這時就可以用repository封裝這段code:
class ArticleRepository { public function getFeaturedArticles($count = 3) { return Article::where('featured', true) ->where('status', '=', Article::STATUS_PUBLISHED) ->orderBy('published_at', 'desc') ->take($count) ->get(); } }
以這段封裝來講,不但讓controller內的code可讀性提昇,同時還提供取出不同數量的參數功能。
// in controller $repository = new ArticleRepository(); $articles = $repository->getFeaturedArticles(10);
ARCA內的repository都依賴Active Record提供的query實作,
所以建立一個abstract class再讓所有repository都繼承它是一個好主意。
參考這份EloquentRepository和ArticleRepository實作。
封裝與business logic無關,只與呈現(presentation)相關的logic。
entity相關的名稱、金額、日期,常常在不同地方有不同呈現格式。
將這些寫成entity的function可不是好主意。
這類presentation logic可以獨立成presenter類別。
(或稱View Objects…。注意:軟體名詞在不同社群的用法極度混亂。)
概念上只是將entity塞進presenter,也就是decorator pattern。
實作上可以依不同語言、框架的特色發揮創意。
像是這套善用了PHP特性,讓presentation 相關的code明確分離。
Presenter長這樣:
class UserPresenter extends Presenter { public function fullName() { return $this->first . ' ' . $this->last; } }
接著可以從entity直接使用presenter實體,呼叫presentation logic。
$user->present()->fullName
處理使用者從HTTP表單填入的資料,封裝驗證(validation) logic。
web application少不了大量的運用html表單與使用者互動。
針對user input的驗證 logic(例如字串長度、日期、金額大小)可以抽出來成為Form類別。
(在Laravel 5稱為Form Request,在Rails社群稱為Form object)
原理上來說,就是將表單傳來的參數傳給Form類別,在裡面驗證過後才決定是否執行下一步驟。
舉例來說,這件事若在controller內進行,看起來就像:
$form = new ArticleForm( Input::all() ); if ( ! $form->isValid() ){ return Redirect::back()->with( [ 'errors' => $form->getErrors() ] ); } $article = new Article( Input::only('title', 'content', 'status') ); $article->save();
而Form類別的實作則依照語言/框架的不同各自發揮。
舉例:Rails可用Virtus、Laravel可應用Validator
擺放跨domain概念的程式碼。
多個service類別會放在此資料夾。
概念上不屬於任何entity的business logic可以獨立成service。
通常是施加在多個entity上(來自不同domain)的複雜行為。
舉例來說,計算訂單的折扣與售價,通常會涉及訂單與禮券,並且運算邏輯較為複雜:
class PriceService { public function calculate(Order $order, Coupon $coupon) { // ... } }
使用時,將entity傳進去即可:
// in somewhere, maybe a controller $order = /* get the order */; $coupon = /* get the coupon */; $service = new PriceService(); $service->calculate($order, $coupon);
圍繞某種行為概念的程式碼可以整理起來、放在一起。
當service開始依賴其他service時,代表這項行為開始變得複雜、開始造成理解的困難。
試著看清是哪個概念(這很不容易)、以它命名(也不容易),建立出Operation model。
舉例來說,電子商務公司提供報價會同時涉及「禮券折扣」、「出貨日期」、「總金額」的計算。
也就是QuotationService(報價)會用到DiscountService(計算折扣金額),
DueDateService(計算出貨日期), PriceService(計算總金額)。
這時可以將它們從Service資料夾移出、建立一個Quotation資料夾、再全部丟進去。
丟進去之後可以進行適當的refactoring,增加這幾個類別間的cohesion,降低之後理解的困難。
也可以進一步使用Facade Pattern封裝這些類別,建立一個統一對外的接口,
降低其他地方的程式碼與這個Operation Model的coupling。
以上面報價的例子來說:
class QuotationManager // 我決定將它直接視為某種管理員,不再視為單純的service { protected $due; protected $price; protected $discount; public function __construct(DueDateService $due, PriceService $price, DiscountService $discount) { $this->due = $due; $this->price = $price; $this->discount = $discount; } public function calculate(Order $order) { // 可能依序執行某些business logic,也可能只是直接呼叫底下的service /* $this->due->calculate($order); $this->price->calculate($order); */ } public function discount(Order $order, Coupon $coupon) { // 可能依序執行某些business logic,也可能只是直接呼叫底下的service // $this->discount->calculate($order, $coupon); } }
討論架構時,通常只討論概念,不將檔案結構列入討論。
我認為那樣的討論讓很多人有看沒有懂,所以將檔案結構一併納入ARCA說明了。
ARCA不是一種理論或是無敵的架構,只是一種用來搭配Active Record,
將常用元素(或說是Design Pattern)組合起來的一種方式而已。
可以將ARCA架構視為一種適合新創公司快速開發的、通用的中型基本架構。
隨著情況不同,做出調整、增減元素(例如好用的Factory類別)即可。
有任何疑問,或是有推薦的好用Design Pattern,覺得適合加進ARCA嗎?
歡迎在下方留言,或是在 Twitter @howtomakeaturn 和我討論。
Q1: Service類別的例子怪怪的…明明可以寫進其中一種entity啊!像是這樣就可寫進訂單entity:
class Order extends Eloquent { public function calculatePrice(Coupon $coupon) { // ... } }
何時放在entity,何時獨立成service呢?
如你所說,一項行為究竟是否足夠「複雜」到需要獨立出來,的確屬於開發者的主觀判斷。
但是認定所有行為都不「複雜」的話,entity很容易變得極度肥胖喔。
Q2: 我覺得用Active Record會讓測試很難寫,有解法嗎?
這確實是Active Record的一大缺點:在測試時很難不去碰到database。
使用快速建立測資的套件,似乎是唯一能勉強接受的方法。
Ruby有factory_girl,PHP有Factory Muffin。
參考看看吧。
(Photo via SuperCar-RoadTrip.fr, CC licensed)