ISO 8601形式のDateTimeの扱いには気をつけよう

## 概要

これは、私の体験談ですがFlutterでクライアントサイドの開発をしている際、APIのレスポンスで来る日付と、アプリ内部で持っている現在日時を比較するというロジックに関する不具合報告が上がってきました。
それについて調査していたところ、*ISO 8601*形式のDateTimeがAPIから返却されるのに対し、何も考えずただDateTime型にパースしていました。

その結果、よからぬ挙動を起こしていたため備忘録として残したいと思います。

## DateTimeの種類（抜粋）
データーベースのシステムや、FirebaseなどのNoSQLサービスなどにも依存すると思いますが、DateTimeはいろいろな形式があります。

:::note warn
この記事でのサンプルは、JST（日本標準時）の補正をかけた時間を用います
:::


#### ISO 8601 
ISO8601が今回の問題の日付フォーマットですが、以下のような種類があります
- 完全形式: `2023-08-01T00:00:00+09:00`
- 短縮形式: `20230801T000000+0900`
- 時刻情報なし: `2023-08-01`
- etc...

#### RFC 3339
こちらも意外と使われやすいのではないでしょうか
- `2023-08-01T00:00:00+09:00`

#### ASP.NET JSON Date
- `/Date(1694056800000+0900)/` (ミリ秒単位のUNIXタイムスタンプ、JST)

## 実際に不具合の事象
Dartを使っていたので、事例だけDartで記載します。
後半の方で説明する解決法については Java, TypeScript, Dart, PHPで記載します

```dart
void main() {
  final DateTime currentTime = DateTime.parse('2023-08-01T16:00:00.000Z');
  final DateTime apiResponseDate = DateTime.parse('2023-08-02T00:00:00.000+09:00');
  
  print(currentTime); // 2023-08-01 16:00:00.000Z
  print(apiResponseDate); // 2023-08-01 15:00:00.000Z
  print(currentTime.isBefore(apiResponseDate)); // false
}
```

`apiResponseDate`を何も考えずに変換してしまった結果、当然のとおりJSTの補正が入り9時間ずれてしまいました。
正直、サーバー側で補正かけるかUTCで共通にしておいてくれ...と思いましたが今回のプロジェクトではクライアントで補正するようになりました。

# 対処案１：力技で時間補正を削除する
クライアントで処理するにあたり、日付と時刻まで必要で補正分だけ無視しなくていけなかったので、補正時刻を排除するメソッドを作成しました。
実際に、言語別で書いてみました

:::note warn
基本的に、相当なことがない限り力技は好ましくありません。対処案2の方で記載した組み込み関数や専用のライブラリで解決する手法の方が適しています。
:::

### Dart

```dart
void main() {
  final DateTime jstDateTime = DateTime.parse('2023-08-02T00:00:00.000+09:00');
  print(jstDateTime); // 2023-08-01 15:00:00.000Z
  
  // 補正時刻を排除した処理
  final DateTime currentTime = DateTime.parse(removeTimezone('2023-08-01T16:00:00.000Z'));
  final DateTime apiResponseDate = DateTime.parse(removeTimezone('2023-08-02T00:00:00.000+09:00'));
  
  print(currentTime); // 2023-08-01 16:00:00.000
  print(apiResponseDate); // 2023-08-02 00:00:00.000
  print(currentTime.isBefore(apiResponseDate)); // true
}

// 補正時刻の排除をする
String removeTimezone(String date) {
  RegExp regExp = RegExp(r"(\+\d{2}:\d{2}|\-\d{2}:\d{2}|Z)$");
  return date.replaceAll(regExp, '');
}
```

### Java
```java
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

public class Main {
    public static void main(String[] args) {
        ZonedDateTime jstDateTime = ZonedDateTime.parse("2023-08-02T00:00:00.000+09:00");
        System.out.println(jstDateTime); // 2023-08-02T00:00+09:00

        LocalDateTime currentTime = LocalDateTime.parse(removeTimezone("2023-08-01T16:00:00.000Z"), DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"));
        LocalDateTime apiResponseDate = LocalDateTime.parse(removeTimezone("2023-08-02T00:00:00.000+09:00"), DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"));

        System.out.println(currentTime); // 2023-08-01T16:00
        System.out.println(apiResponseDate); // 2023-08-02T00:00
        System.out.println(currentTime.isBefore(apiResponseDate)); // true
    }

    public static String removeTimezone(String date) {
        return date.replaceAll("(\\+\\d{2}:\\d{2}|\\-\\d{2}:\\d{2}|Z)$", "");
    }
}
```

