JavaScriptにおけるTZ関連のDateの挙動と日付比較時の注意点

久々に実装してみると混乱する挙動1位に君臨するくらい、TZの扱いが苦手です。
案の定今回dayjsを使って日付の比較処理を行っていた際に、意図しない挙動が起きて混乱してしまいました。

しっかりテストを書いて安心していたが、そもそもそのテストの実行環境のTZまで考慮できていなかったという要因も混ざって苦戦しました。

悪戦苦闘したのち「完全に理解した」ので備忘録としてまとめます。
動作確認しながら書いてはいるのですが、もし認識に間違いがありましたら教えていただけると嬉しいです。

先に結論

・Dateの初期化はローカルでの開発時にTZを指定していないとJSTになる
・Dateの初期化はdeployした環境(Dockerfile使用)ではTZを指定していないとUTCになる
・JSTの日付ベースでの比較を行うときは比較対象を両方JSTのTZにしてから比較する必要がある

今回動作確認した環境のバージョンは以下の通りです

Node.js 18.12.1
dayjs 1.11.7

目次

問題となったコードとその挙動

MySQLにday_jstというdate型のカラムが存在していて、その日付が当日かどうかを判定するロジックをサーバー側で以下のように記載していました。
詳細は省きますが、ORMとしてprismaを使用しています。

const foo = await this.prisma.foo.findUnique({ where: id })

dayjs(foo.day_jst).isSame(dayjs(), "date")

prismaを使って対象レコードを引いてきた後、day_jstが当日であるかどうかを判定するという意図です。
(dayjs()は現在時刻を返します)

しかし、このコードはTZに関して考慮できておらず、ある条件下では意図しない挙動となってしまいます。

順を追って説明するために、以下ではまずdayjs(Dateオブジェクト)の初期化時の挙動を見て、その後上記のコードの何が問題かを追っていきます。

Dateの初期化時の挙動

まず、JSでnew Date()でdateオブジェクトを生成する時の挙動は、その引数によって3パターンに分けられます。

こちらの記事がかなり分かりやすく、おおいに参考にさせていただきました。

ほぼ上記の記事の再掲となりますが、今回はNode.js上でそれぞれのパターンで実行した時の挙動を見ていきます。

1. 引数なしで初期化

引数なしで初期化した場合は現在時刻になりますが、これは実行環境でのTZが使用されます

new Date().toString()
// Sun Mar 19 2023 02:02:47 GMT+0000 (Coordinated Universal Time)
// Sun Mar 19 2023 11:02:22 GMT+0900 (Japan Standard Time)

2. TZ指定ありの文字列で初期化

TZ指定ありの文字列を使った時も、1と同じく実行環境でのTZが使用されます

new Date("2023-03-19T12:00:00.000Z").toString()
// Sun Mar 19 2023 12:00:00 GMT+0000 (Coordinated Universal Time)
// Sun Mar 19 2023 21:00:00 GMT+0900 (Japan Standard Time)

3. TZ指定しない場合の文字列で初期化

TZを指定しない文字列の場合は、実行環境でのTZでその時刻となるDateオブジェクトが生成されます

new Date("2023-03-19T12:00:00.000").toString()
// Sun Mar 19 2023 12:00:00 GMT+0000 (Coordinated Universal Time)
// Sun Mar 19 2023 12:00:00 GMT+0900 (Japan Standard Time)

上記2のケースでは元の時間からTZを考慮して9時間プラスされた時間が出力されるのに対し、こちらのケースでは与えられた時間がそのままそのTZでの時間として解釈されます

TZを指定しないと実行環境によって値の持つ意味が変わってしまうので、基本的には3のケースは避けた方が無難な気がしています

ここではJSのDateオブジェクトで説明しましたが、dayjsを使った場合も同様の挙動となります。

何が問題だったか?

先ほどの問題となったコードを再掲します

const foo = await this.prisma.foo.findUnique({ where: id })

dayjs(foo.day_jst).isSame(dayjs(), "date")

まず、prismaを使った場合は、date型のカラムはUTCでその日の00:00:00のDateオブジェクトとして取得されます
(今回は関係ありませんが、MySQLのTZが設定されていても無視されてUTCとして取得されてしまうらしいです…)

2023-03-19                  // MySQLのday_jstカラム
'2023-03-19T00:00:00.000Z'  // prismaによってインスタンス化されたday_jst

そのため、以下のコードにおけるdayjsオブジェクトは両方実行環境のTZが適用されます。

dayjs(foo.day_jst).isSame(dayjs(), "date")

同じTZにおける比較なので当初はこれでよいと思っていました。
しかし、要件は「日本時間で当日かどうか」を判定する必要があるのでこのままでは問題があります。

どういうことかというとMySQLのカラムの中身が2023-03-19だった場合、foo.day_jst'2023-03-19T00:00:00.000Z'なのでUTCでもJSTでも日付ベースで見ると2023-03-19となります。
これは意図通りです。

問題はdayjs()の方でこれは現在時刻を返すのですが、先ほど見た仕様から実行環境のTZが使用されます。
仮にこのコードがTZ=UTCで実行されたとすると、日本時間のAM9時までに実行された場合とそれ以降で実行された場合では以下のようになります。

// TZ=UTC
dayjs()
// '2023-03-18T18:00:00.000Z'  ← 日本時間のAM3時に実行された場合
// '2023-03-19T00:00:00.000Z'  ← 日本時間のAM9時に実行された場合

