ARCA架構:以 Active Record 為軸心的架構

越來越多的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都繼承它是一個好主意。

參考這份EloquentRepositoryArticleRepository實作。

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可用VirtusLaravel可應用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)