使用 PhpStorm 自動幫我們重構 Namespace

在 TDD 開發流程,為了第一個 綠燈,一開始可能在同一個 namespace 下只有一個 class,但隨著重構的進行,可能重構出更多的 class 與 interface,為了更加的高內聚,低耦合,我們可能會將更相關的 class 與 interface 重構到其他 namespace,導致相依的 class 也必須修改,在重構 namepsace 時,PhpStorm 可以幫我們將相依的 class 一併修改,非常方便。

Version


PHP 7.0.0
Laravel 5.2.37

實際案例


我們將以經典的 service + repository 模式為例,以 PostService 處理商業邏輯,以 PostRepository 處理資料庫邏輯,將全部 post 顯示在網頁上。

最後使用 PhpStorm 重構 PostServicePostRepository

單元測試


以 TDD 方式開發,因此必須先寫單元測試。

PostServiceTest.php1 1GitHub Commit : 單元測試 : 建立 PostServiceTest.php

tests/Unit/PostServiceTest.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
use App\Post;
use App\Services\PostService;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class PostServiceTest extends TestCase
{

use DatabaseMigrations;

/** @test */
public function 顯示所有文章()
{

/** arrange */
$expected = [
['title' => 'title1', 'description' => 'desc1', 'content' => 'content1'],
['title' => 'title2', 'description' => 'desc2', 'content' => 'content2'],
['title' => 'title3', 'description' => 'desc3', 'content' => 'content3'],
];

collect($expected)->each(function ($value) {
Post::create($value);
});

/** act */
$actual = app(PostService::class)->displayAllPosts()->toArray();

/** assert */
$this->assertArraySubset($expected, $actual);
}
}

13 行

1
2
3
4
5
6
7
8
9
10
/** arrange */
$expected = [
['title' => 'title1', 'description' => 'desc1', 'content' => 'content1'],
['title' => 'title2', 'description' => 'desc2', 'content' => 'content2'],
['title' => 'title3', 'description' => 'desc3', 'content' => 'content3'],
];

collect($expected)->each(function ($value) {
Post::create($value);
});

由於單元測試是使用 SQLite in Memory 為資料庫,只要測試一結束,記憶體就會釋放,因此每次測試都要重新新增資料。

使用 Collection->each()$expected 中的資料透過 Post::create() 新增。

23 行

1
2
/** act */
$actual = app(PostService::class)->displayAllPosts()->toArray();

測試 PostService->displayAllPosts()2 2此時 PostServicedisplayAllPost() 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 PostServicedisplayAllPost()

displayAllPosts() 回傳的是 Collection,但 PHPUnit 無法對 Collection 做 assertion,必須先轉成 array。

26 行

1
2
/** assert */
$this->assertArraySubset($expected, $actual);

這裡不能使用 assertEquals(),因為 posts table 還包含 created_atupdated_at 兩個欄位,若使用 assertEquals() 一定失敗,必須改用 assertArraySubset()

PostService


實際跑測試,會得到第 1 個 紅燈,PHPUnit 抱怨 PostServicedisplayAllPosts() 尚未建立,須趕快補上。

PostService.php3 3GitHub Commit : 建立 PostService

app/Services/PostService.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
namespace App\Services;

use App\Repositories\PostRepository;
use Illuminate\Database\Eloquent\Collection;

class PostService
{

/**
* @var PostRepository
*/

private $postRepository;

/**
* PostService constructor.
* @param PostRepository $postRepository
*/

public function __construct(PostRepository $postRepository)
{

$this->postRepository = $postRepository;
}

/**
* @return Collection
*/

public function displayAllPosts() : Collection
{

return $this->postRepository->getAllPosts();
}
}

第 8 行

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @var PostRepository
*/

private $postRepository;

/**
* PostService constructor.
* @param PostRepository $postRepository
*/

public function __construct(PostRepository $postRepository)
{

$this->postRepository = $postRepository;
}

因為 PostService 須使用到 PostRepository,使用 constructor injection 注入 PostRepository

22 行

1
2
3
4
5
6
7
/**
* @return Collection
*/

public function displayAllPosts() : Collection
{

return $this->postRepository->getAllPosts();
}

呼叫 PostRepositorygetAllPosts(), 回傳 Collection4 4此時 PostRepositorygetAllPosts() 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 PostRepositorygetAllPosts()

