Java 代码里写 SQL 太痛苦了?JOOQ 类型安全查询——编译期就能发现 SQL 错误

引言

线上事故复盘:

OrderMapper.xml 中有一行 SQL:
  SELECT * FROM order WHERE user_id = #{userId}

半夜上线后报错:You have an error in your SQL syntax
原因:`order` 是 MySQL 关键字,应该加反引号 `order`

这种错误你大概率遇到过。MyBatis 把 SQL 写在 XML 里,本质上还是字符串拼接——表名错了、字段名错了、参数类型错了,只有运行时才暴露

有没有办法在写代码时就发现这些错误?有——用 jOOQ。

jOOQ(Java Object Oriented Querying)把数据库表映射成 Java 类,用 DSL(领域特定语言)写 SQL,编译期就能检查表名、字段名、类型。IDE 自动补全,重构跟着改,错字段直接红线。

本文对比 MyBatis 和 jOOQ,从代码生成到实战 CRUD、复杂联表、动态条件、事务,最后附 Spring Boot 集成配置和常见坑。


一、MyBatis 的痛点:SQL 是字符串

1.1 字符串拼接的代价

<!-- OrderMapper.xml -->
<select id="selectById" resultType="Order">
    SELECT id, order_no, user_id, amount, status
    FROM orders
    WHERE id = #{id}
</select>

看起来清爽,但问题是——这段 SQL 是字符串,编译器看不到它的语义。

1.2 五类运行时才暴露的错误

错误类型例子何时暴露
表名拼错FORM orders运行时 SQL 异常
字段名拼错SELECT oder_no运行时
类型不匹配WHERE id = 'abc'(id 是 Long)运行时
关键字冲突FROM order运行时
字段重命名后忘改user_id 改成 uid,XML 没改运行时

1.3 字符串 SQL 的真实代价

场景:把 users 表的 phone 字段重命名为 mobile

MyBatis:
  全局搜索 "phone"
  找到 20 个 XML 文件涉及
  逐一改 → 漏改 3 个 → 上线后部分接口报错

jOOQ:
  数据库改完 → 重新生成代码 → 编译
  3 个文件直接编译报错 → 改完 → 安全上线

二、JOOQ 是什么

2.1 核心思想

把数据库的 schema 反向工程成 Java 类,每个表对应一个 Table 对象,每个字段对应一个 Field 对象。

数据库 orders 表              Java 类(jOOQ 生成)
┌──────────────┐             ┌──────────────────────┐
│ id BIGINT    │ ──────────→ │ ORDERS.ID            │
│ order_no     │ ──────────→ │ ORDERS.ORDER_NO      │
│ user_id      │ ──────────→ │ ORDERS.USER_ID       │
│ amount       │ ──────────→ │ ORDERS.AMOUNT        │
│ status       │ ──────────→ │ ORDERS.STATUS        │
└──────────────┘             └──────────────────────┘

写 SQL 就成了写 Java 代码:

// jOOQ 写法
List<Order> orders = create
    .select(ORDERS.ID, ORDERS.ORDER_NO, ORDERS.AMOUNT)
    .from(ORDERS)
    .where(ORDERS.USER_ID.eq(123L))
    .fetchInto(Order.class);

每个字段都是强类型的对象:

  • ORDERS.USER_ID 类型是 Field<Long>,传字符串编译失败
  • ORDERS.AMOUNT 类型是 Field<BigDecimal>,传整数自动转换
  • 表名重命名?重新生成代码 → 编译报错 → 改完 → 安全

2.2 MyBatis vs JOOQ 对比

维度MyBatisjOOQ
SQL 写在哪XML 字符串Java DSL
表名/字段错运行时编译期
字段重命名全局搜索重新生成代码,编译报错
IDE 补全无(XML)✅ 完整
重构支持✅ 跟随 Java 重构
动态 SQL`` 标签,复杂Condition 对象组合
复杂联表难写难读DSL 链式,清晰
学习曲线
适合场景复杂报表 SQL业务 CRUD + 标准查询

2.3 jOOQ 的另一个优势:DSL = SQL

jOOQ 的 DSL 几乎和 SQL 语法 1:1 对应:

