標籤彙整: php

PHP與撰寫測試入門

許多人對於撰寫測試躍躍欲試,卻因為PHPUnit等工具太複雜、

相關名詞艱澀難懂而不知該如何開始。

其實PHPUnit這類測試工具並非必須品,相關專有名詞也可以先擺一邊,

只要發揮創意想辦法測試就是第一步了。

我們從最簡單的形式開始談起吧!

假設我們有一個簡單的加法程式要測試


最簡單的測試其實只要這樣寫


要執行測試非常簡單,只要打開terminal然後輸入

php simple_add_test.php

就可以看到測試結果了!

PHP Parse error:  syntax error, unexpected '{' in /home/howtomakeaturn/projects/testing-tutorial/simple_add_test.php on line 10

突然發現if打成了$if,修正之後再次執行的結果是

PHP Parse error:  syntax error, unexpected '}', expecting ',' or ';' in /home/howtomakeaturn/projects/testing-tutorial/simple_add_test.php on line 12

發現少打一個分號,修正之後再次執行的結果是

PHP Parse error:  syntax error, unexpected '';' (T_ENCAPSED_AND_WHITESPACE), expecting ',' or ';' in /home/howtomakeaturn/projects/testing-tutorial/simple_add_test.php on line 13

發現多打一個單引號,修正之後再次執行的結果是

simple_add_test passed

終於通過測試了!

注意到了嗎?光是替這樣簡單的程式撰寫簡單的測試,就可以省下不少開發時間:原本要打開瀏覽器重新整理三次才能修完bug(人工測試3次),變成只要在terminal跑三次就修完bug了!

Q1: 打開瀏覽器重新整理三次其實沒花多少時間...寫測試真麻煩,而且不划算。

有人說寫測試一定划算,也有人說寫測試一定不划算,其實都是不對的說法!
某些情況下確實不用寫測試,光靠瀏覽器就人工測試完成了。
但很多時候人工測試的時間會大於撰寫測試的時間。譬如說一個需要先登入、輸入一串假資料、東點西點按鈕才出現結果的頁面。那種時候,寫測試就會划算了。

Q2: 二加三等於五是什麼爛測試...,也太不嚴謹了吧。

這類稱之為happy path的測試確實非常隨便。但是以入門來說,光寫這類測試其實已經有很出色的測試效果:

1. syntax error全部會被找到
2. 至少確認happy path通過了。很多時候程式出錯就是連這種happy path都過不了

不過,為了謹慎起見,我們來寫更完整的測試吧!


接著在terminal輸入

php simple_add_better_test.php

會看到結果顯示

simple_add_test passed
simple_add_test passed
simple_add_test passed
simple_add_test passed

恭喜,通過更嚴酷的測試了!

Q3: 老實說你的測試還是很爛...只是拿1跟2跟0跟-1隨便測一下而已,實在不能保證什麼。

沒有錯,其實軟體測試從來就無法保證軟體不會出錯。
有人說軟體測試是為了讓程式正確執行,其實不太正確!
軟體測試只是為了讓你對程式有信心而已。
要測到什麼程度則取決於:

1. 你心臟有多大顆
2. 這個程式值得你花多少時間去測到何種程度

以你的高標準來說,可以再拿999,999,999跟-999,999,999,999,999,999等數字去測,甚至拿null、空字串、亂數字串丟進去測(如果你覺得有意義的話)。

很不幸的,大部份的程式不會像上面那樣單純顯示結果,而是會包含一堆html。
像是一個新增文章的程式,使用者會從表單填寫標題與內容,接著以HTTP POST送出。


Title:

像這樣結果複雜的程式,該怎麼測試呢?

嘿嘿,還記得一開始說的「只要發揮創意想辦法測試」嗎?

我們來檢查最後結果有沒有包含特定字串吧!


接著在terminal輸入

php blog_submit_test.php

會看到結果顯示

blog_submit_test for title passed
blog_submit_test for content passed

奇妙吧!根本還沒打開瀏覽器,就幾乎可以確定這個新增文章的程式沒問題了!
不但syntax確認沒問題,也很有信心會正確新增文章!