つまりTZがUTCの環境では、日本時間のAM9時までに実行された場合は日付ベースで見ると前日となってしまい、以下のコードはTZがUTCの環境では日本時間のAM9時までに実行された場合とそれ以降に実行された場合で結果が異なってしまいます。

// TZ=UTC
dayjs(foo.day_jst).isSame(dayjs(), "date")
// false ← 日本時間のAM3時に実行された場合
// true  ← 日本時間のAM9時に実行された場合

そしてdeployした環境はDockerfileを使用していて、特にTZも指定していないのでUTCとなっており、まさにこの現象が起こってしまっていました。

対応策

今回は、「日本時間で当日かどうか」を判定する必要があるためシンプルにTZをJSTとした上で比較すれば良さそうです。

dayjs.extend(utc)
dayjs.extend(timezone)

dayjs(foo.day_jst).tz("Asia/Tokyo").isSame(dayjs().tz("Asia/Tokyo"), "date")

dayjsでtzを指定した場合はdayjsオブジェクトの中でTZが設定されるので、実際にformatして出力した場合にはTZを考慮した値として吐き出されます。
(Dateオブジェクトにした際には、関係なくシステムのTZで変換されてしまう)

console.log(dayjs().toDate().toString())
console.log(dayjs().tz("Asia/Tokyo").toDate().toString())
// Sun Mar 19 2023 03:45:10 GMT+0000 (Coordinated Universal Time)
// Sun Mar 19 2023 03:45:10 GMT+0000 (Coordinated Universal Time)

console.log(dayjs().format("YYYY-MM-DD HH:mm:ss"))
console.log(dayjs().tz("Asia/Tokyo").format("YYYY-MM-DD HH:mm:ss"))
// 2023-03-19 03:45:10
// 2023-03-19 12:45:10

先ほどのisSameメソッドはdayjsオブジェクトとして比較操作を行っているので両方JSTに設定した上で比較を行えば意図通り「日本時間で当日かどうか」を判定できそうです。

ちなみにpluginの中にisTodayというものがありますが、内部実装を見てみると比較対象の日付と現在時刻(UTC)のformatした値で突き合わせを行なっていそうでした。
従って、この関数では今回のように「日本時間での日付が同じかどうか」という判定には使えなさそうに思います。

export default (o, c, d) => {
  const proto = c.prototype
  proto.isToday = function () {
    const comparisonTemplate = 'YYYY-MM-DD'
    const now = d()

    return this.format(comparisonTemplate) === now.format(comparisonTemplate)
  }
}

まとめると、時間を加味しない「日付」ベースで比較を行う際はちゃんとそのTZ同士での比較を行う必要がありそうです。

今考えると当たり前なんですが、理解が浅く整理できてない当時は気づけませんでした。

ローカルでの挙動とdeploy先での挙動を合わせる

実は今回のロジックが正しいことを担保するために、テストはきちんと書いていました。

ローカルでテストが通ったのでCI上でテストを実行した際、テストが落ちてしまいその原因がまさにTZによるものでした。
TZを指定していないのでローカルでテストを実行した際はシステム上のTZが使用されるのでJSTになります。
一方でCI上ではUTCとなります。

この時あまり全体像をよく把握せずに、実行環境がJSTじゃないのでテストが落ちるのがいけないと思いCI上のTZをJSTに設定したのがそもそもの間違いでした。

これによってテストではTZ=JSTの場合の挙動が担保できただけでTZ=UTCの挙動が担保できていません。
実際にdeploy先の実行環境はTZ=UTCなので意味がないものとなってしまっていました。

同じようにローカルでの動作環境も行なって問題ないことを確認していましたが、こちらも同様システム上のTZが使用されてTZ=JSTで動作確認していたためでした。

以上の経緯から、そもそもローカル環境とCI環境、deploy先の環境はTZを揃えた方が良さそうです。

DBに入ってる値はUTCであることなどを加味すると全てUTCで扱った方が良さそうなので、今回はdeploy先の環境はそのままにし、ローカル環境とCI上の環境でTZ=UTCとすることにしました。

従ってローカルでサーバーを起動する時とテストを実行する時のコマンドとしてTZ=UTCを指定するようにしました。

{
  "scripts": {
    "start:dev": "TZ=UTC nest start --watch",
    "start:debug": "TZ=UTC nest start --debug --watch",
    "test": "TZ=UTC jest",
    "test:watch": "TZ=UTC jest --watch",
  }
}

この状態で動作確認とテストが通ればdeploy先での挙動を担保できそうです。

諸事情により今回はローカルの開発はDockerを使用していないのですが、ローカル開発をDocker環境で行なっていればデフォルトのTZがUTCになるので動作確認時に気付けたかもしれません。

ちなみに似たような操作をサーバー側だけでなくフロントの方でも書いてるのですが、そちらの方はTZを指定しなくても特に問題なさそうでした。
というのも実行されるのはブラウザ上で、サービス特性上そのコードはJST環境で実行されることを前提としているからです。

まとめ

TZ関連は昔からかなり苦手意識があったのですが、今回手を動かしながら丁寧に処理を追っていったことと、こうして整理できたことでかなり理解が進んだ気がします。

認識間違っている場所があれば教えていただけると嬉しいです。

この記事が参考になったからコーヒーくらいおごってもいいぜという方は、以下からサポートいただけると次の記事書くモチベになりますのでよろしくお願いします

Buy Me A Coffee

参考

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次