尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Kite:Kotlin与Java通用的全自动ORM框架,告别XML手写SQL

Kite:Kotlin与Java通用的全自动ORM框架,告别XML手写SQL 一个 Kotlin 后端项目做到中期最让人烦的可能不是业务逻辑本身而是那些永远写不完的样板代码Entity、Mapper、XML、Repository、DTO 转换……Kotlin 明明已经把语法糖给到了嘴边结果一到数据库访问层又掉回 Java 时代的泥坑里。这也是我做 Kite 这个 Kotlin/Java 通用全自动 ORM 框架的初衷——让数据访问层回归“声明即所得”把写 SQL、写映射、写模板的时间省下来留给真正该花心思的业务。Kite 不是什么颠覆性的黑科技它的定位很朴素一个 Java 和 Kotlin 都能直接用的 ORM 框架不需要 XML 映射文件不需要手写 SQL不需要继承一堆基类定义好数据类CRUD、分页、多表查询自动生成。如果你正在做 Android 或 JVM 后端项目厌倦了 MyBatis 的繁琐配置或者觉得现有 ORM 对 Kotlin 的支持总差那么点意思这篇文章应该能帮你打开思路。1. Kite 的定位与整体设计思路1.1 为什么还要做一个新的 ORM先说痛点。JVM 生态里的 ORM 框架其实不少但真要在 Kotlin/Java 混编项目里挑一个“顺手”的并不容易。MyBatis 在国内使用人数很多但它本质上是个“半自动”框架SQL 还是得自己写XML 或者 Annotation 里的 SQL 字符串拼接依然是噩梦。一旦表结构变动SQL 和 Java 代码的同步维护成本就上来了。Hibernate/JPA 的自动化程度够高但是太重实体关系映射的坑非常多而且在 Kotlin 下用起来很别扭——Kotlin 的 data class 默认是 final 的懒加载、代理、无参构造函数这些 JPA 依赖的机制跟 Kotlin 的语法特性天然冲突。Kotlin 生态里的 Exposed 和 Ktorm 不错但它们只服务于 KotlinJava 代码没法直接用。Kite 想做的是“全自动”和“跨语言”两个事的交集全自动指的是从建表 SQL 到 CRUD 查询再到结果映射框架全部替你搞定跨语言指的是同一个框架Kotlin 和 Java 的项目都能接入团队里有人用 Java、有人用 Kotlin不至于被语言绑定卡脖子。这个定位看起来简单但实际设计起来牵扯的问题非常多后面我会展开讲。1.2 全自动的核心元数据驱动Kite 的“全自动”不是靠运行时反射硬猜而是靠一套明确的元数据约定。你在实体类上标注Table、Column、Id等注解Kite 在编译期就解析这些元数据生成对应的查询模型和 SQL 构建器。这样做有几个好处。第一运行时不需要大范围反射性能损耗小第二IDE 能识别生成代码字段写错了编译期就报错而不是等运行到那行才炸第三SQL 生成是确定性的不会因为反射顺序不同导致莫名其妙的结果。我把这类设计叫做“声明式表结构”意思是你只需要关心实体类怎么定义数据库那边的事情交给框架去同步。Table(user) data class User( Id Column(id, autoIncrement true) val id: Long 0, Column(nickname) val nickname: String , Column(age) val age: Int 0 )上面这段代码就定义了一张 user 表三个字段。Kite 在编译期看到这个类就能生成建表语句、插入语句、按 ID 查询的语句等一整套东西。注意Id标注的字段用了autoIncrement true这意味着建表时字段会带上自增属性插入时自动忽略该字段让数据库生成主键。1.3 框架整体架构拆解Kite 在工程上拆成了几个模块职责分离是这套设计里比较关键的一环。kite-core定义注解、公共 API、运行时类型转换、方言接口等基础能力。kite-compiler基于 KSP 和 APT 的编译期处理器负责扫描实体类、校验注解、生成代码。kite-dialect数据库方言实现目前覆盖了 MySQL、PostgreSQL、SQLite、H2日常开发测试和线上部署都比较够用。kite-spring封装 Spring Boot 自动配置项目里如果用 Spring 生态只需要引一个 starter 就能跑起来。编译期处理这块需要单独说一下。Java 环境下我们用的是 APTAnnotation Processing ToolKotlin 环境下优先走 KSPKotlin Symbol Processing。为什么不用 Kotlin 编译期的 KAPT因为 KAPT 的生成效率和类型解析体验不如 KSP而且 KSP 是 JetBrains 主推的方向后续对 Kotlin 新版本特性的支持会更及时。当然为了兼容老项目我们保留了 KAPT 的适配层但这种兼容配置默认不会启用。2. 核心细节解析从建表到查询的完整链路2.1 数据库表结构的自动同步Kite 提供了一个类似 Hibernate 的ddl-auto机制启动时扫描实体类对比数据库现有表结构决定是创建新表、增加字段还是更新索引。这个功能在项目初期非常好用——表结构频繁调整实体类改完重启项目表就自动跟着变了。但生产环境我强烈建议把ddl-auto设置为none原因很简单自动同步终究是尽量合理猜测某些复杂的索引调整、数据迁移、删列操作机器没法理解业务含义容易出问题。我的习惯是开发阶段用update发布前自己 review 一轮同步日志确认无误后手动执行 DDL再把配置切回none。Kite 的建表逻辑内置了几条默认规则表名和字段名默认由实体类名和属性名转换而来驼峰转下划线字符串类型默认映射为VARCHAR(255)可以通过Column(length 512)调整整数默认根据 Java/Kotlin 类型选择INT还是BIGINTLocalDateTime映射为DATETIME或TIMESTAMP取决于方言实现。2.2 查询条件的类型安全设计Kite 没有用字符串拼接查询条件而是设计了类型安全的查询 DSL。Kotlin 下长这样val users kite.queryUser() .where { (User::age between 18 and 30) and (User::nickname like %张%) } .orderByDescending(User::age) .page(1, 20) .list()Java 下面向对象的风格略有不同但也保持了同样的表达力ListUser users kite.query(User.class) .where(User::getAge, Op.between(18, 30)) .and(User::getNickname, Op.like(%张%)) .orderByDesc(User::getAge) .page(1, 20) .list();为什么把条件 DSL 单独拎出来说因为这是“全自动 ORM”里翻车概率最高的地方。很多框架为了省事让用户传WHERE age 18这种字符串虽然灵活但 SQL 注入风险和字段名硬编码问题又回来了。Kite 的做法是用 lambda 或者XXX表达式引用字段编译期就拿到对应的列名杜绝硬编码字符串。类型上between这类方法限定了参数类型范围写错类型编译期直接报错。2.3 结果映射与类型转换机制实体类属性跟数据库字段之间不是总是直接对应的。数据库存的是TINYINT(1)实体类里想用Boolean数据库字段是DATETIME实体类里是LocalDateTime甚至有些字段在数据库里是 JSON 字符串实体类里希望直接得到一个对象。Kite 内置了一套 TypeHandler 机制来处理这些转换。自定义 TypeHandler 需要实现convertFromColumn和convertToColumn两个方法分别负责入库和出库的转换。public class BooleanIntHandler implements TypeHandlerBoolean { Override public Boolean convertFromColumn(Object columnValue) { return Integer.valueOf(1).equals(columnValue); } Override public Object convertToColumn(Boolean value) { return Boolean.TRUE.equals(value) ? 1 : 0; } }在实体字段上加TypeHandler(BooleanIntHandler.class)就能启用自定义映射。实际项目里JSON 字段加一个通用的 JSON 处理器状态码字段加一个枚举处理器就覆盖了绝大多数场景。2.4 Java 与 Kotlin 的语义差异处理完全跨语言比想象的麻烦。Kotlin 的 data class 有默认参数值Java 实体类通常有多个构造函数Kotlin 属性是val只读的Java 字段有 getter/setter 约定Kotlin 里null语义是类型系统的一部分Java 的注解反射处理方式完全不同。Kite 对两者的支持策略很明确Kotlin 优先利用 data class 的构造器参数来实例化对象Java 优先找无参构造函数没有无参构造时按Column注解的字段名匹配 setter 方法。这套策略让两个语言的实体类都不用被迫继承某个基类或者实现某个接口保持了数据类的纯粹性。3. 实操过程从零搭建一个 Kite 项目3.1 环境准备与依赖配置Kite 要求 JDK 11 以上Kotlin 项目要求 1.8 以上版本。这里以一个 Spring Boot 3.x Kotlin 的项目为例看 Maven 依赖怎么写dependency groupIdcom.kiteorm/groupId artifactIdkite-spring-boot-starter/artifactId version1.2.0/version /dependency dependency groupIdcom.kiteorm/groupId artifactIdkite-compiler/artifactId version1.2.0/version scopeprovided/scope /dependencyGradle Kotlin DSL 这样配置plugins { kotlin(jvm) version 1.9.0 kotlin(kapt) version 1.9.0 id(com.google.devtools.ksp) version 1.9.0-1.0.13 } dependencies { implementation(com.kiteorm:kite-spring-boot-starter:1.2.0) ksp(com.kiteorm:kite-compiler:1.2.0) // 如果团队用 Java 纯享版就换成 annotationProcessor // annotationProcessor(com.kiteorm:kite-compiler:1.2.0) }有一点要留意SQLite 这类轻量数据库的方言实现和 MySQL 不完全一样比如分页语法就不同。Kite 的设计是自动根据 JDBC URL 探测数据库类型不需要手动指定方言但如果你用了自定义类加载器或代理数据源建议显式配置kite.dialectmysql以防探测失败。3.2 定义实体类和初始化数据源来看一个完整例子模拟一个简单的用户订单场景Table(orders) data class Order( Id Column(id, autoIncrement true) val id: Long 0, Column(user_id) val userId: Long 0, Column(total_amount) val totalAmount: BigDecimal BigDecimal.ZERO, Column(status) val status: Int 0, Column(created_at) val createdAt: LocalDateTime LocalDateTime.now() )Java 版本Table(orders) public class Order { Id Column(name id, autoIncrement true) private Long id 0L; Column(name user_id) private Long userId 0L; Column(name total_amount) private BigDecimal totalAmount BigDecimal.ZERO; Column(name status) private Integer status 0; Column(name created_at) private LocalDateTime createdAt LocalDateTime.now(); // 省略 getter/setter }初始化的时候只需要一个DataSourceKite 不绑死连接池。HikariCP 的配置示例val dataSource HikariDataSource() dataSource.jdbcUrl jdbc:mysql://localhost:3306/app dataSource.username root dataSource.password password dataSource.maximumPoolSize 20 val kite Kite.builder() .dataSource(dataSource) .ddlAuto(Kite.DdlAuto.UPDATE) .scanPackages(com.example.entity) .build()scanPackages是扫描实体类包名编译期处理器生成的元数据就在这里注册。如果你不指定Kite 会扫描当前应用的所有类但那样启动速度会慢一些还是建议显式声明。3.3 增删改查的基础操作基础 CRUD 的 API 设计得比较直白// 插入记录id 自动回填 val order Order(userId 1L, totalAmount BigDecimal(99.00), status 0) val saved kite.insert(order) // 按主键查询 val orderById kite.findById(Order::class, saved.id) // 更新非空字段 kite.update(orderById.copy(status 1)) // 条件更新 kite.update(Order::class) .where { Order::status eq 0 } .set(Order::status, 2) .execute() // 删除 kite.deleteById(Order::class, saved.id)几个注意点。insert之后Kite 会把数据库自增生成的主键回填到传入对象的 id 字段上saved.id不再是初始值update默认只更新非 null 的字段如果要把某个字段置为 null需要显式调用set(Order::status, null)这也是为了避免 Java/Kotlin 默认值误伤数据条件更新和删除都必须带 where 条件否则拒绝执行算是一道安全底线。3.4 多表关联查询的实现方式全自动 ORM 最容易被人质疑的就是多表查询。Kite 的关联查询方案是用Relation注解声明关系然后框架自动组装 SQL。Table(user) data class User( Id Column(id) val id: Long 0, Column(nickname) val nickname: String , Relation(column id, targetColumn user_id, type Relation.Type.ONE_TO_MANY) val orders: ListOrder emptyList() )查询时Kite 先查主表数据再根据关系注解批量查询关联表最后在内存中组装关系映射。也就是说默认情况下它是两条 SQL而不是一条大 JOIN。为什么这么设计一条大 JOIN 在数据量大、关联层级深的情况下传输数据冗余非常严重而且一旦查出来的行数膨胀内存占用和带宽都很吃亏。两条 SQL 配合批量 IN 查询在绝大多数业务场景下性能反而更好这就是常说的 N1 问题的反向优化方案。如果你确实需要一条 SQL 出结果Kite 也提供fetchJoin方法指定某几个关联字段走 JOIN 查询剩下的继续走批量查询。这种“按需 JOIN”的设计比一刀切更符合现实业务。3.5 分页查询与排序分页是每个业务系统都绕不开的功能。Kite 的page方法同时返回当前页数据和总数val pageResult kite.queryOrder() .where { Order::status eq 0 } .orderByDescending(Order::createdAt) .page(2, 10) val total pageResult.total // 总记录数 val list pageResult.records // 当前页数据分页的实现策略根据方言自动变化。MySQL 是LIMIT offset, sizePostgreSQL 是LIMIT size OFFSET offsetSQLite 跟 MySQL 类似。Kite 在生成 count 查询时会做一步优化如果原始查询没有 group by 和 distinct就把 select 子句替换为COUNT(*)避免把大字段传入 count 语句里。4. 工具选型与关键性能策略4.1 为什么不选择运行时反射为主力全自动 ORM 领域有一个经典的技术岔路口运行时反射解析还是编译期生成代码。运行时反射的好处是代码简单坏处是慢。虽然现代 JVM 对反射做了很多优化但在高频 CRUD 场景下每次查询都反射一次实体类的字段、注解、方法CPU 开销依然可观。更麻烦的是反射拿到的信息是“运行时的对象”一些字段类型信息在泛型擦除之后会丢失导致类型转换必须靠猜测。Kite 走的是编译期生成路线。KSP/APT 在编译时直接输出实体类的元数据类里面包含字段名、数据库列名、类型处理器、默认值等所有信息运行时只是简单读取这些生成类。元数据类的生成格式是纯 Java 代码Kotlin 和 Java 实体类在编译器处理后处于同一起跑线这也是实现跨语言统一的底层保证。举个例子你定义了User实体编译后会自动生成_UserMeta类框架查询时读取的是这个类而不是反射User::class.java.getDeclaredFields()。4.2 连接池与批处理参数调优Kite 本身不管理连接池但它对连接池参数很敏感。以 HikariCP 为例我一般在项目中这样配置spring.datasource.hikari.maximum-pool-size20 spring.datasource.hikari.minimum-idle5 spring.datasource.hikari.connection-timeout30000 spring.datasource.hikari.idle-timeout600000 spring.datasource.hikari.max-lifetime1800000maximum-pool-size不是越大越好。连接池大小和数据库 CPU 核数、磁盘队列深度强相关建议从(CPU核数 * 2) 1这个经典公式起步压测之后再调整。如果应用是 IO 密集或者调用第三方面 API 较多可能还需要给不同业务分配不同的数据源避免慢查询拖垮所有连接。批处理这个要特别强调。Kite 的insertBatch默认是拼接多 VALUES 的批量插入MySQL 下一条 SQL 可以带 500 条左右的记录性能提升明显。但 PostgreSQL 的批量插入走 SQL 拼接效果不好这个方言下 Kite 会自动切换为PreparedStatement的addBatch()机制所以迁移数据库时要留意 SQL 日志里的插入形式。4.3 ddl-auto 在各环境的策略建议我用一张表把不同环境的策略列出来环境ddl-auto 配置原因本地开发update改实体类自动更新表结构快速迭代测试环境update或validate验证结构一致性必要时自动更新生产环境none避免误操作改表结构DBA 手动审核 DDLvalidate模式会在启动时比对实体类和数据库表不一致就报错适合要求严格的团队在测试环境使用。注意update模式不会删除列实体类中删除了某个字段数据库里那一列还在需要手工 DROP。这是所有自动 DDL 框架的通用限制Kite 不例外。5. 常见问题与排查技巧实录5.1 表名或字段名撞上关键字实体类叫order、group、desc这些名字时生成 SQL 很容易撞上数据库保留字。Kite 默认会给表名和列名加反引号MySQL或双引号PostgreSQL但如果你关闭了kite.quoteIdentifiersfalse就会遇到语法错误。解决办法是显式指定表名别偷懒Table(order) data class Order(...)PostgreSQL 的写法是把双引号写进注解里Table(\order\) data class Order(...)我的建议数据库表名尽量避开保留字实在避不开统一加前缀比如t_order。这不是 Kite 的问题是我们建表规范的问题。5.2 查询超时和慢 SQL 排查Kite 有内置slowSqlLog配置超过指定毫秒数的 SQL 会打印 WARN 日志默认 1000ms。kite.slow-sql-threshold500 kite.format-sqltrue生产环境排查慢 SQL我一般流程是先打开format-sql和show-sql看生成 SQL 的形态接着看是不是少了索引尤其是Relation关联查询里的 targetColumn 很可能漏建索引再看是不是total计数查询太慢count 不走索引是常见原因。Kite 生成的 SQL 都很规整拿到数据库用EXPLAIN跑一遍基本就能定位问题。5.3 Kotlin 协程环境下的挂起 APIKotlin 项目用协程的场景非常普遍。Kite 单独提供了一套挂起 API避免线程阻塞suspend fun getUserOrders(userId: Long): ListOrder { return kite.async { queryOrder() .where { Order::userId eq userId } .list() } }kite.async内部把数据库操作放到 IO 调度器上。这里有个细节事务和协程之间的关系不简单Transactional注解在挂起函数里默认行为是绑定到调用线程的如果你跨协程切换事务可能失效。Kite 提供的事务管理接口干脆改用显式声明kite.transaction { // 在这个块里的所有操作共享同一个连接 val order queryOrder().where { Order::id eq 1L }.first() update(order.copy(status 1)) }5.4 循环依赖和 N 1 查询实体类之间双向Relation很常见但查询时如果无脑展开就会产生无限递归。Kite 的每个关系查询默认最多展开两层超过的部分返回空集合并打印一条 WARN 日志。N1 问题主要出在循环里调查询比如 for 循环里逐条查订单关联的商品信息。避坑方案是查完数据后手动改造成批量查询模式。Kite 的queryAPI 同样支持in条件配合一次查询加载全部关联数据。你可以在数据库层面做冗余字段减少关联也可以在内存里做分组 map 来组装。这两种方式我用得最多尤其内存组装配合 Kotlin 的groupBy代码量不大且很直观。6. Java 与 Kotlin 双语言兼容的工程化细节6.1 编译期处理器的选择策略项目里同时存在 Kotlin 和 Java 模块时KSP 和 APT 会各处理各的部分。Kotlin 实体类走 KSPJava 实体类走 APTKite 编译期模块会把两类产物输出到统一的元数据注册表。有一个实际问题如果 Kotlin 模块引用了一个 Java 实体类Kotlin 编译器在打包时会把 Java 的元数据一起带过去吗答案是会只要kite-compiler在 classpath 上能识别到Table注解的类无论它来自哪个语言都会生成元数据。这是字节码层面的注解保留机制Table的Retention我们设置的RUNTIME所以跨模块编译没有问题。6.2 Java Builder 风格与 Kotlin 具名参数的观念差异Java 程序员习惯了 Builder 模式Kotlin 程序员则更喜欢具名参数和默认值。Kite 没有强制推某种风格而是让两者各得其所。Java 下提供Builder注解支持Order order Kite.builder(Order.class) .set(Order::getUserId, 1L) .set(Order::getStatus, 0) .build();Kotlin 下直接用构造函数就行data class 的copy方法在做更新操作时非常好用。一个框架同时兼顾两种习惯关键是内部统一走列名映射而不是方法名映射否则两边的注解解析逻辑会严重分叉。7. 个人使用体会与后续扩展思路Kite 这个项目从产生想法到今天我踩过的坑不算少。最深的体会是做一个全自动 ORM 框架比起单纯“自动生成 SQL”更难的是“自动做正确的决策”。比如什么时候该用 JOIN、什么时候该用子查询字段为 null 时的更新语义是什么这些看似小的问题实际设计起来要反复斟酌。代码生成只是起点语义一致性和边界情况的处理才是持续投入的地方。如果你打算在自己的项目里尝试 Kite我建议不要一开始就追求把所有 Query 场景都用上先把Table、Id、基础条件查询、分页这四件事跑通感受一下编译期生成模型带来的确定感。后面接触Relation和批处理时再逐步加深。另外团队里如果有人对 SQL 特别敏感Kite 也保留了nativeQuery接口让你能在全自动的基础上按需手写 SQL不至于被迫走极端。最后再分享一个小技巧Kite 生成的 SQL 日志在开发阶段一定要打开看。很多你以为“不该是这样”的查询看一眼生成的 SQL 就会明白——绝大多数情况问题出在实体类定义上而不是框架逻辑上。搞清楚这一点排查问题的速度会快非常多。
返回列表