示範3種日期格式轉換的寫法

由於多國語言的需求,不只有語言部分必須翻譯,連日期格式也必須符合當地習慣,最典型的如美國習慣Dec 25, 2015,而英國卻習慣25 Dec, 2015,台灣則習慣2015/12/25,不過在資料庫存的卻是同一份日期,只是因為顯示邏輯的不同,在view必須用不一樣的格式呈現。

Version


PHP 7.0.0
Laravel 5.1.22

日期格式


PHP談到日期格式,就一定要想到Carbon,Laravel已經內建Carbon,且只要資料庫是datedatetimetimestamp型態,model的該欄位已經自動轉成Carbon物件,也就是說,可以直接使用Carbon的format()去做任何日期格式的轉換。

雖然Carbon可以幫我們轉換日期格式,但該在哪裡使用format()呢?本文提供3種寫法,並深入討論3種寫法的優缺點 :

  1. Blade
  2. Mutator
  3. Presenter

Blade


由於日期格式顯示邏輯,最直覺的寫法就是在blade透過Carbon去轉換日期格式。

blade很難維護

由於Laravel已經將created_at欄位轉成Carbon物件,所以自帶format(),我們可以透過format()得到我們要的日期格式。

使用blade寫法的優缺點如下 :

  • 優點 :

    1. 顯示邏輯寫在blade,非常直覺。
  • 缺點 :

    1. @if@elseif@ednif與HTML夾雜,不易維護。
    2. 由於直接寫在blade,無法對顯示邏輯做重構物件導向
    3. 違反SOLID開放封閉原則 : 將來若有新的語言,只能不斷的加上@elseif判斷。

Mutator


Laravel為model欄位提供了mutatoraccessor,簡單的說,就是model欄位的gettersetter,因為欲顯示的日期格式與資料庫存的日期格式不同,所以我們只要在mutator使用Carbon的format(),就可以得到我們要的日期格式。

app/MyBlog/User.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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
namespace MyBlog;

use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Support\Facades\App;

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 string
*/

protected $hidden = ['password', 'remember_token'];


/**
* [created_at]的mutator
*
* @param $value
* @return string
*/

public function getCreatedAtAttribute($value): string
{

$locale = App::getLocale();

$date = $this->asDateTime($value);

if ($locale === 'uk') {
return $date->format('d M, Y');
} elseif ($locale === 'tw') {
return $date->format('Y/m/d');
} else {
return $date->format('M d, Y');
}
}
}

40行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* [created_at]的mutator
*
* @param $value
* @return string
*/

public function getCreatedAtAttribute($value): string
{

$locale = App::getLocale();

$date = $this->asDateTime($value);

if ($locale === 'uk') {
return $date->format('d M, Y');
} elseif ($locale === 'tw') {
return $date->format('Y/m/d');
} else {
return $date->format('M d, Y');
}
}

在model加上getCreatedAtAttribute(),其中get為規定的prefix,而Attribute為規定的postfix,中間為為欄位名稱created_at,改用CamelCase表示。

App::getLocale()會傳回由controller所設定的語系,如ustwuk…等。

$value為mutator所傳進的資料,其中model的asDateTime()可將mutator的$value轉成Carbon物件。

依Carbon的format()轉成各國所慣用的日期格式。

使用mutator寫法的優缺點如下 :

  • 優點 :

    1. 將邏輯寫在PHP,沒有寫在blade,程式較容易維護,且可以重構物件導向
    2. 只要在model寫一次,就可以套用在所有的view。
  • 缺點 :

    1. 顯示邏輯寫在model,違反SOLID單一職責原則,model就算要寫,也應該寫資料庫邏輯,而不適合寫顯示邏輯。
    2. 若不是每個view都套用此顯示邏輯,使用mutator會對其他view產生side effect。
    3. 若要對mutator做unit test,需要碰到資料庫。1 1雖然可以使用SQLite In-Memory Database使測試速度加快,但還是會比使用Presenter沒碰資料庫慢很多。

Presenter