Q4: 這個測試未免也太爛了。一看就知道bug一堆。要是測試標題輸入「Title」不就會抓到h1裡面那個「Title」?那就是根本沒測到!另一個測試「I am happy」更扯,如果頁面其他地方本來就有字串包含「I am happy」,不就毫無意義嗎?

說得真好!不過Q3已經說了,軟體測試只是為了對程式有信心而已。
雖然只寫了兩個破爛測試,但我已經對程式很有信心囉!我認為OK的!
而且不是說了要發揮創意嗎?不然我還能怎麼測呢?

好吧,其實會這麼鳥是因為呈現邏輯(presentation logic)和商業邏輯(business logic)混在一起的關係。
把business logic獨立出來測吧!

先把business logic獨立出來放進另一個檔案,包進function內


再改一下原本程式


Title:

測試則改寫成


接著在terminal輸入

php blog_submit_v2_test.php

會看到結果顯示

blog_submit_test for title passed
blog_submit_test for content passed

酷吧!你提到的「頁面其他地方本來就有字串包含『I am happy』」的bug解決了!
這下是不是對程式更有信心了?
除此之外,現在測試裡面不再硬塞value給$_POST變數,也不再需要output buffering的技巧了。
事情更單純了吧。你常聽到有人說測試要一小塊一小塊測,就是這麼一回事。

Q5: 不對喔...我發現事情不對勁。這下你的測試根本沒include到blog_submit_v2.php這支檔案!檔名根本不配blog_submit_v2_test.php。變成只測到business logic而沒測到presentation logic了。

我測完business logic其實已經很安心了,至於presentation logic的話我會直接打開瀏覽器,頁面正確顯示我就安心了。你真要那麼教條化嗎?好吧那你再寫一個測試去include blog_submit_v2.php這檔案好了。(寫那種測試的時間真的值得嗎?直接開瀏覽器檢查不好嗎?)
但是我同意blog_submit_v2_test.php這命名有問題。就改成add_blog_to_db_test.php吧!

Q6: 等一下!我發現你的範例跟測試都是個笑話!你把add_blog_to_db函式最重要的任務「資料庫存取」省略了!回傳那array根本搞笑,真的存取資料庫是要怎麼測試?你倒是說說看。

老話一句,發揮創意吧。把mysql函式寫得不成功便成仁就是一種方式

if (mysqli_query($conn, $sql)) {
    return [$title, $content];
} else {
    die("Error: " . $sql . "\n" . mysqli_error($conn));
}

或是最後回傳true之類的,讓測試程式能夠檢查就好。發揮創意吧,發揮創意吧。

讀到這邊,相信你已經發現一件事了:最傳統的一個php檔案寫到底的寫法,非常難以測試。就算測了心裡也很不踏實。聽過有人說「寫測試不難,難的是寫出能被測試的程式」嗎?

以後不要再寫像那樣老舊的php code了,建議把重要的business logic全部包成獨立的function,然後再分別測試function比較好!

除此之外,前面只用到==運算子和strpos函式而已,如果想寫出更多種的測試,光是內建函式就有這些可以用喔
is_​array
is_​bool
is_​callable
is_​double
is_​float
is_​int
is_​integer
is_​long
is_​null
is_​numeric
is_​object
is_​real
is_​resource
is_​scalar
is_​string
很酷吧!記得,重點是發揮創意,寫出能被測試的code、從最基本的測試開始做起!

有測試總比沒有好!

Q7: 我發現盲點了。寫測試說是要節省開發時間,但是光跑測試本身我就要輸入

php simple_add_test.php
php simple_add_better_test.php
php blog_submit_test.php
php blog_submit_v2_test.php

這東西四次!想想看,每次增加新功能或是修改舊功能,為了確保全部程式都正確,我要重新輸入這東西四次!光是打字我就飽了,哪有更省時間?

這個問題很好解決,寫一個測試主程式就好啦


以後只要在terminal輸入

php test_suite.php

會看到結果顯示

