標籤彙整: 軟體架構

分離式客製化開發:兼顧模組化與客製化的 Laravel 彈性架構

接案的時候,常常很想把一套系統寫完之後,一口氣賣給多個客戶。

偏偏每個客戶都有各自不同的客製化需求,沒辦法真的都拿同一個專案去佈署。

只好把一個專案複製成好幾份,然後分別客製化給不同客戶。

但這樣又會出現維護問題:各專案的差異越變越大。

之後就算更新了核心功能、修理了核心 bug,也很難一口氣替所有客戶升級。

如果各專案的差異是無法避免的,那有沒有辦法至少,讓核心系統與客製化的程式碼分開來放呢?

最好能讓各專案的檔案結構都像這樣:

System/
Customization/

如此一來,雖然 Customization/ 內的 code 不能重複使用,至少 System/ 內的 code 可以。

可以將 System/ 內的 code 更新進其他專案,也可以拿強化後的核心系統去接未來的新案子。

我把這種檔案結構稱為「分離式客製化架構」,下文用 SCA (Separated Customization Architecture) 代稱。

這篇文章跟大家分享在 Laravel 底下如何使用這種 SCA 架構。

前置需求

使用 SCA 架構非常簡單,只要安裝 laravel-modules 套件就可以了。

laravel-modules 套件非常單純,它只是運用了 Laravel 原生的 Package Development 觀念,幫助你把 app/ 與 resources/ 資料夾底下的程式碼移進另外的 Modules/ 資料夾而已。

Modules/ 內可以有一或多個資料夾,每個資料夾就跟一個獨立 laravel app 一樣,有自己的 controllers, views, entities, routes, migrations… 等等內容。

laravel-modules 套件就只是幫你讀取各個模組的內容而已,沒有其他黑魔法。

如此一來,你的 Laravel 專案的檔案結構會變成這樣:

Modules/
app/
resources/
...

注意到了嗎?它剛好對應到前面說的檔案結構:

System/
Customization/

Modules/ 就是 System/,而 app/ 與 resources/ 就是 Customization/。

那麼實際上該如何開發呢?以下直接舉例說明。

情境:你開發了一套 Awesome 通用系統來接案

首先你根據過去的經驗開發了一套 Awesome 電商官網模組,打算用它來接許多案子,賺大錢。

你的 laravel 檔案結構長這樣:

Modules/Awesome/
app/
resources/

Awesome 模組的 controllers, views, entities, routes, migrations… 等等內容都在 Modules/Awesome/ 裡面,就跟一個獨立 laravel app 一樣。

而 app 與 resources 資料夾內目前只有 laravel 預設檔案,你沒寫半行 code 在裡面。

你拿 Awesome 系統開始接案,接著客戶出現。他們有以下的客製化需求。

情境:客戶想修改 UI

客戶要求對首頁畫面微調。

這個時候請利用 laravel 原生的 Overriding Package Views 機制,你直接新增一個檔案:

resources/views/vendor/awesome/homepage.blade.php

就在這個檔案內隨心所欲的重寫或是亂寫吧!它會直接取代原本的這個檔案:

Modules/Awesome/Resources/views/homepage.blade.php

如果不只是畫面微調,這種模板覆蓋不夠用,那就可以視為修改系統的 business logic,請接著往下讀。

情境:客戶想修改系統的 business logic

客戶想修改結帳前的購物車頁面與功能。

此功能原本的 routing 定義在 Modules/Awesome/Http/routes.php 內:

Route::group(['middleware' => 'web', 'namespace' => 'Modules\Awesome\Http\Controllers'], function()
{
    Route::get('/shopping/cart', 'ShoppingController@cart');
    // blah blah...
});

請在原本 laravel 預設的 routes/web.php 內新增:

Route::get('/shopping/cart', 'ShoppingController@cart');

接著建立 app/Http/Controllers/ShoppingController.php 檔案,去繼承原先在 Modules/Awesome/ 內的 controller:

namespace App\Http\Controllers;

use Modules\Awesome\Http\Controllers\ShoppingController as BaseController;
use Illuminate\Http\Request;

class ShoppingController extends BaseController
{
    function cart(Request $request)
    {
        // 隨你修改商業邏輯 ...

        // 繼續用原有模板與覆蓋機制
        // return view('awesome::shopping.cart');

        // 或是在 resources/views/ 內建一個新的也行
        return view('the-new-cart');
    }
}

這樣就會直接取代原本的功能了!

最棒的是,這只是用上了基本的 OOP 類別繼承觀念,以及 laravel 的特性:「重複的話,後定義的 route 會覆蓋先定義的 route」。

