これは、なにをしたくて書いたもの?
DateTimeFormatter
で文字列として表現された日時をパースする時に桁数や空白の扱いについてあまり意識していなかったので、
ちょっと見てみることにしました。
DateTimeFormatter (Java SE 17 & JDK 17)
環境
今回の環境は、こちら。
$ java --version openjdk 17.0.5 2022-10-18 OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu122.04) OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu122.04, mixed mode, sharing) $ mvn --version Apache Maven 3.8.7 (b89d5959fcde851dcb1c8946a785a163f14e1e29) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 17.0.5, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.15.0-58-generic", arch: "amd64", family: "unix"
準備
確認は、テストコードで行います。必要なMaven依存関係等はこちら。
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.9.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.24.2</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>3.0.0-M7</version> </plugin> </plugins> </build>
パースする
最初はパースから。
以下をテストコードの雛形にしていきます。
src/test/java/org/littlewings/datetimeformatter/ParseTest.java
package org.littlewings.datetimeformatter; import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.format.ResolverStyle; import java.util.Calendar; import java.util.GregorianCalendar; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; public class ParseTest { // ここに、テストを書く!! }
SimpleDateFormatとDateTimeFormatterにおける、パース時の桁数に対する振る舞い
このエントリーを書くきっかけになった話ですが。
まずは昔ながらのSimpleDateFormat
を素直に使ってみます。日付と日時に対して、それぞれパース。
@Test void simpleDateFormat() throws ParseException { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd"); simpleDateFormat.setLenient(true); Calendar date = new GregorianCalendar(2022, 9, 25); assertThat(simpleDateFormat.parse("2022-10-25")) .isEqualTo(date.getTime()); SimpleDateFormat simpleDateTimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); simpleDateTimeFormat.setLenient(true); Calendar dateTime = new GregorianCalendar(2022, 9, 25, 16, 30, 25); assertThat(simpleDateTimeFormat.parse("2022-10-25 16:30:25")) .isEqualTo(dateTime.getTime()); }
続いて、DateTimeFormatter
。
@Test void dateTimeFormatter() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2022-10-25", dateFormatter)) .isEqualTo(LocalDate.of(2022, 10, 25)); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25)); }
まあ、ふつうだと思います。
次に、桁数が少ないデータを渡してみます。
SimpleDateFormat
。
@Test void shortWidthSimpleDateFormat() throws ParseException { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd"); simpleDateFormat.setLenient(true); Calendar date = new GregorianCalendar(2023, 0, 5); assertThat(simpleDateFormat.parse("2023-1-5")) .isEqualTo(date.getTime()); SimpleDateFormat simpleDateTimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); simpleDateTimeFormat.setLenient(true); Calendar dateTime = new GregorianCalendar(2023, 0, 5, 2, 3, 4); assertThat(simpleDateTimeFormat.parse("2023-1-5 2:3:4")) .isEqualTo(dateTime.getTime()); }
問題なくパースできます。
DateTimeFormatter
。
@Test void shortWidthDateTimeFormatter() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); assertThatThrownBy(() -> LocalDate.parse("2023-1-5", dateFormatter)) .isInstanceOf(DateTimeParseException.class) .hasMessage("Text '2023-1-5' could not be parsed at index 5"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); assertThatThrownBy(() -> LocalDateTime.parse("2023-1-5 2:3:4", dateTimeFormatter)) .isInstanceOf(DateTimeParseException.class) .hasMessage("Text '2023-1-5 2:3:4' could not be parsed at index 5"); }
こちらは、パースに失敗します。これを回避するにはどうしたら?というのが、今回のエントリーを書いたきっかけですね。
当たり前ですが、足りない桁を0埋めすればパースには失敗しません。
@Test void shortWidthDateTimeFormatter2() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2023-01-05", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.parse("2023-01-05 02:03:04", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); }
ちなみに、空白を置いてもダメです。
@Test void shortWidthDateTimeFormatter3() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu/MM/dd").withResolverStyle(ResolverStyle.STRICT); assertThatThrownBy(() -> LocalDate.parse("2023/ 1/ 5", dateFormatter)) .isInstanceOf(DateTimeParseException.class) .hasMessage("Text '2023/ 1/ 5' could not be parsed at index 5"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu/MM/dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); assertThatThrownBy(() -> LocalDateTime.parse("2023/ 1/ 5 2: 3: 4", dateTimeFormatter)) .isInstanceOf(DateTimeParseException.class) .hasMessage("Text '2023/ 1/ 5 2: 3: 4' could not be parsed at index 5"); }
このパターンの時は、区切り文字を/
にしています。
パターンを1文字にする
それで、こういう時にパースしたかったらどうするかというと、パターンを1文字にするとパースできるようになります。
@Test void shortWidthShortDateTimeFormatter() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu-M-d").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2022-10-25", dateFormatter)) .isEqualTo(LocalDate.of(2022, 10, 25)); assertThat(LocalDate.parse("2023-1-5", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-M-d H:m:s").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25)); assertThat(LocalDateTime.parse("2023-1-5 2:3:4", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); }
オプションで空白を置く
DateTimeFormatter
には「オプション」というものがあり、[]
で表現します。
オプションのセクション: オプションのセクションのマーカーは、DateTimeFormatterBuilder.optionalStart()およびDateTimeFormatterBuilder.optionalEnd()の呼出しとまったく同様に機能します。
DateTimeFormatter (Java SE 17 & JDK 17)
DateTimeFormatterBuilder.html#optionalStart
の説明を見ると、こんなことが書かれています。
書式設定の出力には、オプションのセクションを含めることができ、それらは入れ子にすることができます。 オプションのセクションは、このメソッドの呼出しによって始まり、optionalEnd()の呼出しかビルダー・プロセスの終了によって終わります。
オプションのセクションに含まれるすべての要素は、オプションとして扱われます。 書式設定時は、セクション内のすべての要素に関するデータがTemporalAccessorで使用可能な場合のみ、セクションが出力されます。 解析時は、解析された文字列からセクション全体が欠けている場合があります。
DateTimeFormatterBuilder#optionalStart)
DateTimeFormatter
の場合は、[]
で囲った範囲がオプションになるということみたいです。
試してみましょう。
@Test void shortWidthOptionalSpaceDateTimeFormatter() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2022/10/25", dateFormatter)) .isEqualTo(LocalDate.of(2022, 10, 25)); assertThat(LocalDate.parse("2023/1/5", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); assertThat(LocalDate.parse("2023/ 1/ 5", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); assertThat(LocalDate.parse("2023/01/05", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d [ ]H:[ ]m:[ ]s").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.parse("2022/10/25 16:30:25", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25)); assertThat(LocalDateTime.parse("2023/1/5 2:3:4", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); assertThat(LocalDateTime.parse("2023/ 1/ 5 2: 3: 4", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); assertThat(LocalDateTime.parse("2023/01/05 02:03:04", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); }
空白があってもなくてもよくなり、2桁であってもパースできますね。1桁でも2桁でもパースできるのは、パターンを1文字にしているからで
あくまでオプションなのは空白なのですが。
オプションをもう少し
もう少し、オプションのパターンを試してみましょう。
日時の部分をオプションにしてみます。
@Test void optionalDateTimeFormatter() { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd[ HH:mm:ss]").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2022-10-25", dateTimeFormatter)) .isEqualTo(LocalDate.of(2022, 10, 25)); assertThat(LocalDate.parse("2023-01-05", dateTimeFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25)); assertThatThrownBy(() -> LocalDateTime.parse("2023-01-05 2:3:4", dateTimeFormatter)) .isInstanceOf(DateTimeParseException.class) .hasMessage("Text '2023-01-05 2:3:4' could not be parsed, unparsed text found at index 10"); }
オプションをネストさせることもできます。こちらは秒の部分をオプションにしています。
@Test void nestedOptionalDateTimeFormatter() { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd[ HH:mm[:ss]]").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2022-10-25", dateTimeFormatter)) .isEqualTo(LocalDate.of(2022, 10, 25)); assertThat(LocalDate.parse("2023-01-05", dateTimeFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25)); assertThat(LocalDateTime.parse("2023-01-05 02:03:04", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25)); assertThat(LocalDateTime.parse("2023-01-05 02:03", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 0)); }
パディング
少し観点を変えて、パディングを指定する場合はp
を使います。
@Test void shortWidthPaddingDateTimeFormatter() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu/ppM/ppd").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2022/10/25", dateFormatter)) .isEqualTo(LocalDate.of(2022, 10, 25)); assertThat(LocalDate.parse("2023/ 1/ 5", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); assertThat(LocalDate.parse("2023/01/05", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu/ppM/ppd ppH:ppm:pps").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.parse("2022/10/25 16:30:25", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25)); assertThat(LocalDateTime.parse("2023/ 1/ 5 2: 3: 4", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); assertThat(LocalDateTime.parse("2023/01/05 02:03:04", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); }
pp
で、2桁であることを求めていますが、桁数に満たない場合は空白が必須になります。
なので、以下のように桁数が不足する場合にパディングしていないとパースできません。
@Test void shortWidthPaddingDateTimeFormatter2() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu/ppM/ppd").withResolverStyle(ResolverStyle.STRICT); assertThatThrownBy(() -> LocalDate.parse("2023/1/5", dateFormatter)) .isInstanceOf(DateTimeParseException.class) .hasMessage("Text '2023/1/5' could not be parsed at index 10"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu/ppM/ppd ppH:ppm:pps").withResolverStyle(ResolverStyle.STRICT); assertThatThrownBy(() -> LocalDateTime.parse("2023/1/5 2:3:4", dateTimeFormatter)) .isInstanceOf(DateTimeParseException.class) .hasMessage("Text '2023/1/5 2:3:4' could not be parsed at index 10"); }
1文字のパターンは長さを見ていない
こう見ると、1文字のパターンとオプションの組み合わせが柔軟で良さそうに見えますが、1文字のパターンは長さを見ていない感じが
しますね。
以下のようなパターンもパースできてしまいます。
@Test void looseDateTimeFormatter() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu-M-d").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.parse("2023-00001-00005", dateFormatter)) .isEqualTo(LocalDate.of(2023, 1, 5)); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-M-d H:m:s").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.parse("2023-00001-00005 00002:00003:00004", dateTimeFormatter)) .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4)); }
この桁数を厳密に制御しようと思うと、DateTimeFormatter#ofPattern
でパターン文字列から作るのではなく、
DateTimeFormatterBuilder
クラスのappendValue`メソッドを使うことになりそうです。
厳密解析モードでは、解析される桁数の最小はminWidth、最大はmaxWidthです。 非厳密解析モードでは、解析される桁数の最小は1、最大は19です(隣接値解析によって制限される場合を除く)。
DateTimeFormatterBuilder#appendValue)
今回は、DateTimeFormatterBuilder
クラスは扱いませんが。
フォーマットする
パースができたので、次はフォーマットしてみます。
こちらもテストコードで確認するので、雛形から。
src/test/java/org/littlewings/datetimeformatter/FormatTest.java
package org.littlewings.datetimeformatter; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.format.ResolverStyle; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; public class FormatTest { // ここに、テストを書く!! }
シンプルなパターンでフォーマットする
まずは、シンプルなパターンでフォーマットしてみます。
@Test void format() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter)) .isEqualTo("2022-10-25"); assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter)) .isEqualTo("2023-01-05"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter)) .isEqualTo("2022-10-25 16:30:25"); assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter)) .isEqualTo("2023-01-05 02:03:04"); }
この場合、桁数に満たない値は0埋めされます。
パターン文字を1にする
次に、パターン文字を1文字にしてみます。
@Test void shortFormat() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu-M-d").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter)) .isEqualTo("2022-10-25"); assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter)) .isEqualTo("2023-1-5"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-M-d H:m:s").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter)) .isEqualTo("2022-10-25 16:30:25"); assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter)) .isEqualTo("2023-1-5 2:3:4"); }
この場合、桁数がそのまま文字列長に反映される感じになりますね。
オプションで空白を置いてみる
パースする時に、空白をオプションにするようにしてみましたが、これだとどうなるでしょう。
@Test void optionalShortFormat() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter)) .isEqualTo("2022/ 10/ 25"); assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter)) .isEqualTo("2023/ 1/ 5"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d [ ]H:[ ]m:[ ]s").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter)) .isEqualTo("2022/ 10/ 25 16: 30: 25"); assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter)) .isEqualTo("2023/ 1/ 5 2: 3: 4"); }
常に空白が確保されるようになりました…。
パディングを使う
パディングを使うと、p
で指定した桁数に満たない場合は空白で埋められます。
@Test void paddingShortFormat() { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("uuuu/ppM/ppd").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter)) .isEqualTo("2022/10/25"); assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter)) .isEqualTo("2023/ 1/ 5"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu/ppM/ppd ppH:ppm:pps").withResolverStyle(ResolverStyle.STRICT); assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter)) .isEqualTo("2022/10/25 16:30:25"); assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter)) .isEqualTo("2023/ 1/ 5 2: 3: 4"); }
こんなところでしょうか。
まとめ
いかに雰囲気でDateTimeFormatter
を使っていたのかが、よくわかりました…。
書式文字列のチェックでDateTimeFormatter
を使う時は、他の確認方法と組み合わせることも考えた方がいいのかもしれません。
また、パースとフォーマットで同一のパターン定義で良いのかも考えどころですね。
あとは、こちらを使うなどでしょうか。
DateTimeFormatter#parse(java.lang.CharSequence,java.text.ParsePosition))