simple_add_test passed
simple_add_test passed
simple_add_test passed
simple_add_test passed
simple_add_test passed
blog_submit_test for title passed
blog_submit_test for content passed
blog_submit_test for title passed
blog_submit_test for content passed

光是這麼無聊的範例,看到9個測試都通過就很有成就感了,正式上線的application那種幾百幾千個測試通過的感覺更充實、更對系統穩定性有把握。

每次增加新功能或是修改舊功能,為了確保全部程式都正確,記得都跑一次php test_suite.php喔!再也不需要打開瀏覽器點來點去半天了!

結語

這篇文章是對測試基本觀念的說明,實際開發時使用PHPUnit或是其他測試框架,在做的事情其實跟本文差不多。

如果認為PHPUnit太複雜的話,可以先從比較簡單的測試工具開始,像是SimpleTest就不錯。

至於所謂嚴謹的測試技巧,以及物件導向的開發測試手法,我們下次再談吧!


社群feedback(last updated: 2015-10-14)

本文在PHP台灣FB社群引起一些討論與檢討,

推薦大家一併延伸閱讀

討論串一

討論串二

這樣寫測試錯了嗎?


如果您喜歡我的文章,可以在這裡訂閱。我有新想法的時候,很樂意跟你分享。

本文的程式碼可以在這裡取得。

(Photo via Masahito Oku, CC Licensed.)

胖胖 Model 的減重方法:Form

Web application常常需要對使用者輸入的參數做驗證。

這些驗證(validation)logic該放哪裡呢?

現代框架通常會提供工具協助這件事,

以Laravel來說,使用內建的Validator可以在controller內這麼做:

$validation = Validator::make(
    array(
        'name' => Input::get( 'name' ),
        'email' => Input::get( 'email' ),
    ),
    array(
        'name' => array( 'required', 'alpha_dash' ),
        'email' => array( 'required', 'email' ),
    )
);
 
if ( $validation->fails() ) {
    $errors = $validation->messages();
}

同樣的validation logic只出現一次還好,出現在不同controller就會導致這段code duplicate。

該怎麼辦呢?

試試看放進model:

class Ball extends Eloquent
{
    private $rules = array(
        'color' => 'required|alpha|min:3',
        'size'  => 'required',
        // .. more rules here ..
    );

    public function validate($data)
    {
        // make a new validator object
        $v = Validator::make($data, $this->rules);
        // return the result
        return $v->passes();
    }
}

controller內的code就會簡化成這樣:

$b = new Ball();

if ($b->validate(Input::all())){
    // success code
}else{
    // failure code
}

還算OK的作法…如果你能接受將validation logic視為business logic的話。

這類的code有比controller和model更適合的地方。寫一個form class即可:

class ArticleForm
{
    protected $validationRules = [
        'title' => 'required',
        'content' => 'required',
    ];
    protected $inputData;
    protected $validator;

    public function __construct($input)
    {
        $this->inputData = $input;
    }

    public function isValid()
    {
        $this->validator = Validator::make($this->input, $this->validationRules);
        return $this->validator->passes();
    }
    
    public function getErrors()
    {
        return $this->validator->errors();
    }

}

controller內直接呼叫即可,看起來readable許多:

$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();

如果多個model都有form classs的話,可以將共用的code抽出來成為抽象類別:

abstract class FormModel
{
    protected $validationRules;
    protected $inputData;
    protected $validator;

    public function __construct($input)
    {
        $this->inputData = $input;
    }

    public function isValid()
    {
        $this->validator = Validator::make($this->input, $this->validationRules);
        return $this->validator->passes();
    }
    
    public function getErrors()
    {
        return $this->validator->errors();
    }

}

這樣原本的form class可以簡化到只剩下validation rules:

class ArticleForm extends FormModel
{
    protected $validationRules = [
        'title' => 'required',
        'content' => 'required',
    ];
}

