看到 findFirstByDriverAndStateIn 这样的方法名,用过 Spring Data 的人立刻能猜到它要查什么。Codename One 把这套仓库模型搬到了客户端和后端,但走的是另一条路:构建时生成具体实现,再直接编译进原生可执行文件。

打开网易新闻 查看精彩图片

Codename One 是一个开源框架,用同一份 Java 或 Kotlin 代码构建原生 iOS、Android、桌面和 Web 应用。它这次带来的仓库接口,写法眼熟,运行方式却完全不同。

差别在于运行时那套准备工作被拿掉了。构建阶段生成仓库的具体实现,原生打包时提前编译,启动时不需要扫描类去找仓库,也不需要生成代理。直接调用还让编译器拿到了可分析和优化的代码。

本周的 Wayline 应用把仓库用在了网约车服务上。与之配套的还有 Spring 风格的事务隔离和完成回调,用来控制系统的其他部分何时能对一次数据库变更做出反应。

查询写在找得到的地方

Wayline 的仓库接口直接声明自己要读什么。下面这段来自 RideRepository 的简化接口:

  • 接口标注 @Repository,指定实体为 Ride、键类型为 String;
  • 继承 CrudRepository;
  • 声明 findFirstByRiderAndStateIn 和 findFirstByDriverAndStateIn 两个方法。

实现由构建生成,直接调用会话,不在运行时扫描类来发现仓库。没有 Hibernate 依赖,也没有运行时代理生成。同一套仓库 API 在客户端 ORM 会话和后端会话上都能用。

Spring Data 用户会认出这套命名约定,Hibernate 用户会认出托管实体和脏检查。这些约定在 Codename One 自己的包下受支持,API 边界是有意收窄的。方法名看着眼熟,不等于现有的 Spring 或 Hibernate 依赖就能直接搬过来。

继承来的方法包括 insert、save、findById、findAll、count、existsById 和 deleteById。有两个细节要记住:findById 找不到时返回 null;save 返回的是 merge 产生的托管对象,如果还要继续修改它,请保留这个返回值。

静态编译把准备工作做一次

仓库接口在构建期间变成具体实现。借助 ParparVM,这份 Java 实现和应用的其余部分一起进入原生二进制。没有反射式的仓库分发,也没有需要初始化的运行时实现工厂。

这省掉的是启动和分发仓库调用上的开销,数据库查询本身的成本并没有消失:索引、往返次数、行数依然重要。CASE 聚合和分页能把这类工作留在数据库里,避免加载不必要的实体。

方法名长,不一定就是最清晰的查询。派生查询支持比较、And/Or、显式排序和限制结果数。给列表查询加上 PageRequest(offset, size),就能拿到一页数据,而不必额外发一次总数查询。排序里要带上唯一的决胜字段,否则分数相同的行会在分页之间来回漂移。

条件聚合则把查询写出来。下面这个简化例子用到了指南里的 RepositoryRecord 实体,它有一个数值字段 score:

@Query("select sum(case when r.score >= :floor then 1 else 0 end) " + "from com.codenameone.developerguide.snippets.repository.RepositoryRecord r")
Long qualified(@Param("floor") long floor);

CASE 表达式让数据库去数符合条件的行,而不是让应用把它们全取回来。命名值以绑定参数传入。原生 SQL 查询同样可用,只是标量结果 API 更窄,还要承担所选 SQL 的可移植性代价。

仓库参考文档写明了语法。不支持的方法签名会在生成阶段失败。JPQL 语法和映射名在方法创建查询时、SQL 执行之前接受检查。这个区别值得留意:它并不承诺所有查询错误都会变成编译错误。

事务留在服务层

仓库不开启也不提交事务,边界由服务持有。下面是 Wayline 实际的读取路径:

@Transactional(readOnly = true)
public RideDto activeForRider(String rider) throws IOException {
Ride ride = rides.findFirstByRiderAndStateIn(rider, ACTIVE);
return ride == null ? none() : toDto(ride, rider, new HashMap());
}

这段代码位于 Rides 中,旁边是它的仓库和映射辅助方法。要改动多个实体的方法,会用一个事务包住整个工作单元。仓库的会话参与其中,因此修改托管实体后,事务完成时可以刷写出去。

批量操作需要单独对待。派生删除和声明的修改型查询会先刷写待处理变更,执行批量 SQL,再清空持久化上下文。它们绕过逐实体回调和级联。继续操作前要重新加载对象,手里留着的引用仍然装着旧的 Java 值。

在外层边界选择隔离级别

注解现在可以接受隔离级别:

@Transactional(isolation = Isolation.SERIALIZABLE)

这是一段注解摘录,放在启动整个工作的那个方法上。它请求一个该级别的物理事务,不会悄悄升级已经存在的外层事务。

加入的作用域如果请求了显式级别,必须与外层作用域的显式级别一致。加入 DEFAULT 作用域却提出显式请求,会失败。要么在开始时选好级别,要么在操作确实需要独立事务和连接时使用 REQUIRES_NEW。

数据库点头之后再通知

在线程绑定的事务内部,可以注册 Transactions.Synchronization。下面是指南给出的回调形态,注释标出了应用逻辑该放的位置:

  • beforeCommit:在 ORM 刷写和提交之前做校验;
  • afterCommit:让缓存失效或安排事件;
  • afterCompletion:按 COMMITTED、ROLLED_BACK 或 UNKNOWN 释放资源。

afterCommit 执行时,连接已经回到池里。要做更多数据库操作,得开一个新事务。如果回调失败,原来的数据库变更仍然已提交,调用方会收到说明这一点的异常。盲目重试整个操作,可能把工作做两遍。

这些回调是同步的、在内存里的,不能让事件投递在进程崩溃后依然可靠。对于必须最终送达的消息,在原始事务里写一条 outbox 记录,再单独投递。

Wayline 目前用一个事务性私有方法加随后的通知代码来表达类似的顺序。编译器把事务处理写进方法里,所以通过 this 调用也绕不过去。完成钩子则给其他服务提供了一个明确的位置,安放提交之后的工作。

仓库、CASE 表达式、隔离级别和回调是几项独立的补充,放在一起更有用:查询的管道代码少了,重要的顺序依然看得见。