Featured image of post 年月を扱ってみる

年月を扱ってみる

Kotlin(Java)では、java.timeパッケージのクラスで日付や時間を処理することができます。例えばLocalDateTimeLocalDateなどがありますね。サーバサイドではこれらのクラスを使ってDBに日付や時間を入力したり、認証用のトークンの有効期間を設定したりの処理ができるようになります。他にもPeriodDurationがあって、「期間」を扱うこともできますね。

ただ、「年月」という単位を扱いたい場合はどうしたらいいでしょうか。例えば、口座の入出金明細などを照会する時に、「2月から4月まで」という風に期間を設定するケースなどがあるとしたら、いらない「日」や「時間」まで含めるのはあまり効率的でなく、場合によってはバグの原因になるかもしれません。こういった場合は確かな「年月」としてデータを扱うか、数字として表現するかなどどちらかの方法を考える必要があるでしょう。

ということで、今回はこの年月を扱う方法について少し述べたいと思います。

年月を年と月に

年月を扱うということは、つまり、いつでも「年」と「月」という二つのデータとして分離できるようにしたいということにもなりますね。ここでは二つの方法で、「年月」を「年」と「月」の二つに分けて扱う方法について説明します。

YearMonthとして

LocalDateLocalDateTimeでは、基本的にISO-8601形式で日付を扱うことができます。もちろん、DateTimeFormatterを使って他の形式を指定することもできますが、扱うデータの形が違うだけで、本質的には「年月日」が基本となりますね。

ISO-8601の「年月日」形式で日付を扱っているということは、つまり、SpringでREST APIを作っている場合、リクエストの値がISO-8601の形式を守っていればLocalDateTimeLocalDate形式に自動変換されるということでもあります。例えば以下のようなリクエストのJSONがあるとしましょう。

{
    "id": "1",
    "date": "2021-04-01"
}

Spring側では以下のようなコードで、リクエストのdateをLocalDateに変換することができます。

// リクエストボディ
data class DateRequest(val id: Int, val date: LocalDate)

// コントローラ
@PostMapping("/date")
fun date(@RequestBody request: DateRequest) {
    // ...
}

そして全く同じやり方で、LocalDateYearMonthに変えることで年月に対応することができます。例えば以下のようなリクエストがあるとします。

{
    "id": "1",
    "yearMonth": "2021-04"
}

ここでyearMonthYearMonthに変えるだけです。以下のようになります。

// リクエストボディ
data class YearMonthRequest(val id: Int, val yearMonth: YearMonth)

// コントローラ
@PostMapping("/year-month")
fun yearMonth(@RequestBody request: YearMonthRequest) {
    // ...
}

YearMonthを使うことのメリットは、LocalDateTimeLocalDateと同じくjava.timeパッケージに属するオブジェクトなので、それらと互換性があり、相互変換が自由ということでもあります。例えば以下のように使えます。

>>> val yearMonth = YearMonth.now() // 現在の年月を取得
>>> println(yearMonth)
2021-04
>>> val localDate = yearMonth.atDay(1) // 年月に日を指定してLocalDateにする
>>> println(localDate)
2021-04-01

また、YearMonthは時間に関する便利なメソッドを多く提供しているので、単純に数値としての年月を扱うだけでなく、色々な要件に合わせて日付関連の処理が必要な場合に便利かもしれません。例えば以下のような機能が提供されます。

>>> val yearMonth = YearMonth.of(2021, 5)
>>> println(yearMonth)
2021-05
>>> println(yearMonth.getYear()) // 年を取得
2021
>>> println(yearMonth.getMonth()) // 月(Enum)を取得
MAY
>>> println(yearMonth.getMonthValue()) // 月(数字)を取得
5
>>> println(yearMonth.isLeapYear()) // うるう年であるかどうか
false
>>> println(yearMonth.atEndOfMonth()) // 月の最後の日(LocalDate)
2021-05-31

数字として

YearMonthで受け取って処理した方がもっとも綺麗な方法に見えますが、状況によっては素直にInt型で受け取った方が良い(もしくはそうするしかない)ケースもあるはずです。例えば以下のようなリクエストが送らられて来るようなケースですね。

{
    "id": "1",
    "yearMonth": 202104
}