由於有多國語言的需求,我們直接建立presenter interface,各語言可實作此interface,使用@inject將presenter注入到view,並透過App::bind()去切換物件,相當於切換語言。2 2實務上並不是一開始就會去開presenter interface,而是先建立presenter物件,然後透過重構的方式產生interface,在此是因為講解的原因,所以從interface開始講起。

Presenter Interface

app/MyBlog/Presenters/DateFormatPresenterInterface.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace MyBlog\Presenters;

use Carbon\Carbon;

interface DateFormatPresenterInterface
{

/**
* 顯示日期格式
*
* @param Carbon $date
* @return string
*/

public function showDateFormat(Carbon $date) : string;
}

定義了showDateFormat(),各語言必須在showDateFormat()使用Carbon的format()去轉換日期格式。

Presenter

app/MyBlog/Presenters/DateFormatPresenter_uk.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace MyBlog\Presenters;

use Carbon\Carbon;

class DateFormatPresenter_uk implements DateFormatPresenterInterface
{

/**
* 顯示日期格式
*
* @param Carbon $date
* @return string
*/

public function showDateFormat(Carbon $date) : string
{

return $date->format('d M, Y');
}
}

DateFormatPresenter_uk實現了DateFormatPresenterInterface,並將轉換成英國日期格式的Carbon的format()寫在showDateFormat()內。

app/MyBlog/Presenters/DateFormatPresenter_tw.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace MyBlog\Presenters;

use Carbon\Carbon;

class DateFormatPresenter_tw implements DateFormatPresenterInterface
{

/**
* 顯示日期格式
*
* @param Carbon $date
* @return string
*/

public function showDateFormat(Carbon $date) : string
{

return $date->format('Y/m/d');
}
}

DateFormatPresenter_tw實現了DateFormatPresenterInterface,並將轉換成台灣日期格式的Carbon的format()寫在showDateFormat()內。

app/MyBlog/Presenters/DateFormatPresenter_us.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace MyBlog\Presenters;

use Carbon\Carbon;

class DateFormatPresenter_us implements DateFormatPresenterInterface
{

/**
* 顯示日期格式
*
* @param Carbon $date
* @return string
*/

public function showDateFormat(Carbon $date) : string
{

return $date->format('M d, Y');
}
}

DateFormatPresenter_us實現了DateFormatPresenterInterface,並將轉換成美國日期格式的Carbon的format()寫在showDateFormat()內。

Presenter Factory

由於每個語言的日期格式都是一個presenter物件,那勢必遇到一個最基本的問題 : 我們必須根據不同的語言去new不同的presenter物件,直覺我們可能會在controller去new presenter。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function index(Request $request)
{

$users = $this->userRepository->getAgeLargerThan(10);

$locale = $request['lang'];

if ($locale === 'uk') {
$presenter = new DateFormatPresenter_uk();
} elseif ($locale === 'tw') {
$presenter = new DateFormatPresenter_tw();
} else {
$presenter = new DateFormatPresenter_us();
}

return view('users.index', compact('users'));
}

這種寫法雖然可行,但有幾個問題 :

  1. 違反SOLID開放封閉原則 : 若將來有新的語言需求,只能不斷去修改index(),然後不斷的新增elseif,就算改用switch也是一樣。
  2. 違反SOLID依賴反轉原則 : controller直接根據語言去new相對應的class,高層直接相依於低層,直接將實作寫死在程式中。
  3. 無法做unit test : 由於presenter直接new在controller,因此要測試時,無法對presenter做mock。

比較好的方式是使用Factory Pattern

app/MyBlog/Presenters/DateFormatPresenterFactory.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace MyBlog\Presenters;

use Illuminate\Support\Facades\App;

class DateFormatPresenterFactory
{

/**
* @param string $locale
*/

public function create(string $locale)
{

App::bind(DateFormatPresenterInterface::class,
'MyBlog\Presenters\DateFormatPresenter_' . $locale);
}
}

使用Presenter Factorycreate()去取代new建立物件。

