越來越多的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架構
ARCA架構由幾個元素組成,檔案結構看起來如下(以MVC舉例):
一個元素會是一個資料夾,或是一個檔案(類別)。
/View資料夾
/Controller資料夾
/Model資料夾
…./Entity Model資料夾
……../Entity類別
……../Repository類別
……../Presenter類別
……../Form類別
…./Service資料夾
……../Service類別
…./Operation Model資料夾
Entity Model資料夾
擺放與一種entity相關概念的程式碼。
將一種entity相關的程式碼放在同一個資料夾。
Entity類別
entity是公司在乎的商業領域(domain)中,代表現實世界的一種事物。
常見的entity包含訂單、商品、顧客、禮券、文章。
通常會代表現實生活中的一種事物、需要具備ID。
在ARCA中負責直接繼承Active Record的類別。
這是ARCA架構的重點所在。遵照少數的類別命名、資料表命名慣例(convention),
直接賦予這些entity類別多種能力:
- 物件property存進資料庫的相關操作(CRUD)
- entity間的relationship
- 對資料庫取出的值做適當轉換
而開發者所要做的,只是寫好類別定義與幾個函式而已。
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()
{
// ...
}
}
Repository類別
封裝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實作。
Presenter類別
封裝與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
Form類別
處理使用者從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
Service資料夾
擺放跨domain概念的程式碼。
多個service類別會放在此資料夾。
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);
Operation Model資料夾
圍繞某種行為概念的程式碼可以整理起來、放在一起。
當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 和我討論。
Q&A
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)