🐥

Carbonとday.jsでISO8601形式の日時文字列を扱い、現地時刻で表示/保存する方法

2023/02/17に公開

目的

以下の状態を実現することを目的として、日時表記の国際標準であるISO8601やLaravel、Carbonでの利用方法について調査した結果をまとめた。

  • ユーザーの各端末(Web、アプリ)では表記が現地時刻になっている
    • 同じデータを見ていても、端末側の時刻設定に応じて時刻表記が異なる
    • たとえばJST2023年3月1日5時のデータをイギリスで開いたときは、2月28日20時と表示される
  • 日本リージョンに置かれておりAsia/Tokyoタイムゾーンで動作しているサーバーからのプッシュ通知やメール通知にて、本文中の時刻がユーザーに合わせて現地時刻表記される
  • 日付を含むデータを投稿した場合、投稿後の日時が現地時刻になっている
  • 日時を含むデータを日時で検索した場合、現地時刻で検索できている
    • たとえば、JST2023年3月1日5時のデータを、UTCで3月1日〜31日で検索した場合はヒットしない

なお、提供しているアプリケーションの技術要件は、WebフロントエンドがReact、バックエンドがLaravel、スマホアプリがReact Native(Expo)とする。

正確に理解が及んでいないことも何点か残っているので、本記事の内容を適用するときは各自環境にて再検証することを推奨する。

基礎知識

タイムゾーン

世界各地には時差があり、時差が同じでかつ地理的に近い地域をひとまとめにした地域をタイムゾーンという。
日本はAsia/Tokyoというタイムゾーンに属しており、UTC(ロンドンのタイムゾーン)と比較して+9時間の時刻が流れている。

ISO8601

時刻を表す世界標準の文字列の形式。特徴としては、文字列の最後に現地のUTCと比較した時差を記載する。標準形式に従うことで、日時を扱うライブラリとの統合が容易になる。


2023-03-01T12:00+09:00
これはUTCでと比較して+9時間(日本)の地域において3月1日12時であることを意味する。

したがって、

  • 2023-03-01T12:00+09:00
  • 2023-03-01T03:00+00:00 ※一般的ではない
  • 2023-03-01T03:00Z ※UTCの場合はこのように表す
  • 2023-03-01T00:00-03:00

は全く同じ時刻を意味する。

Carbonとタイムゾーン

一般に、日本で提供されているサービスにおいて、Laravelを使っていれば、日時ライブラリCarbonのタイムゾーンはAsia/Tokyoにデフォルトで設定されていることが多い。(config/app.php)

したがって以下のテストコードは通る。

        $jst = Carbon::create(2023, 1, 16, 4, 0, 0);
        self::assertSame('Asia/Tokyo', $jst->tzName);

これはどういうことを意味するかというと、時差をつけていない形式(2023-03-01 12:00など)でバックエンドに送信された文字列がCarbon::createに渡されると、日本時刻として扱われることを意味する。

したがって、前述したことと関連するが、フロントエンドからバックエンドサーバーに日時文字列を渡したいときは、現地時刻にしつつISO8601形式にした状態でPOST/GETするか、割り切ってフロントエンド側で日本時刻に治してPOST/GETすることの2択であるといえる。そうでなければ、現地時刻と日本の時差分ずれたデータがPOSTされたり、GETされてしまう。

以下のように、ISO8601形式でNew Yorkの日時をCarbonにわたすと、タイムゾーンが-5時間として扱われることがわかる。

        $timeInNY = Carbon::create('2023-01-15T14:00:00-05:00');
        self::assertSame('-05:00', $timeInNY->tzName);

また、一度生成したCarbon型のインスタンスはあとからタイムゾーンを変更することができる。

        $jst = Carbon::create(2023, 1, 16, 4, 0, 0);
        self::assertSame('Asia/Tokyo', $jst->tzName);
        self::assertSame('2023-01-16T04:00:00+09:00', $jst->toIso8601String());
        self::assertSame('2023-01-16 04:00:00', $jst->toDateTimeString());
        $jst->utc();
        self::assertSame('2023-01-15T19:00:00+00:00', $jst->toIso8601String());
        self::assertSame('2023-01-15 19:00:00', $jst->toDateTimeString());
        $jst->setTimezone('America/New_York');
        self::assertSame('2023-01-15T14:00:00-05:00', $jst->toIso8601String());
        self::assertSame('2023-01-15 14:00:00', $jst->toDateTimeString());

Carbonとデータ保存

これは非常に奇妙な挙動なので、解決策が他にあったり他の方法で回避できる可能性が否定できていないのだが、テストコードで証明できたDBへの日付データ保存時の挙動について解説する。

