ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

KotlinでDateの操作を簡単にするライブラリをつくってみる(その3)

f:id:ldi-contributor:20210916070736p:plain


開発者Gです。

前回はSimpleDateFormatの使用例と問題点について書きました。
今回はそれらの問題を回避する他の方法を確認します。


なお、ソースコードはJUnitのテストコードの全部、または一部を抜粋して表示します。

Apache Commonsはどうなの?

DateUtils/DateFormatUtils

Apache CommonsのDateUtils/DateFormatUtilsを使用すると、SimpleDateFormatの扱いにくさが緩和されます。

DateUtils/DateFormatUtilsの使用例
    @Test
    fun `DateUtilsで文字列をパースする`() {

        val date = DateUtils.parseDate("2021/09/01", "yyyy/MM/dd")
        println(date)
        println(DateFormatUtils.format(date, "yyyy/MM/dd"))
    }
実行結果
Wed Sep 01 00:00:00 JST 2021
2021/09/01


文字列のパースはDateUtilsのparseDate()またはparseDateStrictly()、日付のフォーマットはDateFormatUtilsのformat()で、それぞれ実行でき、SimpleDateFormat使用時に必要となる初期化は不要です。

また、これらのメソッドはスレッドセーフなので、マルチスレッド環境でも安全に使用できます。


前回のSimpleDateFormatとの比較のために、DateUtils/DateFormatUtilsの使用例を以下に示します。

DateUtils/DateFormatUtilsの使用例(2)
import org.apache.commons.lang3.time.DateUtils
import org.junit.Test
import org.sqlite.date.DateFormatUtils

class DateUtils1 {

    @Test
    fun `DateUtils#parseDateで文字列をパースする`() {

        println("DateUtilsで文字列をパースする")

        val date = DateUtils.parseDate("2021/09/01", "yyyy/MM/dd")
        println(date)
        println(DateFormatUtils.format(date, "yyyy/MM/dd"))
        println()
    }

    @Test
    fun `DateUtils#parseDateで存在しない日付の文字列がパースできてしまう`() {

        println("DateUtils#parseDateで存在しない日付の文字列がパースできてしまう")

        val date = DateUtils.parseDate("2021/08/32", "yyyy/MM/dd")
        println(date)
        println(DateFormatUtils.format(date, "yyyy/MM/dd"))
        println()
    }

    @Test
    fun `DateUtils#parseDateStrictlyで存在しない日付の文字列をパースする(例外が発生する)`() {

        println("DateUtils#parseDateStrictlyで存在しない日付の文字列をパースする(例外が発生する)")

        val date = DateUtils.parseDateStrictly("2021/08/32", "yyyy/MM/dd")
        println(date)
        println(DateFormatUtils.format(date, "yyyy/MM/dd"))
        println()
    }

    @Test
    fun `DateUtils#parseDateで指定した書式ではない文字列(字足らず)がパースできてしまう`() {

        println("DateUtils#parseDateで指定した書式ではない文字列(字足らず)がパースできてしまう")

        val original = "2021/09/1"
        val date = DateUtils.parseDate(original, "yyyy/MM/dd")   // yyyy/MM/ddの書式ではないのにパースしてしまう(例外が発生しない)
        val reformat = DateFormatUtils.format(date, "yyyy/MM/dd") // 再度yyyy/MM/ddにフォーマットする
        println("変換前文字列:$original")
        println("変換後文字列:$reformat")
        println()
    }

    @Test
    fun `DateUtils#parseDateStrictlyで指定した書式ではない文字列(字足らず)がパースできてしまう`() {

        println("DateUtils#parseDateStrictlyで指定した書式ではない文字列(字足らず)がパースできてしまう")

        val original = "2021/09/1"
        val date = DateUtils.parseDateStrictly(original, "yyyy/MM/dd")   // yyyy/MM/ddの書式ではないのにパースしてしまう(例外が発生しない)
        val reformat = DateFormatUtils.format(date, "yyyy/MM/dd") // 再度yyyy/MM/ddにフォーマットする
        println("変換前文字列:$original")
        println("変換後文字列:$reformat")
        println()
    }

