如何實現 Null Object Pattern ?
當我們透過 Eloquent 對資料庫抓資料時,由於 where()
的條件可能撈不到資料,導致 first()
傳會 null
,若再對 null
物件的欄位屬性做存取,會出現 Trying to get property of non-object
的錯誤訊息,當然有各種方法避開這個錯誤,但比較理想的方式是引入 Null Object
模式。
Version
PHP 7.0.0
Laravel 5.2.39
實際案例
實務上我們有個 post
table,內有 title
、description
與 title
三個欄位,根據需求,我們想要有個 PostService
有個 showTitle()
的 API,只要傳入 post
table 的 ID
,就會回傳該筆資料的 title
。
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
28namespace App\Services;
use App\Repositories\PostRepository;
class PostService
{
/** @var PostRepository */
private $postRepository;
/**
* PostService constructor.
* @param PostRepository $postRepository
*/
public function __construct(PostRepository $postRepository)
{
$this->postRepository = $postRepository;
}
/**
* 顯示 title
* @param int $id
* @return string
*/
public function showTitle(int $id) : string
{
return $this->postRepository->getTitle($id);
}
}
在 PostService
的 showTitle()
,我們會呼叫 PostRepository
的 getTitle()
傳回字串。
PostRepository.php 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19namespace App\Repositories;
use App\Post;
class PostRepository
{
/**
* 回傳 post.title
* @param int $id
* @return string
*/
public function getTitle(int $id) : string
{
return Post::whereId($id)
->get()
->first()
->title;
}
}
在 PostRepository
的 getTitle()
,我們會直接使用 Eloquent 的 where()
去抓資料,get()
回傳的是 Collection
,然後再透過 Collection
的 first()
傳回第一筆 Post
model,最後再抓 Post
model 的 title
屬性。
目前這種寫法,若 where()
抓得到資料時就不會出錯,但若 where()
抓不到資料,first()
將會傳回 null
,再存取其 title
屬性就會出現 Trying to get property of non-object
的錯誤,這是大家常見的錯誤訊息。
常見解決方式
判斷 null
1 | public function getTitle(int $id) : string |
既然 first()
可能傳回 null
,那就在讀取 title
屬性前先判斷 $post
是否為 null
,若不是 null
則傳回 title
,若為 null
則傳回預設值。
try catch
1 | public function getTitle(int $id) : string |
既然會出現 Trying to get property of non-object
exception,就用 try catch
去攔,若有 exception 就傳回預設值。
這兩種解法雖然都可行,但有個致命傷,違反 Tell, Don't Ask
原則。
好的 API,應該只負責 tell,也就是告訴 API 我的需求是什麼,然後就傳回我要的資料,而不是 ask API 之後,呼叫端還要再做額外的判斷或加工。
因為只要呼叫端還需要判斷,就有可能因為忘記判斷而造成不可預期的錯誤,這就不是好的 API。
第一個方式必須使用 if else
判斷是否為 null
,第二個方式還必須去 try catch
,都不算是好的 API,比較理想的方式是只要 return Post::whereId()->get()->first()->title
一次就可以抓到想要的資料,不需要額外判斷。
Null Object 模式
Replace the null value with the null object.
將 null 值替換成 null 物件。
Null Object 模式並非出自設計模式一書,而是出現在重構的 Ch 9.7,教大家將 null
值重構成 null
物件,因為只要有 null
值,就必須去 if
判斷是否 null
,甚至於去 try catch
,這樣的 API 並不好用,而且只要忘記判斷就可能出錯。
什麼是 Null Object 呢? 剛剛會出錯,就是因為我們期望是一個 Post
物件,也有 title
屬性,但因為回傳 null
值,沒有 title
屬性才出錯,既然如此,假如我們也能傳回一個 Post
物件,也有 title
屬性,這樣就不會錯了,這就是 Null Object 概念。
至於 Null Object 的 title
屬性該存放什麼值呢? 這沒有一定的答案,完全看需求端定義,可能需求端認為若找不到資料,傳回空字串即可,也可能傳回no title
即可,這些值就是 Null Object 的 title
屬性的值。
我們來將原來的程式碼重構成 Null Object 模式。
PostService.php 1 1GitHub Commit : 建立 PostService(使用 Null Object 模式)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
29namespace App\Services;
use App\Repositories\PostRepository;
class PostService
{
/** @var PostRepository */
private $postRepository;
/**
* PostService constructor.
* @param PostRepository $postRepository
*/
public function __construct(PostRepository $postRepository)
{
$this->postRepository = $postRepository;
}
/**
* 顯示 title
* @param int $id
* @param string $default
* @return string
*/
public function showTitle(int $id, string $default = '') : string
{
return $this->postRepository->getTitle($id, $default);
}
}
在 PostService
的 showTitle()
多了第二個參數 $default
,預設值為空字串,若需求端想要有自己的預設值,如 no title
,可自行傳入。
另外只要呼叫 PostRepository
的 getFirstPost()
即可,不用加入任何 if
判斷或 try catch
。
PostRepository.php 2 2GitHub Commit : 建立 PostRepository(使用 Null Object 模式)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20namespace App\Repositories;
use App\Post;
class PostRepository
{
/**
* 回傳 post.title
* @param int $id
* @param string $default
* @return string
*/
public function getTitle(int $id, string $default = '') : string
{
return Post::whereId($id)
->get()
->first(null, new Post(['title' => $default]))
->title;
}
}
在 Collection
的 first()
第一個參數傳入 null,第二個參數傳入一個 Post
model,也就是我們的 Null Object,至於 title
該如何定義,則由需求端傳入的參數 $default
決定。
這樣的 first()
寫法的意義為 : 若 where()
找得到資料,則 first()
依照正常方式傳回 Post
物件,若找不到資料,請傳回我們自己的 new Post()
,也就是 Null Object。
為什麼 first()
的第一個參數為 null
呢? 在 Laravel 官網並沒有解釋,這要實際看 Laravel 的 source code。
Collection.php 1
2
3
4
5
6
7
8
9
10
11/**
* Get the first item from the collection.
*
* @param callable|null $callback
* @param mixed $default
* @return mixed
*/
public function first(callable $callback = null, $default = null)
{
return Arr::first($this->items, $callback, $default);
}
Collection
的first()
,預設可以都不傳任何參數,就會傳回第一個物件。- 也可以第一個參數傳進 closure,告訴
Collection
該以何種條件去回傳第一個物件。 - 也可以第二個參數傳進
$default
,當first()
找不到任何資料時,該回傳什麼預設值。
因為 Null Object 就是預設值,所以我們要傳入第二個參數,但第一個參數的 closure 我們不用傳,所以傳一個 null
即可。
單元測試
PostServiceTest.php 3 3GitHub Commit : 建立單元測試 PostServiceTest1
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
34use App\Post;
use App\Services\PostService;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class PostServiceTest extends TestCase
{
use DatabaseMigrations;
/** @test */
public function 有資料取title欄位資料()
{
/** arrange */
factory(Post::class, 3)->create();
/** act */
$actual = $this->target->showTitle(1, 'no title');
/** assert */
$this->assertEquals('title1', $actual);
}
/** @test */
public function 無資料的title欄位資料()
{
/** arrange */
factory(Post::class, 3)->create();
/** act */
$actual = $this->target->showTitle(4, 'no title');
/** assert */
$this->assertEquals('no title', $actual);
}
}
最後補上單元測試,分別測試 where()
找得到資料與找不到資料的測試案例,證明 Null Object 重構成功。
Conclusion
- Null Object 模式是實務上常常使用的模式,當程式碼出現需要判斷
null
值,就該考慮重構成 Null Object 模式,這種風格符合物件導向多型的原則,不該因為null
值而有不同的行為,也符合Tell, Don't Ask
原則,可以寫出更好用的 API。
Sample Code
完整的範例可以在我的 GitHub 上找到。
Reference
- Martin Fowler, Refactoring : Improving the Design of Existing Code