この記事ではLaravelなどでもよく使われる、PHPの日付ライブラリ「Carbon」についての記事です。
私が実際に業務の中で出くわした「Carbon::parse()の引数がnullの場合」の事例について紹介します。
知っておかないと気付かぬうちにバグが発生しかねないのでよければ参考にしてみてください。
この記事を読むメリット
- Carbon::parse()を使用する際の注意点を理解できる
Carbon::parse()でハマった事例
まずはどんな事例でハマってしまったか。
それは、Carbon::parse()でCarbonのインスタンスを作成する際に起こりました。
Carbonを使うにはまず、Carbonのインスタンスを作成する必要があります。
作成方法は、「new Carbon()」や「Carbon::today()」など、いくつかあります。
詳しく知りたい方は以下記事を参考にしてみてください。
「Carbon::parse()」を使って、引数にはユーザーが設定した日付データが入るようになっていました。
$settingDate = Carbon::parse($setting->date);
こんな感じです。
引数に入っている「$setting->date」がきちんと設定されている場合は正常に設定された日付が表示されていました。
しかし問題はこの値が設定されてない場合、「null」の場合です。
その場合、日付は表示されますがなぜか設定したものと異なる日付が表示されるようになっていました。
Carbon::parse()の引数がnullの場合はCarbon::now()になる
原因は「Carbon::parse()の引数がnull」になっていたことでした。
Carbon::parse()の引数がnullだと、現在時刻を返すようです。
Carbon::parse()の引数によって変わることを確認
実際にコードでも確認してみます。
Route::get('/', function () {
$carbon = Carbon::parse('2021-01-01 00:00:00');
$nullCarbon = Carbon::parse(null);
return view('welcome')
->with([
'carbon' => $carbon,
'nullCarbon' => $nullCarbon,
]);
});
引数に値が入っている「$carbon」と引数がnullの「$nullCarbon」を用意しました。
この2つの値を表示させてみます。
<p>
{{$carbon}}
</p>
<p>
{{$nullCarbon}}
</p>
結果は以下のようになります。
$carbonの方はきちんと引数に入れた値が表示されています。
$nullCarbonの方は現在時刻が表示されています。(実行時が2024/8/27でした)
実際にCarbon::parse()の引数がnullだと現在時刻、Carbon::now()の値が入ることが確認できました。
エンジニアにおすすめ書籍
エンジニアになりたて、これから勉強を深めていきたいという方におすすめの書籍はこちら!
Carbon::parse()の引数はnullが入らないようにする
対策としては、「引数に入れる値がきちんとnullではない値がどうか確認する」ことです。
そしてnullだった場合は別の表示方法にするかエラーを返すなどの処理を加えるようにする必要があります。
nullは基本的には許容しないのがベストです。
nullの取り扱いは意識してするようにしないと予期せぬエラーが発生しやすいです。
Carbonの中身をのぞいてみる
現象と対策は理解できたかと思います。
ここからは「Carbon::parse(null)」の場合に実際にどのような流れで現在時刻が表示される流れになっているのか見てみたいと思います。
対象コードは「vendor/nesbot/carbon/src/Carbon/Traits/Creator.php」に記述されています。
流れはざっくり以下のようになります。
- Carbon::parse()が呼び出される。
- $functionが設定されていない場合、Carbon::rawParse()が呼び出される。
- Carbon::rawParse()はnew static($time, $timezone)を実行し、Carbonクラスのコンストラクタが呼び出される。
- コンストラクタ内で、$timeがnull、空文字、または’now’の場合、’now’がデフォルト値として設定される。
- parent::__construct($time ?? ‘now’, $timezone)が実行され、現在時刻が設定される。
実際のコードの中身をのぞいてみます。
function parse()の中身
まずはparse()関数の中身を見てみます。
public static function parse(
DateTimeInterface|WeekDay|Month|string|int|float|null $time,
DateTimeZone|string|int|null $timezone = null,
): static {
$function = static::$parseFunction;
if (!$function) {
return static::rawParse($time, $timezone);
}
if (\is_string($function) && method_exists(static::class, $function)) {
$function = [static::class, $function];
}
return $function(...\func_get_args());
}
$functionが設定されていない場合、「return static::rawParse($time, $timezone);」が呼び出されます。
function rawParse()の中身
続いてrawParse()の中身をみてみます。
public static function rawParse(
DateTimeInterface|WeekDay|Month|string|int|float|null $time,
DateTimeZone|string|int|null $timezone = null,
): static {
if ($time instanceof DateTimeInterface) {
return static::instance($time);
}
try {
return new static($time, $timezone);
} catch (Exception $exception) {
// @codeCoverageIgnoreStart
try {
$date = @static::now($timezone)->change($time);
} catch (DateMalformedStringException|InvalidFormatException) {
$date = null;
}
// @codeCoverageIgnoreEnd
return $date
?? throw new InvalidFormatException("Could not parse '$time': ".$exception->getMessage(), 0, $exception);
}
}
ここでは、「if ($time instanceof DateTimeInterface)」で$timeがDateTimeInterfaceのインスタンスであるかチェックをします。
nullの場合は条件から外れるのでtry文の「return new static($time, $timezone);」が実行されます。
コンストラクタの中身
「return new static($time, $timezone);」でCarbonクラスのコンストラクタが呼び出されます。
その中身は以下になります。
public function __construct(
DateTimeInterface|WeekDay|Month|string|int|float|null $time = null,
DateTimeZone|string|int|null $timezone = null,
) {
$this->initLocalFactory();
if ($time instanceof Month) {
$time = $time->name.' 1';
} elseif ($time instanceof WeekDay) {
$time = $time->name;
} elseif ($time instanceof DateTimeInterface) {
$time = $this->constructTimezoneFromDateTime($time, $timezone)->format('Y-m-d H:i:s.u');
}
if (\is_string($time) && str_starts_with($time, '@')) {
$time = static::createFromTimestampUTC(substr($time, 1))->format('Y-m-d\TH:i:s.uP');
} elseif (is_numeric($time) && (!\is_string($time) || !preg_match('/^\d{1,14}$/', $time))) {
$time = static::createFromTimestampUTC($time)->format('Y-m-d\TH:i:s.uP');
}
// If the class has a test now set, and we are trying to create a now()
// instance then override as required
$isNow = \in_array($time, [null, '', 'now'], true);
$timezone = static::safeCreateDateTimeZone($timezone) ?? null;
if (
($this->clock || (
method_exists(static::class, 'hasTestNow') &&
method_exists(static::class, 'getTestNow') &&
static::hasTestNow()
)) &&
($isNow || static::hasRelativeKeywords($time))
) {
$this->mockConstructorParameters($time, $timezone);
}
try {
parent::__construct($time ?? 'now', $timezone);
} catch (Exception $exception) {
throw new InvalidFormatException($exception->getMessage(), 0, $exception);
}
$this->constructedObjectId = spl_object_hash($this);
self::setLastErrors(parent::getLastErrors());
}
try文の中身「parent::__construct($time ?? ‘now’, $timezone);」で$timeがnullの場合には「now」が設定されています。
読み解くと現在時刻が表示される理由がはっきりわかりますね。
ライブラリの動きには要注意
いかがだったでしょうか。
Carbonに限らず、ライブラリは便利に使える反面、やっていることが見えにくいです。
このような罠にハマらないためにライブラリの使い方をきちんと把握したり、nullの取り扱いに気をつける必要があります。
自分もハマってみてライブラリがどういうロジックで動いているかコードを追うきっかけにもなったのでよかったです。
同じような現象が起こった方の参考になれば幸いです。