Edited at

[JSR-310 Date and Time API] 和暦文字列 → 日付型へ変換する際のフォーマット定義に対する注意点

新元号(令和)になって1ヶ月が経ち、Javaの各バージョンでも正式サポートが進んでいます。新元号の対応状況については、 @yamadamn さんの「Javaバージョン別の改元(新元号)対応まとめ」に非常に詳しくまとめられているので、そちらをご覧ください。

で・・・私が現在携わっている開発案件でも、(残念ながら?w)和暦を扱う必要があり、令和対応の検証を進めていた(実際は私の同僚が検証していた)のですが・・・、和暦文字列(例:R010501,H310430,S510101,T100830とか)を日付型(例:LocalDateなど)へ変換すると・・・特定の範囲(各元号の1〜11年)で+100年されてしまい同僚が頭を抱えていました(実際には頭を抱えてはいなかったですけどw)。

直感的にはバグ?と思ったのですが、ソースを追いかけてみたら、どうやらバグではなく仕様らしいということがわかりました。ただ・・・直感的には、仕様とは思えませんでしたけどw


検証バージョン

今回私が検証したのは(諸事情により)Java 8(AdoptOpenJDK 8u222 Nightly build for macOS x64 -7 June 2019版-)だけになりますが、おそらく全てのバージョンで同じ動作になると思われます。


問題となるパターン指定

具体的には、冒頭で紹介した形式の和暦文字列を日付型に変換する必要があったため、「GGGGGyyMMdd」(暦年の桁を2文字固定にするためにyyを指定)というパターン文字列を指定して、DateTimeFormatter を生成していました。


問題が発生する指定例

DateTimeFormatter df = DateTimeFormatter

.ofPattern("GGGGGyyMMdd", Locale.JAPAN)
.withChronology(JapaneseChronology.INSTANCE)
.withResolverStyle(ResolverStyle.LENIENT);

多くのエンジニアの方は直感的には、問題が出るとは思わないのではないでしょうか?

実際に以下のように各元号の開始日・終了日を表す和暦文字列を日付型に解析してみると・・・

System.out.println("M---");

System.out.println(df.parse("M060101", LocalDate::from)); // 明治6年より前はサポートされていないため実際の開始日ではない
System.out.println(df.parse("M450729", LocalDate::from));
System.out.println("T---");
System.out.println(df.parse("T010730", LocalDate::from));
System.out.println(df.parse("T151224", LocalDate::from));
System.out.println("S---");
System.out.println(df.parse("S011225", LocalDate::from));
System.out.println(df.parse("S640107", LocalDate::from));
System.out.println("H---");
System.out.println(df.parse("H010108", LocalDate::from));
System.out.println(df.parse("H310430", LocalDate::from));
System.out.println(df.parse("H310501", LocalDate::from)); // H表記で令和元日を表す和暦文字列
System.out.println("R---");
System.out.println(df.parse("R010501", LocalDate::from));
System.out.println(df.parse("R000501", LocalDate::from));


実行結果

M---

1973-01-01
1912-07-29
T---
2012-07-30
1926-12-24
S---
2026-12-25
1989-01-07
H---
2089-01-08
2019-04-30
2019-05-01
R---
2119-05-01
2118-05-01

となり、和暦は未来に進むのではなく、過去へ遡る暦であることがわかります



・・・もちろん和暦も未来へ進む暦ですので、バグ or パターン指定がまずい?ことが予想されます。


バグ vs 仕様?

感覚的にはバグだと思ってしまいましたが、ソース+JavaDocを確認したところ・・・どうやらこの動作は仕様のようです。

まず、y を処理しているソースをみてみたところ・・・


DateTimeFormatterBuilderのコードの一部を抜粋

// ...

case 'y':
if (count == 2) { // `yy`には特別に意味がありそう・・・ということがわかる
appendValueReduced(field, 2, 2, ReducedPrinterParser.BASE_DATE);
} else if (count < 4) {
appendValue(field, count, 19, SignStyle.NORMAL);
} else {
appendValue(field, count, 19, SignStyle.EXCEEDS_PAD);
}
break;
// ...

となっており、JavaDocyに対する仕様を確認してみると・・・・

 Pattern  Count  Equivalent builder methods