有需要的話,domain model 內的類別也可以用同樣手法繼承來擴充。

比如說原本有個 Modules/Awesome/Cart.php 類別:

namespace Modules\Awesome;

use Illuminate\Database\Eloquent\Model;

class Cart extends Model
{
    // blah ...
}

你可以建立 app/Cart.php 這樣的子類別:

namespace App;

use Modules\Awesome\Cart as BaseCart;

class Cart extends BaseCart
{
    // blah ...
}

在新建立的 controller 內就可以使用這些擴充後的新 domain model 類別。

用這種手法可以自由新增程式碼!而且全是在 app/ 或 resources/ 資料夾內新增,完全沒碰到 Modules 內的東西。

情境:客戶想開發新功能

開發全新功能就更簡單了,直接在 routes/web.php 內增加 routing 後,在 app/ 與 resources/ 內增加你需要的檔案就行了。

就跟平常開發 laravel 專案一樣!

有需要的話,就在新的檔案裡面引入或是繼承 Modules/ 內的類別就行了。

情境:需要更新資料庫 schema

laravel-modules 有提供指令幫忙產生 migration 檔案。

所以屬於 Awesome 系統的 migration 檔案會在 Modules/Awesome/Database/Migrations/ 底下。

如果客製化時需要更新 schema ,就用 laravel 原生的 migration 指令在 database/migrations/ 內建立 migration file 即可。

連 migration file 都可以完全分離在 System/ 與 Customization/ 裡面!

結論

SCA 的特色在於它不預設你打算把專案模組化或是客製化到哪種程度。

你可以為了客戶在 Customization/ 內先寫完功能。之後有空再完全重構或是部份重構進 System/。

也可以遇到需求就先強化 System/,寫到不適合的地方再開始從 Customization/ 接手客製化。

你可以視情況來來回回,以一種混合的方式寫 code。

你可以邊寫邊決定,事後隨時反悔也沒問題,所以 SCA 非常適合接案類型的軟體開發。

針對 SCA 還有許多進階議題可以討論,本文只談最基本的觀念與技巧,但應該足以解決大部份的問題。

我自己用這種方式接案開發了幾個月,效果非常不錯,分享給各位參考。

有任何問題歡迎留言讓我知道,或是寄信到 [email protected] 與我討論。

(完)

胖胖 Model 的減重方法:Repository

開發application常常要對資料庫做各種不同條件的查詢。

這類的查詢code很容易到處重複。

以 Laravel Eloquent來說,controller很容易出現這樣的code:

$users = User::where('votes', '>', 100)
                        ->whereGender('W')
                        ->orderBy('created_at')
                        ->get();

這段「取出受歡迎的女性使用者」的code,在中小型application還可以接受,

一旦規模稍大就會在不同controller到處重複(duplicate)。

利用內建的query scope功能,雖然能夠稍微改善,但還是不夠:

$users = User::popular()->women()->orderBy('created_at')->get();

不只如此,還會讓model裡面多出定義query scope的程式碼,導致model變胖:

class User extends Eloquent {

    public function scopePopular($query)
    {
        return $query->where('votes', '>', 100);
    }

    public function scopeWomen($query)
    {
        return $query->whereGender('W');
    }

}

這種時候,適合寫一個Repository進行封裝:

class UserRepository
{
    public function getPopularWomen()
    {
        return User::where('votes', '>', 100)->whereGender('W')->orderBy('created_at')->get();
    }
}

它會讓controller內的code簡潔、可讀許多:

$repository = new UserRepository();

$users = $repository->getPopularWomen();

如果您是 Laravel 使用者,這種搭配Automatic Resolution的寫法一定經常看到:

class UserController extends BaseController
{
    protected $users;
    
    public function __construct(UserRepository $repository)
    {
        parent::__construct();
        
        $this->users = $repository;
    }
    
    public function getIndex()
    {
        $women = $this->users->getPopularWomen();
    }
}

甚至可以寫一個共用的abstract class減少duplicate code:

model = $model;
    }
    
    public function getById($id)
    {
        return $this->model->find($id);
    }
    
    public function getAll()
    {
        return $this->model->all();
    }

    public function save($data)
    {
        if ($data instanceOf Model) {
            return $this->storeEloquentModel($data);
        }
    }
    
    public function saveMany($collection)
    {
        foreach($collection as $model)
        {
            $this->storeEloquentModel($model);
        }
    }

    public function delete($model)
    {
        return $model->delete();
    }

    protected function storeEloquentModel($model)
    {        
        if ($model->getDirty()) {
            return $model->save();
        } else {
            return $model->touch();
        }
    }

}

透過Repository封裝還有一個好處:

