Kotlin Exposed 模式
全面且可投入生产的 JetBrains Exposed ORM 数据库访问模式,包括 DSL 查询、DAO、事务和配置。
使用时机
- 使用 Exposed 设置数据库访问
- 使用 Exposed DSL 或 DAO 编写 SQL 查询
- 使用 HikariCP 配置连接池
- 使用 Flyway 创建数据库迁移
- 使用 Exposed 实现仓库模式
- 处理 JSON 列和复杂查询
工作原理
Exposed 提供两种查询风格:DSL 用于直接的类 SQL 表达式,DAO 用于实体生命周期管理。HikariCP 管理一个可重用数据库连接池,通过 HikariConfig 配置。Flyway 在启动时执行版本化的 SQL 迁移脚本,保持模式同步。所有数据库操作都在 newSuspendedTransaction 块内运行,以确保协程安全和原子性。仓库模式将 Exposed 查询封装在接口后面,使业务逻辑与数据层解耦,测试可以使用内存中的 H2 数据库。
示例
DSL 查询
suspend fun findUserById(id: UUID): UserRow? =
newSuspendedTransaction {
UsersTable.selectAll()
.where { UsersTable.id eq id }
.map { it.toUser() }
.singleOrNull()
}
DAO 实体使用
suspend fun createUser(request: CreateUserRequest): User =
newSuspendedTransaction {
UserEntity.new {
name = request.name
email = request.email
role = request.role
}.toModel()
}
HikariCP 配置
val hikariConfig = HikariConfig().apply {
driverClassName = config.driver
jdbcUrl = config.url
username = config.username
password = config.password
maximumPoolSize = config.maxPoolSize
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validate()
}
数据库设置
HikariCP 连接池
// DatabaseFactory.kt
object DatabaseFactory {
fun create(config: DatabaseConfig): Database {
val hikariConfig = HikariConfig().apply {
driverClassName = config.driver
jdbcUrl = config.url
username = config.username
password = config.password
maximumPoolSize = config.maxPoolSize
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validate()
}
return Database.connect(HikariDataSource(hikariConfig))
}
}
data class DatabaseConfig(
val url: String,
val driver: String = "org.postgresql.Driver",
val username: String = "",
val password: String = "",
val maxPoolSize: Int = 10,
)
Flyway 迁移
// FlywayMigration.kt
fun runMigrations(config: DatabaseConfig) {
Flyway.configure()
.dataSource(config.url, config.username, config.password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.load()
.migrate()
}
// Application startup
fun Application.module() {
val config = DatabaseConfig(
url = environment.config.property("database.url").getString(),
username = environment.config.property("database.username").getString(),
password = environment.config.property("database.password").getString(),
)
runMigrations(config)
val database = DatabaseFactory.create(config)
// ...
}
迁移文件
-- src/main/resources/db/migration/V1__create_users.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
role VARCHAR(20) NOT NULL DEFAULT 'USER',
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
表定义
DSL 风格表
// tables/UsersTable.kt
object UsersTable : UUIDTable("users") {
val name = varchar("name", 100)
val email = varchar("email", 255).uniqueIndex()
val role = enumerationByName<Role>("role", 20)
val metadata = jsonb<UserMetadata>("metadata", Json.Default).nullable()
val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone)
val updatedAt = timestampWithTimeZone("updated_at").defaultExpression(CurrentTimestampWithTimeZone)
}
object OrdersTable : UUIDTable("orders") {
val userId = uuid("user_id").references(UsersTable.id)
val status = enumerationByName<OrderStatus>("status", 20)
val totalAmount = long("total_amount")
val currency = varchar("currency", 3)
val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone)
}
object OrderItemsTable : UUIDTable("order_items") {
val orderId = uuid("order_id").references(OrdersTable.id, onDelete = ReferenceOption.CASCADE)
val productId = uuid("product_id")
val quantity = integer("quantity")
val unitPrice = long("unit_price")
}
复合表
object UserRolesTable : Table("user_roles") {
val userId = uuid("user_id").references(UsersTable.id, onDelete = ReferenceOption.CASCADE)
val roleId = uuid("role_id").references(RolesTable.id, onDelete = ReferenceOption.CASCADE)
override val primaryKey = PrimaryKey(userId, roleId)
}
DSL 查询
基础 CRUD
// 插入
suspend fun insertUser(name: String, email: String, role: Role): UUID =
newSuspendedTransaction {
UsersTable.insertAndGetId {
it[UsersTable.name] = name
it[UsersTable.email] = email
it[UsersTable.role] = role
}.value
}
// 根据 ID 选择
suspend fun findUserById(id: UUID): UserRow? =
newSuspendedTransaction {
UsersTable.selectAll()
.where { UsersTable.id eq id }
.map { it.toUser() }
.singleOrNull()
}
// 带条件的选择
suspend fun findActiveAdmins(): List<UserRow> =
newSuspendedTransaction {
UsersTable.selectAll()
.where { (UsersTable.role eq Role.ADMIN) }
.orderBy(UsersTable.name)
.map { it.toUser() }
}
// 更新
suspend fun updateUserEmail(id: UUID, newEmail: String): Boolean =
newSuspendedTransaction {
UsersTable.update({ UsersTable.id eq id }) {
it[email] = newEmail
it[updatedAt] = CurrentTimestampWithTimeZone
} > 0
}
// 删除
suspend fun deleteUser(id: UUID): Boolean =
newSuspendedTransaction {
UsersTable.deleteWhere { UsersTable.id eq id } > 0
}
// 行映射
private fun ResultRow.toUser() = UserRow(
id = this[UsersTable.id].value,
name = this[UsersTable.name],
email = this[UsersTable.email],
role = this[UsersTable.role],
metadata = this[UsersTable.metadata],
createdAt = this[UsersTable.createdAt],
updatedAt = this[UsersTable.updatedAt],
)
高级查询
// 连接查询
suspend fun findOrdersWithUser(userId: UUID): List<OrderWithUser> =
newSuspendedTransaction {
(OrdersTable innerJoin UsersTable)
.selectAll()
.where { OrdersTable.userId eq userId }
.orderBy(OrdersTable.createdAt, SortOrder.DESC)
.map { row ->
OrderWithUser(
orderId = row[OrdersTable.id].value,
status = row[OrdersTable.status],
totalAmount = row[OrdersTable.totalAmount],
userName = row[UsersTable.name],
)
}
}
// 聚合
suspend fun countUsersByRole(): Map<Role, Long> =
newSuspendedTransaction {
UsersTable
.select(UsersTable.role, UsersTable.id.count())
.groupBy(UsersTable.role)
.associate { row ->
row[UsersTable.role] to row[UsersTable.id.count()]
}
}
// 子查询
suspend fun findUsersWithOrders(): List<UserRow> =
newSuspendedTransaction {
UsersTable.selectAll()
.where {
UsersTable.id inSubQuery
OrdersTable.select(OrdersTable.userId).withDistinct()
}
.map { it.toUser() }
}
// LIKE 和模式匹配 —— 始终转义用户输入以防止通配符注入
private fun escapeLikePattern(input: String): String =
input.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
suspend fun searchUsers(query: String): List<UserRow> =
newSuspendedTransaction {
val sanitized = escapeLikePattern(query.lowercase())
UsersTable.selectAll()
.where {
(UsersTable.name.lowerCase() like "%${sanitized}%") or
(UsersTable.email.lowerCase() like "%${sanitized}%")
}
.map { it.toUser() }
}
分页
data class Page<T>(
val data: List<T>,
val total: Long,
val page: Int,
val limit: Int,
) {
val totalPages: Int get() = ((total + limit - 1) / limit).toInt()
val hasNext: Boolean get() = page < totalPages
val hasPrevious: Boolean get() = page > 1
}
suspend fun findUsersPaginated(page: Int, limit: Int): Page<UserRow> =
newSuspendedTransaction {
val total = UsersTable.selectAll().count()
val data = UsersTable.selectAll()
.orderBy(UsersTable.createdAt, SortOrder.DESC)
.limit(limit)
.offset(((page - 1) * limit).toLong())
.map { it.toUser() }
Page(data = data, total = total, page = page, limit = limit)
}
批量操作
// 批量插入
suspend fun insertUsers(users: List<CreateUserRequest>): List<UUID> =
newSuspendedTransaction {
UsersTable.batchInsert(users) { user ->
this[UsersTable.name] = user.name
this[UsersTable.email] = user.email
this[UsersTable.role] = user.role
}.map { it[UsersTable.id].value }
}
// 冲突时更新或插入
suspend fun upsertUser(id: UUID, name: String, email: String) {
newSuspendedTransaction {
UsersTable.upsert(UsersTable.email) {
it[UsersTable.id] = EntityID(id, UsersTable)
it[UsersTable.name] = name
it[UsersTable.email] = email
it[updatedAt] = CurrentTimestampWithTimeZone
}
}
}
DAO 模式
实体定义
// entities/UserEntity.kt
class UserEntity(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<UserEntity>(UsersTable)
var name by UsersTable.name
var email by UsersTable.email
var role by UsersTable.role
var metadata by UsersTable.metadata
var createdAt by UsersTable.createdAt
var updatedAt by UsersTable.updatedAt
val orders by OrderEntity referrersOn OrdersTable.userId
fun toModel(): User = User(
id = id.value,
name = name,
email = email,
role = role,
metadata = metadata,
createdAt = createdAt,
updatedAt = updatedAt,
)
}
class OrderEntity(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<OrderEntity>(OrdersTable)
var user by UserEntity referencedOn OrdersTable.userId
var status by OrdersTable.status
var totalAmount by OrdersTable.totalAmount
var currency by OrdersTable.currency
var createdAt by OrdersTable.createdAt
val items by OrderItemEntity referrersOn OrderItemsTable.orderId
}
DAO 操作
suspend fun findUserByEmail(email: String): User? =
newSuspendedTransaction {
UserEntity.find { UsersTable.email eq email }
.firstOrNull()
?.toModel()
}
suspend fun createUser(request: CreateUserRequest): User =
newSuspendedTransaction {
UserEntity.new {
name = request.name
email = request.email
role = request.role
}.toModel()
}
suspend fun updateUser(id: UUID, request: UpdateUserRequest): User? =
newSuspendedTransaction {
UserEntity.findById(id)?.apply {
request.name?.let { name = it }
request.email?.let { email = it }
updatedAt = OffsetDateTime.now(ZoneOffset.UTC)
}?.toModel()
}
事务
挂起事务支持
// 好:使用 newSuspendedTransaction 支持协程
suspend fun performDatabaseOperation(): Result<User> =
runCatching {
newSuspendedTransaction {
val user = UserEntity.new {
name = "Alice"
email = "[email protected]"
}
// 此块中的所有操作都是原子的
user.toModel()
}
}
// 好:带保存点的嵌套事务
suspend fun transferFunds(fromId: UUID, toId: UUID, amount: Long) {
newSuspendedTransaction {
val from = UserEntity.findById(fromId) ?: throw NotFoundException("User $fromId not found")
val to = UserEntity.findById(toId) ?: throw NotFoundException("User $toId not found")
// 扣款
from.balance -= amount
// 存款
to.balance += amount
// 两者要么都成功,要么都失败
}
}
事务隔离级别
suspend fun readCommittedQuery(): List<User> =
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_READ_COMMITTED) {
UserEntity.all().map { it.toModel() }
}
suspend fun serializableOperation() {
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) {
// 针对关键操作的最严格隔离级别
}
}
仓库模式
接口定义
interface UserRepository {
suspend fun findById(id: UUID): User?
suspend fun findByEmail(email: String): User?
suspend fun findAll(page: Int, limit: Int): Page<User>
suspend fun create(request: CreateUserRequest): User
suspend fun update(id: UUID, request: UpdateUserRequest): User?
suspend fun delete(id: UUID): Boolean
suspend fun countByRole(role: Role): Long
}
实现
class ExposedUserRepository : UserRepository {
override suspend fun findById(id: UUID): User? = newSuspendedTransaction {
UserEntity.findById(id)?.toModel()
}
override suspend fun findByEmail(email: String): User? = newSuspendedTransaction {
UserEntity.find { UsersTable.email eq email }.firstOrNull()?.toModel()
}
override suspend fun findAll(page: Int, limit: Int): Page<User> = newSuspendedTransaction {
val total = UserEntity.all().count()
val data = UserEntity.all()
.orderBy(UserEntity::createdAt to SortOrder.DESC)
.limit(limit)
.offset(((page - 1) * limit).toLong())
.map { it.toModel() }
Page(data, total, page, limit)
}
override suspend fun create(request: CreateUserRequest): User = newSuspendedTransaction {
UserEntity.new {
name = request.name
email = request.email
role = request.role
}.toModel()
}
override suspend fun update(id: UUID, request: UpdateUserRequest): User? = newSuspendedTransaction {
UserEntity.findById(id)?.apply {
request.name?.let { name = it }
request.email?.let { email = it }
request.role?.let { role = it }
}?.toModel()
}
override suspend fun delete(id: UUID): Boolean = newSuspendedTransaction {
UserEntity.findById(id)?.delete() != null
}
override suspend fun countByRole(role: Role): Long = newSuspendedTransaction {
UserEntity.find { UsersTable.role eq role }.count()
}
}
默认方法测试
class UserRepositoryTest {
private lateinit var repository: UserRepository
@BeforeEach
fun setup() {
// 使用内存 H2 进行测试
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver")
transaction {
SchemaUtils.create(UsersTable)
}
repository = ExposedUserRepository()
}
@Test
fun `should create and find user`() = runBlocking {
val created = repository.create(CreateUserRequest("Alice", "[email protected]", Role.USER))
assertNotNull(created.id)
val found = repository.findById(created.id)
assertEquals("Alice", found?.name)
}
}
集成测试
@Testcontainers
class UserRepositoryIntegrationTest {
companion object {
@Container
val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
}
}
private lateinit var repository: UserRepository
@BeforeEach
fun setup() {
val config = DatabaseConfig(
url = postgres.jdbcUrl,
username = postgres.username,
password = postgres.password,
)
Database.connect(HikariDataSource(HikariConfig().apply {
driverClassName = "org.postgresql.Driver"
jdbcUrl = config.url
username = config.username
password = config.password
maximumPoolSize = 5
}))
transaction {
SchemaUtils.create(UsersTable)
}
repository = ExposedUserRepository()
}
@Test
fun `should persist and retrieve user correctly`() = runBlocking {
val user = repository.create(CreateUserRequest("Bob", "[email protected]", Role.ADMIN))
val retrieved = repository.findByEmail("[email protected]")
assertEquals(user.id, retrieved?.id)
assertEquals(Role.ADMIN, retrieved?.role)
}
}
最佳实践
连接管理
// 好:在应用程序启动时配置一次
fun Application.configureDatabase() {
val config = DatabaseConfig(
url = environment.config.property("database.url").getString(),
username = environment.config.property("database.username").getString(),
password = environment.config.property("database.password").getString(),
maxPoolSize = environment.config.property("database.pool.size").getString().toIntOrNull() ?: 10,
)
runMigrations(config)
DatabaseFactory.create(config)
}
// 好:使用延迟初始化避免循环依赖
val database by lazy { DatabaseFactory.create(loadConfig()) }
错误处理
suspend fun <T> safeTransaction(block: suspend Transaction.() -> T): Result<T> =
runCatching {
newSuspendedTransaction(block = block)
}
// 使用示例
suspend fun findUserSafely(id: UUID): Result<User?> =
safeTransaction {
UserEntity.findById(id)?.toModel()
}
性能考虑
- 对只读查询使用
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_READ_UNCOMMITTED) - 对批处理使用
batchInsert而不是循环插入 - 通过
maxPageSize设置限制页面大小,并使用limit和offset - 为频繁查询的列添加数据库索引
- 使用
fetchSize提高大型结果集的内存效率 - 监控 HikariCP 池指标以调整
maximumPoolSize
安全考虑
- 始终转义 LIKE 模式中的用户输入
- 使用参数化查询(Exposed 默认行为)
- 使用
inSubQuery而不是原始连接进行子查询 - 验证 Flyway 迁移脚本中的输入长度和类型
- 避免在暴露的 API 中泄露数据库错误消息
- 对敏感操作使用最小权限的数据库用户