そもそもyearmonthのように別の項目になっていたとしたらもっとやりやすいのですが、このように年月が一つのInt型のデータとして送られてくる場合は自分で年と月を抽出する処理を作るしかないですね。例えば以下のようなextension functionを書くことができるでしょう。

// 年を抽出する
fun Int.extractYear(): Int = this / 100
// 月を抽出する
fun Int.extractMonth(): Int = this % 100

実際のコードを動かしてみると、ちゃんと意図通り動くのを確認できます。

>>> fun Int.extractYear(): Int = this / 100
>>> 202104.extractYear()
res4: kotlin.Int = 2021
>>> fun Int.extractMonth(): Int = this % 100
>>> 202104.extractMonth()
res6: kotlin.Int = 4

しかし、パラメータとして渡されたものはただのInt型なので、期待した通りの値ではない可能性もあるという問題があります。常にYYYYMMという形でデータが送られてくるかどうかをチェックする必要がありますね。

そういう場合に、上記のコードだとリクエストのyearMonthが正しい年月の形式になっているかどうかがわかりません。なので、正規式を用いたバリデーションチェックを挟むことにしたらより安全になるでしょう。例えば、以下のようなコードを使えます。

fun Int.toYearMonth(): Pair<Int, Int> =
    if (Regex("^(19|20)\\d{2}(0[1-9]|1[012])").matches(this.toString()))
        this / 100 to this % 100
    else
        throw IllegalArgumentException("cannot convert")

上記の関数は、以下のような使い方ができます。簡単に使えるのでいい感じですね。

>>> val (year, month) = 202104.toYearMonth()
>>> println(year)
2021
>>> println(month)
4

元の値を二つのIntに分けるために戻り値としてPairを使いましたが、場合によってはYearMonthの方が良いかもしれません。そういう場合は、以下のようなコードが使えます。

fun Int.toYearMonth(): YearMonth =
    if (Regex("^(19|20)\\d{2}(0[1-9]|1[012])").matches(this.toString()))
        YearMonth(this / 100, this % 100)
    else
        throw IllegalArgumentException("cannot convert")

年と月を年月に

さて、今回は逆に「年」と「月」を繋げて「年月」にする場合の処理を考えてみましょう。二つのIntを合わせて、一つのInt(YYYYMM)にする形です。ここでまず考えられる方法は二つです。YearMonthを使った方法と、文字列に変換してから処理するという方法です。

YearMonthで

まずYearMonthを利用する場合は、年と月をそのまま引数として渡した後、Intに変換すれば良いですね。ただ、YearMonthは基本的にISO-8601形式なので、2021年4月だと2021-04となるのでIntへ変換ができません。なので、まずStringに変えてから、-を消してIntに変換することにします。以上の処理は、以下のようなコードになります。

fun toYearMonth(year: Int, month: Int): Int = 
    YearMonth.of(year, month).toString().replace("-", "").toInt()

文字列で

文字列で処理する場合は、単純にString templatesを使うことでも可能ですが、注意したいのは、月は112という範囲を持つので、単純にtemplateで年と月を繋げると20214のような形になり得る可能性もあるということですね。なので、padStart()を利用して、月が19の場合は先頭に0をつけるようにします。そのあとはIntに変換するだけですね。これは以下のようなコードになリます。

fun toYearMonth(year: Int, month: Int): Int = "${year}${month.toString().padStart(2, '0')}".toInt()

これらの方法は、引数が二つなので、infixとして定義することもできます(好みの問題かと思いますが)。

>>> infix fun Int.toYearMonthWith(month: Int): Int = "${this}${month.toString().padStart(2, '0')}".toInt()
>>> 2021 toYearMonthWith 5
res10: kotlin.Int = 202105

最後に

いかがだったでしょうか。あまり難しいコードではなかったので、あえて記事にまでする必要があったのか、という気もしましたが、個人的にはYearMonthというクラスの存在を初めて知ったのもあり、Kotlinならではのコード(extension function)を書いてみたく試したことを共有したいと思った次第です。もしKotlinやJavaで年月を扱う必要がある方には、少しでも役に立てるといいですね。

では、また!

Built with Hugo
Theme Stack designed by Jimmy