使用 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 重構 PostService 與 PostRepository。

單元測試


以 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此時 PostService 與 displayAllPost() 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 PostService 與 displayAllPost()。

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

26 行

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

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

PostService


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

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

呼叫 PostRepository 的 getAllPosts(), 回傳 Collection。4 4此時 PostRepository 與 getAllPosts() 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 PostRepository 與 getAllPosts()。

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

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

PostRepository


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

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 個 綠燈,完成 PostService 與 PostRepository。

整合測試


單元測試目的是寫出 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']);
});

期望在網頁上看到 title、description 與 content 等資料。

使用 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@index。10 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 個 綠燈,完成 routes 與 PostController 與 post.index view。

重構 PostService


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

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

  • PostService 的 namespace 必須修改。
  • 單元測試的 PostServiceTest 的 use 必須修改。
  • Controller 的 PostController 的 use 必須修改。

本文只是很簡單的範例,已經要改 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 strings 與 Search for text occurrences 都打勾。
  • 按下 Preview 可以先看一下 PhpStorm 將做哪些重構,按 Refactor 則直接重構。

PhpStorm 預告將對 PostController、PostServiceTest 與 PostService 做重構,與我們的預期相同。

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

最後按 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 的 PostService 的 use 必須修改。

若目錄下有很多 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 strings 與 Search for text occurrences 都打勾。
  • 按下 Preview 可以先看一下 PhpStorm 將做哪些重構,按 Refactor 則直接重構。

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

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

最後按 Do Refactor 開始重構。

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

Conclusion


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

Sample Code


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

▲