Miles' Blog

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

Faker 套件使用方法非常單純--使用工廠(Factory)建構產生器(Generator),然後把產生器拿來用就對了。

如果有認真翻原始碼的話,會發現它是有經過設計的。內部元件間是鬆耦合狀態,這也表示我們也是能夠簡單地客製化自己的產生器的。

以下會以類別名討論,為節省版面空間,將會把 Faker 命名空間省略。

負責生產線的 Factory

Factory 是標準的 Simple Factory Pattern 實作,它使用靜態方法 create() 取得固定一種類型的物件--Generator。類別圖如下:

@startuml
Class Client
Class Factory
Class Generator
Client -- Factory
Client --> Generator : use
Factory -> Generator : create
@enduml

Generator 是需要經過組裝的,因客戶要求的 $locale 不同,而會有不同的組裝內容。跟現實生活的生產線一樣,組裝 Generator 的任務是交由 Factory 負責的。

Client,也就是使用 Faker 套件的客戶端,只要使用 Factory::create() 就能保證一定會拿到 Generator。如果物件組裝過程有問題的話,則會丟例外。

負責產生假資料的 Generator

Generator 的 doc block 定義了很多屬性和方法,但會發現裡面完全沒有實作,也就是全靠 __get()__call() 達成目的。

public function __get($attribute)
{
return $this->format($attribute);
}

public function __call($method, $attributes)
{
return $this->format($method, $attributes);
}

接著我們會發現它們裡面用不同的方法呼叫了同一個方法 format()

public function format($formatter, $arguments = array())
{
return call_user_func_array($this->getFormatter($formatter), $arguments);
}

這裡因為 method 參數命名的關係,也搞混了一陣子。後來才發現是這樣的:我們來找 doc block 裡屬性與方法名字一樣的,如 namename(),然後代入上面的 Magic Method 試試:

public function __get($attribute = 'name')
{
return $this->format($attribute);
}

public function __call($method = 'name', $attributes = [])
{
return $this->format($method, $attributes);
}

這時代入 format() 就會非常容易理解了:

$this->format('name');
$this->format('name', []);

因此 format() 的任務就很明白了:它會用取到的 Formatter 拿來當 callback 呼叫。

再來翻 getFormatter() 做了什麼:

public function getFormatter($formatter)
{
foreach ($this->providers as $provider) {
if (method_exists($provider, $formatter)) {
$this->formatters[$formatter] = array($provider, $formatter);

return $this->formatters[$formatter];
}
}
throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter));
}

首先最開頭的 if 實作方法,有點類似 Registry of Singleton Pattern--手邊有一系列的物件,但想確保每個物件都是單例。

if (isset($this->formatters[$formatter])) {
return $this->formatters[$formatter];
}

第二段的 foreach 會把所有的 Provider 拿出來一個一個找看看有沒有同名的 method。

foreach ($this->providers as $provider) {
if (method_exists($provider, $formatter)) {
$this->formatters[$formatter] = array($provider, $formatter);

return $this->formatters[$formatter];
}
}

像剛剛的 name 屬性或方法,實際呼叫會找到 Provider\Personname 方法。接下來會把 callback 設定單例,之後 Client 就能經由 Generator 直接轉接到 Provider\Person 裡的同名函式了。

這是標準 Facade Pattern--所有對 Provider 操作的行為,都隱藏在 Generator 的 getFormatter() 裡面。

而最後如果都找不到的話,就會丟例外:

throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter));

組合技 parse

parse() 的原始碼如下:

public function parse($string)
{
return preg_replace_callback('/\{\{\s?(\w+)\s?\}\}/u', array($this, 'callFormatWithMatches'), $string);
}

preg_replace_callback 函式文件說明,第二個參數是 callback,實際呼叫的函式是下面這一個:

protected function callFormatWithMatches($matches)
{
return $this->format($matches[1]);
}

這個正則主要會把下面的文字抓出來,然後一個一個丟到 callback:

// 原始文字
$string = '{{ word1 }} {{ word2 }}';

// 實際 preg_replace_callback 會做的事
$this->callFormatWithMatches([
'{{ word1 }}',
'word1,
])

