Kotlin(Java)では、java.time
パッケージのクラスで日付や時間を処理することができます。例えばLocalDateTimeやLocalDateなどがありますね。サーバサイドではこれらのクラスを使ってDBに日付や時間を入力したり、認証用のトークンの有効期間を設定したりの処理ができるようになります。他にもPeriodやDurationがあって、「期間」を扱うこともできますね。
ただ、「年月」という単位を扱いたい場合はどうしたらいいでしょうか。例えば、口座の入出金明細などを照会する時に、「2月から4月まで」という風に期間を設定するケースなどがあるとしたら、いらない「日」や「時間」まで含めるのはあまり効率的でなく、場合によってはバグの原因になるかもしれません。こういった場合は確かな「年月」としてデータを扱うか、数字として表現するかなどどちらかの方法を考える必要があるでしょう。
ということで、今回はこの年月を扱う方法について少し述べたいと思います。
年月を年と月に
年月を扱うということは、つまり、いつでも「年」と「月」という二つのデータとして分離できるようにしたいということにもなりますね。ここでは二つの方法で、「年月」を「年」と「月」の二つに分けて扱う方法について説明します。
YearMonthとして
LocalDate
やLocalDateTime
では、基本的にISO-8601形式で日付を扱うことができます。もちろん、DateTimeFormatterを使って他の形式を指定することもできますが、扱うデータの形が違うだけで、本質的には「年月日」が基本となりますね。
ISO-8601
の「年月日」形式で日付を扱っているということは、つまり、SpringでREST APIを作っている場合、リクエストの値がISO-8601
の形式を守っていればLocalDateTime
やLocalDate
形式に自動変換されるということでもあります。例えば以下のようなリクエストの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) {
// ...
}
そして全く同じやり方で、LocalDate
をYearMonthに変えることで年月に対応することができます。例えば以下のようなリクエストがあるとします。
{
"id": "1",
"yearMonth": "2021-04"
}
ここでyearMonth
をYearMonth
に変えるだけです。以下のようになります。
// リクエストボディ
data class YearMonthRequest(val id: Int, val yearMonth: YearMonth)
// コントローラ
@PostMapping("/year-month")
fun yearMonth(@RequestBody request: YearMonthRequest) {
// ...
}
YearMonth
を使うことのメリットは、LocalDateTime
やLocalDate
と同じく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
}
そもそもyear
とmonth
のように別の項目になっていたとしたらもっとやりやすいのですが、このように年月が一つの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で年と月を繋げると9の場合は先頭に20214
のような形になり得る可能性もあるということですね。なので、padStart()
を利用して、月が10
をつけるようにします。そのあとは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で年月を扱う必要がある方には、少しでも役に立てるといいですね。
では、また!