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