$this->callFormatWithMatches([
'{{ word2 }}',
'word2,
])

format() 會接到陣列第二個值,也就是 word1word2,取代則是整個 pattern 取代。而 format() 前面也追過原始碼了,它會轉接到 Provider 對應的方法。

也許有點難理解,來看看它的測試案例好了:

public function testParseReturnsStringWithTokensReplacedByFormatters()
{
$generator = new Generator();
$provider = new FooProvider();
$generator->addProvider($provider);
$this->assertEquals('This is foobar a text with foobar', $generator->parse('This is {{fooFormatter}} a text with {{ fooFormatter }}'));
}

它裡面用了一個自定義的 FooProvider,裡面長這樣:

class FooProvider
{
public function fooFormatter()
{
return 'foobar';
}

public function fooFormatterWithArguments($value = '')
{
return 'baz' . $value;
}
}

因此這個 Generator 加上 FooProvider 會有這樣的效果:

$generator = new Generator();
$provider = new FooProvider();
$generator->addProvider($provider);

$generator->fooFormatter // foobar

而使用在 parse() 上則會有這樣的效果:

$generator->parse('This is {{fooFormatter}} a text with {{ fooFormatter }}');

// 將會回傳 'This is foobar a text with foobar'

講這麼多,其實結論就是:下面這兩段程式碼的效果是一樣的:

echo "你好我是 {$generator->name},這位 {$generator->name} 是我的好朋友\n";

echo $generator->parse("你好我是 {{ name }},這位 {{ name }} 是我的好朋友\n");

輸出結果:

你好我是 Ms. Elissa Schinner,這位 Miss Dannie Mraz II 是我的好朋友
你好我是 Candelario Leffler,這位 Robyn Lubowitz 是我的好朋友

其他方法相較單純,像 addProvider() 之類的,就不介紹了。

今天把生線工人 Factory 與產生器 Generator 介紹完了,明天來細看 Provider 的設計。

參考資料

在開發階段時,取名是讓開發者覺得非常困擾的任務之一。

當然,變數或函式命名必須得好好想想,不然容易造成別人看不懂的技術債。但有一種很想亂打就好,但系統會要求你不能亂打的--測試資料。

比方說,前兩天上測試環境要註冊帳號看到:

miles 這個使用者名稱已有人使用,請試試其他名稱。

對厚,上禮拜才用這個帳號,那換 miles123 試試:

miles123 這個使用者名稱已有人使用,請試試其他名稱。

可…可惡,又重覆了!那 miles482842781937382383724 總沒用過吧

miles482842781937382383724 可以使用哦,揪咪 ^.<

系統是在揪咪什麼啦!算了,總之而言,註冊好了。

(十分鐘後…)

嗯…剛剛的帳號名稱是什麼?忘了,再註冊一個吧!(上面的故事再循環一次)

又或者是,系統上會有 100 多個姓「麥」的,然後有 50 個都叫「爾斯」,當測試環境出問題的時候,看到「麥爾斯」出錯,還真的不知道是哪一個在搞鬼。

登登登登!假.文.產.生.器!

https://www.youtube.com/watch?v=ecjQvXCsVl4

這套件的功能,就是產生假資料,常見的姓名當然難不倒它:

$faker = Faker\Factory::create();

echo $faker->name;

再來看看它還可以產生什麼:

// 地址
echo $faker->address;

// 電話
echo $faker->phoneNumber;

// email
echo $faker->email;

// 密碼
echo $faker->password;

// IP
echo $faker->ipv4;

// User Agent
echo $faker->userAgent;

// 信用卡
echo $faker->creditCardNumber;

// 廢文
echo $faker->text;

// ...

各式各樣的假資料都能產生,不僅如此,它還支援多語系:

$faker = Faker\Factory::create('en_US');
echo "$faker->name\n";
$faker = Faker\Factory::create('zh_TW');
echo "$faker->name\n";
$faker = Faker\Factory::create('ja_JP');
echo "$faker->name\n";
$faker = Faker\Factory::create('ko_KR');
echo "$faker->name\n";

輸出可能如下:

Robert Becker
帥哲哲
野村 千代
옥형민