甚至能寫多種validation rules給不同controller:

   protected $happyArticleValidationRules;
    protected $angryArticleValidationRules;
    protected $funnyArticleValidationRules;

    public function isValidHappy()
    {
        $this->validator = Validator::make($this->input, $this->happyArticleValidationRules);
        return $this->validator->passes();
    }
    
    public function isValidAngry()
    {
        $this->validator = Validator::make($this->input, $this->angryArticleValidationRules);
        return $this->validator->passes();
    } 
   
    public function isValidFunny()
    {
        $this->validator = Validator::make($this->input, $this->funnyArticleValidationRules);
        return $this->validator->passes();
    }

實作上可以參考Laravel官方論壇原始碼

(注意:若您是用Laravel 5,Request的封裝可以取代這件事。)

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

(Photo via Susanne Nilsson, CC licensed)

胖胖 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 的減重方法:Presenter

開發application時,常常會有大量的呈現(presentation)邏輯。

像是日期格式、金額格式(是否穿插逗號)、名稱(是否需要大寫)等等。

把這些logic寫在model的話,很容易就會導致model過胖。

舉例來說,如果文章model在不同地方需要呈現日期、日期時間、西方格式、台灣格式,

model就會長這樣(以Laravel Eloquent舉例):

class Article extends Eloquent
{
    public function getDate(){/*...*/}

    // 呈現給台灣地區的時間格式
    public function getTaiwaneseDateTime(){/*...*/}

    // 呈現給歐美地區的時間格式
    public function getWesternDateTime(){/*...*/}

    public function getTaiwaneseDate(){/*...*/}

    public function getWesternDate(){/*...*/}
}

要將這類presentation logic抽出來,許多框架採用了「helper function」的作法,

也就是定義好幾個functions用來處理呈現邏輯。

除了helpers的作法之外,還有一個非常簡單有效的方法。

也就是使用Decorator pattern來封裝出presenter,像這樣:

class ArticlePresenter
{
    protected $article;

    public function __construct(Article $article)
    {
        $this->article = $article;
    }

    // 呈現給台灣地區的時間格式
    public function getTaiwaneseDateTime(){
        return date('Y-m-d', $this->article->created_at);
    }

    // 呈現給歐美地區的時間格式
    public function getWesternDateTime(){/*...*/}

    public function getTaiwaneseDate(){/*...*/}

    public function getWesternDate(){/*...*/}
}

概念上只是把原本的model從constructor傳進去,原本的function搬過去,

把$this改寫成$this->article即可。

然而,這樣會導致view裡面需要初始化物件:

@foreach($articles as $article)
    
    發文日期:{{ $presenter->getTaiwaneseDate() }}
@endforeach

在view裡面做這種事,怎麼看都是很醜的。

多寫一個function即可改善:

class Article extends Eloquent
{
    public function present()
    {
        return new ArticlePresenter($this);
    }
}

view內會變成這樣:

@foreach($articles as $article)
    發文日期:{{ $article->present()->getTaiwaneseDate() }}
@endforeach

新的問題來了:多次呼叫present會浪費記憶體。

可以這樣改善:

class Article extends Eloquent
{
	protected $presenterInstance;

	public function present()
	{
		if ( ! $this->presenterInstance)
		{
			$this->presenterInstance = new ArticlePresenter($this);
		}

		return $this->presenterInstance;
	}

}

多個model都使用presenter的話,這段code會重複。善用PHP trait可以解決這個問題:

trait PresentableTrait {

	protected $presenterInstance;

	public function present()
	{
                // ...

		if ( ! $this->presenterInstance)
		{
			$this->presenterInstance = new $this->presenter($this);
		}

		return $this->presenterInstance;
	}

} 

Laravel社群至少有3套成熟的presenter實作:

https://github.com/laracasts/Presenter

https://github.com/robclancy/presenter

https://github.com/laravel-auto-presenter/laravel-auto-presenter

有的功能豐富、有的比較陽春,但是概念上一樣,都是decorator pattern的應用。

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

(Photo via Susanne Nilsson, CC licensed)

胖胖 Model 減重的五個方法

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

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

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

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

  • Presenter
  • Repository
  • Form
  • Service
  • Package

投影片在此

影片在此

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

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

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台灣