Javaにおける日付文字列の書式・日付チェックは落とし穴も多く、自分の身の回りでもそれによる障害等も発生しているので整理してみます。
例えば、ある文字列が、yyyy/MM/dd(e.g. 2016/01/01)という書式かつ有効な日付であることをチェックしたい場合を考えてみます。
Java8の場合
Date and Time APIを使うのがいいと思います。
以下の例では9/31は存在しないため例外が発生します。
LocalDate.parse("2016/09/31",
DateTimeFormatter.ofPattern("uuuu/MM/dd").withResolverStyle(ResolverStyle.STRICT));
または
DateTimeFormatter.ofPattern("uuuu/MM/dd")
.withResolverStyle(ResolverStyle.STRICT)
.parse("2016/09/31", LocalDate::from);
※注意すべきこと
-
ResolverStyle.STRICT
を指定することを忘れないようにしないといけません。以下のように指定しない場合、例外は発生せず、9/31が9/30とパースされます。LocalDate.parse("2016/09/31", DateTimeFormatter.ofPattern("uuuu/MM/dd"));
ResolverStyleについてはAPIドキュメント https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/ResolverStyle.html に記載がある通り、デフォルトでは ResolverStyle.SMART
として扱われ、数字は有効範囲におさまっている必要があるものの(13月とかはダメ)、31が存在しない月なら月末としてパースされます。
- 年がyyyyではなくuuuuです。
https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatter.html に記載の通り、yyyyの場合、ERA(元号、G)が必要になります。
Java7以下の場合
方法1. 一旦、Date型に変換して書式指定で文字列にしたものとチェック対象文字列を文字列比較する
こういうやつです。
String value = "2016/09/31";
SimpleDateFormat df = new SimpleDateFormat("yyyy/MM/dd");
df.setLenient(false);
Date parsedDate = df.parse(value);
return df.format(parsedDate).equals(value);
標準APIのみで実装でき、また、日付ライブラリAPIの細かい仕様がわかっていなくても、これが通れば抜けはないはずというのがわかりやすい方法だと思います。
方法2. Joda-Timeを使う
Java7以前までJavaにおける日付ライブラリの定番的な存在だったJoda-Time http://www.joda.org/joda-time/ を使う方法です。
new DateTimeFormatterBuilder()
.appendFixedDecimal(DateTimeFieldType.year(), 4)
.appendLiteral('/')
.appendFixedDecimal(DateTimeFieldType.monthOfYear(), 2)
.appendLiteral('/')
.appendFixedDecimal(DateTimeFieldType.dayOfMonth(), 2)
.toFormatter()
.parseLocalDate("2016/09/31");
※注意すべきこと
-
Date and Time APIのように
LocalDate.parse("2016/09/31", DateTimeFormat.forPattern("yyyy/MM/dd"));
または、
DateTimeFormat.forPattern("yyyy/MM/dd").parseLocalDate("2016/09/31");
と書いた場合は2016/09/1のように有効な日付でゼロパディングがされていない文字列がエラーになりません。これをチェックするためにDateTimeFormatterBuilderを使う必要があります。この辺、Date and Time APIはJoda-Timeから微妙に仕様が変わっていてややこしい感じがします。
なお、既存でJoda-Timeを使っておらず、将来的にJava8への移行も考えている場合であれば、Joda-Timeの開発者にして、Date and Time API(JSR-310)のスペックリードであるStephen Colebourne氏が、Java8 Date and Time APIのクラスをJava6/7向けにバックポートしたライブラリとしてThreeTen-Backport http://www.threeten.org/threetenbp/ を出していますので、そちらを使うのもいいと思います。
ダメなやり方
1. SimpleDateFormatでsetLenientをfalseにする
setLenient自体を知らなくてハマる人も結構見かける気がしますが、setLenientをfalseにしてもダメです。
APIドキュメント http://docs.oracle.com/javase/jp/7/api/java/text/SimpleDateFormat.html#parse(java.lang.String,%20java.text.ParsePosition) に 解析では、文字列の最後までのすべての文字が使用されるとは限らない
と記載されている通り、SimpleDateFormatは日付と解析できる文字列の後ろに余計な不正文字がついていても無視します。
DateFormat df = new SimpleDateFormat("yyyy/MM/dd");
df.setLenient(false);
df.parse("2016/09/1a");
そういうわけで、これが通ってしまうのでダメです(パース結果は2016/09/01になります)。
2. Apache Commons Validatorを使う
名前からすると、GenericValidator#isDate(String value, String datePattern, boolean strict) が使えそうに思えますが、実装としては、setLenient(false)してさらに、書式の文字数 == 値の文字数チェックをしているだけで、2016/12/1aとかが通ってしまうのでダメです。
cf.) https://github.com/apache/commons-validator/blob/trunk/src/main/java/org/apache/commons/validator/DateValidator.java#L71
3. Apache Commons Langを使う
org.apache.commons.lang.time.DateUtils#parseDateStrictly が名前からするとStrictlyとかついていて厳密なチェックをしてくれそうに思えますが、これも実装としては、setLenient(false)して、ParsePositionを使ってどこまで読んだかチェックし、チェック対象文字列の長さと比較していますが、2016/09/1や2016/09/001が通ってしまうのでダメです。
cf.) https://github.com/apache/commons-lang/blob/LANG_2_6/src/main/java/org/apache/commons/lang/time/DateUtils.java#L323
version3でパッケージも変わり実装も変わったorg.apache.commons.lang3.time.DateUtils#parseDateStrictly も同じく、2016/09/1や2016/09/001が通ってしまうのでやはりダメです。
他にも比較対象を書式文字列の長さにした http://d.hatena.ne.jp/syttru/20100615/1276615231 のようなやり方でも、2016/9/001が通ってしまうため、やはりダメです。
ゼロパディングがありでもなしでもOKな仕様にするなら?
Date and Time APIの場合
以下のようにDateTimeFormatterBuilderを使います。
LocalDate.parse("2016/9/30",
new DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR, 4, 4, SignStyle.NEVER)
.appendLiteral('/')
.appendValue(ChronoField.MONTH_OF_YEAR, 1, 2, SignStyle.NEVER)
.appendLiteral('/')
.appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NEVER)
.toFormatter()
.withResolverStyle(ResolverStyle.STRICT));
※注意すべきこと
以下のように"uuuu/M/d"だと2016/00009/000030とかでも通っちゃうので注意が必要です。
LocalDate.parse("2016/00009/000030",
DateTimeFormatter.ofPattern("uuuu/M/d").withResolverStyle(ResolverStyle.STRICT));
Joda-Timeの場合
上述の通り、
LocalDate.parse("2016/9/30", DateTimeFormat.forPattern("yyyy/MM/dd"));
または、
DateTimeFormat.forPattern("yyyy/MM/dd").parseLocalDate("2016/9/30");
のように書けばOKです。