    @Test
    fun `DateUtils#parseDateStrictlyで指定した書式ではない文字列(字余り)がパースできない(例外が発生する)`() {

        println("DateUtils#parseDateStrictlyで指定した書式ではない文字列(字余り)がパースできない(例外が発生する)")

        val original = "2021/09/01A"
        val date = DateUtils.parseDateStrictly(original, "yyyy/MM/dd")   // yyyy/MM/ddの書式ではないのにパースしてしまう(例外が発生しない)
        val reformat = DateFormatUtils.format(date, "yyyy/MM/dd") // 再度yyyy/MM/ddにフォーマットする
        println("変換前文字列:$original")
        println("変換後文字列:$reformat")
        println()
    }
}
実行結果
DateUtilsで文字列をパースする
Wed Sep 01 00:00:00 JST 2021
2021/09/01

DateUtils#parseDateで存在しない日付の文字列がパースできてしまう
Wed Sep 01 00:00:00 JST 2021
2021/09/01

DateUtils#parseDateで指定した書式ではない文字列(字足らず)がパースできてしまう
変換前文字列:2021/09/1
変換後文字列:2021/09/01

DateUtils#parseDateStrictlyで存在しない日付の文字列をパースする(例外が発生する)

Unable to parse the date: 2021/08/32
java.text.ParseException: Unable to parse the date: 2021/08/32
(省略)

DateUtils#parseDateStrictlyで指定した書式ではない文字列(字足らず)がパースできてしまう
変換前文字列:2021/09/1
変換後文字列:2021/09/01

DateUtils#parseDateStrictlyで指定した書式ではない文字列(字余り)がパースできない(例外が発生する)

Unable to parse the date: 2021/09/01A
java.text.ParseException: Unable to parse the date: 2021/09/01A
(省略)


SimpleDateFormatだと実在しない日付のパースを禁止するには、そのインスタンスを生成後にisLenientプロパティをfalseに設定し、parseメソッドを実行する必要がありましたが、DateUtilsの場合はparseDateStrictlyメソッドを呼ぶだけでOKです。一方、それ以外の曖昧な文字列をパースできてしまう問題はSimpleDateFormatの特徴をそのまま引き継いでいるようです。


なお、Apache CommonsではSimpleDateFormatのスレッドセーフバージョンとしてFastDateFormatが用意されています。これを使用するとマルチスレッド環境でもフォーマット用のインスタンスを安全に再利用することができます。

FastDateFormatの使用例
import org.junit.Test
import org.sqlite.date.FastDateFormat

class FastDateFormat1 {

    val DATE_FORMAT = FastDateFormat.getInstance("yyyy/MM/dd")

    @Test
    fun `FastDateFormatのインスタンスを再利用する`() {

        val date = DATE_FORMAT.parse("2021/09/01")
        println(date)
        println(DATE_FORMAT.format(date))
    }

    @Test
    fun `FastDateFormatのインスタンスを再利用する2`() {

        val date = DATE_FORMAT.parse("2021/08/32")
        println(date)
        println(DATE_FORMAT.format(date))
    }

}


ただし、FastDateFormatにはisLenientプロパティが存在しないため、文字列をパースする場合は上記のような使い方はお勧めできません。

SimpleDateFormatにできてDateUtilsやFastDateFormatにできないこと

SimpleDateFormatはタイムゾーン情報を付与した文字列をパースすることができます。

TimeZone1.kt
package samples

import org.junit.Test
import java.text.SimpleDateFormat

class TimeZone1 {

    @Test
    fun `simpleDateFormat_UTC+00`() {

        val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSSX")
        val text = "2021/09/01 12:34:56.789+00"
        val date = sdf.parse(text)
        println("sdf=${sdf.toPattern()}, text=$text, date=$date")
    }

    @Test
    fun `simpleDateFormat_UTC+0900`() {

        val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSSX")
        val text = "2021/09/01 12:34:56.789+0900"
        val date = sdf.parse(text)
        println("sdf=${sdf.toPattern()}, text=$text, date=$date")
    }

