使用 Null Object 模式將符合 Tell, don't Ask 原則

當我們透過 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,內有 titledescriptiontitle 三個欄位,根據需求,我們想要有個 PostService 有個 showTitle() 的 API,只要傳入 post table 的 ID,就會回傳該筆資料的 title

PostService.php

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

PostServiceshowTitle(),我們會呼叫 PostRepositorygetTitle() 傳回字串。

PostRepository.php

app/Repositories/PostRepository.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace 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;
}
}

PostRepositorygetTitle(),我們會直接使用 Eloquent 的 where() 去抓資料,get() 回傳的是 Collection,然後再透過 Collectionfirst() 傳回第一筆 Post model,最後再抓 Post model 的 title 屬性。

目前這種寫法,若 where() 抓得到資料時就不會出錯,但若 where() 抓不到資料,first() 將會傳回 null,再存取其 title 屬性就會出現 Trying to get property of non-object 的錯誤,這是大家常見的錯誤訊息。

常見解決方式


判斷 null

1
2
3
4
5
6
7
8
9
10
11
12
13
public function getTitle(int $id) : string
{

$post = Post::whereId($id)
->get()
->first();

if ($post != null) {
return $post->title;
}
else {
return 'no title';
}
}

既然 first() 可能傳回 null,那就在讀取 title 屬性前先判斷 $post 是否為 null,若不是 null 則傳回 title,若為 null 則傳回預設值。

try catch

1
2
3
4
5
6
7
8
9
10
11
12
public function getTitle(int $id) : string
{

try {
return Post::whereId($id)
->get()
->first()
->title;
}
catch (Exception $e) {
return 'no title';
}
}

既然會出現 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 物件。

Martin Fowler - Refactoring Ch 9.7 Introduce Null Object

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 模式)

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;

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

PostServiceshowTitle() 多了第二個參數 $default,預設值為空字串,若需求端想要有自己的預設值,如 no title,可自行傳入。

另外只要呼叫 PostRepositorygetFirstPost() 即可,不用加入任何 if 判斷或 try catch

PostRepository.php 2 2GitHub Commit : 建立 PostRepository(使用 Null Object 模式)

app/Repositories/PostRepository.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace 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;
}
}

Collectionfirst() 第一個參數傳入 null,第二個參數傳入一個 Post model,也就是我們的 Null Object,至於 title 該如何定義,則由需求端傳入的參數 $default 決定。

這樣的 first() 寫法的意義為 : 若 where() 找得到資料,則 first() 依照正常方式傳回 Post 物件,若找不到資料,請傳回我們自己的 new Post(),也就是 Null Object。

為什麼 first() 的第一個參數為 null 呢? 在 Laravel 官網並沒有解釋,這要實際看 Laravel 的 source code。

Collection.php

vendor/laravel/framework/src/illuminate/Support/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);
}

  • Collectionfirst(),預設可以都不傳任何參數,就會傳回第一個物件。
  • 也可以第一個參數傳進 closure,告訴 Collection 該以何種條件去回傳第一個物件。
  • 也可以第二個參數傳進 $default,當 first() 找不到任何資料時,該回傳什麼預設值。

因為 Null Object 就是預設值,所以我們要傳入第二個參數,但第一個參數的 closure 我們不用傳,所以傳一個 null 即可。

單元測試


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

app/Repositories/PostRepository.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
use 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