Server Side Kotlin with Micronaut
はじめに
バックエンドでKotlinを採用する場合、フレームワークの選定に迷いますよね。micronaut (+ jdbi3) が最もバランスが良さそうだという結論にいたったので、その構成を実装を混じえながら紹介します。
使用したrepositoryはこちら。
選定理由
Kotlin向けのWebフレームワークとして有名なものとして、SpringとKtorがありますが
- Springは遅い。
- ktorのような薄いフレームワークはDIなど面倒な部分が多い。
そのため、高性能で成熟したフレームワーク(=Java製)かつ、公式にKotlinサポートがあるものを求めた結果、micronautに辿り着きました。
また、データベース操作のモジュールmicronaut-dataは素のSQLを書くのには向いていません。
単純なCrudRepositoryなどはinterfaceだけで簡単に生成できるので便利ですが、SpringのJdbcClientのように複雑なクエリを直接記述したい場合はjdbi3を併用するのがいいでしょう。
今回はjdbi3は使わず、micronaut-data-jdbcのCrudRepositoryだけで実装します。
導入
では実装例を添えて見てみましょう。簡単のため、ドメインは単純なCRUDで済むTODOアプリにしますが、認証などはきちんと作ります。CAベースで設計しますが、DI用のアノテーションをimportする必要があります。
現時点の最新版である、Micronaut 4.8.0を使用しました。
私が慣れているので、言語はKotlin、ビルドシステムはGradleを採用します。IntelliJ IDEA Ultimateを持っているならIDEA上でも作成できますが、今回はmicronaut cliを使用しましょう。sdkmanで入れます。
# まだsdkmanをインストールしていない場合
# curl -s "https://get.sdkman.io" | bash
# source "~/.sdkman/bin/sdkman-init.sh"
sdk update && sdk install micronaut
フラグで有効にする機能を選択します。jdbcをhikariCPで使うことにしました。databaseにはpostgersを選びました。
mn create-app micronaut-demo --lang kotlin --build gradle --test kotest --jdk 21 --features data-jdbc,jdbc-hikari,postgres,flyway,security-jwt
これでカレントディレクトリ以下に micronaut-demo/
というプロジェクトが作成されました。Application.ktだけの空っぽのプロジェクトです。
実装例
では実装を進めていきましょう。
CAっぽく3層(adapter/application/domain)でやります。
Domain
まずはドメイン定義からです。
TODOのタスクを表現する Task
とそれの所有者 User
があればいいでしょう。IDがnullableになってしまうのが嫌なので、新規作成用のモデルは分けます。
domain/
entity/
Task.kt
User.kt
repository/
TaskRepository.kt
package micronaut.demo.domain.entity
import java.time.Instant
data class Task(
val id: Long,
val userId: Long,
val name: String,
val description: String,
val isCompleted: Boolean,
val createdAt: Instant,
val updatedAt: Instant,
)
data class NewTask(
val userId: Long,
val name: String,
val description: String,
)
package micronaut.demo.domain.entity
data class User(
val id: Long,
val username: String,
val passwordHash: String,
)
data class NewUser(
val username: String,
val passwordHash: String,
)
package micronaut.demo.domain.repository
import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task
interface TaskRepository {
fun save(task: NewTask): Task
fun findById(id: Long): Task?
fun findByUserId(userId: Long): List<Task>
fun update(task: Task): Task
fun deleteById(id: Long): Boolean
}
Application (Usecase)
基本操作のusecaseを作成します。簡単のために今回は例外でエラー処理しますが、arrow-coreのEitherなどを使って処理した方がいいと思います。
application/
usecase/
CreateTaskUsecase.kt
ListTaskUsecase.kt
UpdateTaskUsecase.kt
DeleteTaskUsecase.kt
package micronaut.demo.application.usecase
import jakarta.inject.Singleton
import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository
interface CreateTaskUsecase {
fun execute(userId: Long, title: String, description: String): Task
}
@Singleton
class CreateTaskUsecaseImpl(
private val taskRepository: TaskRepository,
) : CreateTaskUsecase {
override fun execute(userId: Long, title: String, description: String): Task {
val newTask = NewTask(userId, title, description)
return taskRepository.save(newTask)
}
}
package micronaut.demo.application.usecase
import jakarta.inject.Singleton
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository
interface ListTaskUsecase {
fun execute(userId: Long): List<Task>
}
@Singleton
class ListTaskUsecaseImpl(
private val taskRepository: TaskRepository,
) : ListTaskUsecase {
override fun execute(userId: Long): List<Task> {
return taskRepository.findByUserId(userId)
}
}
package micronaut.demo.application.usecase
import jakarta.inject.Singleton
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository
interface UpdateTaskUsecase {
fun execute(
userId: Long,
id: Long,
title: String?,
description: String?,
isCompleted: Boolean?,
): Task
}
@Singleton
class UpdateTaskUsecaseImpl(
private val taskRepository: TaskRepository,
) : UpdateTaskUsecase {
override fun execute(
userId: Long,
id: Long,
title: String?,
description: String?,
isCompleted: Boolean?,
): Task {
val task = taskRepository.findById(id)
?: throw IllegalArgumentException("Task with id $id not found")
if (task.userId != userId) {
throw IllegalArgumentException("Task with id $id does not belong to user $userId")
}
val updatedTask = task.copy(
title = title ?: task.title,
description = description ?: task.description,
isCompleted = isCompleted ?: task.isCompleted,
)
return taskRepository.update(updatedTask)
}
}
package micronaut.demo.application.usecase
import jakarta.inject.Singleton
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository
interface UpdateTaskUsecase {
fun execute(userId: Long, id: Long, title: String?, description: String?): Task
}
@Singleton
class UpdateTaskUsecaseImpl(
private val taskRepository: TaskRepository,
) : UpdateTaskUsecase {
override fun execute(userId: Long, id: Long, title: String?, description: String?): Task {
val task = taskRepository.findById(id)
?: throw IllegalArgumentException("Task with id $id not found")
if (task.userId != userId) {
throw IllegalArgumentException("Task with id $id does not belong to user $userId")
}
val updatedTask = task.copy(
title = title ?: task.title,
description = description ?: task.description,
)
return taskRepository.update(updatedTask)
}
}
User関連のUsecaseは認証の章でまとめてやります。
Adapter (Controller)
Annotation baseで書けます。Springっぽいですね。
先に認証周りを作らないとuserIdが取れないので、一旦TODOにしておきます。
また、本来はIDをそのまま外部に公開するべきではありません。プロダクションでは必ず暗号化してください。今回は実装の簡潔さを優先しています。
adapter/
controller/
request/
CreateTaskRequest.kt
UpdateTaskRequest.kt
response/
TaskResponse.kt
TaskController.kt
package micronaut.demo.adapter.controller
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Consumes
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Patch
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Produces
import micronaut.demo.application.usecase.CreateTaskUsecase
import micronaut.demo.application.usecase.DeleteTaskUsecase
import micronaut.demo.application.usecase.ListTaskUsecase
import micronaut.demo.application.usecase.UpdateTaskUsecase
import micronaut.demo.interfaces.controller.request.CreateTaskRequest
import micronaut.demo.interfaces.controller.request.UpdateTaskRequest
import micronaut.demo.interfaces.controller.response.TaskResponse
@Controller("/tasks")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
class TaskController(
private val createTaskUsecase: CreateTaskUsecase,
private val listTaskUsecase: ListTaskUsecase,
private val updateTaskUsecase: UpdateTaskUsecase,
private val deleteTaskUsecase: DeleteTaskUsecase,
) {
@Get
fun listTasks(): List<TaskResponse> {
TODO()
}
@Post
fun createTask(
@Body body: CreateTaskRequest,
): TaskResponse {
TODO()
}
@Patch("/{id}")
@Consumes(MediaType.APPLICATION_JSON_MERGE_PATCH)
fun updateTask(
@PathVariable id: Long,
@Body body: UpdateTaskRequest,
): TaskResponse {
TODO()
}
@Delete("/{id}")
fun deleteTask(
@PathVariable id: Long,
) {
TODO()
}
}
package micronaut.demo.adapter.controller.request
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class CreateTaskRequest(
val title: String,
val description: String,
)
package micronaut.demo.adapter.controller.request
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class UpdateTaskRequest(
val title: String?,
val description: String?,
val isCompleted: Boolean?,
)
package micronaut.demo.adapter.controller.response
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class TaskResponse(
val id: Long,
val userId: Long,
val title: String,
val description: String,
val isCompleted: Boolean,
val createdAt: String,
val updatedAt: String,
)
fun Task.toResponse(): TaskResponse =
TaskResponse(
id = id,
userId = userId,
title = title,
description = description,
isCompleted = isCompleted,
createdAt = createdAt.toString(),
updatedAt = updatedAt.toString(),
)
Adapter (Persistence)
まずはdocker-composeでpostgresを起動しましょう。
簡単のために色々直書きしていますが、実際の開発では環境変数などでセキュアに扱ってください。
services:
micronaut-demo-db:
image: postgres:17.4
container_name: postgres_17
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: demo
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up -d
初期化のSQLも書いておきましょう。flywayの命名規則に従います。
- src/main/resources/db/migration/V1.0.0__init.sql
CREATE TABLE users
(
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE tasks
(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users (id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL,
is_completed BOOLEAN NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
micronaut-flywayを入れているので、アプリを起動すれば自動でマイグレーションが走ります。開発中の変更は、docker compose down -vでデータを丸ごと消してしまうのが楽でしょう。
ただし、今のままだと起動できないと思います。detasourcesを定義してください。私の好みでHOCON(application.conf)に書き直しています。
datasources {
default {
db-type: postgres
dialect: POSTGRES
url: "jdbc:postgresql://localhost:5432/demo"
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
}
}
flyway {
datasources.default.enabled: true
}
micronaut {
application.name: micronaut-demo
security {
authentication: bearer
token.jwt.signatures.secret.generator.secret: ${?JWT_GENERATOR_SIGNATURE_SECRET}
}
}
ではrepositoryの実装に入ります。
単純なCRUD操作なのでmicronaut-data-jdbcのCrudRepositoryで楽します。ただし、table構造をdomain layerに公開しないよう変換処理を挟みます。
レコードに対応するクラスはEntityと命名することが多いかもしれませんが、DDDのDomain Entityと混ざってややこしいのでTableと名付けます。
adapter/
persistence/
crud/
TaskCrudRepository.kt
table/
TaskTable.kt
TaskRepositoryImpl.kt
package micronaut.demo.adapter.persistence.table
import io.micronaut.data.annotation.GeneratedValue
import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.MappedEntity
import io.micronaut.data.annotation.MappedProperty
import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task
import java.time.Instant
@MappedEntity("tasks")
data class TaskTable(
@Id
@GeneratedValue
val id: Long?,
@MappedProperty("user_id")
val userId: Long,
@MappedProperty("title")
val title: String,
@MappedProperty("description")
val description: String,
@MappedProperty("is_completed")
val isCompleted: Boolean,
@MappedProperty("created_at")
val createdAt: Instant,
@MappedProperty("updated_at")
val updatedAt: Instant,
)
fun NewTask.toTable(): TaskTable =
TaskTable(
id = null,
userId = userId,
title = title,
description = description,
isCompleted = false,
createdAt = Instant.now(),
updatedAt = Instant.now(),
)
fun Task.toTable(): TaskTable =
TaskTable(
id = id,
userId = userId,
title = title,
description = description,
isCompleted = isCompleted,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun TaskTable.toTask(): Task =
Task(
id = id ?: throw IllegalStateException("Task Id is null. Maybe not saved yet."),
userId = userId,
title = title,
description = description,
isCompleted = isCompleted,
createdAt = createdAt,
updatedAt = updatedAt,
)
package micronaut.demo.adapter.persistence.crud
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository
import micronaut.demo.adapter.gateway.table.TaskTable
@JdbcRepository(dialect = Dialect.POSTGRES)
interface TaskCrudRepository : CrudRepository<TaskTable, Long> {
fun findByUserId(userId: Long): List<TaskTable>
}
package micronaut.demo.adapter.persistence
import jakarta.inject.Singleton
import micronaut.demo.adapter.gateway.crud.TaskCrudRepository
import micronaut.demo.adapter.gateway.table.toTable
import micronaut.demo.adapter.gateway.table.toTask
import micronaut.demo.domain.entity.NewTask
import micronaut.demo.domain.entity.Task
import micronaut.demo.domain.repository.TaskRepository
import kotlin.jvm.optionals.getOrNull
@Singleton
class TaskRepositoryImpl(
private val taskCrudRepository: TaskCrudRepository,
) : TaskRepository {
override fun save(task: NewTask): Task {
val table = task.toTable()
return taskCrudRepository.save(table)
.toTask()
}
override fun findById(id: Long): Task? {
return taskCrudRepository.findById(id).getOrNull()
?.toTask()
}
override fun findByUserId(userId: Long): List<Task> {
return taskCrudRepository.findByUserId(userId)
.map { it.toTask() }
}
override fun update(task: Task): Task {
val table = task.toTable()
return taskCrudRepository.update(table)
.toTask()
}
override fun deleteById(id: Long): Boolean {
if (!taskCrudRepository.existsById(id)) {
return false
}
taskCrudRepository.deleteById(id)
return true
}
}
認証(jwt)
今回はmicronaut-security-jwtを使います。
/login
エンドポイントは自動で作成されるので、登録処理と認証結果の利用方法を追加すればいいです。
/auth/register
では認証を除外するのを忘れずに。
adapter/
controller/
request/
RegisterRequest.kt
AuthController.kt
package micronaut.demo.adapter.controller
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Post
import micronaut.demo.adapter.controller.request.RegisterRequest
import micronaut.demo.application.usecase.UserRegisterUsecase
@Controller("/auth")
class AuthController(
private val userRegisterUsecase: UserRegisterUsecase,
) {
@Post("/register")
@Secured(SecurityRule.IS_ANONYMOUS)
fun register(
@Body body: RegisterRequest,
) {
userRegisterUsecase.execute(body.name, body.password)
}
}
package micronaut.demo.adapter.controller.request
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class RegisterRequest(
val username: String,
val password: String,
)
ここまで簡単のために色々と雑に書いてきましたが、パスワードのハッシュ化は流石にやります。Springからモジュールだけお借りしましょう。
implementation("org.springframework.security:spring-security-crypto:6.4.4")
implementation("commons-logging:commons-logging:1.3.5") // 依存
application/
usecase/
UserRegisterUsecase.kt
package micronaut.demo.application.usecase
import jakarta.inject.Singleton
import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.repository.UserRepository
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
interface UserRegisterUsecase {
fun execute(username: String, password: String)
}
@Singleton
class UserRegisterUsecaseImpl(
private val userRepository: UserRepository,
) : UserRegisterUsecase {
private val encoder = BCryptPasswordEncoder()
override fun execute(username: String, password: String) {
if (userRepository.existsByUsername(username)) {
throw IllegalArgumentException("User with name $name already exists")
}
val hash = encoder.encode(password)
val user = NewUser(name, hash)
userRepository.save(user)
}
}
Repository周りもまとめて作ります。
domain/
repository/
UserRepository.kt
adapter/
persistence/
crud/
UserCrudRepository.kt
table/
UserTable.kt
UserRepositoryImpl.kt
package micronaut.demo.domain.repository
import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.entity.User
interface UserRepository {
fun save(user: NewUser): User
fun findByUsername(username: String): User?
fun existsByUsername(username: String): Boolean
}
package micronaut.demo.adapter.persistence
import jakarta.inject.Singleton
import micronaut.demo.adapter.persistence.crud.UserCrudRepository
import micronaut.demo.adapter.persistence.table.toTable
import micronaut.demo.adapter.persistence.table.toUser
import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.entity.User
import micronaut.demo.domain.repository.UserRepository
@Singleton
class UserRepositoryImpl(
private val userCrudRepository: UserCrudRepository,
) : UserRepository {
override fun save(user: NewUser): User {
val table = user.toTable()
return userCrudRepository.save(table)
.toUser()
}
override fun findByUsername(username: String): User? {
return userCrudRepository.findByUsername(username)
?.toUser()
}
override fun existsByUsername(username: String): Boolean {
return userCrudRepository.existsByUsername(username)
}
}
package micronaut.demo.adapter.persistence.crud
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository
import micronaut.demo.adapter.gateway.table.UserTable
@JdbcRepository(dialect = Dialect.POSTGRES)
interface UserCrudRepository : CrudRepository<UserTable, Long> {
fun findByName(name: String): UserTable?
fun existsByName(name: String): Boolean
}
package micronaut.demo.adapter.persistence.table
import io.micronaut.data.annotation.GeneratedValue
import io.micronaut.data.annotation.Id
import io.micronaut.data.annotation.MappedEntity
import io.micronaut.data.annotation.MappedProperty
import micronaut.demo.domain.entity.NewUser
import micronaut.demo.domain.entity.User
@MappedEntity("users")
data class UserTable(
@Id
@GeneratedValue
val id: Long?,
@MappedProperty("username")
val username: String,
@MappedProperty("password_hash")
val passwordHash: String,
)
fun NewUser.toTable(): UserTable =
UserTable(
id = null,
username = username,
passwordHash = passwordHash,
)
fun UserTable.toUser(): User =
User(
id = id ?: throw IllegalStateException("User Id is null. Maybe not saved yet."),
username = username,
passwordHash = passwordHash,
)
あとは認証プロバイダを作ります。
adapter/
security/
AuthenticationProviderUserPassword.kt
package micronaut.demo.adapter.security
import io.micronaut.http.HttpRequest
import io.micronaut.security.authentication.AuthenticationFailed
import io.micronaut.security.authentication.AuthenticationRequest
import io.micronaut.security.authentication.AuthenticationResponse
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider
import jakarta.inject.Singleton
import micronaut.demo.domain.repository.UserRepository
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
@Singleton
class AuthenticationProviderUserPassword<B>(
private val userRepository: UserRepository,
) : HttpRequestAuthenticationProvider<B> {
private val passwordEncoder = BCryptPasswordEncoder()
override fun authenticate(
requestContext: HttpRequest<B>?,
authRequest: AuthenticationRequest<String, String>
): AuthenticationResponse {
val username = authRequest.identity
val password = authRequest.secret
val user = userRepository.findByUsername(username)
if (user != null && passwordEncoder.matches(password, user.passwordHash)) {
return AuthenticationResponse.success(username, listOf("ROLE_USER"), mapOf("userId" to user.id))
} else {
return AuthenticationFailed()
}
}
}
これで Authentication.attributes
から userId を取れるようになりました。
Adapter (Controller) 完成
では /tasks
エンドポイントを実装しましょう。
adapter/
controller/
TaskController.kt
package micronaut.demo.adapter.controller
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Consumes
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Delete
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Patch
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.http.annotation.Produces
import io.micronaut.security.annotation.Secured
import io.micronaut.security.authentication.Authentication
import io.micronaut.security.rules.SecurityRule
import micronaut.demo.adapter.controller.request.CreateTaskRequest
import micronaut.demo.adapter.controller.request.UpdateTaskRequest
import micronaut.demo.adapter.controller.response.TaskResponse
import micronaut.demo.adapter.controller.response.toResponse
import micronaut.demo.application.usecase.CreateTaskUsecase
import micronaut.demo.application.usecase.DeleteTaskUsecase
import micronaut.demo.application.usecase.ListTaskUsecase
import micronaut.demo.application.usecase.UpdateTaskUsecase
@Controller("/tasks")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Secured(SecurityRule.IS_AUTHENTICATED)
class TaskController(
private val createTaskUsecase: CreateTaskUsecase,
private val listTaskUsecase: ListTaskUsecase,
private val updateTaskUsecase: UpdateTaskUsecase,
private val deleteTaskUsecase: DeleteTaskUsecase,
) {
@Post
fun createTask(
authentication: Authentication,
@Body body: CreateTaskRequest,
): TaskResponse {
val userId = extractUserId(authentication)
val task = createTaskUsecase.execute(userId, body.title, body.description)
return task.toResponse()
}
@Get
fun listTasks(
authentication: Authentication,
): List<TaskResponse> {
val userId = extractUserId(authentication)
return listTaskUsecase.execute(userId)
.map { it.toResponse() }
}
@Patch("/{id}")
@Consumes(MediaType.APPLICATION_JSON_MERGE_PATCH)
fun updateTask(
authentication: Authentication,
@PathVariable id: Long,
@Body body: UpdateTaskRequest,
): TaskResponse {
val userId = extractUserId(authentication)
val task = updateTaskUsecase.execute(userId, id, body.title, body.description, body.isCompleted)
return task.toResponse()
}
@Delete("/{id}")
fun deleteTask(
authentication: Authentication,
@PathVariable id: Long,
) {
val userId = extractUserId(authentication)
deleteTaskUsecase.execute(userId, id)
}
private fun extractUserId(authentication: Authentication): Long {
return authentication.attributes["userId"] as? Long
?: throw IllegalArgumentException("User ID not found in authentication attributes")
}
}
完成
これで一通りの実装ができました。
/auth/register
でユーザーを登録して、 /login
でトークンを取得し、 /tasks
でCRUD操作ができるようになります。
テストは後日足すかもしれません。
まとめ
Kotlin + Micronaut の構成で、シンプルなTODOアプリを作成しました。
Micronaut は軽量で高性能なフレームワークであり、Kotlin からも簡単に使えます。ぜひお試してあれ。
おまけ
個人的にハマった点メモ
@QueryValueを10個以上付けられない
バグなのか仕様なのか微妙なラインなのですが、@QueryValueを10個以上付けるとぬるぽを吐きます。
@RequestBeanを使ってマッピングすれば回避できます。
ただし、@RequestBeanの対象はJavaのPOJOを期待するので、KSPの制約でconstructor引数にできません。
各プロパティはvarで定義する必要があります。
@Controller("/foo")
class FooController {
@Get
fun get(
@RequestBean fooRequest: FooRequest,
): FooResponse {
TODO()
}
}
@Introspected
class FooRequest {
@QueryValue("foo")
var foo: String? = null
@QueryValue("bar")
var bar: String? = null
@QueryValue("baz")
var baz: String? = null
// ...
}
Flywayのgradle pluginが動かない
Micronautは全く関係ないんですが、flywayのgradle plugin動いたことないです。
毎回自分でgradle taskを作ってます。
build.gradle.ktsで最新版動かせる書き方ご存じの方教えてください。。。
Discussion