为什么要使用代码生成的jOOQ?

1,137 阅读18分钟

我在Stack Overflow上回答了很多jOOQ问题,而且次数很多。这个问题有相同的原因。人们不使用jOOQ的代码生成器。人们似乎没有使用它的主要原因,是因为它需要一些额外的时间来设置,但正如任何设计良好的东西一样,最初的投资总是会有回报的。

在这篇文章中,我想简要地总结一下代码生成器是什么,为什么你应该使用它,以及不使用它的主要原因是什么(提示:你可能没有这种情况)。

什么是jOOQ代码生成器?

jOOQ是一个内部DSL,它假装你可以直接在Java中编写类型安全、嵌入式的动态SQL。就像你可以在PL/SQL、PL/pgSQL、T-SQL、SQL/PSM和所有其他过程性方言中做到这一点。作为内部的,最大的区别是,动态SQL非常容易,因为jOOQ使SQL "可组合"。

作为一个内部DSL,它将SQL语言建模为一连串的Java方法调用。这里描述了流畅的API设计背后的技术。一个典型的jOOQ查询看起来像这样:

var author =
ctx.select(AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
   .from(AUTHOR)
   .where(AUTHOR.ID.eq(10))
   .fetchOne();

现在,那些AUTHOR 表的引用,以及那些AUTHOR.ID 列的引用是生成的代码。它们是你的数据库元数据的Java表示,包括各种信息:

  • 它们所属的目录/模式
  • 它们所属的表(如果是列)
  • 数据类型
  • 一些附加的转换器数据类型的绑定
  • 其他有用的元数据,如约束(主键、唯一键、外键)、身份、默认表达式、生成的表达式等
  • 辅助DSL方法,如类型安全 别名方法

正如你所看到的,你得到了很多开箱即用的功能!

为代码生成所付出的代价是什么?

据我所知,很多用户(尤其是新用户)急于立即尝试使用jOOQ,不想费力地设置代码生成器,这在一开始似乎是一个不必要的步骤。例如,当使用JAXB时,你不必使用 XJC来为你的XSD文件生成注释的绑定(你可能甚至没有XSD)。但是使用jOOQ,情况就不同了。

是的,手册可能让人不知所措,但教程不是例子也不是。在第一次尝试使用jOOQ时,你甚至不需要使用Maven或Gradle插件。为什么不直接使用jbang。只需几行CLI,你就可以马上开始。

为了尝试基本的东西,你也可以使用DDLDatabase,它可以直接从SQL文件中生成代码,而不需要与实际的数据库进行实时连接,尽管有了testcontainers.org和/或docker,这其实已经不是什么大问题了,它可以让你在几秒钟内旋转出一个样本数据库。

我在这里想说的是。不要被淹没了。是的,有一些初始投资,但它将得到巨大的回报(见下文),将防止大量的问题(见下文),而且它只需要多花几分钟时间来设置你的项目。糟糕的是,你甚至可以开始玩玩错误报告模板,它包括Java、Scala和Kotlin的代码生成配置!

你能得到什么回报?

让我们从显而易见的方面开始:

1.编译时的类型安全

仅仅通过使用jOOQ的DSL,你就已经获得了一定程度的编译时类型安全,即你不能错误地输入关键字,如SELECT ,或者忘记在IN 谓词周围的括号,如A IN (B, C) 。而且你的SQL语法可以自动完成:

但是如果没有代码生成,你就不能为你的模式对象获得类型安全。这是一个大问题,一旦你习惯了它,你就不会再想错过它了。

每个表、每个列、每个约束、每个索引、每个序列、每个过程、每个函数、每个包、每个模式、每个目录、每个域、每个枚举类型、每个对象类型都在jOOQ中得到一个生成的Java表示。你不必去找你的ERD,或数据库,或任何地方去查找你的模式。你可以在你的代码中看到它。作为第一步,你可以在你最喜欢的IDE中使用这个自动完成:

不仅仅是这样。你还可以在你的元数据上永不犯错:

是的。不再有错别字。但也!当有人在数据库中重命名一个列时,不会再有退步。一旦列名改变或被删除,你的Java代码就会停止编译。

不再有错误的数据类型。看到上面的截图了吗?它说FIRST_NAME是一个TableField<AuthorRecord, String> 。所以你的Java编译器,通过jOOQ精心设计的通用API,已经知道你在投射一个String列。现在已经不是2003年了。我们有泛型来防止类型转换或不安全转换。看看这个的编译错误吧:

为什么还要担心这种数据类型的问题呢?当你创建表的时候,你已经声明了一次数据类型:

CREATE TABLE author (
  id INTEGER NOT NULL PRIMARY KEY,
  first_name TEXT NOT NULL,
  last_name TEXT NOT NULL,
  ...
);

为什么要在Java中重新声明数据类型呢?事实上,为什么要在Java中重新声明上述的任何东西,要经历所有的忙碌呢?没有人喜欢把所有这些东西输入两次或保持同步。事实上,这是一个彻头彻尾的坏主意。对于你的元模型来说,应该只有一个真理的来源,那就是你在数据库中运行的创建模式的DDL

当然,你可以有其他的数据表现形式,例如一些DDD版本,一些用于前端的JSON版本,等等。但是当你查询你的数据库时,绝对没有理由不使用你已经在数据库中声明的确切元模型。

我想这是很明显的。为什么要为了一点(最初的)额外的设置便利而放弃这一切?

2.模式自省

你不仅仅是在写查询的时候得到这个好处。你也可以在阅读时得到它。你总是可以快速导航到你感兴趣的列,并在Javadoc中阅读其SQL注释。也许你有这样的规定?(完全没有使用的SQL功能!)

COMMENT ON COLUMN author.first_name IS 'The author''s first name';

现在看看jOOQ的代码生成器是如何处理它的:

看上去很明显,不是吗?或者,你想看看谁在使用这个列?只要查看调用层次结构或你感兴趣的任何IDE搜索工具就可以了:

这比单纯的文本搜索FIRST_NAME ,要好得多,因为文本搜索可能是区分大小写的,并且匹配所有类似的字符串,而不仅仅是AUTHOR 表的那个特定列。

3.运行时元模型:数据类型

事实上,你不仅仅会在编译时从这种类型安全中获利,也会在运行时获利。相信我。在一些JDBC驱动和/或SQL方言中,有很多边缘情况,数据类型必须明确地传达给数据库。

当然,你可以在jOOQ中写出这样的东西,而且它可以工作:

var author =
ctx.select(field("author.first_name"), field("author.last_name"))
   .from("author")
   .where(field("author.id").eq(10))
   .fetchOne();

以上是使用普通的SQL模板,这个功能通常被用户用来扩展jOOQ的自定义功能。当然,也可以用它来代替使用代码生成,偶尔,这也是正确的做法。

当绑定变量的类型上下文不足时

但在很多情况下,你应该给SQL引擎更多关于绑定值的上下文。例如,在Derby(或旧版本的Db2)中,你不能只是这样做:

select null
from sysibm.sysdummy1;

你会得到一个错误:

SQL错误[30000] [42X01]。语法错误。在第1行第8列遇到了 "null"。

相反,你必须要CAST

select cast(null as int)
from sysibm.sysdummy1;

如果我们通过广泛的集成测试发现在任何情况下,一种方言可能需要它,jOOQ总是为你添加这些转换。但是jOOQ只有在你提供了一个类型的情况下才能做到这一点,而且你常常隐含地做到了,但有时你没有做到,然后你就不得不调试一个以前能工作的查询。这可能是非常意外的。

当一个Field<Object> 导致编译错误时

这个是很棘手的。Java语言第8版围绕泛型的重载解析做了一个特殊的决定。假设你有这样的重载(而jOOQ中充满了这样的重载,出于方便的原因):

public <T> void setValue(Parameter<T> parameter, T value) {}
public <T> void setValue(Parameter<T> parameter, Field<T> value) {}

那么在Java 8中就出现了一个向后不兼容的编译行为的变化。

虽然这样做完全没有问题:

Parameter<String> p1 = ...
Field<String> f1 = ...
setValue(p1, f1);

这个调用不能编译:

Parameter<Object> p2 = ...
Field<Object> f2 = ...
setValue(p2, f2);

似乎在这种情况下,Field<Object> 并不比Object 更加具体,这似乎很奇怪,但JLS就是这样设计通用重载解析的。事实上,它还是更具体的,但是在我们进入特异性解析之前,另一条规则宣布这两种类型是模糊的。详情请阅读上面的链接。

让我们再看看之前那个不使用代码生成的jOOQ查询:

Record2<Object, Object> author =
ctx.select(field("author.first_name"), field("author.last_name"))
   .from("author")
   .where(field("author.id").eq(10)) // Field<Object>
   .fetchOne();

这里面都是Field<Object> ,所以你最终会遇到这个问题,相信我,主要是在UPDATE .. SET 子句。而且这将是一个相当令人费解的问题。

解决办法是始终将类型信息附加到你的列中:

// Assuming this static import
import static org.jooq.impl.SQLDataType.*;

Record2<String, String> author =
ctx.select(
        field("author.first_name", VARCHAR),
        field("author.last_name", VARCHAR))
   .from("author")
   .where(field("author.id", INTEGER).eq(10)) // Field<Integer>
   .fetchOne();

但为什么要这样做呢,因为代码生成器已经为你自动完成了这一切?如果你把你的AUTHOR.ID 列改为BIGINT ,而不是INTEGER 呢? 你会手动更新每个 查询吗?

4.仿真

如果没有生成的元数据,有些仿真是不可能的。它们主要涉及对INSERT .. RETURNING 语法的仿真,这依赖于知道主键和身份信息,但其他语法也可能受到影响。

你不会相信我支持过多少次用户,他们后来想用内部API来告诉jOOQ关于主键或身份元信息的错综复杂的方法来使之工作,而不是仅仅生成代码。使用生成的代码,下面的查询在很多RDBMS中都是开箱即用的出的Box:

AuthorRecord author =
ctx.insertInto(AUTHOR, AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
   .values("John", "Doe")
   .returning(AUTHOR.ID)
   .fetchOne();
  • 这是否可以在一个单一的查询中运行(例如,在Firebird、Oracle、PostgreSQL中,在SQL Server中使用OUTPUT ,在Db2、H2中使用数据变化delta表)?
  • 是否可以使用JDBC的Statement.getGeneratedKeys() ,在一个单一的查询中运行?
  • 它是否需要第二个查询来获取身份和/或其他列?

在这种情况下,jOOQ总能为你找到办法,但前提是它知道身份是哪一列。

5.转换器

基本SQL只真正有几个内置的数据类型:

  • 各种数字类型
  • 各种字符串和二进制字符串类型
  • 各种时间类型

但是,在你的应用程序中到处使用这些低级类型是个好主意吗?你想在所有地方都使用BigDecimal来表示你的货币数额吗?还是创建一个更有用的Amount 域类更好?一个你可以附加功能、货币等的类?

在jOOQ中,你可以使用转换器(或绑定,如果这影响到你如何将数值绑定到JDBC)。有一个花哨的FirstName 类来模拟各种类型的名字?

/**
 * Not just any ordinary first name string!
 */
record LeFirstName(String firstName) {}

是的,你可以坚持不使用代码生成,并将其附加到你的普通SQL模板查询中:

Record2<LeFirstName, String> author =
ctx.select(
        field("author.first_name", VARCHAR.asConvertedDataType(
            LeFirstName.class, LeFirstName::new, LeFirstName::firstName
        )), 
        field("author.last_name", VARCHAR))
   .from("author")
   .where(field("author.id", INTEGER).eq(10))
   .fetchOne();

是的,你可以在一个辅助类中提取那个字段的定义,以便更好地重复使用:

class LeAuthor {
    static Field<LeFirstName> firstName = field("author.first_name", 
        VARCHAR.asConvertedDataType(
            LeFirstName.class, LeFirstName::new, LeFirstName::firstName
        ));
    static Field<String> lastName = field("author.last_name", VARCHAR));
}

Record2<LeFirstName, String> author =
ctx.select(LeAuthor.firstName, LeAuthor.lastName)
   .from("author")
   .where(field("author.id", INTEGER).eq(10))
   .fetchOne();

而现在,它看起来几乎就像使用了jOOQ的代码生成器。除非它没有。为什么不呢?你只需在代码生成器中进行一些额外的配置,就可以将你的转换器自动附加到你所有表的所有FIRST_NAME 列上,所以你永远不会忘记。特别是,如果你的转换器实现了一些必须使用的逻辑,例如散列或加密等。所以,上面的例子只是:

// After tweaking the codegen configuration, you now
// get your custom type whenever you reference FIRST_NAME
Record2<LeFirstName, String> author =
ctx.select(AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
   .from(AUTHOR)
   .where(AUTHOR.ID.eq(10))
   .fetchOne();

为什么要手动写机器可以为你写的东西?你也不会手动写其他的派生代码,比如字节码,或者汇编?

6.类型安全别名

有一个复杂的查询,并想对你的表进行别名?使用jOOQ没有问题。只要在前面声明别名,你就可以了:

// Declare the alias:
var a = AUTHOR.as("a");

// Use the alias. Columns are still there, type safe
var author =
ctx.select(a.FIRST_NAME, a.LAST_NAME)
   .from(a)
   .where(a.ID.eq(10))
   .fetchOne();

虽然别名也可以在没有代码生成的情况下工作(一切都可以在没有代码生成的情况下工作,这只是为了方便更复杂的API调用),但你不会像上面那样从别名中获得类型安全访问你的列名。而且你仍然可以在IDE中调用你的类型层次结构来检查FIRST_NAME 列被引用的位置。

或者在错别字上得到编译错误,或者自动完成,等等等等。有什么理由不喜欢呢?

7.隐式连接

我最喜欢的jOOQ功能之一是隐式连接。看看这个jOOQ查询:

ctx.select(
          BOOK.author().FIRST_NAME,
          BOOK.author().LAST_NAME,
          BOOK.TITLE,
          BOOK.language().CD.as("language"))
   .from(BOOK)
   .fetch();

从一个子表(例如:BOOK )开始,jOOQ可以隐式(左)连接你的父表AUTHORLANGUAGE ,因为生命太短暂了,不能重复地输入所有相同的连接和连接谓词。上面的内容相当于:

ctx.select(
          AUTHOR.FIRST_NAME,
          AUTHOR.LAST_NAME,
          BOOK.TITLE,
          LANGUAGE.CD.as("language"))
   .from(BOOK)
   .leftJoin(AUTHOR).on(BOOK.AUTHOR_ID.eq(AUTHOR.ID))
   .leftJoin(LANGUAGE).on(BOOK.LANGUAGE_ID.eq(LANGUAGE.ID))
   .fetch();

这是一个品味和风格的问题,是的。你不需要在任何地方使用隐式连接。有些连接不能用隐式连接来表示,但是很多时候,这种风格通过减少噪音大大简化了你的查询。

而且,这只有在生成的代码中才能实现。

为什么呢?因为那些BOOK.author()BOOK.language() 方法是生成的,它们再次返回生成的表的实例,包含生成的列(有隐式表别名)和数据类型,可能还有转换器,等等。

你还在断然排除使用代码生成器的工作,只是因为它需要一点额外的时间来设置?

8.CRUD

jOOQ并不像JPA实现那样是一个成熟的ORM,但你仍然可以获得一些便利,避免一直拼写CRUD查询。 jOOQ调用这些 UpdatableRecord.

当你像这样获取UpdatableRecord的时候:

BookRecord book =
ctx.selectFrom(BOOK)
   .where(BOOK.ID.eq(17))
   .fetchOne();

然后,你可以改变它的值(例如在富客户端中),并再次存储该记录以生成一个UPDATE 语句:

book.setTitle("New title");
book.store();

有一些选择的额外功能,比如乐观的锁定,你猜对了。

所有这些都只能用生成的代码来实现。

9.9.存储程序

如果你有这些存储过程(我强烈建议你这样做,如果只是为了性能的原因),那么使用代码生成就更不用说了。

如果你有,在PostgreSQL中:

CREATE FUNCTION p_count_authors_by_name (
  author_name VARCHAR, 
  result OUT INTEGER
)
AS $$
DECLARE
  v_result INT;
BEGIN
  SELECT COUNT(*)
  INTO v_result
  FROM author
  WHERE first_name LIKE author_name
  OR last_name LIKE author_name;

  result := v_result;
END;
$$ LANGUAGE plpgsql;

现在jOOQ的代码生成器为你生成了以下的例程调用:

public static Integer pCountAuthorsByName(
      Configuration configuration
    , String authorName
) {
    // Do you really care?
}

是的,就是这样。你真的关心幕后发生了什么吗?这种绑定逻辑就像整理袜子一样令人兴奋。每次你有一些RPC技术时都是同样的故事。我们不关心 CORBA 中的实现,不关心 SOAP,不关心 RMI,不关心远程 EJB,我们也不关心如何绑定到存储过程。

它只是一些接受String 并返回Integer 的远程可调用程序。代码生成是你在这里的朋友,再次。是的,像往常一样,如果你认为这个函数缺少第二个参数,你只需添加它,你的客户代码就会停止编译,为你提供重构的机会!

最重要的是,这支持你所有的奇怪的边缘情况,包括:

  • SQL类型(枚举、数组、对象类型、UDTs)
  • PL/SQL类型(记录类型、表类型、关联数组)
  • 参考游标类型
  • 表值函数

10.多租户

jOOQ的一个很酷的特性是,jOOQ支持模式级的多租户,你可以在运行时根据任何情况(如用户租户)动态地重命名你的目录、模式和表名。

这个功能被称为模式映射,显然......如果你使用没有代码生成的普通SQL模板,它就不起作用。(因为普通SQL模板可以包含任何种类的SQL字符串,这就是它们的作用)。

不仅仅是这样。使用生成的代码,你所有的对象默认都是完全限定的,你可以在你喜欢的时候省略限定。对于你自己的模板,你不能轻易地改变它。想把你的模式从A移植到B?好吧,你将手写一切,祝你好运。有了jOOQ生成的代码,它只是标志、配置等。

11.嵌入式类型

还记得转换器吗?更复杂的是嵌入式类型,即jOOQ将多个数据库列包装成一个客户端值的方式,假设你的数据库支持UDTs(如Oracle、PostgreSQL)。

因为当你处理货币时,你真正想做的是把一个AMOUNT 列和一个CURRENCY 列结合起来,因为毕竟USD 1.00EUR 1.00 不是真正的同一回事,它们也不能在算术中直接比较或结合。

嵌入类型目前只能使用代码生成器,它能产生jOOQ的运行时间所需的所有元数据,以映射/解映射你的平面结果集。

这在你使用时尤其强大:

  • 嵌入式键(你唯一与BOOK.AUTHOR_ID 比较的是AUTHOR.ID ,或者是另一个指向相同主键的外键,那么为什么不使用一个类型来执行类型安全?)
  • 嵌入式域(你已经在你的数据库中声明了一个语义类型,所以你希望在客户端代码中也重复使用这个名称)。

12.数据变更管理

人们往往不愿意在前面正确而干净地设置,因为这确实需要一些额外的时间,这就是数据变更管理(例如,使用Flyway或Liquibase)。而一旦项目发展起来,遗憾也是类似的。

使用jOOQ的代码生成器那种迫使你在早期也要考虑到数据变化管理,这是件好事

你的数据模型会随着时间的推移而改变,你在该模型上工作的客户端代码也会改变。jOOQ的代码生成过程应该被嵌入到你的CI/CD管道中:

  • 你自动应用你的模型中的一个变化
  • 您自动重新生成您的代码
  • 您自动运行您的集成测试
  • 你自动将测试结果作为一个单元进行部署

下面是如何用testcontainers和jOOQ完成所有这些工作的,请看这里的一个实际例子

让我再强调一次。

如果你不在项目的早期干净地设置你的CI/CD管道,包括 你的数据库,你以后会做,而且会更难。但如果你做到了,那么将jOOQ的代码生成加入到游戏中是不费吹灰之力的,而且你可以免费得到以上的一切**

结论

当你使用jOOQ的代码生成器时,你还会得到许多其他的小东西。它是这样一个强大的工具,可以最大限度地利用jOOQ和你的数据库。它积极鼓励你使用你的RDBMS的所有功能,包括。

  • 复杂的模型(因为一旦你有了所有的元数据,连接就简单多了,甚至还有隐式连接这样的工具)
  • 视图(这些也是生成的)
  • 数据类型(也是生成的)
  • 程序(同样)
  • 等等

在jOOQ中使用代码生成是成为你自己的数据库的强大用户的明确途径,你(希望)用大量的爱和工艺来设计它。

例外情况

我承诺过一个例外。当你的模式是动态的(即在编译时未知的),你不能使用代码生成器。但只有少数系统是这样设计的。