測試controller或是service的時候可以抽換掉Repository,讓測試時不用碰到資料庫!

下次你的application包含大量query logic時,不妨試試看這個作法。

(Photo via Susanne Nilsson, CC licensed)

胖胖 Model 減重的五個方法

隨著軟體專案不斷增加新功能,你是否隱約感到不安?覺得Model越來越胖、開發速度越來越慢?

一元翻譯用Laravel 4開發半年多,我也遇到同樣問題。

上禮拜受PHP 也有 Day的邀請,

跟大家分享了幾個我在工作上找到的改善方法,分別是:

  • Presenter
  • Repository
  • Form
  • Service
  • Package

投影片在此

影片在此

投影片是以Laravel 4當範例,但是概念上通用於任何語言、框架。

歡迎在這邊留言和我討論,有問題想問也可以。謝謝各位。

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)

CodeIgniter不是一個現代框架

CodeIgniter在今年4月出了第三版。

我在Facebook、批踢踢至今沒看到有人撰文批評它,我覺得這樣不對。

要跟全世界的PHP工程師競爭,只學習CodeIgniter是不夠的。

CodeIgniter不是一個現代框架。必須有人指出這點。

CodeIgniter 3的釋出

這則新聞第一時間就在Reddit出現:

Codeigniter 3 is out

得分最高的評論如下:

是怎樣的被虐狂才會想在2015年用CodeIgniter來開發專案啊?沒有namespaces,沒有autoloading,沒有composer!我乾脆投河自盡好了。

這則評論看起來很偏激嗎?但它拿到最高評論可不只是因為搞笑。

CodeIgniter不是一個現代框架。它沒跟上PHP的幾個趨勢。

怎樣算是現代PHP框架?

現代框架沒有正式定義,但我認為至少要具備三點:

  • 符合PHP元件推薦規範(PHP Standard Recommendations)
  • 支援Composer套件管理
  • 提供成熟的測試環境

為什麼要有這三點才算是現代框架?

因為它們解決了PHP社群幾年來的三個大問題:

  • 一盤散沙的社群
  • 四散的套件
  • web需要軟體測試

一盤散沙的社群

不像Ruby社群,大家全力貢獻在Rails。

PHP社群四散各地、同一個功能在不同框架被重複開發、

框架間難以使用彼此的元件、不同社群難以使用其他社群開發的套件。

這樣的現象在近年終於獲得改善:各框架派出了幾位代表,大家協商出一套coding convention。

從此有了PHP框架互用性小組,制定了PHP元件推薦規範

以後若想要開發新框架,可以從現有的框架中拿幾個元件來組合著用。

PSR規範中對於namespace有清楚的規範,

而CodeIgniter 3中還是到處充滿CI_Controller、CI_Model這種過時的前綴字(prefix)寫法。

四散的套件

在開發套件的時候,PHP有元老級的PEAR、CodeIgniter的Sparks、

Laravel的Bundles、Zend Framework 2的Modules、CakePHP的Bakery。

這種現象是整個PHP社群的損失:PHP社群如此龐大,但是大家各搞各的,心力無法集中。

所幸,Composer的出現解決了這點。

現在大家都開發Composer套件了,整個PHP社群開始共享彼此的成果。

套件相依性的問題也在Composer一併解決,大幅加速了開發的速度與軟體的品質。

CodeIgniter 3還是沒直接支援Composer。

你還是要寫:

$this->load->library('xxx'); 

之類的囉唆東西。

web需要軟體測試

隨著web application越來越複雜、架構越來越龐大,

軟體測試開始成為web圈的討論重點。

軟體測試不但協助設計架構,確保軟體品質,還能協助重構。

web社群已經從「測試要寫很多很多」爭吵到「測試過多沒意義,足夠就好」。

DHH在2012年已經開始主張「測試覆蓋率不要追求100%」、

「code和測試的比例超過1比2就是出問題」

Testing like the TSA

他在2014年更進一步論述「測試先行已死」

TDD is dead. Long live testing.

PHP近年總算有了PHPUnit廣泛出現在各框架,大幅改善了測試環境。

而CodeIgniter呢?連像樣的基本測試都做不到。

結論

CodeIgniter的主要contributor,Phil Sturgeon,在2012年就曾經貼文闡述

「CodeIgniter不重寫就做不到的5件事」:

5 Things CodeIgniter Cannot Do (without a rewrite)

原文我就不翻譯了。他指出的五個缺失如下:

  • 自動載入
  • 命名空間
  • 資料庫結構抽象化
  • 單元測試
  • migration機制

綜合以上,我的結論就是:

要跟全世界的PHP工程師競爭,只學習CodeIgniter是不夠的。