-- SQL
SELECT id, order_no, amount
FROM orders
WHERE user_id = 123 AND status = 'PAID'
ORDER BY create_time DESC
LIMIT 10
// jOOQ
create.select(ORDERS.ID, ORDERS.ORDER_NO, ORDERS.AMOUNT)
      .from(ORDERS)
      .where(ORDERS.USER_ID.eq(123L).and(ORDERS.STATUS.eq("PAID")))
      .orderBy(ORDERS.CREATE_TIME.desc())
      .limit(10)
      .fetchInto(Order.class);

会写 SQL 就会用 jOOQ。


三、Spring Boot 集成 jOOQ

3.1 添加依赖

<dependencies>
    <!-- jOOQ -->
    <dependency>
        <groupId>org.jooq</groupId>
        <artifactId>jooq</artifactId>
        <version>3.19.10</version>
    </dependency>

    <!-- Spring Boot jOOQ Starter -->
    <dependency>
        <groupId>org.jooq</groupId>
        <artifactId>jooq-spring-boot-starter</artifactId>
        <version>3.19.10</version>
    </dependency>

    <!-- MySQL -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
    </dependency>
</dependencies>

3.2 代码生成插件

jOOQ 的核心——根据数据库 schema 生成 Java 类:

<build>
    <plugins>
        <plugin>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-codegen-maven</artifactId>
            <version>3.19.10</version>
            <executions>
                <execution>
                    <goals><goal>generate</goal></goals>
                </execution>
            </executions>
            <configuration>
                <jdbc>
                    <driver>com.mysql.cj.jdbc.Driver</driver>
                    <url>jdbc:mysql://localhost:3306/demo</url>
                    <user>root</user>
                    <password>root123</password>
                </jdbc>
                <generator>
                    <database>
                        <name>org.jooq.meta.mysql.MySQLDatabase</name>
                        <includes>.*</includes>     <!-- 所有表 -->
                        <excludes>undo_log</excludes>
                        <inputSchema>demo</inputSchema>
                    </database>
                    <generate>
                        <pojos>true</pojos>           <!-- 生成 POJO -->
                        <daos>true</daos>             <!-- 生成 DAO -->
                        <records>true</records>       <!-- 生成 Record -->
                    </generate>
                    <target>
                        <packageName>com.example.generated</packageName>
                        <directory>src/main/java</directory>
                    </target>
                </generator>
            </configuration>
        </plugin>
    </plugins>
</build>

执行 mvn jooq-codegen:generate,jOOQ 连接数据库,生成以下类:

com.example.generated.tables
  ├── Orders.java          ← 表对象
  ├── Users.java
  └── OrderItem.java

com.example.generated.tables.records
  ├── OrdersRecord.java    ← 行记录
  ├── UsersRecord.java
  └── OrderItemRecord.java

com.example.generated.pojos
  ├── Orders.java          ← POJO
  ├── Users.java
  └── OrderItem.java

3.3 application.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/demo
    username: root
    password: root123
    hikari:
      maximum-pool-size: 20

jooq:
  sql-dialect: MYSQL      # 方言

3.4 DSLContext 注入

Spring Boot 自动配置 DefaultDSLContext

@Service
public class OrderRepository {

    private final DSLContext create;

    // 自动注入
    public OrderRepository(DSLContext create) {
        this.create = create;
    }
}

四、实战 CRUD

4.1 查询

单表查询

// SELECT * FROM orders WHERE id = ?
Orders order = create.selectFrom(ORDERS)
        .where(ORDERS.ID.eq(123L))
        .fetchOneInto(Orders.class);

// 多条件 + 排序 + 分页
List<Orders> list = create.selectFrom(ORDERS)
        .where(ORDERS.STATUS.eq("PAID"))
        .and(ORDERS.AMOUNT.greaterThan(new BigDecimal("100")))
        .orderBy(ORDERS.CREATE_TIME.desc())
        .limit(20)
        .offset(0)
        .fetchInto(Orders.class);

字段裁剪

// SELECT id, order_no FROM orders WHERE ...
List<SimpleOrder> result = create
        .select(ORDERS.ID, ORDERS.ORDER_NO)
        .from(ORDERS)
        .where(ORDERS.STATUS.eq("PAID"))
        .fetchInto(SimpleOrder.class);

