Exposed 是 [[JetBrains]] 在数年前推出的轻量级 [[ORM]] 框架,Kotlin 编写,已经在 JetBrains 内部多个关键产品使用。

Exposed 是基于 JDBC 实现,屏蔽了底层建立数据库连接,编写 SQL,操作数据,关闭数据库连接的操作,只需要关心数据操作。

Exposed 提供了两种形式 API,面向 DSL 的 API 和面向对象的 API。

特点

  • 纯 Kotlin 实现,轻量级,模块化
  • 类似 SQL 的静态类型化语言,可以轻松查询数据库
  • 类型安全 SQL DSL,提供编译时类型检查,防止 SQL 注入
  • 减少样板代码
  • 支持非常多的数据库,H2,MySQL,MariaDB,Oracle,PostgreSQL,SQL Server,SQLite 等
  • 双重 API 设计,DSL 和 DAO 两种访问方式

使用

首先需要添加依赖

<dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-core</artifactId>
    <version>0.37.3</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-dao</artifactId>
    <version>0.37.3</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-jdbc</artifactId>
    <version>0.37.3</version>
</dependency>

在 Gradle 项目中添加依赖,在 build.gradle.kts 添加 Exposed 依赖

dependencies {
    implementation("org.jetbrains.exposed:exposed-core:1.0.0-beta-2")
    implementation("org.jetbrains.exposed:exposed-dao:1.0.0-beta-2")
    implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0-beta-2")
    
    // 可选模块
    implementation("org.jetbrains.exposed:exposed-java-time:1.0.0-beta-2")
    implementation("org.jetbrains.exposed:exposed-json:1.0.0-beta-2")
    implementation("org.jetbrains.exposed:exposed-crypt:1.0.0-beta-2")
    
    // 数据库驱动(以 H2 为例)
    implementation("com.h2database:h2:2.1.214")
}

数据库连接

// 基本连接方式
Database.connect(
    url = "jdbc:postgresql://localhost:5432/mydb",
    driver = "org.postgresql.Driver",
    user = "username",
    password = "password"
)

// 使用 DataSource 连接(推荐用于企业应用)
val hikariConfig = HikariConfig("db.properties")
val dataSource = HikariDataSource(hikariConfig)
Database.connect(dataSource)

// 内存数据库连接
Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")

DSL 和 DAO 都需要在 transaction 中执行

transaction {
  // do
  commit()
}

DSL

DSL 是领域特定语言,使用 Exposed 的 DSL 定义数据库表。

object Users : Table() {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 50)
    val email = varchar("email", 255).index()
    val age = integer("age").nullable()
    
    override val primaryKey = PrimaryKey(id)
}

object Posts : Table() {
    val id = integer("id").autoIncrement()
    val title = varchar("title", 255)
    val content = text("content")
    val userId = integer("user_id") references Users.id
    
    override val primaryKey = PrimaryKey(id)
}

代码

import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction

object Member : Table("member") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 32)
    val createdAt = timestamp("created_at")
}

fun main() {
    Database.connect(
        url = "jdbc:mysql://127.0.0.1:3306/exposed_example",
        driver = "com.mysql.cj.jdbc.Driver",
        user = "root",
        password = "mysql"
    )
    transaction {
        addLogger(StdOutSqlLogger)

        val id = Member.insert {
            it[name] = "Kotlin"
            it[createdAt] = Instant.now()
        } get Member.id
        println("Inserted id: $id")

        val member: ResultRow = Member.select(Member.id eq id).single()
        println("id: ${member[Member.id]}")
        println("name: ${member[Member.name]}")

        Member.update {
            it[createdAt] = Instant.now()
        }
        Member.deleteWhere { Member.id eq id }
    }
}

基本 CRUD 操作

Exposed 中的所有数据库操作都需要在活动事务中执行。

transaction {
    // 创建表
    SchemaUtils.create(Users, Posts)
    
    // 插入数据
    val userId = Users.insert {
        it[name] = "张三"
        it[email] = "[email protected]"
        it[age] = 25
    } get Users.id
    
    // 批量插入
    Users.batchInsert(listOf(
        Triple("李四", "[email protected]", 30),
        Triple("王五", "[email protected]", 28)
    )) { (name, email, age) ->
        this[Users.name] = name
        this[Users.email] = email
        this[Users.age] = age
    }
    
    // 查询数据
    Users.selectAll()
        .where { Users.age greater 20 }
        .orderBy(Users.name)
        .forEach { row ->
            println("姓名: ${row[Users.name]}, 邮箱: ${row[Users.email]}")
        }
    
    // 更新数据
    Users.update({ Users.id eq userId }) {
        it[age] = 26
    }
    
    // 删除数据
    Users.deleteWhere { Users.age less 18 }
}

复杂 SQL 查询