為什麼 PostService 只有呼叫 PostRepository 而已?

實務上 PostService 除了呼叫 PostRepository 外,還會有自己的商業邏輯要寫,本文因為重點在 namespace 重構,所以簡化了 PostService,關於 Service 模式,詳細請參考如何使用 Service 模式?

PostRepository


實際跑測試,會得到第 2 個 紅燈,PHPUnit 抱怨 PostRepositorygetAllPosts() 尚未建立,須趕快補上。

PostRepository.php5 5GitHub Commit : 建立 PostRepository

app/Repositories/PostRepository.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace App\Repositories;

use App\Post;
use Illuminate\Database\Eloquent\Collection;

class PostRepository
{

/**
* @return Collection
*/

public function getAllPosts() : Collection
{

return Post::all();
}
}

為簡化起見,回傳 post table 所有資料。

得到第 1 個 綠燈,完成 PostServicePostRepository

整合測試


單元測試目的是寫出 service 與 repository,我們要繼續寫整合測試,將 route、controller 與 view 補上。

PostApplicationTest.php6 6GitHub Commit : 整合測試 : 建立 PostApplicationTest

tests/Unit/PostServiceTest.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
use App\Post;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class PostApplicationTest extends TestCase
{

use DatabaseMigrations;

/** @test */
public function 顯示所有文章()
{

/** arrange */
$expected = [
['title' => 'title1', 'description' => 'desc1', 'content' => 'content1'],
['title' => 'title2', 'description' => 'desc2', 'content' => 'content2'],
['title' => 'title3', 'description' => 'desc3', 'content' => 'content3'],
];

collect($expected)->each(function ($value) {
Post::create($value);
});

/** act */
$this->visit('/post');

/** assert */
collect($expected)->each(function ($value) {
$this->see($value['title']);
$this->see($value['description']);
$this->see($value['content']);
});
}
}

第 9 行

1
2
3
4
5
6
7
8
9
10
/** arrange */
$expected = [
['title' => 'title1', 'description' => 'desc1', 'content' => 'content1'],
['title' => 'title2', 'description' => 'desc2', 'content' => 'content2'],
['title' => 'title3', 'description' => 'desc3', 'content' => 'content3'],
];

collect($expected)->each(function ($value) {
Post::create($value);
});

由於單元測試是使用 SQLite in Memory 為資料庫,只要測試一結束,記憶體就會釋放,因此每次測試都要重新新增資料。

使用 Collection->each()$expected 中的資料透過 Post::create() 新增。

22 行

1
2
/** act */
$this->visit('/post');

實際測試 /post URI。7 7此時 route 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 route。

27 行

1
2
3
4
5
6
/** assert */
collect($expected)->each(function ($value) {
$this->see($value['title']);
$this->see($value['description']);
$this->see($value['content']);
});

期望在網頁上看到 titledescriptioncontent 等資料。

使用 Collection->each()$expected 中的資料透過 $this->see() 做 assertion。8 8此時 view 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 view。

Routes


實際跑測試,會得到第 1 個 紅燈,PHPUnit 抱怨找不到 http://localhost/post,因為 route 尚未建立,須趕快補上。

routes.php9 9GitHub Commit : 建立 routes

app/Http/routes.php
1
2
3
4
5
6
7
8
Route::get('/', function () {
return view('welcome');
});

Route::get('/post', [
'as' => 'post',
'uses' => 'PostController@index'
]);

新增 route /post,並指定其 controller 為 PostController@index10 10此時 PostController 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 PostController

PostController


實際跑測試,會得到第 2 個 紅燈,PHPUnit 抱怨 PostController 尚未建立,須趕快補上。

PostController.php11 11GitHub Commit : 建立 PostController

app/Http/Controllers/PostController.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace App\Http\Controllers;

use App\Http\Requests;
use App\Services\PostService;

class PostController extends Controller
{

/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/

public function index()
{

$data['posts'] = app(PostService::class)->displayAllPosts();
return view('post.index', $data);
}
}

使用 app() 建立 PostService,並呼叫其 displayAllPosts()

回傳 post.index view。12 12此時 post.index view 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 post.index view。

Post.Index Blade


