Javaアドベントカレンダー2日目の担当のubansiです!
去年頃、
悪魔のようなJavaコードと戦った人です。

よーし,ビッグデータ解析するぞー!

いろんなDBのDumpファイルをバシバシBigQueryへ投入だ!

え、日付がBigQueryで解析されない…?
他のシステム動いてるから出力は変えられない?
時差もある?
え、DB追加?

・・・

というような目に遭う人のために
(というか、そんな目に逢いました)
Java8で日付文字列をすべてLocalDateTimeへ変換する処理を作ってみます。

解析に使うクラス

イカした時間クラスを紹介するぜ!

ZonedDateTime

タイムゾーンごとの時間付きの日付を扱うクラス。
サマータイムとかも考慮してくれる。

OffsetDateTime

時差ごとの時間付きの日付を扱うクラス。
タイムゾーン指定ではなく時差での指定がある場合はこいつの出番。

LocalDateTime

言わずと知れた時差なしの時間を扱うクラス。
今回はこのクラスに変換します。

LocalDate

時差なしの日付を扱うクラス。
時間が指定されていない場合はこのクラスで解析する。
LocalDate#atStartOfDay()で午前0時としてLocalDateTimeに変換できる。

Timestamp

Unixtimeからの変換に使った。

フロー

はい、それでは解説をはじめます。
手順としては、解析結果として情報量が多い順に変換を試みます。
そして、変換出来た時点で結果を返します。
すべての解析に失敗した時にのみ全部のエラーを吐こうと思います。

構成

解析する抽象クラスを作って、継承します。

クラス図

コード

解析クラスの抽象クラス

TimeParser.java

public abstract class TimeParser {

    /**
     * fails info
     */
    protected List<String> failsMessages = new ArrayList<>();

    /**
     * This function forcibly converts a string to a date.
     *<p>
     * If the return value is null, please throw an {@code DateTimeParseException}.
     * </p>
     * @param input
     * @return LocalDateTime instance or {@code null}
     */
    public abstract LocalDateTime parse(String input);

    protected void addExceptionMessage(Exception e) {
        failsMessages.add(e.getMessage() + " ("+this.getClass().getSimpleName()+")");
    }

    public List<String> getExceptionInfo(){
        return failsMessages;
    };
}

解析部分と、ログ取得部分を備えています。
とりあえず、全部失敗した場合に何が起こったか分かるようにログを貯めてます。

解析クラスの実装

LocalDateParser.java


public class LocalDateParser extends TimeParser {

    // フォーマットを片っ端から定義
    private final static List<DateTimeFormatter> FORMATS = new ArrayList<DateTimeFormatter>() {
        {
            add(DateTimeFormatter.ISO_LOCAL_DATE);
            add(DateTimeFormatter.BASIC_ISO_DATE);
            add(DateTimeFormatter.ISO_DATE);
            add(DateTimeFormatter.ofPattern("yy-MM-dd"));
            add(DateTimeFormatter.ofPattern("yy-M-d"));
            add(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
            add(DateTimeFormatter.ofPattern("yyyy/M/d"));
            add(DateTimeFormatter.ofPattern("yyyy年M月d日"));

        }
    };

    @Override
    public LocalDateTime parse(String input) {
        failsMessages.clear();

        // デフォルト解析を試す
        try {
            return LocalDate.parse(input).atStartOfDay();
        } catch (DateTimeParseException e) {
            addExceptionMessage(e);
        }

        // 指定フォーマットで解析
        for (DateTimeFormatter formatter : FORMATS) {
            try {
                return LocalDate.parse(input, formatter).atStartOfDay();
            } catch (DateTimeParseException e) {
                // 失敗したログはリストへ保存しておく
                addExceptionMessage(e);
            }
        }
        return null;
    }
}

DateTimeParseExceptionが起きた場合にはログをストックするだけにとどめて、
全て握りつぶします。

なぜ例外発生時にロガーで出さないかというと処理に問題が無い場合でも出力されてしまうためです。

こんな感じのTimeParserを継承したクラスを他の日付クラスでも作成します。

解析のファサードとなるクラス

DateTimeParser.java

public class DateTimeParser {

    // エラー保持用リスト
    private List<String> errors = new ArrayList<>();
    // 解析クラスを作成
    private final static List<TimeParser> PARSERS = new ArrayList<TimeParser>() {
        {
            // 情報量が多い順に解析する
            add(new ZonedDateTimeParser());
            add(new OffsetDateTimeParser());
            add(new LocalDateTimeParser());
            add(new LocalDateParser());
            add(new TimestampParser());
        }
    };

    public LocalDateTime parse(String input) {
        errors.clear();

        LocalDateTime result = null;

        for (TimeParser parser : PARSERS) {
            result = parser.parse(input);

            // 成功した時点で結果を返す
            if (result != null) {
                return result;
            }
            errors.addAll(parser.getExceptionInfo());
        }

        throw new DateTimeParseException("解析に失敗しました。(\"" + input + "\")", input, 0);

        // return がない!
    }

    public List<String> getErrors(){
        return errors;
    }

}

解析クラスをリストで持ち、ループします。
解析に成功した場合にLocalDateTimeのインスタンスが返ってくるので、
nullじゃない場合に結果を返しています。

また、途中ででた解析例外はgetErrors()で解析後に取り出せます。
(スレッドセーフじゃないですが…)

そして、失敗した場合のみ最後に例外を投げます。
returnがない変なメソッドになりましたね。

※ちなみにreturnを書くとEclipseがエラーだって怒ります。

テスト

DateTimeParserTest.java
    @Test
    public void testDateFormat() {
        List<String> dates = new ArrayList<String>() {
            {
                add("2017-12-02");
                add("17-12-02");
                add("17-12-2");
                add("20171202");
                add("2017/12/02");
                add("2017/12/2");
                add("2017年12月2日");
            }
        };

        for (String date : dates) {
            LocalDateTime time = dtp.parse(date);

            assertEquals(time.format(formater), "2017-12-02T00:00:00");
        }

    }

雑なテストですが、こんな感じの日付をすべて解析できました。
各種パーサークラスのArrayListにフォーマットを追加していけばどんな日付も変換できるはずです。

やったね!

ちなみに今回使ったサンプルコードはGitHubにて公開されています。
https://github.com/ubansi/DateTimeParser