📆

和暦の落とし穴 - Java Date and Time API で和暦を扱うときの注意点

2024/12/05に公開

この記事は、Java Advent Calendar 2024の5日目の記事です。
昨日は @takeyoda さんによる 意外と知られていない (かもしれない) jackson ObjectMapper の制限 でした。(ちなみに自分はこの制限に引っかかったことがありますが、当時はSpring Bootでの設定方法が検索しても出てこなかったように記憶しています。)

JJUG CCC 2024 Spring のLTで発表した内容を再整理してお届けします。(当日のLT資料は末尾に載せています。)

はじめに

シェルフィー株式会社というところで、建設業向けのSaaS開発をしています。

建設業は守るべき法律やガイドラインが多く、そうすると 「和暦」 を目にする機会が多くあります。そんな和暦にまつわる ドウシテ(´・ω・`) と思ったトピックを書きたいと思います。

※ 実務でやっている処理ではありません。むしろ、実務で避けている処理です。

対象読者

  • Javaで和暦を扱いたい人
  • 和暦を扱いたくないから理由を探している人

動作確認バージョン

  • Java 21

トピック 3つ

Java Date and Time API には、なんと JapaneseChronologyJapaneseDate という、日本の暦を扱うための専用クラスがあります。

それらを用いるときの、ちょっとした注意点を3つ書きます。

  • 幻の日付をparseする
  • 年度を求める
  • 年末を求める

いずれも、2019/04/30が平成最後の日(平成31年04月30日)だったことがポイントになります。

幻の日付をparseする

改元の発表前に作られた書類や免許には、平成33年7月9日のように訪れることのなかった日付が記されていることがあります。

こういった表記をそのままユーザーに入力してもらったとして、どうやってプログラムで扱えばよいでしょうか?


幻となってしまった日付

日付文字列をparseするには

日付文字列をparseするには、DateTimeFormatterを用います。

たとえば西暦の"2012年3月4日"LocalDate型にするにはこうします。

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu年M月d日", Locale.JAPAN);
// formatter ==> Value(Year,4,19,EXCEEDS_PAD)'年'Value(MonthOfYear)'月'Value(DayOfMonth)'日'
LocalDate date = LocalDate.from(formatter.parse("2020年3月4日"));
// date ==> 2020-03-04

和暦の場合は、暦(Chronology)としてJapaneseChronology.INSTANCEを使い、またフォーマット文字列に起源(G)を指定することでparseが可能です。

import java.time.LocalDate;
import java.time.chrono.JapaneseChronology;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("GGGGy年M月d日", Locale.JAPAN);
// formatter ==> Text(Era)Value(YearOfEra)'年'Value(MonthOfYear)'月'Value(DayOfMonth)'日'

LocalDate date = LocalDate.from(formatter
        .withChronology(JapaneseChronology.INSTANCE)
        .parse("令和2年3月4日")
);
// date ==> 2020-03-04

幻の日付の扱い

では早速、幻となってしまった日付をparseしてみましょう。

// ...前略

LocalDate date = LocalDate.from(formatter
        .withChronology(JapaneseChronology.INSTANCE)
        .parse("平成31年5月1日")
);
// date ==> 2019-05-01

お、良い感じですね。

// ...前略

LocalDate date = LocalDate.from(formatter
        .withChronology(JapaneseChronology.INSTANCE)
        .parse("平成33年4月5日")
);
// |  例外java.time.format.DateTimeParseException: Text '平成33年4月5日' could not be parsed: Invalid YearOfEra for Era: Heisei 33
// |        at DateTimeFormatter.createError (DateTimeFormatter.java:2079)
// |        at DateTimeFormatter.parse (DateTimeFormatter.java:1940)
// ...

どうしてparseできたりエラーになったりするんだい (´・ω・`)

回避策を教わった

https://x.com/naotoj/status/1802486275376906252

ResolverStyleLENIENT(寛大, 甘い などの意)にすると良いらしい。

