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

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

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

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


開発者Gです。

前回は日付の操作を簡単にするオレオレライブラリのコンセプトを示し、まずDateとCalendarに関する問題を解決する拡張プロパティを作りました。

今回はさらにDateを文字列にフォーマットする機能と、文字列をパースしてDateを取得する機能を拡張関数で追加してみます。


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

Dateに拡張関数formatを追加する

SimpleDateFormatやDateFormatUtilsを使用するとDateを指定したフォーマットで文字列に出力できますが、いつも同じフォーマットで良いのであれば毎回指定するのは面倒ですし、デフォルトの動作を決めた方が記述ミスも防げます。

以下のような仕様の拡張関数を追加しましょう。

  • ミリ秒が0以外の値であれば "yyyy/MM/dd HH:mm.ss.SSS" で出力する
  • ミリ秒が0 かつ 時分秒のいずれかが0以外の値であれば "yyyy/MM/dd HH:mm.ss" で出力する
  • 上記以外ならば "yyyy/MM/dd" で出力する
  • オプションでタイムゾーンを出力する
DateFormatExtension.kt
package extensions.date.format

import extensions.date.property.hourValue
import extensions.date.property.millisecondValue
import extensions.date.property.minuteValue
import extensions.date.property.secondValue
import java.text.SimpleDateFormat
import java.util.*

/**
 * Dateをフォーマットして文字列を出力します。
 *
 *  @param [pattern] SimpleDateFormatのpattern
 *  @param [tz] SimpleDateFormatのpatternのうちタイムゾーン部分だけを指定したい場合にz,Z,Xを指定できます
 */
fun Date.format(pattern: String? = null, tz: String? = null): String {

    var sdfFormat = (
            if (pattern.isNullOrBlank().not())
                pattern!!
            else if (this.millisecondValue != 0)
                "yyyy/MM/dd HH:mm:ss.SSS"
            else if (this.hourValue != 0 || this.minuteValue != 0 || this.secondValue != 0)
                "yyyy/MM/dd HH:mm:ss"
            else
                "yyyy/MM/dd"
            )
    if (tz != null) {
        when (tz) {
            "z" -> sdfFormat += " z"
            else -> sdfFormat += tz
        }
    }

    val sdf = SimpleDateFormat(sdfFormat)
    return sdf.format(this)
}
使用例
package extensions.date.format

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

class DateFormatExtensionTest {

    @Test
    fun format() {

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56.789", "yyyy/MM/dd HH:mm:ss.SSS")
            val output = date.format()  // 2021/09/01 12:34:56.789
            println("date.format(): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56", "yyyy/MM/dd HH:mm:ss")
            val output = date.format()  // 2021/09/01 12:34:56
            println("date.format(): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01", "yyyy/MM/dd")
            val output = date.format()  // 2021/09/01
            println("date.format(): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56.789", "yyyy/MM/dd HH:mm:ss.SSS")
            val pattern = "yyyy/MM/dd HH:mm:ss.SSSz"
            val output = date.format(pattern)   // 2021/09/01 12:34:56.789JST
            println("date.format(\"$pattern\"): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56.789", "yyyy/MM/dd HH:mm:ss.SSS")
            val pattern = "yyyy/MM/dd HH:mm:ss.SSSZ"
            val output = date.format(pattern)   // 2021/09/01 12:34:56.789+0900
            println("date.format(\"$pattern\"): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56.789", "yyyy/MM/dd HH:mm:ss.SSS")
            val pattern = "yyyy/MM/dd HH:mm:ss.SSSX"
            val output = date.format(pattern)   // 2021/09/01 12:34:56.789+09
            println("date.format(\"$pattern\"): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56.789", "yyyy/MM/dd HH:mm:ss.SSS")
            val output = date.format(tz = "z")  // 2021/09/01 12:34:56.789 JST
            println("date.format(tz=\"z\"): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56.789", "yyyy/MM/dd HH:mm:ss.SSS")
            val output = date.format(tz = "Z")  // 2021/09/01 12:34:56.789+0900
            println("date.format(tz=\"Z\"): $output")
        }