實際跑測試,會得到第 3 個 紅燈,PHPUnit 抱怨 post.index view 尚未建立,須趕快補上。

index.blade.php13 13GitHub Commit : 建立 post.index view

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head>
<title>Posts</title>
</head>
<body>
@foreach($posts as $post)
<div>
<h2>{{ $post->title }}</h2>
<h2>{{ $post->description }}</h2>
<h2>{{ $post->content }}</h2>
</div>
<hr>
@endforeach
</body>
</html>

簡單使用 @foreach 與 binding 將資料顯示。

得到第 1 個 綠燈,完成 routesPostControllerpost.index view。

重構 PostService


到目前為止,已經 綠燈 達成需求,但發現將 PostService 放在 app/Services 下似乎不妥,想將 PostServie 重構放在 app/Services/Post 目錄下。

根據 PSR-4,PHP 的 namespace 必須與目錄相同,也就是說除了將 PostService 放到 app/Services/Post 目錄下外,以下程式碼必須修改 :

  • PostService 的 namespace 必須修改。
  • 單元測試的 PostServiceTestuse 必須修改。
  • Controller 的 PostControlleruse 必須修改。

本文只是很簡單的範例,已經要改 3 個地方,實務上會更複雜,要改的地方會更多,還可能沒改到或改錯。

這時候就要使用 PhpStorm 的重構了。

將滑鼠游標放在要重構的 PostService 的 class 名稱上,按熱鍵 ⌃ + T,選擇 Move

顯示 Move Class 對話框。

  • Move Class PostService to namespace 填入新的 namespace : App\Services\Post,PhpStorm 會自動在 Target destination directory 加上 Post目錄。
  • Search in comments and stringsSearch for text occurrences 都打勾。
  • 按下 Preview 可以先看一下 PhpStorm 將做哪些重構,按 Refactor 則直接重構。

PhpStorm 預告將對 PostControllerPostServiceTestPostService 做重構,與我們的預期相同。

若發現 PhpStorm 失去水準判斷錯誤,可以將其選擇按右鍵將其 ExcludedRemove 掉。

最後按 Do Refactor 開始重構。

重構完趕快跑單元測試與整合測試,確認 PhpStorm 沒有改壞。

重構 PostRepository


前一個例子,是將 PostService 單一 class 重構其 namespace,實務上還有另外一種應用,是將一個目錄下所有 class 重構成另外一個 namespace。

目前想將 app/Repositories 下所有的 class 重構到 app/Repositories/Post 目錄下。

根據 PSR-4,PHP 的 namespace 必須與目錄相同,也就是說除了將 PostRepository 放到 app/Repositories/Post 目錄下外,以下程式碼必須修改 :

  • PostRepository 的 namespace 必須修改。
  • Service 的 PostServiceuse 必須修改。

若目錄下有很多 class,要改的地方會更多,還可能沒改到或改錯。

這時候就要使用 PhpStorm 的重構了。

選擇要重構目錄下的其中一個檔案開啟,本例 app/Repositories 目錄下只有 PostRepository

將滑鼠游標放在要重構的 PostRepository 的 namespace 名稱上,按熱鍵 ⌃ + T,選擇 Move

顯示 Move Namespace 對話框。

  • New Namespace name 填入新的 namespace : App\Repositories\Post,PhpStorm 會自動在 Target destination directory 加上 Post目錄。
  • Search in comments and stringsSearch for text occurrences 都打勾。
  • 按下 Preview 可以先看一下 PhpStorm 將做哪些重構,按 Refactor 則直接重構。

PhpStorm 預告將對 PostRepositoryPostService 做重構,與我們的預期相同。

若發現 PhpStorm 失去水準判斷錯誤,可以將其選擇按右鍵將其 ExcludedRemove 掉。

最後按 Do Refactor 開始重構。

重構完趕快跑單元測試與整合測試,確認 PhpStorm 沒有改壞。

Conclusion


  • 重構單一 class,是將游標放在 class 名稱上。
  • 重構一目錄下所有 class,是將游標放在 namespace 名稱上。
  • Laravel 5 大量使用 namespace 後,只要改 namespace 就是大家永遠的痛,透過 PhpStorm 的重構,與自己寫的單元測試與整合測試保護後,再也不用害怕改 namespace 了。

Sample Code


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