2016年9月2日 星期五

如何使用 Repository 模式?

若將資料庫邏輯都寫在 model,會造成 model 的肥大而難以維護,基於SOLID原則,我們應該使用 Repository 模式輔助 model,將相關的資料庫邏輯封裝在不同的 repository,方便中大型專案的維護。

Version


Laravel 5.1.22

資料庫邏輯


在 CRUD 中,CUD 比較穩定,但 R 的部分則千變萬化,大部分的資料庫邏輯都在描述 R 的部分,若將資料庫邏輯寫在 controller 或 model 都不適當,會造成 controller 與 model 肥大,造成日後難以維護。

Model


使用 repository 之後,model 僅當成Eloquent class 即可,不要包含資料庫邏輯,僅保留以下部分 : 
  • Property : 如$table$fillable…等。
  • Mutator: 包括 mutator 與 accessor。
  • Method : relation 類的 method,如使用 hasMany() 與 belongsTo()
  • 註解 : 因為 Eloquent 會根據資料庫欄位動態產生 property 與 method,等。若使用 Laravel IDE Helper,會直接在 model 加上 @property 與 @method 描述 model 的動態 property 與 method。
User.php
app/User.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
namespace MyBlog;

use Illuminate\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;


/**
 * MyBlog\User
 *
 * @property integer $id
 * @property string $name
 * @property string $email
 * @property string $password
 * @property string $remember_token
 * @property \Carbon\Carbon $created_at
 * @property \Carbon\Carbon $updated_at
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereId($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereName($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereEmail($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User wherePassword($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereRememberToken($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereCreatedAt($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereUpdatedAt($value)
 */