這裡當然可以在create()去寫if...elseif去建立presenter物件,不過這樣會違反SOLID開放封閉原則,比較好的方式是改用App::bind(),直接根據$locale去binding相對應的class,這樣無論在怎麼新增語言日期格式,controller與Presenter Factory都不用做任何修改,完全符合開放封閉原則

Controller

app/Http/Controllers/UserController.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
35
36
37
38
39
40
41
42
namespace App\Http\Controllers;

use App\Http\Requests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use MyBlog\Presenters\DateFormatPresenterFactory;
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.
*
* @param Request $request
* @param DateFormatPresenterFactory $dateFormatPresenterFactory
* @return \Illuminate\Http\Response
*/

public function index(Request $request,
DateFormatPresenterFactory $dateFormatPresenterFactory)

{

$users = $this->userRepository->getAgeLargerThan(10);

$locale = ($request['lang']) ? $request['lang'] : 'us';

$dateFormatPresenterFactory->create($locale);

return view('users.index', compact('users'));
}
}

11行

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

24行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Display a listing of the resource.
*
* @param Request $request
* @param DateFormatPresenterFactory $dateFormatPresenterFactory
* @return \Illuminate\Http\Response
*/

public function index(Request $request,
DateFormatPresenterFactory $dateFormatPresenterFactory)

{

$users = $this->userRepository->getAgeLargerThan(10);

$locale = ($request['lang']) ? $request['lang'] : 'us';

$dateFormatPresenterFactory->create($locale);

return view('users.index', compact('users'));
}

index()會相依於DateFormatPresenterFactory,當然也可以在constructor一併將DateFormatPresenterFactory注入,但是考慮到DateFormatPresenterFactory只有index()使用,因此改用Method Injection的方式,只將DateFormatPresenterFactory注入到index()即可,這樣可以減輕constructor的壓力,不必所有相依物件都寫在constructor。

使用factory pattern之後,controller有了以下的優點 :
  1. 符合SOLID開放封閉原則 : 若將來有新的語言需求,controller完全不用做任何修改。
  2. 符合SOLID依賴反轉原則 : controller不再直接相依於presenter,而是改由factory去建立presenter。
  3. 可以做unit test : 由於將factory依賴注入進controller,因此要測試時,只要將要mock的presenter factory注入進controller即可。
何時該使用Constructor InjectionMethod Injection呢?
  1. 若該相依物件,有很多method要使用,可考慮使用Constructor Injection,如repository、service。
  2. 若該相依物件,只有單個method自己使用,可考慮使用Method Injection,如factory。

View

blade很乾淨

使用@inject注入presenter,讓view也可以如controller一樣使用注入的物件。

使用presenter的showDateFormat()將日期轉成想要的格式。

View搭配presenter之後,有以下優點 :
  1. Blade非常乾淨,完全沒有@if@elseif@endif
  2. 由於邏輯都寫在PHP中,將來可以繼續對顯示邏輯做重構物件導向
  3. Presenter只會影響目前的view,不像mutator會有side effect。
  4. 符合SOLID單一職責原則 : 顯示邏輯被封裝在presenter內,不像mutor將顯示邏輯寫在model內。
  5. 符合SOLID開放封閉原則 : 將來若有新的語言,對於擴展是開放的 : 只要新增class實踐DateFormatPresenterInterface即可;對於修改是封閉的 : controller、factory interface、factory與view都不用做任何修改。
  6. 可單獨對presenter的顯示邏輯做unit test。

Conclusion


  • Blade : 最直覺,但與HTML夾雜,不適合寫太複雜的邏輯,且違反SOLID原則。
  • Mutator : 很方便,但要小心對其他view或controller的side effect,unit test會直接碰到資料庫,速度較慢。
  • Presenter : 較複雜,適合複雜的邏輯,容易維護,符合SOLID原則,unit test不會碰到資料庫,速度非常快。

Sample Code


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

  1. Blade
  2. Mutator
  3. Presenter
2015-12-22