如何依各種語言顯示不同日期格式 ?
由於多國語言的需求,不只有語言部分必須翻譯,連日期格式也必須符合當地習慣,最典型的如美國習慣Dec 25, 2015
,而英國卻習慣25 Dec, 2015
,台灣則習慣2015/12/25
,不過在資料庫存的卻是同一份日期,只是因為顯示邏輯的不同,在view必須用不一樣的格式呈現。
Version
PHP 7.0.0
Laravel 5.1.22
日期格式
PHP談到日期格式,就一定要想到Carbon
,Laravel已經內建Carbon,且只要資料庫是date
、datetime
或timestamp
型態,model的該欄位已經自動轉成Carbon物件,也就是說,可以直接使用Carbon的format()
去做任何日期格式的轉換。
雖然Carbon可以幫我們轉換日期格式,但該在哪裡使用format()
呢?本文提供3種寫法,並深入討論3種寫法的優缺點 :
- Blade
- Mutator
- Presenter
Blade
由於日期格式為顯示邏輯,最直覺的寫法就是在blade透過Carbon去轉換日期格式。
由於Laravel已經將created_at
欄位轉成Carbon物件,所以自帶format()
,我們可以透過format()
得到我們要的日期格式。
使用blade寫法的優缺點如下 :
優點 :
- 將顯示邏輯寫在blade,非常直覺。
缺點 :
@if
、@elseif
與@ednif
與HTML夾雜,不易維護。- 由於直接寫在blade,無法對顯示邏輯做重構與物件導向。
- 違反SOLID的開放封閉原則 : 將來若有新的語言,只能不斷的加上
@elseif
判斷。
Mutator
Laravel為model欄位提供了mutator
與accessor
,簡單的說,就是model欄位的getter
與setter
,因為欲顯示的日期格式與資料庫存的日期格式不同,所以我們只要在mutator
使用Carbon的format()
,就可以得到我們要的日期格式。
1 | namespace MyBlog; |
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所設定的語系,如us
、tw
、uk
…等。
$value
為mutator所傳進的資料,其中model的asDateTime()
可將mutator的$value
轉成Carbon物件。
依Carbon的format()
轉成各國所慣用的日期格式。
使用mutator寫法的優缺點如下 :
優點 :
- 將邏輯寫在PHP,沒有寫在blade,程式較容易維護,且可以重構與物件導向。
- 只要在model寫一次,就可以套用在所有的view。
缺點 :
- 將顯示邏輯寫在model,違反SOLID的單一職責原則,model就算要寫,也應該寫資料庫邏輯,而不適合寫顯示邏輯。
- 若不是每個view都套用此顯示邏輯,使用mutator會對其他view產生side effect。
- 若要對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
1 | namespace MyBlog\Presenters; |
定義了showDateFormat()
,各語言必須在showDateFormat()
使用Carbon的format()
去轉換日期格式。
Presenter
1 | namespace MyBlog\Presenters; |
DateFormatPresenter_uk
實現了DateFormatPresenterInterface
,並將轉換成英國
日期格式的Carbon的format()
寫在showDateFormat()
內。
1 | namespace MyBlog\Presenters; |
DateFormatPresenter_tw
實現了DateFormatPresenterInterface
,並將轉換成台灣
日期格式的Carbon的format()
寫在showDateFormat()
內。
1 | namespace MyBlog\Presenters; |
DateFormatPresenter_us
實現了DateFormatPresenterInterface
,並將轉換成美國
日期格式的Carbon的format()
寫在showDateFormat()
內。
Presenter Factory
由於每個語言的日期格式都是一個presenter物件,那勢必遇到一個最基本的問題 : 我們必須根據不同的語言去new不同的presenter物件
,直覺我們可能會在controller去new presenter。
1 | public function index(Request $request) |
這種寫法雖然可行,但有幾個問題 :
- 違反SOLID的開放封閉原則 : 若將來有新的語言需求,只能不斷去修改
index()
,然後不斷的新增elseif
,就算改用switch
也是一樣。 - 違反SOLID的依賴反轉原則 : controller直接根據語言去new相對應的class,高層直接相依於低層,直接將實作寫死在程式中。
- 無法做unit test : 由於presenter直接new在controller,因此要測試時,無法對presenter做mock。
比較好的方式是使用Factory Pattern。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15namespace 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 Factory
的create()
去取代new建立物件。
這裡當然可以在create()
去寫if...elseif
去建立presenter物件,不過這樣會違反SOLID的開放封閉原則,比較好的方式是改用App::bind()
,直接根據$locale
去binding相對應的class,這樣無論在怎麼新增語言與日期格式,controller與Presenter Factory都不用做任何修改,完全符合開放封閉原則。
Controller
1 | namespace App\Http\Controllers; |
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。
- 符合SOLID的開放封閉原則 : 若將來有新的語言需求,controller完全不用做任何修改。
- 符合SOLID的依賴反轉原則 : controller不再直接相依於presenter,而是改由factory去建立presenter。
- 可以做unit test : 由於將factory依賴注入進controller,因此要測試時,只要將要mock的presenter factory注入進controller即可。
- 若該相依物件,有很多method要使用,可考慮使用Constructor Injection,如repository、service。
- 若該相依物件,只有單個method自己使用,可考慮使用Method Injection,如factory。
View
使用@inject
注入presenter,讓view也可以如controller一樣使用注入的物件。
使用presenter的showDateFormat()
將日期轉成想要的格式。
- Blade非常乾淨,完全沒有
@if
、@elseif
與@endif
。 - 由於邏輯都寫在PHP中,將來可以繼續對顯示邏輯做重構與物件導向。
- Presenter只會影響目前的view,不像mutator會有side effect。
- 符合SOLID的單一職責原則 : 顯示邏輯被封裝在presenter內,不像mutor將顯示邏輯寫在model內。
- 符合SOLID的開放封閉原則 : 將來若有新的語言,對於擴展是開放的 : 只要新增class實踐
DateFormatPresenterInterface
即可;對於修改是封閉的 : controller、factory interface、factory與view都不用做任何修改。 - 可單獨對presenter的顯示邏輯做unit test。
Conclusion
- Blade : 最直覺,但與HTML夾雜,不適合寫太複雜的邏輯,且違反SOLID原則。
- Mutator : 很方便,但要小心對其他view或controller的side effect,unit test會直接碰到資料庫,速度較慢。
- Presenter : 較複雜,適合複雜的邏輯,容易維護,符合SOLID原則,unit test不會碰到資料庫,速度非常快。
Sample Code
完整的範例可以在我的GitHub上找到。