    @Test
    fun `simpleDateFormat`() {

        val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSS")
        val text = "2021/09/01 12:34:56.789"
        val date = sdf.parse(text)
        println("sdf=${sdf.toPattern()},  text=$text, date=$date")
    }
}
実行結果
sdf=yyyy/MM/dd HH:mm:ss.SSSX, text=2021/09/01 12:34:56.789+00, date=Wed Sep 01 21:34:56 JST 2021
sdf=yyyy/MM/dd HH:mm:ss.SSSX, text=2021/09/01 12:34:56.789+0900, date=Wed Sep 01 12:34:56 JST 2021
sdf=yyyy/MM/dd HH:mm:ss.SSS,  text=2021/09/01 12:34:56.789, date=Wed Sep 01 12:34:56 JST 2021


FastDateFormatも同様にタイムゾーン情報を付与した文字列をパースすることができます。

TimeZone2.kt
package samples

import org.apache.commons.lang3.time.FastDateFormat
import org.junit.Test

class TimeZone2 {

    @Test
    fun fastDateFormat() {

        val fdf = FastDateFormat.getInstance("yyyy/MM/dd HH:mm:ss.SSSX")
        val text = "2021/09/01 12:34:56.789+0000"
        val date = fdf.parse(text)
        println("fdf=${fdf.pattern}, date=$date")
    }
}
実行結果
fdf=yyyy/MM/dd HH:mm:ss.SSSX, date=Wed Sep 01 21:34:56 JST 2021


DateUtilsは、タイムゾーン情報を付与した文字列をパースすることができません。

TimeZone3.kt
package samples

import org.apache.commons.lang3.time.DateUtils
import org.junit.Test

class TimeZone3 {

    @Test
    fun dateUtils() {

        val text = "2021/09/01 12:34:56.789+0000"
        val date = DateUtils.parseDate(text, "yyyy/MM/dd HH:mm:ss.SSSX")
        println(date)
    }
}
実行結果
java.text.ParseException: Unable to parse the date: 2021/09/01 12:34:56.789+0000


残念ながら、タイムゾーンを扱う必要がある場合、DateUtilsは使用できません。


Date and Time APIはどうなの?

Java 8で導入された Date and Time APIのDateTimeFormatterを使用すると、SimpleDateFormatの扱いにくさを緩和することができます。

DateTimeFormatterの使用例
package samples

import org.junit.Test
import java.time.LocalDate
import java.time.format.DateTimeFormatter

class DateTimeFormatter1 {

    @Test
    fun `DateTimeFormatterで文字列をパースする`() {

        println("DateTimeFormatterで文字列をパースする")

        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse("2021/09/01", dtf)
        println(localDate)
        println(dtf.format(localDate))
        println()
    }

    @Test
    fun `DateTimeFormatterで存在しない日付の文字列をパースする(例外が発生する)`() {

        println("DateTimeFormatterで存在しない日付の文字列をパースする(例外が発生する)")

        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse("2021/08/32", dtf)
    }

    @Test
    fun `DateTimeFormatterで指定した書式ではない文字列(字足らず)をパースする(例外が発生する)`() {

        println("DateTimeFormatterで指定した書式ではない文字列(字足らず)をパースする(例外が発生する)")

        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse("2021/09/1", dtf)
    }

    @Test
    fun `DateTimeFormatterで指定した書式ではない文字列(字余り)をパースする(例外が発生する)`() {

        println("DateTimeFormatterで指定した書式ではない文字列(字余り)をパースする(例外が発生する)")

        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse("2021/09/01A", dtf)
    }
}
実行結果
DateTimeFormatterで文字列をパースする
2021-09-01
2021/09/01

DateTimeFormatterで存在しない日付の文字列をパースする(例外が発生する)

Text '2021/08/32' could not be parsed: Invalid value for DayOfMonth (valid values 1 - 28/31): 32
java.time.format.DateTimeParseException: Text '2021/08/32' could not be parsed: Invalid value for DayOfMonth (valid values 1 - 28/31): 32
(省略)

DateTimeFormatterで指定した書式ではない文字列(字足らず)をパースする(例外が発生する)