なお、DBのカラムの型はmigrationファイルでtimestampTz関数で指定している。LaravelのデフォルトタイムゾーンはAsia/Tokyoにしている環境下である。

        Schedule::query()->create([
            'teacher_id' => $this->teacherUser->teacher->id,
            'title' => '毎週筋トレに行くんだよ',
            'repetition' => Repetition::EVERY_WEEK,
            // JST 1月16日早朝4時
            // UTC 1月15日夜19時
            'start_at' => Carbon::create(2023, 1, 16, 4, 0, 0),
            'end_at' => Carbon::create(2023, 1, 16, 5, 0, 0),
            'is_all_day' => false,
        ]);

        // JSTで検索する
        $res = Schedule::query()
            ->where('start_at', '>=', Carbon::create(2023, 1, 16, 4, 0, 0))
            ->where('end_at', '<=', Carbon::create(2023, 1, 16, 5, 0, 0))
            ->get();
        self::assertCount(1, $res);

        // UTCで検索する
        $res = Schedule::query()
            ->where('start_at', '>=', Carbon::create(2023, 1, 16, 4, 0, 0)->utc())
            ->where('end_at', '<=', Carbon::create(2023, 1, 16, 5, 0, 0)->utc())
            ->get();
        self::assertEmpty($res);

        // UTCで検索する2
        $res = Schedule::query()
            ->where('start_at', '>=', Carbon::create('2023-01-15T19:00:00+00:00'))
            ->where('end_at', '<=', Carbon::create('2023-01-15T20:00:00+00:00'))
            ->get();
        self::assertEmpty($res);

クエリビルダで日時を使ってデータを検索する場合、あらかじめJSTでデータが保存されていれば、検索するときにJST以外で検索するとヒットしない事象が確認できた。上記コードでは割愛したが、逆にUTCで保存しているときはJSTで検索してもヒットしない。どうやら時差を無視してクエリしてしまっているようで、その証拠に上記コードでUTC1月16日4時〜5時で検索するとヒットした。

以上より、フロントエンドから現地時刻でPOST/GETされたとしても、最終的にDBに格納するときはJSTで統一したり、whereするときにJSTで検索したほうがいいと考えられる。保存・取得時のタイムゾーンがバラバラだとクエリ時の結果がバラつきそうだからである。

この挙動の原因はいくつか考えられるが、現時点ではわかっていない。

  • LaravelでタイムゾーンをAsia/Tokyoに固定していることによる仕様
  • CarbonをtoStringしたときに時差情報が落ちた文字列になっているから(whereの内部挙動を読んでいないが、どこかでstringキャストされているとしたら、そのときの結果は2023-01-15 14:00:00といった時差無し文字列になるため時差を考慮されないクエリ結果になってしまうといえる)
  • whereを使って日時をクエリするのが間違っている(なおwhereDateでクエリしても同じだった)
  • timestampTz型の仕様
  • その他

※原因がわかっていないため、もし当記事を読んで同様の実装に取り組みたい場合、まずお手元の環境でCarbonを使った上記テストコードを書いて通るか検証することをおすすめする。本検証に関係なく、手軽にライブラリの動作を検証する際にテストコードを書くのはおすすめである。

とりあえず、前述の通り、DB保存時や取得時に必ずJSTにすることで本問題は回避できる。面倒だがフロントエンドからPOSTされたりクエリされた現地時刻のISO8601表記文字列からCarbonインスタンスを作った後、setTimezone(Asia/Tokyo)することで回避できた。

getTimezoneOffsetと時差の取得

本節からフロントエンドの話に移る。

Date.prototype.getTimezoneOffset関数は、実行環境におけるUTCとの時差を分単位で求めてくれる。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date/getTimezoneOffset

普段扱っている時差と正負が反転しているのがややこしく、たとえば日本だと-540が返ってきてしまうので、取り扱うときはマイナスをつけると直感的な値になる。

Webブラウザ、React Nativeアプリ(iOS、Android)で動作確認できた。

実行環境における時差を自動で取得できるので、これを記録するAPIをクライアントから定期的または一部の設定画面等でPOSTするようにしておくと、データベースにユーザーの居住地域の時差を保存できる。これを使って、もともと実現したかった要件の一つである、サーバーサイドからの通知本文に時差を反映するという要件が実現できる。

なお、ここはタイムゾーンのマスタを用意して、クライアントからユーザーに自由にタイムゾーンを選択してもらう、という対応も妥当ではあるが、ユーザーが自由にタイムゾーンを選べるようにするとフロントエンドでの時刻対応が面倒になったり、ユーザーにタイムゾーンに関する理解を求めることになるので、今回の実装ではその要件は避けた。

React Nativeに関する注意

