Miles' Blog

天涯何處無幹話,何必要講實務話

昨天了解 Carbon 套件是利用繼承來擴充物件的行為,我們今天一起來看看它是怎麼設計的。

首先原始碼註解很明確的分很多實作區塊,如 GETTERS AND SETTERS,接下來會以這些區塊來說明它擴充的方法。

CONSTRUCTORS

這個區塊的方法都是在建立物件,所以大部分都是靜態方法。

分為幾種類型:

  • 從 DateTime 實例轉成 Carbon 的建立方法(Carbon::instance()
  • 當要用 fluent style 時,官方建議使用靜態方法(Carbon::parse())來取代 new
  • 取得現在時間(Carbon::now()
  • 快速相對時間,如今天(Carbon::today())、昨天(Carbon::yesterday())、明天(Carbon::tomorrow())。
  • Carbon 支援的最大值(Carbon::maxValue())與最小值(Carbon::minValue()
  • 給「年月日時分秒」的建立方法(Carbon::create())與經過把關的建立方法(Carbon::createSafe()
  • 特定來源資料的建立方法(Carbon::createFrom*()
  • 從現有物件複製物件(Carbon::copy()

以下會挑幾個特別的方法做說明:

instance()

這個方法會先判斷是不是自己(Carbon)的實例,再決定要如何做事。

如果是的話會使用 clone,不過事實上改成使用 copy() 這樣也是可行的:

if ($dt instanceof static) {
return $dt->copy();
}

而不是的話則會使用 DateTime 提供的 format 方法與 getTimezone 方法來取得 Carbon 建構所需要的參數。

這樣設計會有個好處:其他繼承 DateTime 的物件也能順利轉換成 Carbon 物件。

parse()

註解裡有提示說,如果要使用 fluent style 的話,用這個靜態方法會比較好,比方說:

echo Carbon::parse($time)->addDay();
echo (new Carbon($time))->addDay();

確實在看原始碼時,上面的小括號少一點,會比較容易看懂。

today() tomorrow() yesterday()

today() 是先取得 now() 的物件後,再設定時間為 00:00:00tomorrow()yesterday() 則是先取得 today() 物件後再加或減一天。

從這裡的原始碼,會發現物件提供許多語意化的方法,會很容易了解並重用物件所提供的行為。

後面會再討論這些的行為是如何設計的。

maxValue() minValue()

因為 DateTime 設計上並不是無上下限的,所以會設計這個方法來取得極限值,來協助判斷是否溢位。很多語言,像 PHP 也有提供 INT 的最大值與最小值的常數:

echo PHP_INT_MIN . ' ~ ' . PHP_INT_MAX;

這裡會看到有重用 create() 方法。

create() createFromDate() createFromTime()

注意:create() 也會受到 $testNow 的影響。

Carbon 對 create() 的設計是,各別的年月日值,如果有給 null,它就會各別設計當下的年月日。

echo Carbon::now();                     // 2017-12-23 18:55:36
echo Carbon::create(2018, null, 31); // 2018-12-31 18:55:36

但時間比較特別,如果「時」給了 null,分秒會跟年月日情況一樣;如果「時」不是給 null,分秒的預設值則會變 0

echo Carbon::now();                           // 2017-12-23 18:58:38
echo Carbon::create(2018, null, 31, null); // 2018-12-31 18:58:38
echo Carbon::create(2018, null, 31, 12); // 2018-12-31 12:00:00

因為時間與日期的特性不同:時間的預設值可以給 0,但日期不行。這樣設計的話,createFromDate()createFromTime() 就能重用這個方法了。

雖然這樣設計的話,create() 方法會有例外行為,使用起來可能不是那麼方便。但是當使用者看到這三個方法與它們的參數時,自然會預期使用方法如下:

echo Carbon::now();                              // 2017-12-23 18:58:38
echo Carbon::createFromTime(12); // 2017-12-23 12:00:00
echo Carbon::createFromTime(0, 10, 20); // 2017-12-23 00:10:20
echo Carbon::createFromDate(2018); // 2018-12-23 18:58:38
echo Carbon::createFromDate(null, null, 31); // 2017-12-31 18:58:38
echo Carbon::create(2017, 12, 31, 23, 59, 50); // 2017-12-31 23:59:50

簡單來說當想省略一些參數時,自然不會選擇 create();使用 createFromDate()null 很明顯預設會是當下的日期;在使用 createFromTime() 的情境下,通常很少人給「時」,但分秒要當下的分秒。

createFromFormat()

昨天在建構子裡有介紹過了,可以參考。

copy()

會另外開 copy() 方法,是保留當未來 clone 無法做到的話(比方說深複製),可以在 copy() 補充。

GETTERS AND SETTERS

這裡有許多取值與設值的方法,還有 Magic Method。但有趣的是,Carbon 並沒有宣告自己的屬性,只有使用繼承的設定方法而已。這樣的設計可以減少狀態處理上的問題,但同時就會有效能上的問題。

year() month()

Carbon 有提供 Magic Method 來取值與設值,所以可以這樣寫:

$date = Carbon::now();

echo $date->year // 2017
echo $date->month // 12

$date->year = 2018;
$date->month = 1;

echo $date->year // 2018
echo $date->month // 1

而這些方法能讓開發者使用 fluent style 撰寫程式:

$date = Carbon::now();

echo $date->year // 2017
echo $date->month // 12

$date->year(2018)
->month(1);

echo $date->year // 2018
echo $date->month // 1

setDate() setDateTime() setTimeFromTimeString()

setDate() 昨天有提過。setDateTime()setTimeFromTimeString() 則是擴充 DataTime 原本的 setDate()setTime() 行為。

timezone() tz()

timezone()tz() 都是 setTimezone() 別名(Alias)方法。

翻 Carbon 的原始碼,會發現它有很多別名方法,回到最一開始說的「它提供許多語意化的行為」,因為有很多別名,使用上容易找得到自己想要的行為。但缺點是:當有很多方法可以達成同一個目的時,團隊做法容易不一致。

WEEK SPECIAL DAYS

每個國家甚至是每個人對於每個禮拜的特別日習慣都不大一樣,像有的人習慣禮拜日是第一天,有的人則是覺得禮拜一才是第一天。這裡可以讓開發者自行定義每個禮拜的特別日。

TESTING AIDS

這裡的方法是提供給測試使用的,昨天已有提過,不再贅述。

LOCALIZATION

這裡可以設定全域的語系,語系的翻譯套件是使用 symfony/translation

STRING FORMATTING

這裡的方法是提供輸出字串的格式化,除了大家常見的 toDateString() 外,還有許多遵守 RFC 標準的輸出。


今天看的都是建立方法與基本取值的方法,明天會看到一些比較神奇的比較方法或修改方法。

Carbon 本身並不複雜,它使用兩個物件,分別繼承了原生 PHP [DateTime][] 與 [DateInterval][] 類別,並實作了新的行為,讓它更好使用。

以下會翻 Carbon 1.22.1 版來說做明。

繼承如何實作才安全

在學 Design Pattern 時,常會聽到要「多用組合,少用繼承」。繼承這麼可怕,怎麼至今大多數語言都支援呢?這表示,繼承雖然有風險,但能避開風險的話,它仍然是個好用的觀念。像 Carbon 就是一個很好的例子,使用繼承擴展功能後,反而受到大多數開發者的喜愛。

我們這幾天可以一起來看看 Carbon 怎麼安全地實作繼承。

繼承的潛在風險

物件導向設計原則中,其中有一個原則是--里氏替換原則,身為一個子類,如果要繼承家業的話,必須要把父類原本做的事做好才行,就算有想要改善或是調整,也不能破壞行為。

所以,首先我們來看 Carbon 類別繼承了 [DateTime][] 哪些實作,來了解它是改善調整,還是破壞行為。

使用 IDE 可以很清楚知道下面這些方法有做覆寫:

public function __construct($time = null, $tz = null)
public static function createFromFormat($format, $time, $tz = null)
public static function getLastErrors()
public function setDate($year, $month, $day)
public function setTimezone($value)
public function modify($modify)

以下來看看這些方法到底做了哪些事:

__construct

原始碼

這裡可以注意到,建構子中間多了 if 判斷;後面在傳 $tz 前,還有做一層手腳,把這兩部分拿掉的話,就跟原本的 DateTime 完全一樣了。

其中,if 判斷主要的任務是為了在測試階段時,要把「現在」替換成指定的時間點。而指定的時間是放在靜態變數裡,建構時再去取得靜態變數(getTestNow())。

這要怎麼用呢?比方說,我們要測試跨年前 10 秒會不會自動啟動煙火機制,直接使用 date 指令調電腦時間實在是太蠢了,來看看 Carbon 怎麼做:

$realNow = new Carbon();
echo "Real: $realNow\n";

Carbon::setTestNow('2017-12-31 23:59:50');

$mockNow = new Carbon();
echo "Mock: $mockNow\n";

輸出結果如下:

Real: 2017-12-22 18:51:32
Mock: 2017-12-31 23:59:50

這還有另一個更顯著的好處:測試時間再怎麼長,任何時間點拿的 now 都會是同一個時間。

$realNow = new Carbon();
echo "Real: $realNow\n";

Carbon::setTestNow('2017-12-31 23:59:50');

$mockNow = new Carbon();
echo "Mock: $mockNow\n";

sleep(1);

$mockNow = new Carbon();
echo "Mock: $mockNow\n";

Carbon::setTestNow();

$cleanNow = new Carbon();
echo "Real: $cleanNow\n";

輸出結果如下:

Real: 2017-12-22 18:55:44
Mock: 2017-12-31 23:59:50
Mock: 2017-12-31 23:59:50
Real: 2017-12-22 18:55:45

如果程式需要依賴「現在」的話,將是非常好用的功能。

然而,它的啟動條件是先做設定「現在時間」(Carbon::setTestNow()),啟動前並不影響任何行為;啟動後則是位移時間,最終還是會傳正確 $time 格式給父類別。

safeCreateDateTimeZone() 則是在做正規化 DateTimeZone 和一些 TimeZone 格式錯誤時的錯誤處理,而且避開了 Bug #52063

因此這兩個功能都有加強原建構子的功能,並沒有破壞行為。

createFromFormat

原始碼

開頭的 if 判斷和正規化 DateTimeZone,與建構子 safeCreateDateTimeZone() 在做的事類似。

這邊會執行父類別建立 DateTime 的方法,接著 setLastErrors() 是存放建立時遇到的錯誤(parent::getLastErrors())。會這麼做的理由在最後面:因為 Carbon 設計這個 function 預期錯誤會丟例外,而不是 DataTime 回傳 false

如果是丟例外的話,需要有個地方取得錯誤訊息。是的,所以需要覆寫 getLastErrors,來取得剛剛呼叫 setLastErrors() 時傳入的 parent::getLastErrors()。這些過程有點繞,總之,Carbon 的目的是為了要把它改成「錯誤丟例外」。

如果 DateTime 成功建立,則會使用 instance() 轉換成 Carbon,再回傳出去。

原則上,這是一個工廠方法,所以回傳的物件應該會是 Class 本身,因此行為有點不同(回傳的是 Carbon 而不是 DateTime),但使用上並不會有任何影響。

getLastErrors

原始碼很單純,會這樣寫的理由請參考 createFromFormat 覆寫的原因。

實際會使用到的時機是在接 createFromFormat 方法所丟出的例外:

try {
$carbon = Carbon::createFromFormat('Y/m/d', 'unknown');
} catch (Exception $e) {
echo $e->getMessage();
}

輸出如下:

A four digit year could not be found
Data missing

setDate

原始碼註解有提到,這裡是為了 workaround 修這個 Bug

setTimezone

原始碼

正規化傳入的 Timezone,減少問題發生的機率。

modify

原始碼

翻了一下 commit 記錄與 issue,這是為了修 DateTime 的 bug。

另外還有一個 CarbonInterval 類別繼承 [DateInterval][]。它只有覆寫 __construct 的實作,換言之,它只改變了建立物件的方法,其他的行為都沒有改變。

行為不變最安全

由上面的分析看來,Carbon 並沒有改變原本物件的行為,因此我們甚至可以拿 Carbon 來取代任何需要使用 DateTime 的方法,達到里氏替換原則的精髓!


今天翻過它跟父類別,也就是跟原有功能有相關的程式,明天來看看擴充的功能有哪些,而這也正是 Carbon 吸引人的地方!

時間旅行一直以來都是電影或動漫的經典主題;時間處理也是--它是程式語言的經典卡關問題。

Carbon 是 PHP 的第三方時間處理套件。它繼承了原生的 Datatime,並新增了許多語意化的行為,讓處理時間的難度降低許多。

比方說:想像自己是未來世界的特南克斯,乘坐的時光機程式是用 PHP 寫的,那該如何知道 20 年前的 timestamp 呢?

讓 Carbon 來處理就很簡單:

use Carbon\Carbon;

echo Carbon::now('Asia/Tokyo')->subYears(20)->timestamp;

或是維斯在黃金弗利沙毀滅地球的時候,決定出手倒退時光。那他怎麼定位出三分鐘前的時間點呢?

對 Carbon 來說只是小菜一碟:

use Carbon\Carbon;

echo Carbon::now('Asia/Tokyo')->subMinutes(3)->timestamp;

為何用 Carbon

總括來說,Carbon 處理了下面的問題:

  1. 語意化的取值方法,如:

    Carbon::now();        // 現在
    Carbon::today(); // 今天 00:00:00
    Carbon::tomorrow(); // 明天 00:00:00
  2. 語意化的比較方法,如:

    $time1->lessThan($time2);          // $time1 是否比 $time2 早
    $time1->closest($time2, $time3); // 取得離 $time1 比較近的時間
    $time1->isWeekend(); // $time1 是週末嗎
  3. 頭痛的時區問題

    echo Carbon::now('Asia/Taipei');
    echo Carbon::now('Asia/Tokyo');

    // 兩個時間會差一小時

明天就來看看 Carbon 是如何解決這些問題的。

參考資料

大家在使用 Laravel 或是其他套件時,相信都用的非常開心。

但是否有想過,為何這些套件會這麼好用?新增功能,加個檔案就行了;修改功能,加個檔案就行了;移除功能,改個設定就行了。怎麼會這麼簡單?

而為何自己做的共用套件卻是常常被人嫌?該要有的功能都實作出來了呀,一樣都是共用,為何命運大不同?

這些套件會有這麼多 Star,當然是有原因的。

首先,套件是需要精心設計的。它們會遵守物件導向設計原則,做出適合擴展的設計,大家才能順利寫出客製化功能。

再來,不僅要有設計,也要有夠完整的測試。測試除了測功能,確保套件行為正常之外,還會測「身為開發者,會如何使用程式」;同時,測試也會是最好的範例文件。

最後還要有簡單易懂的說明文件,才能讓路過的開發者,在最短的時間理解套件的功能,並可以知道套件是否適用於自己的專案上。

以開發者為本

無論是設計、測試或是文件,都是針對多數開發者需求而做的,也因此,大家才能夠愉快地開發。身為一個開發者,先對所有開源作者致上十二萬分的敬謝之意。

往後的日子裡,會開始研究套件的設計,了解巨人的肩膀是如何實作出來的,期望自己在提升設計能力之後,有朝一日也能成為開源作者的一員。

0%