class User extends Model implements AuthenticatableContract,
                                    AuthorizableContract,
                                    CanResetPasswordContract
{
    use Authenticatable, Authorizable, CanResetPassword;

    /**
     * The database table used by the model.
     *
     * @var string
     */
    protected $table = 'users';

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = ['name', 'email', 'password'];

    /**
     * The attributes excluded from the model's JSON form.
     *
     * @var array
     */
    protected $hidden = ['password', 'remember_token'];
}
12行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * MyBlog\User
 *
 * @property integer $id
 * @property string $name
 * @property string $email
 * @property string $password
 * @property string $remember_token
 * @property \Carbon\Carbon $created_at
 * @property \Carbon\Carbon $updated_at
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereId($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereName($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereEmail($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User wherePassword($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereRememberToken($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereCreatedAt($value)
 * @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereUpdatedAt($value)
 */
IDE-Helper 幫我們替 model 加上註解,讓我們可以在 PhpStorm 的語法提示使用 model 的 property 與 method。

Repository


初學者常會在 controller 直接調用 model 寫資料庫邏輯 : 
1
2
3
4
5
6
7
8
public function index()
{
    $users = User::where('age', '>', 20)
                ->orderBy('age')
                ->get();

    return view('users.index', compact('users'));
}
資料庫邏輯是要抓 20 歲以上的資料
在中大型專案,會有幾個問題 : 
  1. 將資料庫邏輯寫在 controller,造成 controller 的肥大難以維護。
  2. 違反 SOLID 的單一職責原則 : 資料庫邏輯不應該寫在 controller。
  3. controller 直接相依於model,使得我們無法對 controller 做單元測試。
比較好的方式是使用 repository : 
  1. 將 model 依賴注入到 repository。
  2. 將資料庫邏輯寫在 repository。
  3. 將 repository 依賴注入到 service。
UserRepository.php
app/Repositories/UserRepository.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
namespace MyBlog\Repositories;

use Doctrine\Common\Collections\Collection;
use MyBlog\User;

class UserRepository
{
    /** @var User 注入的User model */
    protected $user;

    /**
     * UserRepository constructor.
     * @param User $user
     */
    public function __construct(User $user)
    {
        $this->user = $user;
    }

    /**
     * 回傳大於?年紀的資料
     * @param integer $age
     * @return Collection
     */
    public function getAgeLargerThan($age)
    {
        return $this->user
            ->where('age', '>', $age)
            ->orderBy('age')
            ->get();
    }
}
第 8 行
1
2
3
4
5
6
7
8
9
10
11
/** @var User 注入的User model */
protected $user;

/**
 * UserRepository constructor.
 * @param User $user
 */
public function __construct(User $user)
{
    $this->user = $user;
}
將相依的 User model 依賴注入到 UserRepository
21 行
1
2
3
4
5
6
7
8
9
10
11
12
/**
 * 回傳大於?年紀的資料
 * @param integer $age
 * @return Collection
 */
public function getAgeLargerThan($age)
{
    return $this->user
        ->where('age', '>', $age)
        ->orderBy('age')
        ->get();
}
抓 20 歲以上的資料的資料庫邏輯寫在 getAgeLargerThan()
不是使用User facade,而是使用注入的$this->user33這裡也可以使用User facade 的方式,並不會影響可測試性,因為實務上在測試 repository 時,會真的去讀寫資料庫,而不會去 mock User model,因此可以依可測試性決定要用依賴注入還是 Facade。
UserController.php
app/Http/Controllers/UserController.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace App\Http\Controllers;

use App\Http\Requests;
use MyBlog\Repositories\UserRepository;

class UserController extends Controller
{
    /** @var  UserRepository 注入的UserRepository */
    protected $userRepository;

    /**
     * UserController constructor.
     *
     * @param UserRepository $userRepository
     */
    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $users = $this->userRepository
            ->getAgeLargerThan(20);

        return view('users.index', compact('users'));
    }
}
第8行
1
2
3
4
5
6
7
8
9
10
11
12
/** @var  UserRepository 注入的UserRepository */
protected $userRepository;

/**
 * UserController constructor.
 *
 * @param UserRepository $userRepository
 */
public function __construct(UserRepository $userRepository)
{
    $this->userRepository = $userRepository;
}
將相依的 UserRepository 依賴注入到 UserController
26行
1
2
3
4
5
6
7
8
9
10
11
12
/**
 * Display a listing of the resource.
 *
 * @return \Illuminate\Http\Response
 */
public function index()
{
    $users = $this->userRepository
        ->getAgeLargerThan(20);

    return view('users.index', compact('users'));
}
從原本直接相依的 User model,改成依賴注入的 UserRepository
 改用這種寫法,有幾個優點 :
  1. 資料庫邏輯寫在 repository,解決 controller 肥大問題。
  2. 符合 SOLID 的單一職責原則 : 資料庫邏輯寫在 repository,沒寫在 controller。
  3. 符合 SOLID 的依賴反轉原則 : controller 並非直接相依於 repository,而是將 repository 依賴注入進 controller。
 實務上建議 repository 僅依賴注入於 service,而不要直接注入在 controller,本範例因為還沒介紹到 servie 模式,為了簡化起見,所以直接注入於 controller。
 是否該建立 Repository Interface?
理論上使用依賴注入時,應該使用 interface,不過 interface 目的在於抽象化方便抽換,讓程式碼達到開放封閉的要求,但是實務上要抽換 repository 的機會不高,除非你有抽換資料庫的需求,如從 MySQL 抽換到 MongoDB,此時就該建立 repository interface。
不過由於我們使用了依賴注入,將來要從 class 改成 interface 也很方便,只要在 constructor 的 type hint 改成 interface 即可,維護成本很低,所以在此大可使用 repository class 即可,不一定得用 interface 而造成 over design,等真正需求來時再重構成 interface 即可。
 是否該使用 Query Scope?
Laravel 4.2 就有 query scope,到 5.1 都還留著,它讓我們可以將商業邏輯寫在 model,解決了維護重複使用的問題。
User.php
app/User.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
namespace MyBlog;

use Illuminate\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;

/**
 * (註解:略)
 */
class User extends Model implements AuthenticatableContract,
                                    AuthorizableContract,
                                    CanResetPasswordContract
{
    use Authenticatable, Authorizable, CanResetPassword;

    /**
     * The database table used by the model.
     *
     * @var string
     */
    protected $table = 'users';

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = ['name', 'email', 'password'];

    /**
     * The attributes excluded from the model's JSON form.
     *
     * @var array
     */
    protected $hidden = ['password', 'remember_token'];

    /**
     * 回傳大於?年紀的資料
     * @param Builder $query
     * @param integer $age
     * @return Builder
     */
    public function scopeGetAgerLargerThan($query, $age)
    {
        return $query->where('age', '>', $age)
            ->orderBy('age');
    }
}
42行
1
2
3
4
5
6
7
8
9
10
11
/**
 * 回傳大於?年紀的資料
 * @param Builder $query
 * @param integer $age
 * @return Builder
 */
public function scopeGetAgerLargerThan($query, $age)
{
    return $query->where('age', '>', $age)
        ->orderBy('age');
}
Query scope 必須以 scope 為 prefix,第 1 個參數為 query builder,一定要加,是 Laravel 要用的。
第2個參數以後為自己要傳入的參數。
由於回傳也必須是一個 query builder,因此不加上 get()
UserController.php
app/Http/Controllers/UserController.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace App\Http\Controllers;

use App\Http\Requests;
use MyBlog\User;

class UserController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $users = User::getAgerLargerThan(20)->get();

        return view('users.index', compact('users'));
    }
}
在 controller 呼叫 query scope 時,不要加上 prefix,由於其本質是 query builder,所以還要加上 get() 才能抓到Collection。
由於 query scope 是寫在 model,不是寫在 controller,所以基本上解決了 controller 肥大與違反 SOLID 的單一職責原則的問題,controller 也可以重複使用 query scope,已經比直接將資料庫邏輯寫在 controller 好很多了。
不過若在中大型專案,仍有以下問題 : 
  1. Model 已經有原來的責任,若再加上 query scope,造成 model 過於肥大難以維護。
  2. 若資料庫邏輯很多,可以拆成多 repository,可是卻很難拆成多 model。
  3. 單元測試困難,必須面臨 mock Eloquent 的問題。

Conclusion


  • 實務上可以一開始 1 個 repository 對應 1 個 model,但不用太執著於 1 個 repository 一定要對應 1 個 model,可將 repository 視為邏輯上的資料庫邏輯類別即可,可以橫跨多個 model 處理,也可以 1 個 model 拆成多個 repository,端看需求而定。
  • Repository 使得資料庫邏輯從 controller 或 model中解放,不僅更容易維護、更容易擴展、更容易重複使用,且更容易測試。

Sample Code



完整的範例可以在我的 GitHub 上找到。

reference : http://oomusou.io/laravel/laravel-repository/

沒有留言:

wibiya widget