// ...略
import java.time.format.ResolverStyle;
// ...略

LocalDate date = LocalDate.from(formatter
        .withChronology(JapaneseChronology.INSTANCE)
        .withResolverStyle(ResolverStyle.LENIENT)  // ←追加
        .parse("平成33年4月5日")
);
// date ==> 2021-04-05

おー、できた (・ω・)v

たしかに、DateTimeFormatterのJavadoc(日本語, 英語)を見ると、SMARTがデフォルトだと書かれている。
逆に、ResolverStyle.STRICTを指定すれば、平成31年で5月以降を指定したときもエラーになる。

// ...略
import java.time.format.ResolverStyle;
// ...略

LocalDate date = LocalDate.from(formatter
        .withChronology(JapaneseChronology.INSTANCE)
        .withResolverStyle(ResolverStyle.STRICT)  // ← 変更
        .parse("平成31年5月1日") // ← 最初はparseできていた
);
// |  例外java.time.format.DateTimeParseException: Text '平成31年5月1日' could not be parsed: year, month, and day not valid for Era
// |        at DateTimeFormatter.createError (DateTimeFormatter.java:2079)
// |        at DateTimeFormatter.parse (DateTimeFormatter.java:1940)
// ...

なお、SMARTLENIENTは、元号変更に関わらずそもそも存在しない日付を存在したときの挙動に注意が必要だったり。。

たとえば平成31年2月31日

  • SMARTだと 2019-02-28
  • LENIENTだと 2019-02-28

スマートなのか…?

年度を求める

免許証の有効期限で気になるのは「年」でしたが、場合によっては「年度」が大事なこともあります。

令和○年度のようにはっきり書かれている場合もあれば、「○○番号の最初の数字は年度とする」といったように、ひっそりと使われていることもあります。(この場合は、年か年度かパッと見ても分からないですね。)


工事現場に掲示されているここは"許可年度"

Javaには LocalDate のほかに JapaneseDate というものがあります。
そこに年度を取得する関数が含まれているの…かと思いきや、 ありません

Javaの標準ライブラリで、年月日から年度を求める方法は提供されていなさそうです。

なので力業でこうやります

import java.time.LocalDate;
import java.time.Month;

LocalDate date = ...;
if (date.getMonthValue() < Month.APRIL.getValue()) {
    // (date.getYear() - 1) 年度
} else {
    // date.getYear() 年度
}

そもそもの話として

  • 平成31年4月30日(平成最後の日)は、平成31年度でしょうか?
  • その翌日の令和元年5月1日(令和最初の日)は、平成31年度でしょうか?それとも令和元年度でしょうか?

元号については、内閣府大臣官房総務課が担当しているそうです。
https://www.cao.go.jp/others/soumu/gengou/index.html

その中の、平成31年4月1日 新元号への円滑な移行に向けた関係省庁連絡会議申合せ によると、文書の作成日と改元日の関係で平成と令和を使い分けることになっています。

年表示が主な話題ですが、年度表示についても

国の予算における会計年度の名称については、原則、改元日以降は、当年度全体を通じて「令和元年度」とし、

と、改元日以降に作成された書類は令和元年度と表記するよう示されています。

しかし実際には、各自治体では変更処理が追いつかず、平成31年度の表記を引き続き利用し続けた自治体も多いようです。(例 東京都世田谷区, 大分県大分市)