统计查询

// SELECT COUNT(*) FROM orders WHERE status = 'PAID'
long count = create.selectCount()
        .from(ORDERS)
        .where(ORDERS.STATUS.eq("PAID"))
        .fetchOneInto(Long.class);

// SELECT status, COUNT(*) FROM orders GROUP BY status
List<StatusCount> stats = create
        .select(ORDERS.STATUS, DSL.count())
        .from(ORDERS)
        .groupBy(ORDERS.STATUS)
        .fetchInto(StatusCount.class);

4.2 插入

// INSERT INTO orders (order_no, user_id, amount, status) VALUES (?, ?, ?, ?)
int rows = create.insertInto(ORDERS,
        ORDERS.ORDER_NO, ORDERS.USER_ID, ORDERS.AMOUNT, ORDERS.STATUS)
        .values("ORD123", 1L, new BigDecimal("99.9"), "PENDING")
        .execute();

// 插入并返回自增主键
Long id = create.insertInto(ORDERS,
        ORDERS.ORDER_NO, ORDERS.USER_ID, ORDERS.AMOUNT)
        .values("ORD123", 1L, new BigDecimal("99.9"))
        .returning(ORDERS.ID)
        .fetchOne()
        .getId();

批量插入(比 MyBatis <foreach> 简洁):

create.batchInsert(
        create.newRecord(ORDERS, new Orders("ORD1", 1L, new BigDecimal("10"))),
        create.newRecord(ORDERS, new Orders("ORD2", 2L, new BigDecimal("20"))),
        create.newRecord(ORDERS, new Orders("ORD3", 3L, new BigDecimal("30")))
).execute();

4.3 更新

// UPDATE orders SET status = 'PAID' WHERE id = ?
int rows = create.update(ORDERS)
        .set(ORDERS.STATUS, "PAID")
        .set(ORDERS.PAY_TIME, LocalDateTime.now())
        .where(ORDERS.ID.eq(123L))
        .execute();

// UPDATE orders SET amount = amount + 100 WHERE id = ?
create.update(ORDERS)
        .set(ORDERS.AMOUNT, ORDERS.AMOUNT.add(new BigDecimal("100")))  // 表达式
        .where(ORDERS.ID.eq(123L))
        .execute();

4.4 删除

// DELETE FROM orders WHERE id = ?
create.deleteFrom(ORDERS)
        .where(ORDERS.ID.eq(123L))
        .execute();

// 软删除(更常见)
create.update(ORDERS)
        .set(ORDERS.DELETED, 1)
        .set(ORDERS.UPDATE_TIME, LocalDateTime.now())
        .where(ORDERS.ID.eq(123L))
        .execute();

五、复杂联表查询

5.1 两表 JOIN

-- 要写的 SQL
SELECT o.id, o.order_no, u.username, o.amount
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID'
// jOOQ 写法
List<OrderUserVO> result = create
        .select(ORDERS.ID, ORDERS.ORDER_NO, USERS.USERNAME, ORDERS.AMOUNT)
        .from(ORDERS)
        .join(USERS).on(ORDERS.USER_ID.eq(USERS.ID))
        .where(ORDERS.STATUS.eq("PAID"))
        .fetchInto(OrderUserVO.class);

5.2 三表 JOIN + 聚合

-- 复杂 SQL:每个用户的订单数和总金额
SELECT u.id, u.username,
       COUNT(o.id) AS order_count,
       SUM(o.amount) AS total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'PAID'
LEFT JOIN order_item oi ON o.id = oi.order_id
WHERE u.create_time >= '2026-01-01'
GROUP BY u.id, u.username
HAVING SUM(o.amount) > 1000
ORDER BY total_amount DESC
// jOOQ 写法
List<UserOrderStat> stats = create
        .select(
            USERS.ID,
            USERS.USERNAME,
            DSL.count(ORDERS.ID).as("orderCount"),
            DSL.sum(ORDERS.AMOUNT).as("totalAmount")
        )
        .from(USERS)
        .leftJoin(ORDERS).on(USERS.ID.eq(ORDERS.USER_ID))
            .and(ORDERS.STATUS.eq("PAID"))
        .leftJoin(ORDER_ITEM).on(ORDERS.ID.eq(ORDER_ITEM.ORDER_ID))
        .where(USERS.CREATE_TIME.ge(LocalDate.of(2026, 1, 1).atStartOfDay()))
        .groupBy(USERS.ID, USERS.USERNAME)
        .having(DSL.sum(ORDERS.AMOUNT).gt(new BigDecimal("1000")))
        .orderBy(DSL.field("totalAmount").desc())
        .fetchInto(UserOrderStat.class);

