ISO8601の期間を「H:i:s」形式に変換させる

## 環境
・Laravel 9
・PHP 8.2

## 経緯
LaravelでYouTube Data API v3を使いYoutube上の急上昇動画を取得するコンソールアプリを開発していた時のこと。

APIを叩くと動画情報のJSONが返却されるが、その中に再生時間（contentDetails.durationプロパティ）があり、値を取得するとISO8601の期間になっていた。
例えば再生時間が5分の場合、「00:05:00」ではなく「PT5M」が取得される。

~~Carbonライブラリを使っての対応はできなさそうだったので、やむを得ず正規表現を使っての対応。。。~~

<2023/10/28 追記>
PHPの組み込みで日付間隔の操作を提供するDateIntervalクラス（ISO8601の日付にも対応）を使うともっと簡単に記述できるということを教えていただきました。
そのため、元々実装していた正規表現を使ったやり方と新しくDateIntervalクラスを使ったやり方の2種類を以下にに用意してみました。

## 変換メソッド（正規表現）

<details>
<summary>該当メソッド</summary>

時、分、秒が0の場合はそれぞれH、M、Sがなくなることに注意。

```php:YoutubeService.php
<?php

namespace App\Services;

use Illuminate\Support\Str;

class YoutubeService
{
    // ... 割愛

    /**
     * ISO8601の期間を指定のフォーマットされた時間へ変換
     * 動画の長さは「PT#H#M#S」形式であるため、「H:i:s」形式へ変換する
     *
     * @param string $duration
     * @return string
     */
    private function _convertIso8601ToTime(string $duration) : string
    {
        preg_match_all('/PT(([0-9]){1,2}H)?(([0-9]{1,2})M)?(([0-9]{1,2})S)?/', $iso8601Time, $matches);
        $hour = Str::contains($iso8601Time, 'H') ? $matches[2][0] : '00';
        $minute = Str::contains($iso8601Time, 'M') ? $matches[4][0] : '00';
        $second = Str::contains($iso8601Time, 'S') ? $matches[5][0] : '00';
        
        return sprintf('%02d:%02d:%02d', $hour, $minute, $second);
    }
```
※クラスには該当メソッドのみ記載。
</details>

<details>
<summary>テストコード</summary>

```php:ConvertIso8601ToTimeTest.php
<?php

namespace Tests\Unit;

use Illuminate\Support\Str;
use Tests\TestCase;

class ConvertIso8601ToTimeTest extends TestCase
{
    /**
     * ISO8601の期間を指定のフォーマットされた時間へ変換
     * 「PT#H#M#S」形式の期間が「H:i:s」形式の時間へ変換されることを確認する
     *
     * @return void
     */
    public function test_convert_iso8601_to_time() : void
    {
        ### Arrange
        $iso8601Times = [];
        // 時、分、秒のみ
        array_push($iso8601Times, 'PT1H', 'PT5M', 'PT10S');
        // 時、分、秒のうち2種類を含む
        array_push($iso8601Times, 'PT1H10M', 'PT15H1S', 'PT3M20S');
        // 時、分、秒全てを含む
        array_push($iso8601Times, 'PT1H10M5S', 'PT2H1M10S', 'PT10H20M30S');

        ### Act
        foreach ($iso8601Times as $iso8601Time) {
            preg_match_all('/PT(([0-9]){1,2}H)?(([0-9]{1,2})M)?(([0-9]{1,2})S)?/', $iso8601Time, $matches);
            $hour = Str::contains($iso8601Time, 'H') ? $matches[2][0] : '00';
            $minute = Str::contains($iso8601Time, 'M') ? $matches[4][0] : '00';
            $second = Str::contains($iso8601Time, 'S') ? $matches[5][0] : '00';
            $convertedTime = sprintf('%02d:%02d:%02d', $hour, $minute, $second);

            ### Assert
            $this->assertEquals(1, preg_match_all('/([0-9]{2}):([0-9]{2}):([0-9]{2})/', $convertedTime));
        }
    }
}
```
</details>

## 変換メソッド（DateIntervalクラス）

<details>
<summary>該当メソッド</summary>

```php:YoutubeService.php
<?php

namespace App\Services;

use DateInterval;

class YoutubeService
{
    // ... 割愛

    /**
     * ISO8601の期間を指定のフォーマットされた時間へ変換
     * 動画の長さは「PT#H#M#S」形式であるため、「H:i:s」形式へ変換する
     *
     * @param string $duration
     * @return string
     */
    private function _convertIso8601ToTime(string $duration) : string
    {
        return (new DateInterval($duration))->format('%H:%I:%S');
    }
```
※クラスには該当メソッドのみ記載。
</details>

<details>
<summary>テストコード</summary>

```php:ConvertIso8601ToTimeTest.php
<?php

namespace Tests\Unit;

use DateInterval // 追加
use Tests\TestCase;

class ConvertIso8601ToTimeTest extends TestCase
{
    /**
     * ISO8601の期間を指定のフォーマットされた時間へ変換
     * 「PT#H#M#S」形式の期間が「H:i:s」形式の時間へ変換されることを確認する
     *
     * @return void
     */
    public function test_convert_iso8601_to_time() : void
    {
        ### Arrange
        $iso8601Times = [];
        // 時、分、秒のみ
        array_push($iso8601Times, 'PT1H', 'PT5M', 'PT10S');
        // 時、分、秒のうち2種類を含む
        array_push($iso8601Times, 'PT1H10M', 'PT15H1S', 'PT3M20S');
        // 時、分、秒全てを含む
        array_push($iso8601Times, 'PT1H10M5S', 'PT2H1M10S', 'PT10H20M30S');

        ### Act
        foreach ($iso8601Times as $iso8601Time) {
            $convertedTime = (new DateInterval($iso8601Time))->format('%H:%I:%S');
            
            ### Assert
            $this->assertEquals(1, preg_match_all('/([0-9]{2}):([0-9]{2}):([0-9]{2})/', $convertedTime));
        }
    }
}
```
</details>

## まとめ
両者を見比べてみると、DateIntervalクラスを使ったやり方がシンプルで可読性の点においても良いですね。

再生時間がとんでもなく長い動画の場合は上記コードでは対応できないかも知れないので、パターンを見直すか任意の再生時間を超えた場合は例外を投げるようにする必要が出てくるかと思います。

~~可読性の点で複雑な正規表現は後々メンテが大変なので最終手段以外では使いたくない。。。~~

## 余談
■ Youtube Data APIの仕様について

24時間を超える動画の再生時間は「PT#H#M#S」形式で取得されるのか、「P#DT#H#M#S」形式で取得されるのか気になったので時間があるときに調査してみようと思います。

## 参考

■ Youtube Data APIについて

https://developers.google.com/youtube/v3/docs/videos?hl=ja#properties

■ ISO8601形式の期間について

https://amg-solution.jp/blog/9491

■ DateIntervalクラスについて

https://notepad-blog.com/content/154/