Army 对 jOOQ

项目 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-coreio.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 一行都不用动。

这为什么重要?有两个原因:

  1. reactive 从来不是"JDBC 外面套线程池"。 Army 的 git 历史里有一套完整的 reactive 实现(reactive session impl v1v3、typed reactor 会话、响应式事务管理、reactive MySQL/乐观锁测试用例),对接的是真正的响应式驱动;后来维护者无力同时维护两条技术栈,通过两个提交(drop reactor moduledrop reactive package)将其移除。移除模块没有伤及 core------这正是抽象为真而非装饰的证据。
  2. 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)和四个终局语句族(QueryInsertUpdateDelete)。约 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* 家族就包含 fetchfetchSinglefetchOnefetchLazyfetchAsyncfetchStreamfetchOptionalfetchGroupsfetchMap......每个还带大量重载。)

这不是蠢人犯的错------这是一个精确的取舍。公开继承链给了人类开发者全行业最激进的 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 的子句层自己用了 0default 方法、0sealed 关键字------它在 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.orgQueryDSL 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 SyncSessionFactorypermits 子句禁止),也无法继承 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

测量时间:2026 年 9 月,基于 jOOQ 主干与 Army 0.6.x 源码。作者与两个项目均无关联。

相关推荐
泡海椒1 小时前
规则热更新实现:JQuick-Java无需重启更新业务规则实战
后端
二炮手亮子1 小时前
java责任链模式
java
右耳朵猫AI1 小时前
PHP周刊2026W37 | Symfony 三维护版齐发、Laravel AI SDK 0.11、LSP 服务器上线
后端·php·laravel
AI深栈1 小时前
第 10 章 · Embedding、VectorStore 与 RAG
java·人工智能
geovindu2 小时前
CSharp:Condition Variable Pattern
后端·设计模式·c#·.net·.netcore·条件变量模式·同步型模式
Java内核笔记2 小时前
Spring Boot 4 SSRF 防护源码剖析:InetAddressFilter 挡住内网地址与云元数据
java·后端
白远山2 小时前
健身场馆无人自动化系统实战指南:从架构设计到部署全流程解析
java·开发语言·架构·需求分析
user_admin_god2 小时前
第 09 篇:实践一 —— 文本摘要(Map-Reduce 分块)
java·人工智能·spring boot·语言模型
右耳朵猫AI2 小时前
Node.js周刊2026W37 | 三处进程崩溃修复、fs 内置 glob、Workers 模块注册表、Vitest 5.0
javascript·后端·node.js