对比 MyBatis 写同样 SQL

<select id="queryUserOrderStats" resultType="UserOrderStat">
    SELECT u.id, u.username,
           COUNT(o.id) AS order_count,
           SUM(o.amount) AS total_amount
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'PAID'
    LEFT JOIN order_item oi ON o.id = oi.order_id
    WHERE u.create_time >= #{startDate}
    GROUP BY u.id, u.username
    HAVING SUM(o.amount) > #{minAmount}
    ORDER BY total_amount DESC
</select>

字符串拼接,改字段名时全局搜索。jOOQ 重构跟着 IDE 走。

5.3 子查询

// 查有订单的用户
List<Users> users = create
        .selectFrom(USERS)
        .where(USERS.ID.in(
            create.selectDistinct(ORDERS.USER_ID).from(ORDERS)
        ))
        .fetchInto(Users.class);

// EXISTS 子查询
create.selectFrom(USERS)
      .whereExists(
          create.selectOne().from(ORDERS)
              .where(ORDERS.USER_ID.eq(USERS.ID))
      )
      .fetchInto(Users.class);

六、动态条件构建

6.1 MyBatis 的痛点

MyBatis 写动态条件用 <if> 标签,复杂查询时 XML 看起来像迷宫:

<select id="searchOrders" resultType="Order">
    SELECT * FROM orders
    <where>
        <if test="status != null">
            AND status = #{status}
        </if>
        <if test="userId != null">
            AND user_id = #{userId}
        </if>
        <if test="minAmount != null">
            AND amount &gt;= #{minAmount}
        </if>
        <if test="maxAmount != null">
            AND amount &lt;= #{maxAmount}
        </if>
        <if test="startDate != null">
            AND create_time &gt;= #{startDate}
        </if>
        <if test="endDate != null">
            AND create_time &lt;= #{endDate}
        </if>
    </where>
    ORDER BY create_time DESC
</select>

6.2 jOOQ 的 Condition 组合

public List<Orders> searchOrders(OrderQuery query) {
    // 收集条件
    Condition condition = DSL.noCondition();  // 1=1 等价

    if (query.getStatus() != null) {
        condition = condition.and(ORDERS.STATUS.eq(query.getStatus()));
    }
    if (query.getUserId() != null) {
        condition = condition.and(ORDERS.USER_ID.eq(query.getUserId()));
    }
    if (query.getMinAmount() != null) {
        condition = condition.and(ORDERS.AMOUNT.ge(query.getMinAmount()));
    }
    if (query.getMaxAmount() != null) {
        condition = condition.and(ORDERS.AMOUNT.le(query.getMaxAmount()));
    }
    if (query.getStartDate() != null) {
        condition = condition.and(ORDERS.CREATE_TIME.ge(query.getStartDate()));
    }
    if (query.getEndDate() != null) {
        condition = condition.and(ORDERS.CREATE_TIME.le(query.getEndDate()));
    }

    return create.selectFrom(ORDERS)
            .where(condition)
            .orderBy(ORDERS.CREATE_TIME.desc())
            .fetchInto(Orders.class);
}

6.3 用 DSL 条件对象组合

更优雅——构造 Condition 列表:

public List<Orders> searchOrdersV2(OrderQuery query) {
    List<Condition> conditions = new ArrayList<>();

    Optional.ofNullable(query.getStatus())
            .map(ORDERS.STATUS::eq)
            .ifPresent(conditions::add);

    Optional.ofNullable(query.getUserId())
            .map(ORDERS.USER_ID::eq)
            .ifPresent(conditions::add);

    Optional.ofNullable(query.getMinAmount())
            .map(ORDERS.AMOUNT::ge)
            .ifPresent(conditions::add);

    Optional.ofNullable(query.getStartDate())
            .map(ORDERS.CREATE_TIME::ge)
            .ifPresent(conditions::add);

    return create.selectFrom(ORDERS)
            .where(conditions)  // 自动 AND 连接
            .orderBy(ORDERS.CREATE_TIME.desc())
            .fetchInto(Orders.class);
}