CodeIgniter不是一個現代框架。


Q&A

Q1: 我耳聞Composer很久了,但到底怎麼入門呢?

COMPOSER設計原理與基本用法

COMPOSER進階原理:PHP命名空間與PSR-0


Q2: 我暫時不可能離開CodeIgniter,有什麼辦法嘗試軟體測試?

CI 2曾經有人把simpletest整合進去。不知道CI 3做不做得到就是了。

CodeIgniter with SimpleTest


Q3: CodeIgniter也許是有些小缺點,但真有那麼落後嗎?

我在2014年2月曾經寫信詢問Phil Sturgeon意見。他的答覆如下:

Hey Tony,

(上略)

我不會再理CodeIgniter了,它玩完了。試試看Laravel吧,然後看一下這些短片:

https://laracasts.com/

你會學到很多:)


Phil Sturgeon

原文

Hey Tony,

You definitely don’t want to rate yourself based on your own age and ability. I’m 25 and there are lot of people around who are younger and smarter, or older and smarter, and more successful. I rate myself based on my improvements over time, and as long as I’m always learning then I’m always getting better.

I’d ignore CodeIgniter completely, its done. Try getting into Laravel, and watch these screencasts:

https://laracasts.com/

You’ll learn a lot 🙂


Phil Sturgeon


社群看法

PHP台灣

CodeIgniter不是OOP好老師

寫完框架不應該有「MODELS」資料夾之後,

收到林先生來信如下:

您好, 我剛拜讀完您的「框架不應該有「models」資料夾」
我對文章的內容有許多地方不理解…不…可以說是混亂
我本身較熟悉的框架只有CI, 平時的撰寫模式就是將使用者的輸入放在controller做驗證, 驗證通過之後才會將之丟進model class(或是您稱呼的 entities)做處理, 丟進model class處理時會有一些除了真正該執行的任務外以外的行為(像是註冊新會員時我會需要檢查帳號是否已被使用或是不是黑名單), 根據model class 的回應結果決定controller該輸出什麼結果或view
然後我看到了「抽出行為邏輯變成Service Objects、抽出表單驗證邏輯變成Form Objects、抽出資料庫查詢邏輯變成Query Objects、抽出呈現邏輯變成View Objects…聽起來真棒,也確實很有幫助,不是嗎?」這一段, 您想表達的意思是否是將各種行為(驗證輸入資料、查詢/寫入資料庫或其他的行為邏輯)細分出來放在model(或像您所的依照專案/公司名稱做分類)底下, 而controller則是扮演操作這些entities角色?

我可以理解你的困惑,我可以理解你的混亂。

我有整整一年使用CodeIgniter開發。

當我第一次看到Reddit上頭大家的討論、當我第一次看到Laravel論壇的原始碼

我也是完全一頭霧水:這些人在用的PHP跟我在用的PHP是同一個嗎?

轉進Laravel社群幾個月之後,我終於知道問題出在哪了。請聽我娓娓道來。

CI不鼓勵你打造Entity

…驗證通過之後才會將之丟進model class(或是您稱呼的 entities)…

這句話不正確。CI的Model不是Entity。

…而controller則是扮演操作這些entities角色?

這句話不正確。service object, view object, form object都不是entity。它們是與entity相關的類別。

CI的Model並沒有代表「現實生活中的一種事物」,

它只是負責「把一張資料表的內容直接丟回去」而已。

以Blog Model舉例,假設它在資料表有title跟content欄位,但是沒有summary欄位。

如果我們需要文章簡介summary(由title加上content前100字組合而成),

在Laravel它會是這樣:

class Blog extends Eloquent
{
    public function summary()
    {
        return $this->title . substr($this->content, 0, 100);
    }
}

在Model之外用到Entity的時候,會是這樣:

$blog = Blog::find($id);

echo $blog->summary();

你得到了$blog這個entity,它代表著一篇部落格文章。

這篇文章很樂意告訴你它的summary。

但是在CI呢?

class Blog_model extends CI_Model {
    //...
}

$this->load->model('Blog_model', 'blog');

$blog = $this->blog->get($id);

echo $blog->summary();// 噴error!我根本不是一篇文章!我只是一個陣列呀!

從CI的Model拿到的東西,只代表「跟文章相關的一坨資料」。可以是Array或是stdClass。

並不代表一篇部落格文章,也無法具有「行為」。

CI的Model不是Entity。

我提到的Service Objects、Query Objects、View Objects都是圍繞著Entity打轉,而CI本身只幫你拉資料出來,沒幫你做Entity。也難怪會一團混亂了。

怎麼會這樣?