        run {
            val date = DateUtils.parseDate("2021/09/01 12:34:56.789", "yyyy/MM/dd HH:mm:ss.SSS")
            val output = date.format(tz = "X")  // 2021/09/01 12:34:56.789+09
            println("date.format(tz=\"X\"): $output")
        }
    }
}
実行結果
date.format(): 2021/09/01 12:34:56.789
date.format(): 2021/09/01 12:34:56
date.format(): 2021/09/01
date.format("yyyy/MM/dd HH:mm:ss.SSSz"): 2021/09/01 12:34:56.789JST
date.format("yyyy/MM/dd HH:mm:ss.SSSZ"): 2021/09/01 12:34:56.789+0900
date.format("yyyy/MM/dd HH:mm:ss.SSSX"): 2021/09/01 12:34:56.789+09
date.format(tz="z"): 2021/09/01 12:34:56.789 JST
date.format(tz="Z"): 2021/09/01 12:34:56.789+0900
date.format(tz="X"): 2021/09/01 12:34:56.789+09


なお、日時パターンの記述方法についてはSimpleDateFormatのヘルプを参照してください。
SimpleDateFormat (Java Platform SE 8)


文字列をパースしてDateを取得する

どのような文字列をパースして日付を得たいかはケースバイケースですが、やはりデフォルトの挙動を決め打ちにすることで、フォーマットを都度指定する手間を省き、ミスを防止することができます。

以下のような仕様の拡張関数を追加しましょう。

  • オプションでタイムゾーンを指定できる。指定しない場合はシステムデフォルトを適用する
  • タイムゾーンを除く部分について
    • 文字列長が8ならば"yyyyMMdd"としてパースする
    • 文字列長が10ならば"yyyy/MM/dd"としてパースする
    • 文字列長が14ならば"yyyyMMddHHmmss"としてパースする
    • 文字列長が17ならば"yyyyMMddHHmmssSSS"としてパースする
    • 文字列長が19ならば"yyyy/MM/dd HH:mm.ss"としてパースする
    • 文字列長が23ならば"yyyy/MM/dd HH:mm.ss.SSS"としてパースする
    • 上記以外ならば"yyyyMMdd"としてパースする
StringDateExtension.kt
package extensions.string.date

import java.text.SimpleDateFormat
import java.time.ZoneId
import java.util.*

/**
 * 文字列をDateに変換します。変換できない場合は例外を発生させます。
 *
 * length -> format
 * 8 -> "yyyyMMdd"
 * 10 -> "yyyy/MM/dd"
 * 14 -> "yyyyMMddHHmmss"
 * 17 -> "yyyyMMddHHmmssSSS"
 * 19 -> "yyyy/MM/dd HH:mm:ss"
 * 23 -> "yyyy/MM/dd HH:mm:ss.SSS"
 * else -> "yyyy/MM/dd"
 */
fun String.toDate(pattern: String? = null, tz: String? = null, strict: Boolean = true): Date {

    val tokens = this.replace("-", "|-").replace("+", "|+").split("|")
    val datePart = tokens[0]
    val zonePart = if (tokens.count() == 2) tokens[1] else ""
    val zonePattern =
        if (tz != null) tz
        else if (tokens.count() == 2) "X"
        else ""

    val sdfPattern = pattern
        ?: when (datePart.length) {
            8 -> "yyyyMMdd"
            10 -> "yyyy/MM/dd"
            14 -> "yyyyMMddHHmmss"
            17 -> "yyyyMMddHHmmssSSS"
            19 -> "yyyy/MM/dd HH:mm:ss"
            23 -> "yyyy/MM/dd HH:mm:ss.SSS"
            else -> "yyyy/MM/dd"
        } + zonePattern
    try {
        val sdf = SimpleDateFormat(sdfPattern)
        sdf.isLenient = false
        if (zonePart.isNotBlank()) {
            sdf.timeZone = TimeZone.getTimeZone(ZoneId.of(zonePart))
        }
        val date = sdf.parse(this)

        if (strict) {
            val reformat =
                sdf.format(date).replace("Z", "").replace(zonePart, "").replace(zonePart.replace("00", ""), "")
            val thisWithoutZonePart = this.replace(zonePart, "")
            if (thisWithoutZonePart != reformat) {
                throw IllegalArgumentException("strict=$strict が指定されています。")
            }
        }
        return date
    } catch (t: Throwable) {
        throw IllegalArgumentException("Dateに変換できません。(this=$this, pattern=$pattern)", t)
    }
}