6.4 OR 条件组合

// (status = 'PAID' AND amount > 100) OR (status = 'PENDING' AND amount > 50)
Condition c1 = ORDERS.STATUS.eq("PAID").and(ORDERS.AMOUNT.gt(100));
Condition c2 = ORDERS.STATUS.eq("PENDING").and(ORDERS.AMOUNT.gt(50));

create.selectFrom(ORDERS)
      .where(c1.or(c2))
      .fetch();

七、事务支持

7.1 Spring @Transactional

jOOQ 和 Spring 事务无缝集成:

@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderRequest req) {
        // 1. 创建订单
        Long orderId = create.insertInto(ORDERS,
                ORDERS.ORDER_NO, ORDERS.USER_ID, ORDERS.AMOUNT, ORDERS.STATUS)
                .values("ORD123", req.getUserId(), req.getAmount(), "PENDING")
                .returning(ORDERS.ID)
                .fetchOne()
                .getId();

        // 2. 创建订单项
        for (ItemDTO item : req.getItems()) {
            create.insertInto(ORDER_ITEM,
                    ORDER_ITEM.ORDER_ID, ORDER_ITEM.PRODUCT_ID,
                    ORDER_ITEM.QUANTITY, ORDER_ITEM.PRICE)
                    .values(orderId, item.getProductId(),
                            item.getQuantity(), item.getPrice())
                    .execute();
        }

        // 3. 异常会自动回滚
        if (req.getAmount().compareTo(new BigDecimal("10000")) > 0) {
            throw new RuntimeException("金额超限");
        }
    }
}

7.2 jOOQ 原生事务

create.transaction(config -> {
    DSLContext ctx = DSL.using(config);

    ctx.insertInto(ORDERS, ORDERS.ORDER_NO, ORDERS.AMOUNT)
       .values("ORD123", new BigDecimal("99"))
       .execute();

    ctx.insertInto(ORDER_ITEM, ORDER_ITEM.ORDER_ID, ORDER_ITEM.PRODUCT_ID)
       .values(1L, 100L)
       .execute();
});

八、常见坑

8.1 坑 1:忘记重新生成代码

问题:数据库加了字段 remark,但忘了重新生成代码。

现象ORDERS.REMARK 编译报错"找不到符号"。

解决:每次改数据库 schema 后,立刻 mvn jooq-codegen:generate。可以在 CI 流水线里自动触发。

8.2 坑 2:生成的代码太多导致包膨胀

问题:生产库几百张表,生成的代码几十 MB。

解决:只生成需要的表:

<database>
    <includes>
        orders|users|order_item|product   <!-- 只生成这些表 -->
    </includes>
</database>

或者按 schema 拆分多个生成配置。

8.3 坑 3:Schema 变更导致编译报错淹没

问题:一个表改名,几百个文件编译报错。

解决

  1. 小步重构,一次改一个表
  2. 用 IDE 的"批量修复"功能
  3. CI 检查 schema 变更影响范围

8.4 坑 4:复杂 SQL 写不出

问题:窗口函数、WITH RECURSIVE 等高级 SQL,jOOQ DSL 写起来复杂。

解决:jOOQ 支持原生 SQL 模板:

// 用 DSL.sql 写原生片段
create.select(
        DSL.field("ROW_NUMBER() OVER (ORDER BY amount DESC)"),
        ORDERS.ID,
        ORDERS.AMOUNT
    )
    .from(ORDERS)
    .fetch();

// 或者 DSL.sql 完整原生
create.fetch("SELECT * FROM orders WHERE id = ?", 123L);

8.5 坑 5:多数据库方言

问题:不同数据库 SQL 语法不同,jOOQ 会不会写死方言?

解决:jOOQ 支持 20+ 数据库方言,DSL 自动适配:

// 同一份代码在 MySQL 和 PostgreSQL 上跑出不同的 SQL
create.selectFrom(ORDERS).limit(10).fetch();
// MySQL:  SELECT * FROM orders LIMIT 10
// Oracle: SELECT * FROM orders WHERE ROWNUM <= 10
// SQL Server: SELECT TOP 10 * FROM orders