什麼!區區一個假文產生器怎麼這麼自戀,叫什麼「帥哲哲」!?沒關係,這幾天讓我們一起來一探假文產生器的奧妙,到時要叫「金城武」還是「金秀賢」都隨開發者高興了!

參考資料

這幾天將會使用 Faker v1.7.1 做範例。

學一個程式語言,最快的學法就是直接從實作中學。從今天開始要進入應用程式實作階段了!

閱讀全文 »

今天來繼續看 Carbon 還有擴充哪些功能

COMPARISONS

Carbon 提供許多比較的方法,讓我們在判斷時間會方便很多。

eq() lt() gt()

這些方法相信許多開發者都有看過。即使沒看過,每一個方法也都有相對應的別名可以參考使用。

between() min() max()

這些方法是基於上面的基本比較方法實作出來的。

closest() farthest()

這兩個方法是基於 DIFFERENCES 功能實作的。

isPast() isFuture() is*()

這些方法因為非常語言化,所以用起來很方便。會這樣設計,是因為我們在平常用詞時,會不自覺使用這些字眼:「這日期過去了嗎?」「這日期是未來嗎?」「這日期是今天嗎?」等等。

而且都可以跟昨天提到的建構方法直接串接:

// 明天是週末嗎?
Carbon::tomorrow()->isWeekend();

// 今天是禮拜日嗎?
Carbon::now()->isSunday();

// 訂單過期了嗎?
Carbon::parse($expireDate)->isPast();

// 交付日到了嗎?
Carbon::parse($deliveryDate)->isFuture();

// 是生日嗎?當然不是
Carbon::createFromDate(2000, 1, 1)->isBirthday();

// 硬把日期改成生日
Carbon::setTestNow(Carbon::createFromDate(2018, 1, 1));

// 是了吧!
Carbon::createFromDate(2000, 1, 1)->isBirthday();

ADDITIONS AND SUBTRACTIONS

如果直接修改字串來調整時間,不知道要寫多少程式碼來判斷例外狀況,像是閏年、大小月等等,但使用 Carbon 就很簡單了:

// 三小時前
Carbon::now()->subHours(3);

// 下個月
Carbon::now()->addMonth();

// 去年的今天
Carbon::now()->subYear();

// 再 10 秒後就跨年了嗎?
Carbon::now()->addSeconds(10)->isNextYear();

// 19 號開始的鐵人賽,要到 2018-01-18 才會完賽
Carbon::createFromDate(2017, 12, 19)->addDays(30)->toDateString();

除了年月日時分秒的計算外,它還有一季(三個月)、一世紀(百年)、週(七天)等等常見的算法。

DIFFERENCES

通常老闆問還有多少天才做得完,那可以用 ADDITIONS 來加日期來跟老闆說哪時會好,但如果是老闆壓時間的話,就得用 DIFFERENCES 來看我們還有多少天可以趕了:

// 跨年前要完成
$deadline = Carbon::createFromDate(2018, 1, 1);

// 天啊只剩不到 10 天
Carbon::now()->diffInDays($deadline);

MODIFIERS

它能把物件的時間調整成如方法描述一樣,比方說:

// 這個月月初
Carbon::now()->startOfMonth();

// 週未的最後一刻
Carbon::now()->endOfWeek();

// 下禮拜五
Carbon::now()->next(Carbon::FRIDAY);

// 下一個工作日
Carbon::now()->nextWeekday();

總結

翻完 Carbon 程式碼,會發現它實作語意化行為的基礎,是建立在許多基本功能上,比方說加減日期、時間調整和計算差異,這些基本功能可以實作出平常對談的用詞,像是「明天」、「上個月」、「下週」等等。

另外有趣的是,它幾乎所有的方法都是公開的(public),雖然最小知識原則提醒我們最好不要這麼做。但是這三天原始碼看下來,這些方法都是大家一般對時間的認知,因此反而是公開會比較恰當。

最後,這是一個繼承很棒的範例,如果覺得繼承後寫出來的東西不如想像中好用,或許可以參考 Carbon,了解繼承還可以如何寫!

昨天了解 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 標準的輸出。


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

0%