Javaで文字列の日付チェックするときは気を付けようという話

たまにはプログラマっぽいことを書いてみようと思います。

「こいつ今更何言ってんの?」とか「そんなこと知ってますー、ぷすすー」といった反応をする人もいるかもしれません。
それでも書きます。
だって、書くネタが思いつかないんだもの。

よくある日付チェック

日付チェックというと、おそらく大半の人が以下ような方法で実装すると思います。

  • 正規表現で日付形式をチェックする
  • 各フィールドに分けてそれぞれを数値チェックし、問題なければ連結して妥当性チェックをする
  • SimpleDateFormatを使って日付型に変換できるかチェックする

やり方としては全てが正解。
ただ、SimpleDateFormatを使ったチェックには落とし穴が存在します。

SimpleDateFormatの落とし穴

SimpleDateFormatはデフォルトでは厳密にチェックしない

SimpleDateFormatのインスタンスはデフォルトだと厳密にチェックを行わないません。

例えば、”2017/01/32″といった文字列をSimpleDateFormatで日付型に変換すると、エラーにならずに2017年2月1日として扱われます。

try {
    DateFormat df = new SimpleDateFormat("yyyy/MM/dd");
    String s1 = "2017/01/32";
    String s2 = df.format(df.parse(s1));
    System.out.println(s2); // ←2017/02/01が出力される
} catch (ParseException p) {
    p.printStackTrace();
}

システム的にこれでも問題ない仕様ならこのままでもいいのですが、大半のシステムはこれはエラーとして扱ってほしいと思います。

SimpleDateFormatで厳密にチェックをする場合、以下のようにします。

try {
    DateFormat df = new SimpleDateFormat("yyyy/MM/dd");
    df.setLenient(false); // ←これで厳密にチェックしてくれるようになる
    String s1 = "2017/01/32";
    String s2 = df.format(df.parse(s1)); // ←df.parseでParseExceptionがThrowされる
    System.out.println(s2);
} catch (ParseException p) {
    p.printStackTrace();
}

これで”2017/01/32″といった存在しない日付は不正な日付形式として扱われるようになります。

SimpleDateFormatは前方一致

SimpleDateFormatは前方一致で処理をします。

どういうことかというと、SimpleDateFormatのインスタンスを”yyyy/MM/dd”の日付形式で作成したときに”2017/03/1A”といった文字列がどういうわけかformatできてしまい、”2017年3月1日”として扱われてしまうということです。

try {
    DateFormat df = new SimpleDateFormat("yyyy/MM/dd");
    df.setLenient(false);
    String s1 = "2017/03/1A";
    String s2 = df.parse(df.format(s1));
    System.out.println(s2); // ←2017/03/01が出力される
} catch (ParseException p) {
    p.printStackTrace();
}

で、Webシステムの開発をしている人にはApacheのCommons Validatorを使ってる人も少なくないと思います。
このCommons Validatorに実装されている日付チェックが以下のようになってるので、”2017/03/1A”といった不正日付が正常パターンとして処理されてしまいます。

public boolean isValid(String value, String datePattern, boolean strict) {

    if (value == null
            || datePattern == null
            || datePattern.length() <= 0) {

        return false;
    }

    SimpleDateFormat formatter = new SimpleDateFormat(datePattern);
    formatter.setLenient(false);

    try {
        formatter.parse(value);
    } catch(ParseException e) {
        return false;
    }

    if (strict && (datePattern.length() != value.length())) {
        return false;
    }

    return true;
}

もうね、ふざけんなって話ですよね。

SimpleDateFormatの仕様のおかげでCommons Validatorの日付チェックだけでは厳密に日付形式をチェックできません。
入力フィールドが「年」「月」「日」で分かれていればそれぞれを数値チェックしてから日付形式チェックすることで回避できますが、そこはWebシステムの仕様次第です。
もし仕様が「年月日のフィールドは一つ」となっていた場合、日付チェック以外に正規表現でのチェック等が必要になります。
その場合に問題になるのが、入力チェックエラー時のメッセージ出力。
おそらく仕様書には「入力された日付形式が正しくありません」といったメッセージを出力するように書かれていると思います。
おそらく入力チェックの類はフィールドに対してアノテーションを使用して実装すると思います。
そうすると、正規表現チェックと日付チェックの両方でエラーとなる値を入力するとメッセージが重複して出力されてしまいます。

美しくない。
というかおそらくバグ扱いされるか仕様にないチェックするなって言われる。
というわけで日付チェックについて独自で拡張する必要があります。

SimpleDateFormatで本当の意味で厳密にチェックする

SimpleDateFormatを使って本当の意味での厳密にチェックをする必要があります。
厳密日付チェックの流れはざっくり言うと以下のようになります。

  1. 入力値を指定されたフォーマットで日付型に変換
  2. 変換できなかったらエラー、変換できたら指定されたフォーマットで日付型を再度文字列に変換
  3. 入力値と変換後日付を文字列にしたものを比較し、異なっていたらエラー

ソースコードにすると

try {
    DateFormat df = new SimpleDateFormat("yyyy/MM/dd");
    df.setLenient(false);
    String s1 = "2017/03/1A"; // ←ここが画面などからの入力値になる
    String s2 = df.format(df.parse(s1));
    if (s1.equals(s2)) {
        // 正常
    } else {
        // 日付チェックエラー
    }
} catch (ParseException p) {
    p.printStackTrace();
}

みたいな感じです。

Commons Validatorを使用して独自Validatorを実装するなら

public boolean isValid(String value, String datePattern, boolean strict) {

    if (!GenericValidator.isDate(value, datePattern, strict)) {
        return false;
    }

    SimpleDateFormat formatter = new SimpleDateFormat(datePattern);
    formatter.setLenient(false);

    try {
        if (!value.equals(formatter.format(formatter.parse(value)))) {
            return false
        }
    } catch(ParseException e) {
        return false;
    }

    if (strict && (datePattern.length() != value.length())) {
        return false;
    }

    return true;
}

のようにすればいいかと思います。

Java8使えるならDateTimeFormatterを使おう

Java8では日付関連のライブラリがいろいろ新しくなってます。
その中にあるDateTimeFormatterはSimpleDateFormatの上位互換クラスです。
システムの要件でJava8が使えるなら、日付関連は極力Java8のものを使用しましょう。

Java7までの実装

try {
    DateFormat df = new SimpleDateFormat("yyyy/MM/dd");
    df.setLenient(false);
    String s1 = "2017/03/1A";
    String s2 = df.format(df.parse(s1));
    System.out.println(s2); // ←2017/03/01が出力される
} catch (ParseException p) {
    p.printStackTrace();
}

Java8での実装

try {
    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd");
    String s1 = "2017/03/1A";
    String s2 = dtf.format(LocalDate.parse(s1, dtf)); // ←LocalDate.parseでDateTimeParseExceptionがThrowされる
    System.out.println(s2);
} catch (DateTimeParseException dtp) {
    dtp.printStackTrace();
}

DateTimeFormatterを使用すれば”2017/01/32″や”2017/03/1A”といった不正な値を全部エラーにしてくれます。

まとめ

  • SimpleDateFormatで日付チェックする場合は変換前と変換後の比較をしよう
  • Java8が使える環境ならDateTimeFormatterを使用しよう
  • 理不尽な仕様と直面したら深呼吸してから向き合おう

久しくコーディングしてなかったのですが、体に染みついた癖は全然抜けないものですね。