這裡的Entity說穿了就是將資料庫的資料轉換成物件而已。

可以自己寫Class手動將資料庫的value一個一個轉成物件的property。這麼做又麻煩又緩慢。

常見的作法是直接用ORM,可以是Data Mapper Pattern或是更常見的Active Record Pattern。

許多框架都會提供Active Record Pattern的實作,Laravel, Rails都是。

有Entity可以幹嘛?

可以讓程式碼更優雅。

我就直接拿View Objects、Query Objects、Form Objects做舉例了。

View Objects

這個View Object實作為例,

你可以把User Entity中只與「呈現」相關的logic抽出來成這種類別:

use Laracasts\Presenter\Presenter;

class UserPresenter extends Presenter {

    public function fullName()
    {
        return $this->first . ' ' . $this->last;
    }

    public function accountAge()
    {
        return $this->created_at->diffForHumans();
    }

}

使用的時候這樣即可:

 echo "Hello, "  . $user->present()->fullName;

Query Objects

這個Query Object實作為例,

你可以把取得文章的多種不同方式logic獨立出來,成為這樣的class:

class ArticleRepository extends EloquentRepository
{
    public function getFeaturedArticles($count = 3)
    {
        return $this->model->with(['author', 'slug'])
                           ->where('status', '=', Article::STATUS_PUBLISHED)
                           ->orderBy('published_at', 'desc')
                           ->take($count)
                           ->get();
    }
    public function getAllPublishedByTagsPaginated($tags, $perPage = 10)
    {
        return $this->getAllPublishedByTagsQuery($tags)->paginate($perPage, ['articles.*']);
    }
    public function getAllPublishedByTagsQuery($tags)
    {
        $query = $this->model->with(['author'])
                             ->where('status', '=', Article::STATUS_PUBLISHED)
                             ->join('article_tag', 'articles.id', '=', 'article_tag.article_id')
                             ->orderBy('published_at', 'desc')
                             ->groupBy('articles.id');
        if ($tags->count() > 0) {
            $query->whereIn('article_tag.tag_id', $tags->lists('id'));
        }
        return $query;
    }
    public function getArticlesByAuthorPaginated(User $author, $perPage = 20)
    {
        return $this->getArticlesByAuthor($author)->paginate($perPage);
    }
    public function getArticlesByAuthor(User $author)
    {
        return $author->articles()->orderBy('articles.status', 'asc')->orderBy('published_at', 'desc')->orderBy('created_at', 'desc');
    }

}

在使用的時候,只要這樣即可:

    //這是controller中的一個function
    public function getIndex()
    {
        $repository = new ArticleRepository();
        // 取得已發布的特定標籤的特定分頁下的所有文章
        $articles = $repository->getAllPublishedByTagsPaginated($tags);
        // ...
    }

Form Objects

Form Objects倒是跟entity比較無關,但我還是舉例給你看它長相。

這個Form Object實作為例,

你可以把建立Article的表單驗證logic抽出來成這種類別:

class ArticleForm extends FormModel
{
    protected $validationRules = [
        'title'           => 'required|min:10',
        'content'         => 'required',
        'laravel_version' => 'required',
        'status'          => 'required',
        'tags'            => 'required|max_tags:3',
    ];
    protected function beforeValidation()
    {
        \Validator::extend('max_tags', function($attribute, $tagIds, $params) {
            $maxCount = $params[0];
            $tagRepo = \App::make('Lio\Tags\TagRepository');
            $tags = $tagRepo->getTagsByIds($tagIds);
            if ($tags->count() > $maxCount) {
                return false;
            }
            return true;
        });
    }
}

在使用的時候,只要這樣即可:

   // 這是在controller內的一個function
   public function postCreate()
   {
        $form = new ArticleForm();
        if ( ! $form->isValid()) { //驗證表單內容是否滿足條件
             // 顯示錯誤訊息叫使用者重新輸入
        }
        // ...
   }

以上,你會發現各式各樣的logic都可以拆分成獨立class,讓思維上比較OOP。

controller裡面的code可讀性也高很多。

這些在CI中比較難做到。CodeIgniter不是OOP好老師。

當然了,有些人很討厭OOP,對它一點興趣也沒有。看著辦吧。


Q&A

Q1: 可是CI有Active Record類別啊?

CI的Active Record類別是整個CI社群最悲慘的誤會之一。

CI的Active Record完全是叫爽的。

那根本沒有實作Active Record Pattern,只是個陽春的Query Builder(幫你打造SQL query的助手)而已。

CodeIgniter’s “Active Record” isn’t what the real active record is all about.

You’re also being lead astray by CI’s rather confusing name ActiveRecord, which actually doesn’t implement the ActiveRecord pattern.