Text '2021/09/1' could not be parsed at index 8
java.time.format.DateTimeParseException: Text '2021/09/1' could not be parsed at index 8
(省略)

DateTimeFormatterで指定した書式ではない文字列(字余り)をパースする(例外が発生する)

Text '2021/09/01A' could not be parsed, unparsed text found at index 10
java.time.format.DateTimeParseException: Text '2021/09/01A' could not be parsed, unparsed text found at index 10
(省略)


上記のようにDateTimeFormatterではSimpleDateFormatにおける曖昧なパースに関する問題が発生しません。しかも、スレッドセーフなのでマルチスレッド環境でも安心して使えます。

  • ならば、DateTimeFormatterを使えばいいじゃないか!
  • 車輪の再発明は必要ない。したがって、オレオレライブラリなんか作る必要ない!

そういう声が聞こえてきそうです。そうかもしれません。

だが、ちょっと待ってほしい。

DateTimeFormatで得られるのはDate型ではないんです。返ってくるのはLocalDate型なんです。

LocalDate型はDate型とはまったく別の型であり、既存のDate型を前提に作られたライブラリとの互換性がないので、それらを使うためにはLocalDate型からDate型に変換しなければならないという別の問題が発生します。そして、そこにも新たな問題が待ち構えているという地獄。。

LocalDateとDateの相互変換の例
import org.apache.commons.lang3.time.DateUtils
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.*

class LocalDateTime1 {

    @Test
    fun `心が折れそうになるLocalDateからDateへの変換(1)`() {

        println("心が折れそうになるLocalDateからDateへの変換(1)")

        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse("2021/09/01", dtf)
        val date = Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant())
        // 必要なタイピング量が多い。なぜ localDate.toDate()のように簡単に使えるメソッドを用意しないのか。。

        println("localdate: $localDate")
        println("date: $date")
        println()
    }

    @Test
    fun `心が折れそうになるLocalDateからDateへの変換(2)`() {

        println("心が折れそうになるLocalDateからDateへの変換(2)")

        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse("2021/09/01", dtf)
        val date = DateUtils.parseDateStrictly(dtf.format(localDate), "yyyy/MM/dd")
        // 文字列を経由して変換する方法もあるが、やはりタイピング量が多く、問題は解決しない。

        println("localdate: $localDate")
        println("date: $date")
        println()
    }
}
実行結果
心が折れそうになるLocalDateからDateへの変換(1)
localdate: 2021-09-01
date: Wed Sep 01 00:00:00 JST 2021

心が折れそうになるLocalDateからDateへの変換(2)
localdate: 2021-09-01
date: Wed Sep 01 00:00:00 JST 2021


うーん。心が折れますね。
型変換したいだけなのに、複数行の記述が必要になります。

(タイムゾーンがシステムデフォルトでいい場合はlocalDate.toDate()のように変換できるメソッドを標準APIが用意してればいいんですけどね。)

でも、ちょっと立ち止まって考えてみましょう。
そもそもLocalDateをDateに変換する必要があるのでしょうか?
古い設計のDateを使用せず、新しく導入されたLocalDateを使用してアプリケーションを作成すればよいのではないでしょうか?

全く新規でアプリケーションを作成する場合はDateを使用せずLocalDateを使用するというのは良い選択肢だと思いますし、世の中の潮流的にはそっち方向に進んでいくのでしょう。

しかし、使用するライブラリでDateを使用しているものがあるならば、やはりLocalDateからDateへの変換の必要があります。
Dateを使用したライブラリはあまりにも多く、今すぐ完全排除するのは困難です。

LocalDateとDateを相互変換する拡張関数を作る

LocalDateを利用する場合に、Dateへの変換が楽になる拡張関数を作るとしたら、以下のように実装できます。