というわけで、
Javaによらずそもそも元号変更で年度をシステムで正確に扱うのはムズカシイというのが探れば探るほど分かります (´・ω・`) 特に解決策はなし

年末を求める

前2つの話は、所定の日付が本当は令和何年なのか、令和(平成)何年度なのか を求めるのがムズカシイという話題でした。

ところで、「とある日から○日後」の年月日を求めたいとき というのもよくあるケースだと思いますが、稀に、「年末」を求めたいなんてこともあります。
たとえば、監理技術者講習(工事現場で監理技術者という役割で働くために受講が必要と定められているもの)は、5年後の年末が有効期限とされています。

では年末を求めてみましょう。

import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;

LocalDate date = LocalDate.of(2019, 1, 1);
> date ==> 2019-01-01

LocalDate lastDay = date.with(TemporalAdjusters.lastDayOfYear())
lastDay ==> 2019-12-31

安定ですね。

ところで、先ほどまでと同様に和暦で求めたいんだよなぁ という時のために、JapaneseDate型があります。使ってみましょう。

import java.time.chrono.JapaneseDate;
import java.time.temporal.TemporalAdjusters;

JapaneseDate date = JapaneseDate.of(2019, 1, 1);
// date ==> Japanese Heisei 31-01-01

JapaneseDate lastDay = date.with(TemporalAdjusters.lastDayOfYear());
// lastDay ==> Japanese Heisei 31-04-30  // ← !!

なんと、2019/01/01の年末はJapanese Heisei 31-04-30と出てきました。

なるほど、平成と令和は違う年として扱われるんですかね?5/1スタートだとどうなるか試してみましょう。

// ...
JapaneseDate date = JapaneseDate.of(2019, 5, 1);
// date ==> Japanese Reiwa 1-05-01

JapaneseDate lastDay = date.with(TemporalAdjusters.lastDayOfYear());
// lastDay ==> Japanese Reiwa 1-09-02  // ← !?!?

2019/05/01の年末は、Japanese Reiwa 1-09-02らしいです。
……(´・ω・`)ドウイウコト?

APIの仕様通りではあるようだ

TemporalAdjusters.lastDayOfYear()のJavadocには次のように書かれています。

この動作は、ほとんどの暦体系での使用に適しています。 これは次と同等です。 

    long lastDay = temporal.range(DAY_OF_YEAR).getMaximum();
    temporal.with(DAY_OF_YEAR, lastDay);

https://docs.oracle.com/javase/jp/21/docs/api/java.base/java/time/temporal/TemporalAdjusters.html#lastDayOfYear()

令和1年は、5月1日から12月31日までの245日間しかないので、年始から数えて245番目の日である2019/09/02が年末として計算されてしまうらしい。

API仕様どおりではあるが、なぜそのような仕様にしたかは謎ですね。

JJUGで発表した後に、気になって調べてくれた方もいらっしゃったのですが、明確な意図があったのか否かはたどり着けず。

https://x.com/tbtdis/status/1805611494811541747

どうやらこのあたりのIssueで、ISOの暦と和暦とのDAY_OF_YEARの考え方について議論されていた模様。

https://github.com/ThreeTen/threeten/issues/272

ISOとの一貫性はどうか、どこで諦めるか、それは許容できるか みたいなことが議論されているので、諦めたのかな。
あと、lastDateOfYearではなくlastDayOfYearなので、そのあたりを自分が解釈できていないのかもしれない..?

感想

ドウシテ… とばかりLTで言ってたらJJUG代表の谷本さんに、"仕様策定当時の日本のユーザー会の力が足りなかったばかりに.."的な発言をさせてしまいました……。
でも、専用の暦が標準で用意されているのは、和暦を含めて4つだけなので、和暦サポートが存在するだけでとても感謝です。

Javaのクラス/メソッドの動きをもとに話しましたが、発表タイミングなど含めてもろもろ「和暦はシステムと相性が良くない」だなと思いました。

また、和暦は現代においては西暦と1対1で対応するので、歴史を扱うわけでなければほとんどの期間で困ることはありません。(明治初頭まで遡ると、太陰暦が使われてて〜みたいな話もあるようですが)

システム的には一般には、日付より日時を扱うほうが困る事が多いでしょうか。時差の考慮と、夏時間の考慮と。OffsetDateTimeを使うかZonedDateTimeを使うか、、など。

システムであっても、物理学であっても、時間というのはムズカシイものですね。

JJUGでのLT資料

最後に

明日は @gingkさんによる
GraalVM Native Image のソースコードを雑に読んだ (1)です。お楽しみに(・ω・)ノシ

Discussion