CodeIgniter has a query builder it calls “ActiveRecord”, but which does not implement the Active Record pattern.

CI的狗屁Active Record就是一個對使用者有害、混淆觀念、沒人要點破、

大家不求甚解、為了行銷而存在的垃圾名詞。

業界不像學界嚴謹,大家永遠為了行銷在瞎搞,這種狗屁到處都是。

「MVC」就是其中程度最誇張的例子。

Q2: 我整篇文章都看不太懂,我現在更緊張了。

別急,慢慢來。OOP要寫得優雅,本來就非常不容易。

有空去摸摸看Laravel吧,感受一下,也許會有靈感。

有問題可以到Laravel台灣發問唷。


社群看法(2015-4-6)

PHP台灣

框架不應該有「models」資料夾

各大back-end framework幾乎都採用了「MVC」架構。

它們至少會有「views」、「 controllers」、「 models」三個資料夾。

「views」跟「controllers」沒太大問題,但是「models」資料夾根本不該存在。

我要對這些框架提出嚴厲指控:

「models」資料夾的存在是一種錯誤的架構設計。它不但阻礙新手學習,還會傷害scalability。

任何back-end framework都不應該有「models」資料夾。

我會在這篇文章解釋理由,並且提出改善架構設計的幾個方向。

哪些框架有「models」資料夾?

光是我接觸過的Ruby、PHP框架,就至少有:

  • Rails(Ruby)
  • CodeIgniter(PHP)
  • Yii(PHP)

除此之外,像是Java, Python還是什麼語言,

一定也有框架做這種事:放一個「models」資料夾在那邊。

這真是大錯特錯。

你到底要放什麼東西到「models」資料夾裡面?你覺得Model是什麼?

Model是什麼?

撇開我之前提到的MVC正名爭議不談,光是MVC的M該如何解釋就已經是個大哉問。

看看這則出色的Stack Overflow問答:

How should a model be structured in MVC?

Model是layer、它包羅萬象、它涵蓋你全部的business logic。

眾說紛紜中,這是我們唯一能有的共識。

Model難以定義、沒有絕對正確的架構設計。聽起來真令人洩氣,對吧?

不!這樣很好!這樣才對!軟體架構本來就是大哉問,有無限種可能的方法,

這也是我們所有人應該要一起討論和嘗試的地方。

而「models」資料夾卻嚴重妨礙我們討論、阻止我們思考,

它不但阻礙新手學習,還會傷害scalability。

「models」資料夾如何阻礙新手學習?

說明這段之前,我先定義一個名詞:「entity」。

我將entity定義成「代表現實生活中的一種事物」。

以常見的Active Record pattern來說,

// user.rb
// Rails
class User < ActiveRecord::Base
// user.php
// Laravel
class User extends Eloquent
// post.php
// Yii
class Post extends CActiveRecord

類似這樣的東西,你一定看過。

user.rb、user.php、post.php,這些就是我所謂的「entity」。

也就是這些「entity」,讓新手容易誤以為「entity」就是MVC裡面的M。

錯!M是layer,entity只是M裡面的組成元素之一而已。

「models」資料夾的存在本身,會讓新手以為「弄幾個entity類別丟進去就搞定架構了」。

然後entity的行為、對entity做出的行為、關乎兩個以上entity的行為,管他什麼logic,

管他什麼行為,全部想辦法塞進entity類別。

下場通常就是:那些entity類別最後變得超肥胖、難以理解、動輒達到上千行程式碼。

我稱之為「胖胖entity」。

「models」資料夾帶給新手「model == entity」錯覺!

「models」資料夾誘惑新手去做出一堆胖胖entity!

「models」資料夾如何傷害scalability?

看看Rails社群這篇出名的文章:

7 Patterns to Refactor Fat ActiveRecord Models

幹得好!它點出「胖胖entity」的問題,並給出7個patterns去協助你設計軟體架構。

抽出行為邏輯變成Service Objects、抽出表單驗證邏輯變成Form Objects、抽出資料庫查詢邏輯變成Query Objects、抽出呈現邏輯變成View Objects...聽起來真棒,也確實很有幫助,不是嗎?

問題來了:抽出來的這些類別,到底要放哪裡?

我們看看文章下面comments提到的範例:GItLab

它的檔案結構如下:

/lib
    /gitlab
    /tasks
    /...
/app
    /assets	
    /controllers	
    /finders	
    /helpers	
    /mailers	
    /models	
    /services	
    /uploaders	
    /views	
    /workers