ユーザーの居住地域の時差を自動で取得するというと、タイムゾーンを取得する方法も考えられる。たとえば後述するday.jsにはタイムゾーンを取り扱えるプラグインもあるので、それを通してWebブラウザ、またReact Native on iOSにおいてはタイムゾーンを取得できる。
しかしReact Native on Androidにおいては、Date.prototype.toLocaleStringとIntlが無いのでタイムゾーンの取得ができない。IntlのPolyfillをformatjsからインストールして設定する方法がGitHub等で説明されているのを見かけて手元環境にて試したが最終的に動作しなかった。
また、expo-localizationというライブラリでタイムゾーンをネイティブ層から取得できる可能性もあったが、実際にインストールしてみると、実機起動時にクラッシュする問題が起きて取得できなかった。結果として、React Native on Expoのアプリでも時差を自動取得したい場合、getTimezoneOffset関数しか見当たらないという結果になった。

day.jsとISO8601

day.jsは日時を取り扱うことのできるJavaScriptライブラリで、最もメジャーなものの1つである。

day.jsもISO8601表記に対応しており、以下のように、ISO8601表記でday.jsインスタンスを初期化すれば、現地時刻(日本でクライアントが動いていれば日本時刻)で扱われるし、ISO8601表記ではないシンプルな日時表記だと時差分ずらされずに扱われるようである。

  console.log(dayjs('2023-03-01T00:00Z').format('YYYY-MM-DD HH:mm:ss')); // 2023-03-01 09:00:00
  console.log(dayjs('2023-03-01 00:00').format('YYYY-MM-DD HH:mm:ss')); // 2023-03-01 00:00:00

この挙動はWebブラウザ、React Nativeアプリ(iOS、Android)で確認できた。

そのため、バックエンドから返すAPIレスポンス内の日時は原則ISO8601表記にしておけば、day.jsを介してフォーマットするだけで現地時刻表記となる。フォーマット関数は共通化してday.jsを使っていることを抽象化しておくと便利だ。

一方、たとえばUIでDatePickerを使っていて、DatePickerが吐き出す日付文字列が'2023-03-01 00:00'といったISO8601ではない(時差を含まない)文字列だった場合、POSTする際に注意が必要ともいえる。この場合、DatePickerの挙動を修正してISO8601形式を吐き出すように修正するか、難しい場合は、前述したgetTimezoneOffsetを使って強制的にISO8601形式に治すことが必要と考えられる。

たとえば、以下のように実装すると、formatToISO8601WithTZ('2023-03-31 00:00')を実行した結果がAsia/Tokyoにおいては2023-03-31T09:00+09:00が返ってきて、現地時刻でのISO8601表記でPOSTできるようになる。これをDatePickerから返ってくる値に噛ませてからPOSTすると正しく動作する(これも、dayjsのtimezoneプラグインを使うなどすればもっとシンプルに組めるかもしれないが、React Native on Android対応があるので以下のように力技で実装した)。
とはいえ都度対応するのは面倒なので、DatePicker側で修正するほうが望ましいかもしれない。

export const formatToISO8601WithTZ = (dateString: string): string => {
  return formatToISO8601WithTZByDayJs(createDayjs(dateString));
};
const formatToISO8601WithTZByDayJs = (dayjs: dayjs.Dayjs): string => {
  const timeDiffHere = new Date(dayjs.toISOString()).getTimezoneOffset();
  return (
    dayjs.subtract(timeDiffHere, 'minutes').toISOString().split('.')[0] +
    timezoneOffsetToHoursString(dayjs.toISOString())
  );
};
const timezoneOffsetToHoursString = (isoDateString: string) => {
  let minutes: string | number = new Date(isoDateString).getTimezoneOffset() * -1;
  const sign = minutes < 0 ? '-' : '+';
  minutes = Math.abs(minutes);
  let hours: string | number = Math.floor(minutes / 60);
  minutes = minutes % 60;
  hours = hours < 10 ? '0' + hours : hours;
  minutes = minutes < 10 ? '0' + minutes : minutes;
  return sign + hours + ':' + minutes;
};

サマータイム

サマータイムもタイムゾーンの実装に関してややこしい要因の一つである。

