项目 GitHub 地址:github.com/PillArmy/ar...
本文每一段代码样例都取自 Army 自己的源码树(army-example 模块或构建生成产物),仅做了少量 import 精简;每一项 jOOQ 测量数据都来自我本地的一份 jOOQ 主干检出。所有结论你都可以自行复现。
一个把 JDBC 当作"执行器之一"的 SQL DSL:对照 jOOQ 研读 Army
Java 世界里类型安全的 SQL DSL 已经存在很久了。QueryDSL 出现于 2007 年,jOOQ 出现于 2009 年。你完全有理由以为这个设计空间已经关闭------剩下的事情无非是打磨、加方言、做代码生成。
Army 是一个小型、1.0 之前(0.6.x)、单人维护的 Java ORM,它不同意这个判断。在通读它 16 个 Maven 模块、约 23.7 万行主源码之后,我认为它做出了四个即使你永远不采用它也值得研究的设计决策------其中一个架构级押注,我在 Java ORM 领域没有见过第二个。
这不是一篇"jOOQ 很烂"的文章。jOOQ 是优秀、成熟、经过商业验证的产品。真正有意思的问题更窄:在语言能力完全相同的前提下,两个项目各自选择了什么取舍,这些取舍的代价是什么?
1. JDBC 不是地基,它只是一个可拔插的执行器
这是整个代码库里最重要的一点。
在 MyBatis 里,session 持有一个 java.sql.Connection。在 jOOQ 里,JDBC 是宇宙中心,R2DBC 支持是多年后补上的次级适配层。而在 Army:
text
$ grep -rn "import java.sql" army-core/src/main/java | wc -l
0
整个 core 模块零 JDBC 导入 ,它的 pom.xml 也没有 JDBC 依赖。执行器契约在构造上就是驱动中立的。以下摘自 army-core 中 io.army.executor.ExecutorFactory 的真实 Javadoc:
java
/// For example:
///
/// - JDBC
/// - JDBD
/// - ODBC
///
/// @return driver spi name
String driverSpiName();
/// @return JDBC always return false, JDBD always return true.
boolean isResultItemDriverSpi();
/// For example: io.army.jdbc or io.army.jdbd
/// @return executor vendor
String executorVendor();
JDBC 只是被列举的驱动 SPI 之一 ,与 JDBD(一种响应式驱动)和 ODBC 并列。阻塞式 JDBC 实现------3,566 行的 JdbcExecutor------被关在独立模块 army-jdbc 里,可以被替换,而 SQL 引擎、方言层、会话 API 一行都不用动。
这为什么重要?有两个原因:
- reactive 从来不是"JDBC 外面套线程池"。 Army 的 git 历史里有一套完整的 reactive 实现(
reactive session impl v1到v3、typed reactor 会话、响应式事务管理、reactive MySQL/乐观锁测试用例),对接的是真正的响应式驱动;后来维护者无力同时维护两条技术栈,通过两个提交(drop reactor module、drop reactive package)将其移除。移除模块没有伤及 core------这正是抽象为真而非装饰的证据。 - SQL 生成与传输协议解耦。 方言面向内部 context 渲染 SQL,执行器负责绑定和发送。同一条渲染好的语句,今天可以被阻塞驱动消费,明天可以被响应式驱动消费。
对照一下把响应式事后加装到以 JDBC 为中心的设计上要付的代价,你就明白这是一个押注,不是装饰。
2. 编译期子句状态机------藏在唯一一个公开入口背后
先看真实的用户侧查询代码,来自 army-example/.../bank/dao/sync/region/StandardRegionDao.java:
java
final Select stmt;
stmt = SQLs.query()
.select(ChinaRegion_.id)
.from(ChinaRegion_.T, AS, "t")
.where(ChinaRegion_.name.equal(SQLs::param, regionName))
.and(ChinaRegion_.regionType.equal(SQLs::literal, regionType))
.asQuery();
return this.sessionContext.currentSession().queryOne(stmt, Long.class);
SQLs.query() 返回的不是一个"宽查询对象",而是 StandardQuery.WithSpec<Select>------一个收窄的入口类型。每个子句方法的返回类型都被参数化为下一个合法阶段 。摘自 army-core/.../criteria/standard/StandardQuery.java:
java
// 唯一的公开入口;Javadoc 原文:"public interface that developer can directly use"
interface WithSpec<I extends Item> extends _StandardDynamicWithClause<SelectSpec<I>>,
_StandardStaticWithClause<SelectSpec<I>>,
SelectSpec<I> {
}
// select() 把你带到这里......
interface _StandardSelectClause<I extends Item>
extends _ModifierListSelectClause<SQLs.Modifier, _StandardSelectCommaClause<I>>,
_DynamicModifierSelectClause<SQLs.Modifier, _FromSpec<I>> {
}
// ......from() 把你带到这里,join 的分支/汇合是一张类型层面的图:
interface _JoinSpec<I extends Item> extends _StandardJoinClause<_JoinSpec<I>, _OnClause<_JoinSpec<I>>>,
_JoinCteClause<_OnClause<_JoinSpec<I>>>,
_CrossJoinCteClause<_OnClause<_JoinSpec<I>>>,
_WhereSpec<I> {
}
interface _WhereSpec<I extends Item>
extends Statement._QueryWhereClause<_GroupBySpec<I>, _WhereAndSpec<I>>, _GroupBySpec<I>> {
}
顺着链条走下去,你会得到完整的 SQL 语法:
text
WithSpec → SelectSpec → _FromSpec → _JoinSpec → _WhereSpec
→ _GroupBySpec → _HavingSpec → _WindowSpec
→ _OrderBySpec → _LimitSpec → _LockSpec → 终局
子句顺序写错,编译器直接拒绝 ,严格程度与 jOOQ 相同。分支也是真实存在的:_JoinSpec 同时继承"继续 join"的能力和 _WhereSpec,所以"继续 join 或转入 WHERE"是在类型层表达的,不是运行时检查。
关键转折在于:每一个中间 spec 都是嵌套接口,其 Javadoc 写着:
Application developer isn't allowed to directly use this interface... army don't guarantee compatibility to future distribution.(应用开发者不允许直接使用此接口......Army 不保证对未来版本的兼容性。)
用户能看到的只有一个入口(WithSpec)和四个终局语句族(Query、Insert、Update、Delete)。约 15 个阶段的状态机完整存在------只是被锁进了黑盒。
jOOQ 如何编码同一套语法
jOOQ 用的是一条公开的线性继承链 。以下是我在当前主干检出上沿真实 extends 子句逐层走出来的:
text
SelectSelectStep → SelectDistinctOnStep → SelectIntoStep → SelectFromStep
→ SelectWhereStep → SelectConnectByStep → SelectGroupByStep → SelectHavingStep
→ SelectWindowStep → SelectQualifyStep → SelectOrderByStep → SelectLimitStep
→ SelectForUpdateStep → SelectForStep → SelectOptionStep → SelectUnionStep
→ SelectCorrelatedSubqueryStep → SelectFinalStep → Select
19 层公开接口 。Select*Step 源文件共 68 个 。而上帝入口 DSLContext.java 的体量是:
text
16,556 行
约 848 个方法声明
280 个不同的方法基名
(光 fetch* 家族就包含 fetch、fetchSingle、fetchOne、fetchLazy、fetchAsync、fetchStream、fetchOptional、fetchGroups、fetchMap......每个还带大量重载。)
这不是蠢人犯的错------这是一个精确的取舍。公开继承链给了人类开发者全行业最激进的 IDE 补全收窄,而且这 68 个类型中的每一个都是付费企业客户赖以生存的兼容性契约。jOOQ 甚至做过一次破坏性重写(3.0,把 Factory 拆成 DSL/DSLContext),并保留了 Step 链------他们不是动不了大手术,是这条链在承重。
区别纯粹在于状态机放在哪里:
| QueryDSL | jOOQ | Army | |
|---|---|---|---|
| 子句顺序约束时机 | 运行时 | 编译期(继承链) | 编译期(泛型 spec 图) |
| 状态机可见性 | 用户自行把握 | 68 个公开 Step,全是契约 | 约 15 个 spec 全部禁用,仅 1 个公开入口 |
| 增加一个子句的成本 | 加一个方法 | 插入一层、修改沿途所有返回类型 | 把一个 spec 组合进对应阶段 |
这是 2009 年的三岔路,不是 2023 年的新发现
人们很容易用"当年的 Java 不允许别的写法"来为它开脱。但当年允许。泛型 2004 年就发布了;包私有类型和多接口组合从 Java 第一天就有;Army 的子句层自己用了 0 个 default 方法、0 个 sealed 关键字------它在 Java 5 上就能编译。
而且替代方案当时已经在生产中运行:QueryDSL(创建于 2007 年)发布了宽接口式的流式 SQL API。Lukas Eder 本人在 2014 年写道:"In the beginning of jOOQ in 2009, QueryDSL was ahead of us." (jOOQ 刚起步的 2009 年,QueryDSL 走在我们前面。)出处:blog.jooq.org、QueryDSL 1.2.0 手册,©2007--2009。
2009 年,三条路同时摆在桌面上:宽接口(QueryDSL)、公开继承步骤(jOOQ)、组合式泛型 spec(Army 后来选择的构造)。三条路在当年都合法。
3. 参数 / 字面量 / 类型的决策发生在表达式节点诞生之时
再看一眼那个 WHERE 子句:
java
.where(ChinaRegion_.name.equal(SQLs::param, regionName))
.and(ChinaRegion_.regionType.equal(SQLs::literal, regionType))
equal(SQLs::param, x) 把 x 绑定为 JDBC 的 ? 参数;equal(SQLs::literal, x) 把 x 内联为 SQL 字面量。这个选择在表达式节点诞生的那一刻 就做出了,而且节点自带它的映射类型。不存在一份与渲染文本并行行走的旁路参数映射列表(不像 MyBatis 的 BoundSql + ParameterMapping),也不存在一个默默接受一切的 eq(Object) 逃生门。
在数据库类型会改变 SQL 语法 的地方,这一点最关键。PostgreSQL 的 jsonb 值可能需要 cast,数组字面量需要 ARRAY[...] 构造,枚举有三种不同的存储约定。Army 把这些建模为 army-core/.../mapping/ 下的一等映射类型:
text
NameEnumType CodeEnumType LabelEnumType
JsonType / JsonbType / JsonMappingType / JsonbMappingType
ArrayMappingType CompositeType VectorType XmlType ...
在 jOOQ 里,枚举是二等公民,靠配置 Converter/EnumConverter(forced type)处理;渲染知识并不内在于表达式节点。另外,虽然 Field<T>.eq(T) 是类型安全的,jOOQ 宽阔的 API 表面同时提供了 eq(Object)、plain-SQL 字段和 DSL.field(String, DataType)------错误的操作数类型可以通过这些路径编译通过,在绑定期才失败。泛型参数主要决定你 fetch 出来 的东西是什么,并不能完全看管什么东西进入 AST。
生成的元模型在编译期进一步加固。以下是项目构建目录里注解处理器的真实产物:
java
@Generated(value = "io.army.modelgen.ArmyMetaModelDomainProcessor")
public abstract class ProductInfo_ {
private ProductInfo_() { throw new UnsupportedOperationException(); }
public static final CompositeType T;
static {
T = CompositeType.from(ProductInfo.class);
final int fieldSize = T.fieldList().size();
if (fieldSize != 8) {
throw _TableMetaFactory.compositeFieldSizeError(ProductInfo.class, fieldSize);
}
}
public static final CompositeField productId = T.field("productId");
public static final CompositeField productName = T.field("productName");
public static final CompositeField price = T.field("price");
// ...
}
处理器还会拒绝非法表名(camelCase)和引用了不存在字段的索引声明------schema 错误根本到不了运行中的数据库。
4. 公开 API 是 sealed 接口,实现全部包私有
构建工厂,原文取自 army-example/src/test/java/io/army/session/FactoryUtils.java:
java
public static SyncSessionFactory createArmyBankSyncFactory(final Database database) {
return SyncFactoryBuilder.builder()
.name(mapDatabaseToFactoryName(database))
.packagesToScan(Collections.singletonList("io.army.example.bank.domain"))
.datasource(DataSourceUtils.createDataSource(database))
.environment(createEnvironment(database))
.jsonCodec(FastJsonCodec.getInstance())
.fieldGeneratorFactory(new SimpleFieldGeneratorFactory())
.build();
}
静态工厂入口、流式选项、final 不可变工厂。接口是 sealed 的,只 permit 唯一的包私有实现:
java
public sealed interface SyncSessionFactory permits ArmySyncSessionFactory { ... }
public sealed interface SyncFactoryBuilder permits ArmySyncFactoryBuilder { ... }
final class ArmySyncFactoryBuilder extends ... { ... } // 包私有
final class ArmySyncSessionFactory implements ... { ... } // 包私有
你无法实例化实现类,无法自己写 implements SyncSessionFactory(permits 子句禁止),也无法继承 builder。不存在一个同时充当非官方 API 的 public DefaultXxx 类------对照 MyBatis:它的 DefaultSqlSessionFactory 及其构造器都是 public,放在 defaults 包下,只靠一个君子协定约束用户。
(一个诚实的小瑕疵:builder 的 datasource(...) 参数类型是 Object,以便同时接受 javax.sql.DataSource 和 Army 自己的读写分离数据源。这是一个真实存在、虽然不大的编译期安全漏洞。)
执行语句的面同样很窄------整个 sync 会话表面约 63 个方法,查询消费统一在 ResultItem 背后(行、更新计数、元数据是一条带序号的流),而不是几百个 fetchXxx 变体:
java
SyncLocalSession session = factory.localSession();
Long id = session.queryOne(stmt, Long.class); // 单值
List<Map<String, Object>> rows = session.queryObjectList(stmt, HashMap::new);
session.update(insertStmt); // DML
session.save(domain); // 风格化的实体保存
5. 为什么我认为这对 AI 重要------超越炒作
重点不是"AI 喜欢 Army"。重点是:AI 编程助手本质上是编译器监督下的程序变换器,它的失败模式是生成结构上看似合理的文本。
面对三种 SQL DSL 架构,一个 agent 得到的结构性反馈是不同的:
- 宽接口(QueryDSL 式):任何状态下所有方法都可调用,错误的子句顺序最晚暴露------在运行时。
- 19 个公开 Step 类型(jOOQ 式) :补全收窄对人类极好,但那 68 个
SelectConnectByAfterStartWithConditionStep形态的名字在训练数据中极其罕见;agent 必须在一张巨大的公开类型图上推理"我现在处在哪一层"。 - 一个入口 + 隐藏的泛型 spec(Army 式):模型需要持有的公共词汇表极小(一个入口、四个终局语句、屈指可数的子句函数),而错误顺序依然被编译器杀死。约束强度不打折,需要推理的公共类型少了约一个数量级。
当表达式节点还自带映射类型时,agent 不只是发出看起来像 PostgreSQL 的文本------所需的 cast/数组/枚举语法由渲染器确定性地产生。类型系统实际上是一个便宜且不徇私情的环境:编译器错误是一种不依赖任何人语言的反馈。
6. 诚实的反方章节
你不应把这篇文章读成"Army 打败了 jOOQ"。在对生产环境重要的大多数维度上,它没有:
- 成熟度:jOOQ 有约 17 年的发布史、30+ 方言、商业支持、海量手册和数以万计的部署。Army 是 0.6.x,用户基本只有一个。
- 方言深度是集中的 :PostgreSQL 和 MySQL 很深(像
PostgreDocumentFunctions.java这样的单文件超过 6,000 行),SQLite/H2 较薄,Oracle 只有五个文件的骨架。 - 相对于范围,测试偏薄:约 2.08 万行测试代码对约 23.7 万行主代码。jOOQ 的测试矩阵比主源码还大。
- reactive 技术栈已被移除,不是从未建造------是因维护者资源不足而有意识地砍掉------所以今天交付的只有阻塞式/SPI 故事。
- 风险:单人维护、无商业实体、尽管有 sealed 纪律,1.0 之前 API 仍可能变动。
Army 确实提供的是一种存在性证明:一个 Java SQL DSL 可以(1)把 JDBC 当作可替换的细节,(2)通过唯一一个公开入口在编译期强制完整的子句顺序,(3)把参数/字面量/映射语义绑进表达式节点,(4)封闭整个运行时表面------而且全部使用 2004 年就已存在的语言特性。
我愿意写下的最强判词是:jOOQ 为跨二十年兼容性的人类可发现性优化了它的公开表面;Army 为最小化和编译器强制的结构优化了它的公开表面。在越来越多的 SQL 由 agent 编写、由编译器裁决的世界里,第二个押注值得被看见。
附录:如何验证
bash
# jOOQ(当前主干检出)
wc -l jOOQ/src/main/java/org/jooq/DSLContext.java
ls jOOQ/src/main/java/org/jooq | grep -c '^Select.*Step'
# Army
grep -rn "import java.sql" army-core/src/main/java | wc -l # 0
find . -path '*/src/main/java/*.java' | wc -l # 1361
- 源码:github.com/PillArmy/ar...
- QueryDSL 1.2.0 参考手册(© 2007--2009 Mysema Ltd):PDF
- Lukas Eder:QueryDSL vs jOOQ(2014):blog.jooq.org
- jOOQ 3.0 迁移说明(Factory 拆分、RowN 从 8 提到 22):官方手册
测量时间:2026 年 9 月,基于 jOOQ 主干与 Army 0.6.x 源码。作者与两个项目均无关联。