新的問題來了:那個「models」資料夾到底代表什麼?它是我們包羅萬象的偉大Model layer嗎?那finders、services、uploaders為什麼跟models在同一層,而不是在models裡面?lib/底下的gitlab/又是怎麼回事?難道GitLab的商業邏輯也出現在lib?

這就是我想說的:「models」資料夾的存在從一開始就污染了架構設計。

它引誘人們把entity全丟進去。結果除了entity以外的東西,像是前面的Service Objects、 Form Objects、Query Objects、View Objects,還有後面的finders、services、uploaders、gitlab全都不知道放哪了。只好隨便亂放。

GItLab原始碼中的models有代表MVC的M嗎?怎麼不改名叫entities?

就算改了又如何?Model layer到底在哪裡?四分五裂、結構鬆散。

一團混亂的設計、難以理解的命名、與MVC的M不相容的檔案結構。

所以我說,「models」資料夾傷害scalability!

那該怎麼辦?

要解決這個問題,首先得要了解MVC是三個極度不對稱的存在。

V: 負責呈現UI
C: 負責接受request、請M處理、回傳response
M: 負責全部的business logic

M幾乎是你的整個application。

你可以在框架底下,找地方建一個空資料夾,用公司名稱或是專案名稱替它命名,

然後開始煩惱軟體架構這件事。

好好煩惱entity要放在哪裡、Service Objects、 Form Objects、Query Objects、View Objects、finders、services、uploaders這些要放哪裡,彼此又要怎麼分門別類。

MVC不是萬靈丹,只是軟體架構的入門磚。

架構設計本來就是這麼難,OOP本來就是這麼難。

恭喜你,至少你跨出第一步了:

你不再把一堆胖胖entity丟進「models」資料夾,然後覺得設計完軟體架構了。


Q&A

Q1: 「models」資料夾毫無優點嗎?

「models」資料夾還是有少數優點。
它是一種quick and dirty作法,鼓勵你眼中只看見entity,然後把所有business logic全塞進裡面。
換句話說,它在小型的專案可以幫你節省時間。但它的優點也僅此而已。

Q2: 你的結論好空泛,什麼建一個公司名稱空資料夾啊。拜託給點方向?

沒問題,我給你兩個架構設計的參考方向。

第一個來自這篇文章:

Rails is Not Your Application

引用作者的話,核心精神如下:

Rails不是你的application。它可以是你的views還有資料來源,但不是你的application。把你的application放在Gem裡面或是lib/資料夾底下。

我不覺得這樣有很優雅,但至少點出一個可能方向,並且至少不再有models資料夾。

我第二個要給你的,是Laravel官方論壇原始碼

這個Laravel.io專案簡稱為Lio,結構如下:

/app
    /controllers
    /views
    /Lio
        /Core
            /...
        /Accounts
            /User.php
            /UserPresenter.php
            /UserRepository.php
            /UserCreator.php
            /....
        /Articles
            /Article.php
            /ArticlePresenter.php
            /ArticleRepository.php
            /ArticleCreator.php
            /....
        /Comments
        /...

光看檔案結構就很優雅。也正是我前面所說的:建一個空資料夾,用公司名稱或是專案名稱替它命名,然後開始煩惱架構設計這件事。

想想看Laravel官方論壇的原始碼為什麼長這樣吧。

Q3: 講得好像多有道理!我覺得你只是在鬼扯!框架的製作團隊都是業界大神,既然他們決定要有「models」資料夾,必定有它的正當性!

不,你錯了。那些業界大神只是背負了行銷框架的壓力。

他們為了滿足用戶的錯誤期待而委屈地放了個「models」資料夾在那。

但還是有高尚的人存在。PHP最被推崇的框架Laravel就沒有「models」資料夾。

向Laravel的Taylor Otwell致敬吧!

他不願成為殘害新手的幫凶,硬是把「models」拿掉了,

強迫你去思考:「軟體架構到底該長怎樣」。

你要自己在Laravel裡面做一個「models」資料夾,然後把那些entity class全丟進去嗎?

那你是自願把entity當成整個Model,真遺憾,

但別說是Laravel鼓勵你這麼做。Laravel盡力了。

Q4: 少自以為了解Taylor Otwell了!你憑什麼代替他發言!

Reddit上有一則 Why would anyone choose Laravel over Symfony or Silex?

Taylor Otwell本人親自做出回答。下文擷取自第四段:

我個人在開發Laravel 4的早期階段就想把「models」資料夾整個移除了。因為我不覺得它有用,也不覺得它能協助你設計軟體架構。而且它還會引誘人們掉入「model == database」的陷阱裡。所以,我希望你不要覺得我對架構設計很無知。我花了點時間才想清楚我到底想在PHP世界打造什麼。