------- ----- --------------------------
y 1 appendValue(ChronoField.YEAR_OF_ERA, 1, 19, SignStyle.NORMAL);
yy 2 appendValueReduced(ChronoField.YEAR_OF_ERA, 2, 2000);
yyy 3 appendValue(ChronoField.YEAR_OF_ERA, 3, 19, SignStyle.NORMAL);
y..y 4..n appendValue(ChronoField.YEAR_OF_ERA, n, 19, SignStyle.EXCEEDS_PAD);

という記載がありました。今度は・・・ appendValueReducedメソッドの JavaDoc を確認してみると・・・フォーマット文字列として「yy」を指定すると「2000-01-01」を基準日付として、暦内の基準年(開始年?)を算出して、その基準年から100年間を扱うことができるフォーマット定義が生成されるようになっているみたいです。

JavaDocに記載されているルールを和暦に適用すると・・・・基準年は「12」になります。「12」になる経緯は・・・・


  • 「2000-01-01」は和暦だと「平成12年1月1日」

  • 「平成12年1月1日」の元号内での年は12

ということのようです。

なので・・・・令和であれば、


  • 12〜99 : 令和12年〜令和99年

  • 00〜11 : 令和100年〜令和111年

となるため、前述の実行結果がバグではなく仕様であることがわかります。ただし・・・少なくても我々日本人にとっては和暦を扱う場合には直感的ではないですよね。おそらく・・・「2000-01-01」がデフォルトの基準日付になっているのは、西暦での「00-99」を「2000年〜2099年」と解釈するするためだと思われます。


NOTE:

上記は令和を例に説明しましたが、基本的には他の元号(明治、大正、昭和、平成)でも同様のルールが適用されます。ただし、それぞれ終了年が決まっているため、終了年以降を表す値を指定した時の解釈については、令和とは異なることになります。



適切なフォーマット指定は?

要件に応じて、以下の2つの定義方法を使い分けることになると思われます。


NOTE:

実際に問題になることは限りなく0に近いと思いますが、「R000101」を指定した時の動作が異なります。ざっくり言うと・・・前者は「0年」として扱いエラーになるのですが、後者は「100年」として解釈した日付に変換されます。現在の元号法の範囲では、令和が100年以上続くことは事実上ないと思われる+その頃Javaが使われている可能性はかなり低い!?と思うので気にする必要はないかな〜と。



パターン表記を yy → y にする

暦年部が2桁固定であることにこだわる必要がなければ・・・「GGGGGyMMdd」とすれば、和暦文字列を正しく解釈することができます。


和暦文字列を正しく解釈するためのフォーマット定義例1

DateTimeFormatter df = DateTimeFormatter

.ofPattern("GGGGGyMMdd", Locale.JAPAN) // 'yy' -> 'y' に変更
.withChronology(JapaneseChronology.INSTANCE)
.withResolverStyle(ResolverStyle.LENIENT);


appendValueReducedメソッドを併用して暦の開始年を1にする

暦年部を2桁固定に限定する必要がある場合は・・・ パターン文字列とappendValueReducedメソッドを併用してフォーマット定義を組み立てることで、和暦文字列を正しく解釈することができます。


和暦文字列を正しく解釈するためのフォーマット定義例2

DateTimeFormatter df = new DateTimeFormatterBuilder() // Builderを使う

.appendPattern("GGGGG")
.appendValueReduced(ChronoField.YEAR_OF_ERA, 2, 2, 1) // 歴年の部分はappendValueReducedを使って基準値を1に指定
.appendPattern("MMdd")
.toFormatter(Locale.JAPAN)
.withChronology(JapaneseChronology.INSTANCE)
.withResolverStyle(ResolverStyle.LENIENT);


formatとの相互運用性の考慮

parse時だけのことを考えれば、ほとんどの場合はパターンとしてyを指定すれば良いと思いますが、format時との相互運用が必要+format時も2文字固定(yy)で出力にする必要がある場合は、「appendValueReducedメソッドを併用して暦の開始年を1にする」方法を採用して、formatparse時に同じフォーマット定義を利用することを検討すると良いと思います。


まとめ

和暦文字列の解析用のパターン指定で「yy」は使えないよ!


補足

SimpleDateFormatに指定するパターンに「yy」を指定した場合は、直感的に期待する動作になります。SimpleDateFormatとの互換性を捨ててまで仕様を変えた理由は私には思いつきませんでした(きっと深い理由があるのでしょうけど)。


参考サイト(関連サイト)