/**
 * 文字列をDateに変換します。変換できない場合はnullを返します。
 *
 * length -> format
 * 8 -> "yyyyMMdd"
 * 10 -> "yyyy/MM/dd"
 * 14 -> "yyyyMMddHHmmss"
 * 17 -> "yyyyMMddHHmmssSSS"
 * 19 -> "yyyy/MM/dd HH:mm:ss"
 * 23 -> "yyyy/MM/dd HH:mm:ss.SSS"
 * else -> "yyyy/MM/dd"
 */
fun String.toDateOrNull(pattern: String? = null): Date? {

    try {
        return this.toDate(pattern = pattern)
    } catch (t: Throwable) {
        return null
    }
}
使用例
package extensions.string.date

import extensions.date.format.format
import org.junit.Test

class StringDateExtensionTest {

    /**
     * 文字列をDateに変換します。変換できない場合は例外を発生させます。
     *
     * length -> format
     * 8 -> "yyyyMMdd"
     * 10 -> "yyyy/MM/dd"
     * 14 -> "yyyyMMddHHmmss"
     * 17 -> "yyyyMMddHHmmssSSS"
     * 19 -> "yyyy/MM/dd HH:mm:ss"
     * 23 -> "yyyy/MM/dd HH:mm:ss.SSS"
     * else -> "yyyy/MM/dd"
     */
    @Test
    fun toDate() {

        println()
        println("toDate()")

        run {
            val text = "20210901"
            val date = text.toDate()
            println("(length=8)  $text -> ${date.format()}")
        }

        run {
            val text = "2021/09/01"
            val date = text.toDate()
            println("(length=10) $text -> ${date.format()}")
        }

        run {
            val text = "20210901123456"
            val date = text.toDate()
            println("(length=14) $text -> ${date.format()}")
        }

        run {
            val text = "20210901123456789"
            val date = text.toDate()
            println("(length=17) $text -> ${date.format()}")
        }

        run {
            val text = "2021/09/01 12:34:56"
            val date = text.toDate()
            println("(length=19) $text -> ${date.format()}")
        }

        run {
            val text = "2021/09/01 12:34:56.789"
            val date = text.toDate()
            println("(length=23) $text -> ${date.format()}")
        }

        run {
            val text = "2021/09/01 12:34:56.789+00"
            val date = text.toDate()
            println("(length=23+TZ) $text -> ${date.format()}")
        }

        run {
            val text = "2021/09/01 12:34:56.789+09"
            val date = text.toDate()
            println("(length=23+TZ) $text -> ${date.format()}")
        }

        run {
            val text = "2021/09/01 12:34:56.789+0900"
            val date = text.toDate()
            println("(length=23+TZ) $text -> ${date.format()}")
        }

        try {
            "2021".toDate()
        } catch (t: Throwable) {
            println(t)
        }
    }

    @Test
    fun toDateOrNull() {

        println()
        println("toDateOrNull()")

        run {
            val text = "20210901"
            val date = text.toDateOrNull()
            println("(length=8)  $text -> ${date?.format()}")
        }

        run {
            val text = "2021/09/31"
            val date = text.toDateOrNull()
            println("$text -> $date")
        }
    }
}
実行結果
toDate()
(length=8)  20210901 -> 2021/09/01
(length=10) 2021/09/01 -> 2021/09/01
(length=14) 20210901123456 -> 2021/09/01 12:34:56
(length=17) 20210901123456789 -> 2021/09/01 12:34:56.789
(length=19) 2021/09/01 12:34:56 -> 2021/09/01 12:34:56
(length=23) 2021/09/01 12:34:56.789 -> 2021/09/01 12:34:56.789
(length=23+TZ) 2021/09/01 12:34:56.789+00 -> 2021/09/01 21:34:56.789
(length=23+TZ) 2021/09/01 12:34:56.789+09 -> 2021/09/01 12:34:56.789
(length=23+TZ) 2021/09/01 12:34:56.789+0900 -> 2021/09/01 12:34:56.789
java.lang.IllegalArgumentException: Dateに変換できません。(this=2021, pattern=null)

toDateOrNull()
(length=8)  20210901 -> 2021/09/01
2021/09/31 -> null


上記のように"20210901".toDate()を実行すると、文字列長が8なので"yyyyMMdd"のフォーマットが自動的に決定され、パースが実行されます。

自動決定されるフォーマットがプロダクトの要件にマッチする場合は、上記のように生産性を向上することができます。

次回予告

日付の加算・減算を簡単にする拡張関数を追加する(仮)

乞うご期待。