8.6 坑 6:性能开销

问题:jOOQ 的 DSL 对象创建有开销。

解决

// ❌ 频繁创建 Field 对象
for (Long id : ids) {
    create.selectFrom(ORDERS)
          .where(ORDERS.ID.eq(id))
          .fetch();
}

// ✅ 批量查询
create.selectFrom(ORDERS)
      .where(ORDERS.ID.in(ids))
      .fetch();

// ✅ 复用静态 Field
private static final Field<Long> USER_ID = ORDERS.USER_ID;
create.selectFrom(ORDERS).where(USER_ID.eq(1L)).fetch();

8.7 坑 7:Decimal 类型精度问题

// ❌ 比较时 BigDecimal 精度问题
ORDERS.AMOUNT.eq(new BigDecimal("99.90"));  // 可能不匹配 99.9

// ✅ 用 numeric 类型
ORDERS.AMOUNT.eq(DSL.val(new BigDecimal("99.90"), SQLDataType.NUMERIC(10, 2)));

8.8 坑 8:不开 SQL 日志难调试

jOOQ 生成的 SQL 默认不打印,调试不便。

配置:在 application.yml 开启:

logging:
  level:
    org.jooq.tools.LoggerListener: DEBUG

或代码里设置:

DSLContext create = DSL.using(connection, SQLDialect.MYSQL);
create.settings().setExecuteLogging(true);

九、JOOQ vs MyBatis:何时选哪个

9.1 选 jOOQ 的场景

  • 业务 CRUD 为主(90% 的服务端开发)
  • 表结构经常变(schema-first 团队)
  • 重视类型安全和重构
  • 团队对 Java 比 XML 熟

9.2 选 MyBatis 的场景

  • SQL 复杂度高(统计/报表/复杂联表)
  • DBA 团队写 SQL,开发只接 SQL
  • 对 SQL 性能极致调优
  • 已有大量 MyBatis 项目经验

9.3 混合使用

实际项目可以混合:

// 简单 CRUD 用 jOOQ
public Orders getOrder(Long id) {
    return create.selectFrom(ORDERS)
            .where(ORDERS.ID.eq(id))
            .fetchOneInto(Orders.class);
}

// 复杂统计用 MyBatis(或 jOOQ 的原生 SQL)
@Select("""
    SELECT DATE(create_time) AS day, COUNT(*) AS cnt
    FROM orders
    WHERE create_time >= #{start} AND create_time < #{end}
    GROUP BY DATE(create_time)
    ORDER BY day
""")
List<DailyStat> dailyStats(@Param("start") LocalDate start, @Param("end") LocalDate end);

9.4 决策树

SQL 复杂度?
├── 高(复杂联表 + 窗口函数 + 统计) → MyBatis
└── 低(标准 CRUD) → 是否需要类型安全?
    ├── 是 → jOOQ
    └── 否 → MyBatis-Plus(更轻量)

十、总结

核心价值

问题MyBatisjOOQ
表名错运行时编译期
字段错运行时编译期
类型错运行时编译期
字段重命名全局搜索重新生成
IDE 补全完整
重构支持跟随 Java

一句话

MyBatis 把 SQL 当字符串,jOOQ 把 SQL 当 Java 对象。前者运行时爆炸,后者编译期就拦住。

选型建议

  • 新项目:考虑 jOOQ,类型安全和重构能力对长期维护的价值巨大
  • 老项目:MyBatis 够用,逐步用 jOOQ 重构核心模块
  • 统计场景:两者混合,复杂 SQL 仍用 MyBatis 或原生 SQL

何时考虑 jOOQ

  • 每次改数据库 schema 都漏改 SQL 报错
  • 团队对 XML SQL 维护成本高
  • 想用 IDE 重构能力
  • 重视长期维护性

互动话题:你们项目用 MyBatis 还是 jOOQ?有没有过线上因为 SQL 字段拼错引发的事故?欢迎留言讨论!


参考资料


标题:Java 代码里写 SQL 太痛苦了?JOOQ 类型安全查询——编译期就能发现 SQL 错误
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/11/1786162254095.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消