transaction {
    // 连接查询
    (Users innerJoin Posts)
        .select(Users.name, Posts.title)
        .where { Users.age greaterEq 25 }
        .forEach { row ->
            println("用户: ${row[Users.name]}, 文章: ${row[Posts.title]}")
        }
    
    // 聚合查询
    val avgAge = Users.select(Users.age.avg()).single()[Users.age.avg()]
    
    // 分组查询
    Users.select(Users.age, Users.id.count())
        .groupBy(Users.age)
        .having { Users.id.count() greater 1 }
        .forEach { row ->
            println("年龄: ${row[Users.age]}, 人数: ${row[Users.id.count()]}")
        }
}

DAO

DAO API 提供了更面向对象的数据库访问方式。

数据表和实体类定义。

import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.StdOutSqlLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.transactions.transaction

// 创建名为 member 的表,添加 name 字段
object MemberTable : IntIdTable("member") {
    val name = varchar("name", 32)
    val createdAt = timestamp("created_at)
}

// 创建实体类
class MemberEntity(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<MemberEntity>(MemberTable)

    var name by MemberTable.name
    var createdAt by MemberTable.createdAt
}

fun main() {
    Database.connect(
        url = "jdbc:mysql://127.0.0.1:3306/exposed_example",
        driver = "com.mysql.cj.jdbc.Driver",
        user = "root",
        password = "mysql"
    )
    transaction {
        addLogger(StdOutSqlLogger)

        val member = MemberEntity.new {
            name = "Kotlin"
            createdAt = Instant.now()
        }
        println("Inserted id: ${member.id}")

        MemberEntity.findById(member.id)?.let {
            println("id: ${it.id}")
            println("name: ${it.name}")
        }

        val result = MemberEntity.findById(member.id)

        result?.createAt = Instant.now()
        result?.delete()
    }
}

DAO API 提供面向对象的数据库访问

// 定义实体类
class User(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<User>(Users)
    
    var name by Users.name
    var email by Users.email
    var age by Users.age
    val posts by Post referrersOn Posts.userId
}

class Post(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Post>(Posts)
    
    var title by Posts.title
    var content by Posts.content
    var user by User referencedOn Posts.userId
}

// 使用 DAO 进行操作
transaction {
    // 创建用户
    val user = User.new {
        name = "赵六"
        email = "[email protected]"
        age = 32
    }
    
    // 创建文章
    val post = Post.new {
        title = "我的第一篇文章"
        content = "这是文章内容..."
        this.user = user
    }
    
    // 查询操作
    val users = User.find { Users.age greater 25 }
    users.forEach { user ->
        println("用户: ${user.name}, 文章数: ${user.posts.count()}")
    }
}

批量操作优化

批量插入和预加载关联数据可以提高性能。

transaction {
    // 批量插入
    Users.batchInsert(userList) { user ->
        this[Users.name] = user.name
        this[Users.email] = user.email
    }
    
    // 预加载关联数据
    User.all().with(User::posts)
}

关联数据预加载

使用 preloadwith 方法可以预加载关联数据,减少 N+1 查询问题。

// 预加载关联数据
transaction {
    // 使用 with() 进行急切加载
    val users = User.all().with(User::posts)

    // 使用 preload() 预加载
    val usersWithPosts = User.find { Users.age greater 18 }
        .preload(User::posts)
}

索引定义

Exposed 支持在表中定义索引,以提高查询性能。

单列或者多列索引

object Members : IntIdTable("members") {
    val id = integer("id").autoIncrement()
    val email = varchar("email", 255).index() // 单列索引

    oveerride val primaryKey = PrimaryKey(id)
    val index = index("idx_name_other", true, name, other)
}

分页处理

对于大量数据查询,实施分页处理以避免内存溢出。

transaction {
    // 分页查询
    val pageSize = 100
    val offset = pageNumber * pageSize
    
    Users.selectAll()
        .limit(pageSize, offset.toLong())
        .forEach { row ->
            // 处理数据
        }
}

Spring Boot 集成

<dependencies>
  <dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-spring-boot-starter</artifactId>
    <version>0.37.3</version>
  </dependency>
</dependencies>

数据库连接池优化

使用 HikariCP 连接池,配置连接池参数。

val hikariConfig = HikariConfig().apply {
    jdbcUrl = "jdbc:mysql://localhost:3306/mydb"
    driverClassName = "com.mysql.cj.jdbc.Driver"
    username = "username"
    password = "password"
    
    maximumPoolSize = 10
    minimumIdle = 2
    idleTimeout = 60000
    connectionTimeout = 30000
    maxLifetime = 1800000
}

Database.connect(HikariDataSource(hikariConfig))
  • [[Ktorm]],轻量,无依赖
  • [[Komapper]],编译时安全