Laravel實作Active Record Pattern,資料表映對到entity class。他指的「model == database」陷阱就是我說的「model == entity」錯覺。我並沒有代替他發言。

Q5: 我還是覺得,你沒有資格批評那麼多框架。「models」資料夾就是有某種正當性。除了Taylor Otwell,我看也沒有其他權威支持你的說法!

前面提到的出色Stack Overflow問答:

How should a model be structured in MVC?

作者是tereško。

Stack Overflow上關於MVC的幾個最高分討論,全都是由tereško解答

下文擷取自那篇出色問答的段落「What a model is NOT」:

model不是一個class,也不是任何一個單一物件。這是一個超級常見錯誤,因為大部分的框架都在助長這種誤解。

他選擇這樣帶過。我選擇正面指控。

然後我建議你討論事情的時候,不要太在乎權威還是前輩怎麼講。不如專注於討論事情本身。

Q6: 好啊!那來啊!照你的說法,「models」資料夾底下多放個「entities」資料夾不就搞定一切問題了?你果然是不切實際的理想主義者!最好是有框架幹這麼囉唆的事情!

有!它就是Cake(PHP)框架!

看看Cake在Model底下放了什麼:

/View
/Controller
/Model
    /Behavior
    /Entity
    /Table

看到了嗎?

Cake怕你把entity當成整個model,直接擺好幾個資料夾,逼你去思考entity跟model是什麼。

替這些用心良苦的框架歡呼吧!

Q7: 專注於討論事情本身是不是!那Cake的「models」資料夾就沒問題啊!你還說任何框架都不能有!

你看錯了,Cake沒有「models」資料夾,也沒有「Models」資料夾。它只有「Model」資料夾。

資料夾、package、資料庫table命名,都有一個關於單數/複數的原則可以參考:異質性與同質性。

你的「models」資料夾底下不再是同屬entity的class了,而是分為behavior、entity、以及其他你設計的分類,也就是異質,所以應該用單數命名。

參考這個連結:

Should package names be singular or plural?

簡單地說,既然model代表的是layer而非多個entity,資料夾命名上就應該用單數而非複數。

好吧,我這樣說有點太嚴苛了。

如果你知道自己在幹嘛的話,就繼續用你的「models」資料夾吧,我勉強可以接受。

Q8: 等等,不對勁...你整篇文章流露一股氣息...我覺得你不但反對「models」,你幾乎在否定MVC的價值?你怎麼可以覺得偉大的MVC沒有價值?

我前面提過,Taylor Otwell在Laravel 4移除了「models」資料夾,逼迫大家去思考「軟體架構」到底應該是什麼。

我告訴你第二件事。

你去逛Laravel官網,翻遍官網你都找不到「MVC」三個字。

MVC名氣多麼響亮!哪個framework不想打著MVC當作賣點?但Laravel拒絕這麼做。

我再告訴你第三件事。

2015年最新出爐的Laravel 5,它的views在resources/底下,controllers在app/Http/底下。一樣沒有models。

所以你神聖的MVC在Laravel 5底下長這樣:

/resources
    /views
    /lang
    /assets
/app
    /Commands	
    /Console	
    /Events	
    /Exceptions	
    /Handlers	
    /Http	
    /Providers	
    /Services	
    /Https
        /Controllers
        /Middleware	
        /Requests

你推崇的V跟C不再佔據檔案結構的核心位置了。你最愛的MVC現在看起來是如此渺小,

小到沒有討論價值,小到毫無意義可言。

「MVC是三個極度不對稱的存在」,這是個太過客氣的說法。

MVC這個觀念已經無法協助我們討論和思考了。放下它,往前走吧。

我來自Laravel社群。我們不聲稱自己擁戴MVC。

來把時間花在真正值得討論的概念上吧:你正在用的框架,架構合理嗎?框架有沒有擋住你的路?你在框架之下設計出的專案架構漂亮嗎?大中小型專案通用的架構存在嗎?如何分辨使用時機?怎麼做會最彈性?該怎麼描述某個框架才不會對新手揠苗助長?

放下你凡事都要套進MVC的執著,請直接思考「軟體架構」的本質。

啊,我看到MVC粉絲對Laravel 5的分析了:


MVC依然發揮重要的討論價值!我看到Controllers資料夾了!我看到views資料夾了!
剩下的十幾個資料夾全部統稱為Model!果然是豐富又厚重的layer!
我們來討論Model是什麼吧!MVC萬歲!

朋友,祝福你能得出有意義的結論。


小朱針對本文的延伸討論(2015-4-19補充)

[經驗談] 無招勝有招

(Photo via Alyssa L. Miller, CC licensed.)