概要
これは、私の体験談ですがFlutterでクライアントサイドの開発をしている際、APIのレスポンスで来る日付と、アプリ内部で持っている現在日時を比較するというロジックに関する不具合報告が上がってきました。
それについて調査していたところ、ISO 8601形式のDateTimeがAPIから返却されるのに対し、何も考えずただDateTime型にパースしていました。
その結果、よからぬ挙動を起こしていたため備忘録として残したいと思います。
DateTimeの種類(抜粋)
データーベースのシステムや、FirebaseなどのNoSQLサービスなどにも依存すると思いますが、DateTimeはいろいろな形式があります。
この記事でのサンプルは、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で記載します
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で共通にしておいてくれ...と思いましたが今回のプロジェクトではクライアントで補正するようになりました。
対処案1:力技で時間補正を削除する
クライアントで処理するにあたり、日付と時刻まで必要で補正分だけ無視しなくていけなかったので、補正時刻を排除するメソッドを作成しました。
実際に、言語別で書いてみました
基本的に、相当なことがない限り力技は好ましくありません。対処案2の方で記載した組み込み関数や専用のライブラリで解決する手法の方が適しています。
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
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
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
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();
対処案2:組み込み関数で解決
こういうことって、結構あるあるでは?と思い組み込み関数が言語別にないか調べてみました。
Dart: toLocal()
DartではtoLocal
メソッドがありました!これを用いたらJSTの差分を取り除けそうです
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
を使えば同じことができそうです
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
$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というパッケージを用いればできそうです
npm install --save luxon
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作る側も使う側も気をつけていきたいですね。