Calendar、Dateよりも、LocalDate、LocalDateTimeを使おう
SpringBoot3、kotlin、exposaed、OpenAPIでRESTのAPIサーバを作っています。
従来はjavaで日付、日時型を扱うのは
- java.util.Calendar
- java.util.Date
が一般的でしたが、java8から新しいAPIが使えるようになっています。
- java.time.LocalDatet
- java.time.LocalDateTime
Calendar、Dateクラスは既にレガシーです。
古い方のAPIは何故だか(歴史的な経緯か?)月が0から始まる(1月が0)といういびつな仕様でバグの原因になりやすいです。(よく、+1を忘れる)
しかも、JDBCでSQLを使う場合、日付型と日時型には2種類あり、これまたややこしい。お互いに相互変換が必要になります。
- java.util.Date
- java.sql.Date
- java.sql.Timestamp
RESETでLocalDatet、LocalDateTime使ってやりとりする
RESTはjsonでやりとりしますが、jsonに日付、日時型はありません。日付、日時型は文字列として扱われます。
SpringBoot3において、RESTでやり取りする場合のRequestBody、RequestParameter、ResponseBodyにおいて、SpringBoot3が自動的にリクエストを
- 文字列(日付) → LocalDate
- 文字列(日時) → LocalDateTime
レスポンスを
- LocalDate → 文字列(日付)
- LocalDateTime → 文字列(日時)
変換してくれたら便利です。
SpringBootでJacksonのカスタムシリアライザーとデシリアライザーを使用するためには以下の依存関係が必要です。
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
}
ですが、私の場合は既に依存ライブラリに含まれていました。
org.springframework.boot:spring-boot-starter-web:3.3.0
を依存関係のライブラリに追加すると自動的に追加されます。
application.ymlにjacksonのデフォルトのフォーマットを定義します。
spring:
jackson:
date-format: yyyy-MM-dd'T'HH:mm:ss
time-zone: Asia/Tokyo
RESTでRequestをLocalDatet、LocalDateTimeで受け取る
Requestを@RequestBodyで受け取るdata classの例です
@Schema(description = "サンプルリクエスト")
data class SampleRequest(
@field:JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Tokyo")
@field:JsonProperty("sampleDate", required = true)
@Schema(description = "サンプル日付型", type = "string", format = "date", example = "2022-03-14", required = true )
var sampleDate: LocalDate,
@field:JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Tokyo")
@field:JsonProperty("sampleDateTime", required = true)
@Schema(description = "サンプル日時型", type = "string", format = "date-time", example = "2022-03-14", required = true )
var sampleDateTime: LocalDateTime,
}
@RequestBodyでdata classで受け取る場合、data classの引数はコンストラクタの引数でもあり、フィールでもあります。フィールドに付けるアノテーションは@field:で修飾してやる必要があります。
@RequestParamでQueryParameterとして受け取る場合はControllerクラスのメソッドの引数になるので、@field:で修飾は不要になります。
@GetMapping("myapi/search")
fun search(
@Valid
@DateTimeFormat(pattern = "yyyy-MM-dd")
@Schema(description = "サンプル日付", type = "string", format = "date", example = "2022-03-14", required = true )
@RequestParam("sampleDate") sampleDate: LocalDate,
@Valid
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
@Schema(description = "サンプル日時", type = "string", format = "date-time", example = "2022-03-14T08:42:00.000Z", required = true )
@RequestParam("sampleDateTime") sampleDateTime: LocalDateTime,
)
・・・
}
ミソは @DateTimeFormat で日付型、日時型の文字列書式を指定してやることです。ここはISO8601形式に準じます
- 日付:"yyyy-MM-dd"
- 日時:"yyyy-MM-dd'T'HH: mm:ss"
OpenAPIの@Schemaアノテーションも書式を指定します。
- 日付:format = "date"
- 日時:format = "date-time"
これで、RESTのリクエストの日付、日時をLocalDate、LocalDateTimeで受け取れるようになります
RESTでResponseにLocalDatet、LocalDateTimeで返す
ResponseBodyにdata classでjsonを返す場合はControllerのメソッドの戻り値の型にそのdata classを指定する必要があります。SpringBootが自動的にdata classをjsonにシリアライズしてくれます。
@RestController
class MyController(
@GetMapping("myapi/search")
fun search( ・・・ ): ResponseEntity<MyResponse> {
・・・
}
・・・
}
data classはこのようになります
data class MyResponse (
@field:JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Tokyo")
@Schema(description = "サンプル日付", type = "string", format = "date", example = "2024-01-01", required = false )
var sampleDate: LocalDate,
@field:JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Tokyo")
@Schema(description = "サンプル日時", type = "string", format = "date-time", example = "2024-01-01T00:00:00", required = true )
var sampleDateTime: LocalDateTime,
・・・
}
@DateTimeFormat、@Schemaの書式指定はReqestBody、RequestParamと同じです。
exposed側の設定
exposedにおいては、
dependencies {
implementation("org.jetbrains.exposed:exposed-java-time:0.50.1")
}
exposedのDSL、DAOが扱う日付型、日時型はLocalDate、LocalDateTimeに合わされます。
exposedでSELECTする時、テーブルのjoinが1個か、2個ぐらいなら頑張って、DSL、DAOでも書けますが、何でも頑張ってSQLでやろうとする人、そのDBに依存した関数等を多用していると、DSL、DAOでは困難です。
TransactionManager.current().exec(
"""
SELECT
SAMPLE_DATE, -- 日付型カラム
SAMPLE_DATE_TIME, -- 日時型カラム
たくさんのカラム、関数使用している
FROM
テーブル、いくつもjoinしている・・・
WHERE
・・・
ORDER BY
・・・
""".trimIndent(), args
) { rs ->
while (rs.next()) {
・・・
val sampleDate = rs.getDate("SAMPLE_DATE").toLocalDate()
val sampleDateTime = rs.getTimestamp("SAMPLE_DATE_TIME").toLocalDateTime()
}
}
TransactionManager.current().exec()の最後の引数のラムダの中はjava.sql.ResultSetが渡ってきます。なので普通に生JDBCのAPI(最近ではあまりないか?)を使ってやる必要があります。
日付型のカラムはResultSet#getDateで、一旦java.sql.Date型で取得してそれをLocalDate型に変換してやる必要があります。
日時型のカラムはResultSet#getTimestampで、一旦java.sql.Timestamp型で取得してそれをLocalDateTime型に変換してやる必要があります。
カスタムのjackson objectMapper
以上で、ほぼほぼ、やりたいことは満たせるのですが、一部、カスタムのjackson objectMapperが必要になるケースがあります。
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class DateTimeConfig {
@Bean
fun objectMapper(): ObjectMapper {
return ObjectMapper()
.registerModule(KotlinModule.Builder().build())
.registerModule(JavaTimeModule())
}
}
これをどこで使うのかというとMockMVCでのControllerのテストです
@ExtendWith(SpringExtension::class)
@AutoConfigureMockMvc
@SpringBootTest
class MyControllerTest @Autowired constructor(val mockMvc: MockMvc) {
/** objectMapper */
private val objectMapper = ObjectMapper() // ★
@Test
@DisplayName("倉庫棚卸チェック、正常、200")
fun inventoryChkTest01() {
// 条件
val request = MyRequest(
myDate = LocalDate.of(2024, 1, 1),
myDateTime = LocalDateTime.of(2024, 1, 1, 0, 0, 0)
)
// 実行
val rslt = mockMvc.perform(
MockMvcRequestBuilders
.put(INVENTORY_CHK_PATH)
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON_VALUE)
.characterEncoding(Charsets.UTF_8)
).andReturn().response
}
}
上のテストケースの★印のobjectMapperは、LocalDate、LocalDateTimeをシリアライズできません。
objectMapper.writeValueAsString(request)でエラーになります。
上記、DateTimeConfig.ktで登録したbeanをインジェクションしてそれを使う必要があります。
@ExtendWith(SpringExtension::class)
@AutoConfigureMockMvc
@SpringBootTest
class MyControllerTest @Autowired constructor(
val mockMvc: MockMvc,
val objectMapper: ObjectMapper) { // LocalDate、LocalDateTimeを使う場合はこれ必要
@Test
@DisplayName("倉庫棚卸チェック、正常、200")
fun inventoryChkTest01() {
// 条件
val request = MyRequest(
myDate = LocalDate.of(2024, 1, 1),
myDateTime = LocalDateTime.of(2024, 1, 1, 0, 0, 0)
)
// 実行
val rslt = mockMvc.perform(
MockMvcRequestBuilders
.put(INVENTORY_CHK_PATH)
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON_VALUE)
.characterEncoding(Charsets.UTF_8)
).andReturn().response
}
}
他、内部で独自にjson ←→ LocalDate、LocalDateTimeの変換の処理が必要であれば、カスタムのjackson objectMapperが必要となるケースがあるかもしれません。
最後に
以上で、SpringBoot3、kotlin、exposaed、OpenAPIでRESTのAPIサーバでレガシーなjava.util.Date、Calenarを一切使わずに実現できます。