サマータイムとは、一部の海外の地域において、春〜秋の期間中に時計を1時間進める風習のことである。たとえばアメリカにおいては、3月の第2日曜日から10月末までとされている(なおサマータイムに関しては廃止する方向性を打ち出している地域もあるようだが、ここではその点について議論しない)。
サマータイムについてわかったことを簡潔に以下にまとめておく。

  • Date.prototype.getTimezoneOffset関数は、サマータイムを考慮してくれる。サマータイム期間中のDateインスタンスに対して実行すると同じ地域でも1時間分ずれた分数を返してくれる
  • day.jsでサマータイム期間中の日付をインスタンス化すると、サマータイムを考慮してくれる
  console.log(dayjs('2023-03-01T00:00Z').format('YYYY-MM-DD HH:mm:ss')); // 2023-02-28 19:00:00
  console.log(dayjs('2023-03-21T00:00Z').format('YYYY-MM-DD HH:mm:ss')); // 2023-03-20 20:00:00
  • バックエンドでサマータイムを考慮するのは少々大変で、完璧にできるかどうか自分はわかっていない。
    • 具体的には、ある日付とタイムゾーンがわかったときに、その日付がサマータイム内かどうかわかるのが難しい。タイムゾーン文字列を使ってDateTimeZone型のインスタンスを作って、getTransitionsという関数を実行し、取得できた配列に対してisdstというフラグがTrueかどうかをチェックすることで、特定の日付がサマータイム内かどうかわかるようだ。
    • なお、そもそも時差の分数を保存する方針で実装している場合、時差の分数からサマータイム地域かどうか断定するのは無理だろうから、サマータイム考慮は無理に近しい。
  • 一方、タイムゾーン設定済みのインスタンスが用意できるのであれば、それに対してサマータイムは自動で考慮される
    public function testタイムゾーンを設定すると時刻表記が変わるwithサマータイム()
    {
        $targetDate = Carbon::createSafe(2001, 1, 31, 10, 0, 0, 'Asia/Tokyo');
        self::assertSame('2001-01-31 10:00:00', $targetDate->timezone('Asia/Tokyo')->format('Y-m-d H:i:s'));
        self::assertSame('2001-01-31 07:00:00', $targetDate->timezone('Asia/Almaty')->format('Y-m-d H:i:s'));
        self::assertSame('2001-01-31 01:00:00', $targetDate->timezone('Europe/London')->format('Y-m-d H:i:s'));

        $targetDateInDST = Carbon::createSafe(2001, 3, 31, 10, 0, 0, 'Asia/Tokyo');
        self::assertSame('2001-03-31 10:00:00', $targetDateInDST->timezone('Asia/Tokyo')->format('Y-m-d H:i:s'));
        self::assertSame('2001-03-31 08:00:00', $targetDateInDST->timezone('Asia/Almaty')->format('Y-m-d H:i:s'));
        self::assertSame('2001-03-31 02:00:00', $targetDateInDST->timezone('Europe/London')->format('Y-m-d H:i:s'));
    }

タイムゾーンの検証

Macではシステム環境設定の日付と時刻から、iOSでは設定画面→一般→日付と時刻から、Androidでは端末によるが、同様の方法でタイムゾーンを変更することができる。

まとめ

これまでの基礎知識を踏まえつつ、冒頭の要件を実現するには以下の方針で実装すると良い。

  • ユーザーの各端末(Web、アプリ)では表記が現地時刻になっている
    • バックエンドから返す日付をISO8601形式にすれば、day.jsがよしなに現地時刻でフォーマットしてくれる
    • サマータイムは勝手に考慮される点に注意
  • 日本リージョンに置かれておりAsia/Tokyoタイムゾーンで動作しているサーバーからのプッシュ通知やメール通知にて、本文中の時刻がユーザーに合わせて現地時刻表記される
    • React Native on Android(Expo)での対応が必要な場合に限り、getTimezoneOffsetの値を正負を反転して保存すると良い
    • タイムゾーン文字列(Asia/Tokyo)を保存できるならそちらのほうがサマータイム考慮のしやすさ等を加味しても望ましいと思う
    • なお、時差の分数を保存する方針にしている時点で、バックエンドでのサマータイム考慮は難しそうというのが個人的な結論
  • 日付を含むデータを投稿した場合、投稿後の日時も現地時刻になっている
    • バックエンドにPOSTする際に現地時刻のISO8601表記にして、バックエンド処理にてタイムゾーンをAsia/Tokyoにして保存
    • DatePickerの仕様によるが、もし時差のないシンプルな日時表記を吐き出す場合は、自前の関数でISO8601形式に修正するとよさそう
    • 未検証だがフロントエンドからPOSTする際は地域によらず日本時刻に治す、という方針でもうまくいくかもしれない
  • 日時を含むデータを日時をつかって検索した場合、現地時刻で検索できている
    • クエリパラメータでは現地時刻、バックエンドでwhere検索する際にAsia/Tokyoにタイムゾーンを設定するのが安定
    • クエリパラメータの時点でAsia/Tokyoにするのも可と思われるが、バックエンド処理で現地タイムゾーンが必要になる可能性がある(少なくとも手元の施策では特定ユースケースで現地タイムゾーンが必要になった)ので現地タイムゾーンでクエリするのがよいと思われる
マナリンク Tech Blog

Discussion