如何使用 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.php1
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
56namespace 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 | public function index() |
資料庫邏輯是要抓 20 歲以上的資料
。
在中大型專案,會有幾個問題 :
- 將資料庫邏輯寫在 controller,造成 controller 的肥大難以維護。
- 違反 SOLID 的單一職責原則 : 資料庫邏輯不應該寫在 controller。
- controller 直接相依於model,使得我們無法對 controller 做單元測試。
比較好的方式是使用 repository :
- 將 model 依賴注入到 repository。
- 將資料庫邏輯寫在 repository。
- 將 repository 依賴注入到 service。
UserRepository.php1
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
32namespace 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->user
。3 3這裡也可以使用User
facade 的方式,並不會影響可測試性,因為實務上在測試 repository 時,會真的去讀寫資料庫,而不會去 mock User
model,因此可以依可測試性決定要用依賴注入還是 Facade。
UserController.php1
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
33namespace 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
。
- 將資料庫邏輯寫在 repository,解決 controller 肥大問題。
- 符合 SOLID 的單一職責原則 : 資料庫邏輯寫在 repository,沒寫在 controller。
- 符合 SOLID 的依賴反轉原則 : controller 並非直接相依於 repository,而是將 repository 依賴注入進 controller。
理論上使用依賴注入時,應該使用 interface,不過 interface 目的在於抽象化方便抽換,讓程式碼達到開放封閉的要求,但是實務上要抽換 repository 的機會不高,除非你有抽換資料庫的需求,如從 MySQL 抽換到 MongoDB,此時就該建立 repository interface。
不過由於我們使用了依賴注入,將來要從 class 改成 interface 也很方便,只要在 constructor 的 type hint 改成 interface 即可,維護成本很低,所以在此大可使用 repository class 即可,不一定得用 interface 而造成 over design,等真正需求來時再重構成 interface 即可。
Laravel 4.2 就有 query scope,到 5.1 都還留著,它讓我們可以將商業邏輯寫在 model,解決了維護與重複使用的問題。
User.php1
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
53namespace 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.php1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19namespace 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 好很多了。
不過若在中大型專案,仍有以下問題 :
- Model 已經有原來的責任,若再加上 query scope,造成 model 過於肥大難以維護。
- 若資料庫邏輯很多,可以拆成多 repository,可是卻很難拆成多 model。
- 單元測試困難,必須面臨 mock Eloquent 的問題。
Conclusion
- 實務上可以一開始 1 個 repository 對應 1 個 model,但不用太執著於 1 個 repository 一定要對應 1 個 model,可將 repository 視為邏輯上的資料庫邏輯類別即可,可以橫跨多個 model 處理,也可以 1 個 model 拆成多個 repository,端看需求而定。
- Repository 使得資料庫邏輯從 controller 或 model中解放,不僅更容易維護、更容易擴展、更容易重複使用,且更容易測試。
Sample Code
完整的範例可以在我的 GitHub 上找到。