拡張関数を使用したLocalDateとDateの相互変換
    /**
     * LocalDateをDateに変換する拡張関数
     */
    fun LocalDate.toDate(): Date {

        val pattern = "yyyy/MM/dd"
        val dtf = DateTimeFormatter.ofPattern(pattern)
        val dateString = dtf.format(this)
        val localDate = LocalDate.parse(dateString, dtf)
        val date = DateUtils.parseDateStrictly(dtf.format(localDate), pattern)
        return date
    }

    /**
     * DateをLocalDateに変換する拡張関数
     */
    fun Date.toLocalDate(): LocalDate {

        val pattern = "yyyy/MM/dd"
        val dateString = DateFormatUtils.format(this, pattern)
        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse(dateString, dtf)
        return localDate
    }

    @Test
    fun `拡張関数を使用したLocalDateとDateの相互変換`() {

        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd")
        val localDate = LocalDate.parse("2021/09/01", dtf)

        // LocalDate -> Date
        val date = localDate.toDate()

        // Date -> LocalDate
        val localDate2 = date.toLocalDate()

        println("localDate: $localDate")
        println("date: $date")
        println("localDate2: $localDate2")
    }
実行結果
localDate: 2021-09-01
date: Wed Sep 01 00:00:00 JST 2021
localDate2: 2021-09-01


これはタイムゾーンはシステムデフォルトを前提にしていますが、その前提においてLocalDateとDateの相互変換を極めてコンパクトに記述できます(毎回タイムゾーンを指定する必要がありません)。

グローバルなアプリを作る場合はこの拡張関数はそのままでは使えませんが、日本国内でしか使われないタイムゾーンが固定のアプリを作成するならこのような割り切りもアリでしょう。

プロダクトごとの処理の共通化の重要性

LocalDateからDateの変換の例は、汎用的で高機能に作られている標準APIをそのまま使用すると指定が必要なパラメーターが多いために記述が冗長になるということの典型です。

各プロダクトの要件からAPI利用時に必要なパラメーター設定がパターン化できる場合は、そのパターンの処理を共通化することが開発対象のコード量の抑制と品質低下の防止につながります。

Joda-Timeはどうなの?

Java 8でDate and Time APIが登場する以前にDate型(java.util.Date)の問題を解決するために登場したオープンソースライブラリとしてJoda-Timeがあります。

Joda-Timeは独自のDateTime型を提供し、日時に関する操作を簡単にしてくれますが、他のライブラリはDateTime型をネイティブでサポートするわけではありません。他のライブラリにパラメーターとして日時を渡す際には標準APIであるDateまたはDate and Time APIのLocalDateなどに変換する必要があります。Joda-Timeの場合この変換は実に簡単で、toDateメソッドやtoLocalDateメソッドを呼び出すだけです。

筆者はJoda-Timeでガッツリコードを書いたことはありませんが、まさにこの連載でとりあげたDateの問題を解決するために登場したライブラリであり、優れていると思います。

ただ、公式には以下のように書かれています。

Joda-Time is the de facto standard date and time library for Java prior to Java SE 8. Users are now asked to migrate to java.time (JSR-310).

Joda-TimeはJava SE 8の登場より前にデファクトスタンダードとなった日付と時刻のライブラリである。ユーザーは今は java.time (JSR-310)に移行するように求められている。

このように、Joda-Timeはすでに過去のライブラリとなっており、将来サポートされなくなる可能性があるので、新規で採用するのは控えた方がよさそうです。
※Joda-Timeの作者が参画してJoda-Timeをベースに再設計したのがJSR-310なんだそうです。

結局、日付や日時は何を使えばいいの?

ここまでの状況をいったん整理してみます。

  • SimpleDateFormatやDate(java.uitl.Date)は問題が多く、使用するのはやめたほうがよい
  • 今後はDate and Time APIのLocalDateやLocalDateTimeを使用するのが標準的な流れなので、可能であればこれを採用するのがよい
  • 移行期はDateを使用せざるを得ない場合があるが、LocalDateやLocalDateTimeからDateへの変換は手間がかかるので、これを簡単にする拡張関数を作成して使用するとよい


これらを踏まえた上で、開発プロジェクトの生産性と品質、および開発者のQOLを向上するための、さらに便利なユーティリティを考えていきます。

次回予告

Dateの操作を簡単にする拡張関数を作る(仮)

乞うご期待。