### PHP
```php
<?php
date_default_timezone_set('UTC');

$jstDateTime = new DateTime('2023-08-02T00:00:00.000+09:00');
echo $jstDateTime->format('c'); // 2023-08-02T00:00:00+09:00
echo "\n";

$currentTime = new DateTime(removeTimezone('2023-08-01T16:00:00.000Z'));
$apiResponseDate = new DateTime(removeTimezone('2023-08-02T00:00:00.000+09:00'));

echo $currentTime->format('c'); // 2023-08-01T16:00:00+00:00
echo "\n";
echo $apiResponseDate->format('c'); // 2023-08-02T00:00:00+00:00
echo "\n";
echo $currentTime < $apiResponseDate ? 'true' : 'false'; // true

function removeTimezone($date) {
    return preg_replace('/(\+\d{2}:\d{2}|\-\d{2}:\d{2}|Z)$/', '', $date);
}
?>
```


### TypeScript
```ts
function main() {
    let jstDateTime = new Date('2023-08-02T00:00:00.000+09:00');
    console.log(jstDateTime); // 2023-08-01T15:00:00.000Z

    let currentTime = new Date(removeTimezone('2023-08-01T16:00:00.000Z'));
    let apiResponseDate = new Date(removeTimezone('2023-08-02T00:00:00.000+09:00'));

    console.log(currentTime); // 2023-08-01T16:00:00.000Z
    console.log(apiResponseDate); // 2023-08-02T00:00:00.000Z
    console.log(currentTime < apiResponseDate); // true
}

function removeTimezone(date: string): string {
    let regExp = new RegExp("(\\+\\d{2}:\\d{2}|\\-\\d{2}:\\d{2}|Z)$");
    return date.replace(regExp, '');
}

main();
```

# 対処案２：組み込み関数で解決
こういうことって、結構あるあるでは？と思い組み込み関数が言語別にないか調べてみました。
### Dart： toLocal()
Dartでは`toLocal`メソッドがありました！これを用いたらJSTの差分を取り除けそうです

https://api.dart.dev/stable/3.0.7/dart-core/DateTime/toLocal.html

```dart
void main() {
  final date = "2023-08-02T00:00:00.000+09:00";
  print(DateTime.parse(date).toLocal()); // 2023-08-02 00:00:00.000
}
```

### Java: toLocalDateTime()

Javaも変換をしつつ`toLocalDateTime`を使えば同じことができそうです

https://qiita.com/riekure/items/d83d4ea5d8a19a267453

```java
import java.time.*;

public class Main {
    public static void main(String[] args) {
        String date = "2023-08-02T00:00:00.000+09:00";
        ZonedDateTime zdt = ZonedDateTime.parse(date);
        System.out.println(zdt.toLocalDateTime()); // 2023-08-02T00:00
    }
}
```

### PHP: DateTimeZone + format
PHPは専用関数があるというわけではなさそうです。
`DateTimeZone`と`format`の組み合わせで達成できそうです。

```php
<?php

$dateTime = new DateTime("2023-08-02T00:00:00.000+09:00");
$zoneOffset = new DateTimeZone("Asia/Tokyo");
$dateTime->setTimezone($zoneOffset);

echo $dateTime->format("Y-m-d H:i:s");

?>
```

### TypeScript

TypeScriptでは、Luxonというパッケージを用いればできそうです

https://www.npmjs.com/package/@types/luxon

```
npm install --save luxon
```

```ts
import { DateTime, IANAZone } from 'luxon';

let dateTime = DateTime.fromISO("2023-08-02T00:00:00.000+09:00");
const zoneOffset = new IANAZone("Asia/Tokyo").offset(dateTime);
dateTime = dateTime.setZone("Asia/Tokyo");

console.log(dateTime);
```

## 最後に
今回、やはり原因はサーバーサイドで予期せぬJST（日本標準時）の補正がかかっていたことと、その補正を考慮していなかったことが根本原因だと思われます。
今回はクライアントサイドで開発していたためサーバーサイドの技術スタックやインフラ要件がわからないので直接「これだ！」という原因はわかりませんが、おそらくTimeZoneを`Asia/Tokyo`で指定している関係かなと思います。
意図的にというよりは、フレームワークやインフラ側の設定が影響してそうです。

API作る側も使う側も気をつけていきたいですね。