跳转至

第三章 关系映射

3.1 不可变性的深入实践

3.1.1 不可变性的本质与优势

  • 业务场景

在当今的企业应用开发中,我们经常会遇到这样的场景:多个组件需要同时处理相同的业务对象,例如在电商系统中,一个商品对象可能同时被库存服务、价格计算服务和促销服务访问和处理。传统的可变对象模型在这类场景下容易引发数据一致性问题,特别是在多线程环境中。

考虑以下使用传统Java POJO的场景:

// 传统可变对象模型
public class Product {
    private Long id;
    private String name;
    private BigDecimal price;
    private int stock;
    // getter 和 setter 略
}

// 在多线程环境下的问题
void process() {
    Product product = productRepository.findById(1L);
    // 线程A读取价格
    BigDecimal price = product.getPrice();
    // 同时线程B修改价格
    product.setPrice(price.multiply(new BigDecimal("0.9")));
    // 线程A基于已读取的值计算,但此时价格已经被修改,导致计算错误
}

这种情况下,我们通常通过加锁或创建防御性副本来解决问题,但这会带来性能损失或代码复杂度增加。Jimmer通过引入不可变对象模型,从根本上解决了这类问题。

  • 测试定义

让我们通过测试来验证Jimmer的不可变对象如何在并发环境中保持数据一致性。首先,我们设计一个测试场景,模拟多个线程同时读取和修改商品对象:

@Test
@DisplayName("测试不可变对象在多线程环境中的线程安全性")
public void testThreadSafety() throws InterruptedException {
    // 创建不可变商品对象
    Product product = createTestProduct();

    // 创建线程池
    ExecutorService executor = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(20);
    AtomicReference<Exception> error = new AtomicReference<>();

    // 10个线程派生新对象 - 修改价格
    for (int i = 0; i < 10; i++) {
        final int discount = i + 1;
        executor.submit(() -> {
            try {
                // 基于原对象创建新的促销价格对象
                BigDecimal discountRate = new BigDecimal("0." + discount);
                Product discountedProduct = ProductDraft.$.produce(product, draft -> {
                    draft.setPrice(product.price().multiply(discountRate).setScale(2, RoundingMode.HALF_UP));
                });

                // 验证:原对象不变,新对象正确反映折扣
                assertEquals(new BigDecimal("5999.00"), product.price());
                assertTrue(discountedProduct.price().compareTo(product.price()) < 0);
            } catch (Exception e) {
                error.set(e);
            } finally {
                latch.countDown();
            }
        });
    }

    // 10个线程读取对象
    for (int i = 0; i < 10; i++) {
        executor.submit(() -> {
            try {
                // 在所有线程中,原始对象的值始终不变
                assertEquals(new BigDecimal("5999.00"), product.price());
                assertEquals("高性能笔记本电脑", product.name());
                assertEquals(100, product.stock());
            } catch (Exception e) {
                error.set(e);
            } finally {
                latch.countDown();
            }
        });
    }

    // 等待所有线程完成
    latch.await(5, TimeUnit.SECONDS);
    executor.shutdown();

    // 验证:没有出现并发错误
    assertNull(error.get(), "并发测试出现错误: " + (error.get() != null ? error.get().getMessage() : ""));
}

这个测试展示了不可变对象在多线程环境下的两个关键特性:

  1. 安全读取:多个线程可以同时读取同一个对象,而不需要任何同步机制
  2. 安全修改:通过创建新对象而非修改现有对象,每个线程可以拥有自己的对象视图

  3. 实现解析

Jimmer的不可变对象是基于接口定义的,而非传统的class,这是其与常规Java对象模型的一个重要区别:

@Entity
public interface Product {
    @Id
    String id();

    String name();

    BigDecimal price();

    int stock();

    String description();

    boolean active();

    LocalDateTime createdTime();
}

注意到所有属性都是通过无参数方法声明的,这些方法只返回值而不修改对象状态。那么如何创建和修改这样的对象呢?Jimmer提供了Draft机制:

// 创建新对象
Product product = ProductDraft.$.produce(draft -> {
    draft.setId("product-1");
    draft.setName("高性能笔记本电脑");
    draft.setPrice(new BigDecimal("5999.00"));
    draft.setStock(100);
});

// 基于现有对象创建新对象
Product discountedProduct = ProductDraft.$.produce(product, draft -> {
    draft.setPrice(new BigDecimal("4999.00"));
});

// 验证原对象不变
assert product.price().equals(new BigDecimal("5999.00"));
// 验证新对象包含更新
assert discountedProduct.price().equals(new BigDecimal("4999.00"));
  • 技术内幕

Jimmer通过精巧的内部实现,使不可变对象既保持了概念纯粹性,又实现了良好的性能:

  1. 编译时代码生成:Jimmer使用注解处理器在编译时生成ProductDraft接口和其实现类,避免了运行时反射开销

  2. 结构共享:创建新对象时,只复制被修改的部分,未修改的部分与原对象共享,减少内存消耗

  3. 非递归复制:只修改直接属性时,不会触发关联对象的复制,进一步提升性能

  4. 唯一修改入口:所有修改必须通过Draft接口进行,保证了对象状态的完整性

Jimmer不可变对象模型的核心工作原理图如下:

┌────────────┐      produce()      ┌────────────┐
│            │ ─────────────────>  │            │
│  Product   │                     │ ProductDraft│
│ (不可变接口) │  <───────────────── │  (可变接口) │
│            │     构建完成返回      │            │
└────────────┘                     └────────────┘
       ▲                                 │
       │                                 │
       │                                 │
       │                                 ▼
┌─────────────────┐            ┌─────────────────┐
│                 │            │                 │
│  ProductImpl    │            │  ProductDraftImpl│
│ (不可变实现类)    │            │  (可变实现类)    │
│                 │            │                 │
└─────────────────┘            └─────────────────┘

当我们调用ProductDraft.$.produce()方法时,实际上是创建了一个ProductDraftImpl的临时实例,在lambda表达式中完成所有修改后,最终创建并返回不可变的ProductImpl实例。

3.1.2 不可变对象在并发环境中的优势

  • 业务场景

在现代企业应用中,并发访问是一种常态。以订单处理系统为例,同一订单可能同时被多个服务访问:库存检查服务需要读取订单中的商品信息,价格计算服务需要计算订单总价,而用户可能随时修改订单内容。在这种场景下,如何保证各服务看到的订单数据一致性,是系统设计的关键挑战。

传统解决方案通常有两种:

  1. 加锁:使用同步机制保证在同一时间只有一个线程可以访问订单对象
  2. 缺点:性能瓶颈,可能导致死锁,实现复杂

  3. 防御性复制:每个服务使用前复制一份数据

  4. 缺点:内存消耗增加,复制操作成本高,可能出现不一致

Jimmer的不可变对象提供了一种优雅的替代方案,让我们通过测试来验证。

  • 测试定义

以下测试展示了不可变对象如何在并发环境下提供一致性视图:

@Test
@DisplayName("测试不可变对象在并发环境下的一致性视图")
public void testConsistentView() throws InterruptedException {
    // 创建初始商品
    Product originalProduct = createTestProduct();

    // 准备使用的对象引用
    final AtomicReference<Product> sharedProduct = new AtomicReference<>(originalProduct);
    final CountDownLatch updateLatch = new CountDownLatch(1);
    final CountDownLatch readLatch = new CountDownLatch(5);
    final AtomicReference<Exception> error = new AtomicReference<>();

    // 创建线程池
    ExecutorService executor = Executors.newFixedThreadPool(6);

    // 5个线程读取初始对象,然后等待,再读取更新后的对象
    for (int i = 0; i < 5; i++) {
        executor.submit(() -> {
            try {
                // 读取初始版本
                Product initialView = sharedProduct.get();
                assertEquals(new BigDecimal("5999.00"), initialView.price());
                assertEquals(100, initialView.stock());

                // 等待更新完成
                updateLatch.await();

                // 读取更新后的版本
                Product updatedView = sharedProduct.get();
                assertEquals(new BigDecimal("4999.00"), updatedView.price());
                assertEquals(50, updatedView.stock());

                // 重要:初始视图保持不变
                assertEquals(new BigDecimal("5999.00"), initialView.price());
                assertEquals(100, initialView.stock());
            } catch (Exception e) {
                error.set(e);
            } finally {
                readLatch.countDown();
            }
        });
    }

    // 1个线程更新对象
    executor.submit(() -> {
        try {
            // 稍微延迟,确保读取线程先开始
            Thread.sleep(100);

            // 更新共享对象
            Product updatedProduct = ProductDraft.$.produce(sharedProduct.get(), draft -> {
                draft.setPrice(new BigDecimal("4999.00"));
                draft.setStock(50);
            });

            // 更新引用
            sharedProduct.set(updatedProduct);

            // 通知读取线程继续
            updateLatch.countDown();
        } catch (Exception e) {
            error.set(e);
        }
    });

    // 等待所有读取线程完成
    readLatch.await(5, TimeUnit.SECONDS);
    executor.shutdown();

    // 验证:没有出现并发错误
    assertNull(error.get(), "并发测试出现错误: " + (error.get() != null ? error.get().getMessage() : ""));
}

这个测试模拟了一个关键场景:多个线程先获取对象视图,在对象被另一个线程更新后,它们既可以看到最新的对象状态,又能保留原始视图。这种能力在复杂业务逻辑处理中非常有价值。

  • 实现分析

在这个测试中,我们使用AtomicReference来保存共享的商品对象。当更新线程修改商品时,它不是修改原对象,而是创建一个新对象并更新引用:

// 更新共享对象
Product updatedProduct = ProductDraft.$.produce(sharedProduct.get(), draft -> {
    draft.setPrice(new BigDecimal("4999.00"));
    draft.setStock(50);
});

// 更新引用
sharedProduct.set(updatedProduct);

这种模式带来了几个关键优势:

  1. 无锁并发:读取线程不需要等待更新线程完成
  2. 一致性视图:每个线程看到的对象状态是完整一致的,不存在"部分更新"的中间状态
  3. 时间旅行能力:线程可以同时持有对象的历史版本和最新版本

这种模式非常适合于实现"事件溯源"和"CQRS"等架构模式,因为它天然支持对象状态的历史追踪和变更比较。

  • 并发性能对比

不可变对象是否会带来性能开销?让我们通过测试比较传统的加锁方式与不可变对象在并发环境下的性能差异:

@Test
@DisplayName("比较不可变对象和可变对象在并发环境下的性能和安全性")
public void testImmutableVsMutablePerformance() throws InterruptedException {
    // 测试参数
    final int threadCount = 100;
    final int iterationsPerThread = 1000;

    // 准备测试数据
    Product immutableProduct = createTestProduct();
    MutableProduct mutableProduct = createMutableFromImmutable(immutableProduct);

    // 收集错误
    List<Exception> mutableErrors = new ArrayList<>();
    List<Exception> immutableErrors = new ArrayList<>();

    // 执行测试并测量性能
    long mutableTime = testMutableProductWithLock(mutableProduct, threadCount, iterationsPerThread, mutableErrors);
    long immutableTime = testImmutableProductWithCAS(immutableProduct, threadCount, iterationsPerThread, immutableErrors);

    // 打印性能对比结果
    System.out.println("可变对象(使用锁)执行时间: " + mutableTime + " ms, 错误数: " + mutableErrors.size());
    System.out.println("不可变对象执行时间: " + immutableTime + " ms, 错误数: " + immutableErrors.size());

    // 验证执行结果
    assertTrue(mutableErrors.isEmpty(), "可变对象执行出现错误");
    assertTrue(immutableErrors.isEmpty(), "不可变对象执行出现错误");
}

在这个测试中,我们比较了两种方式在高并发环境下的表现:

  1. 可变对象 + 锁:使用传统的可变对象配合同步锁
  2. 不可变对象 + CAS:使用Jimmer不可变对象配合CAS操作

测试结果表明,在高并发环境下,不可变对象方案通常能获得更好的性能,特别是在读操作远多于写操作的场景中。这是因为:

  1. 不可变对象完全消除了读锁的需要
  2. 写操作使用CAS而非互斥锁,减少了线程等待
  3. 结构共享减少了内存分配和垃圾回收压力

当然,不可变对象也不是万能的,在某些特定场景下,如果对象非常大且需要频繁修改,性能表现可能不如精心优化的可变对象方案。开发者需要根据具体场景选择合适的方案。

3.1.3 历史记录追踪

  • 业务场景

在现代企业应用中,业务对象的变更历史追踪是一个常见需求。例如,在电商系统中,商品的价格、库存和属性会随时间变化,我们常需要记录每一次变更,用于审计、分析和回溯。传统实现通常需要:

  1. 设计专门的历史表结构
  2. 编写复杂的差异比较和记录逻辑
  3. 增加额外的数据库操作

使用不可变对象,我们可以实现一种更简洁优雅的解决方案。

  • 测试定义

以下测试展示了如何利用不可变对象实现商品变更历史追踪:

@Test
@DisplayName("测试使用不可变对象实现时间旅行查询(特定版本访问)")
public void testTimeTravel() {
    // 创建历史追踪器
    ProductHistoryTracker historyTracker = new ProductHistoryTracker();

    // 创建初始版本并记录5个版本的变更
    Product initialProduct = createInitialProduct();
    historyTracker.initializeHistory(initialProduct, "商品初始创建");

    // 逐步模拟商品生命周期
    Product product = initialProduct;

    // 阶段1: 调整价格和描述
    product = historyTracker.createVersion(
        product,
        draft -> {
            draft.setPrice(new BigDecimal("1099.99"));
            draft.setDescription("高品质商品,值得信赖");
        },
        "首次价格调整和描述优化"
    );

    // 阶段2: 促销活动
    final Product finalProduct = product; // 创建一个final副本用于lambda捕获
    product = historyTracker.createVersion(
        product,
        draft -> {
            draft.setPrice(new BigDecimal("899.99"));
            draft.setName(finalProduct.name() + " [促销]");
        },
        "季节性促销活动"
    );

    // 阶段3: 库存变化
    product = historyTracker.createVersion(
        product,
        draft -> draft.setStock(50),
        "销售了50个单位"
    );

    // 阶段4: 促销结束,价格回调
    product = historyTracker.createVersion(
        product,
        draft -> {
            draft.setPrice(new BigDecimal("1199.99"));
            draft.setName(draft.name().replace(" [促销]", ""));
        },
        "促销活动结束"
    );

    // 获取完整历史记录
    List<Product> history = historyTracker.getVersionHistory();

    // 验证历史版本数量
    assertThat(history).hasSize(5);

    // 时间旅行 - 访问促销期间的版本
    Product promotionVersion = history.get(2);
    assertThat(promotionVersion.name()).contains("[促销]");
    assertThat(promotionVersion.price()).isEqualTo(new BigDecimal("899.99"));

    // 比较初始版本和最终版本
    Product finalVersion = history.get(4);
    assertThat(finalVersion.price()).isEqualTo(new BigDecimal("1199.99"));
    assertThat(finalVersion.stock()).isEqualTo(50);
    assertThat(finalVersion.description()).isEqualTo("高品质商品,值得信赖");

    // 验证不可变性确保历史版本没有被修改
    assertThat(initialProduct.price()).isEqualTo(new BigDecimal("999.99"));
    assertThat(initialProduct.stock()).isEqualTo(100);
}

这个测试演示了如何使用不可变对象记录商品的完整变更历史,并实现"时间旅行"功能 - 在任何时候访问任何历史版本。

  • 实现分析

历史追踪器的核心实现非常简洁:

private static class ProductHistoryTracker {
    private final List<Product> versions = new ArrayList<>();
    private final Map<Product, String> changeReasons = new HashMap<>();

    // 创建新的版本
    public Product createVersion(Product baseProduct, Consumer<ProductDraft> modifier, String reason) {
        // 创建新版本
        Product newVersion = ProductDraft.$.produce(baseProduct, draft -> {
            modifier.accept(draft);
        });

        // 记录版本和变更原因
        versions.add(newVersion);
        changeReasons.put(newVersion, reason);

        return newVersion;
    }

    // 获取所有历史版本
    public List<Product> getVersionHistory() {
        return new ArrayList<>(versions);
    }

    // 获取变更日志
    public List<Map<String, Object>> getChangeLog() {
        List<Map<String, Object>> changeLog = new ArrayList<>();

        // 比较每一个版本与前一个版本的差异
        for (int i = 1; i < versions.size(); i++) {
            Product currentVersion = versions.get(i);
            Product previousVersion = versions.get(i - 1);

            Map<String, Object> change = new HashMap<>();
            change.put("version", i + 1);
            change.put("reason", changeReasons.get(currentVersion));
            change.put("changes", detectChanges(previousVersion, currentVersion));

            changeLog.add(change);
        }

        return changeLog;
    }

    // 检测版本间差异
    private Map<String, Object[]> detectChanges(Product oldVersion, Product newVersion) {
        Map<String, Object[]> changes = new HashMap<>();

        // 比较各个属性
        if (!oldVersion.name().equals(newVersion.name())) {
            changes.put("name", new Object[] { oldVersion.name(), newVersion.name() });
        }

        if (!oldVersion.price().equals(newVersion.price())) {
            changes.put("price", new Object[] { oldVersion.price(), newVersion.price() });
        }

        // 更多属性比较...

        return changes;
    }
}

这个实现的关键优势在于:

  1. 无需额外存储:每个历史版本就是一个完整的不可变对象,不需要额外的存储结构
  2. 自动差异检测:可以直接比较两个版本,找出所有变化的属性
  3. 时间点还原:可以轻松恢复任何历史时刻的完整对象状态
  4. 内存效率高:得益于Jimmer的结构共享特性,存储多个版本非常高效

这种实现方式特别适合用于: - 审计日志系统 - 撤销/重做功能 - 业务流程回溯 - 数据分析和趋势识别

  • 与事件溯源的结合

不可变对象的历史追踪能力与事件溯源(Event Sourcing)架构模式有天然的契合性。我们可以将每次对象状态变更视为一个事件,并通过重放这些事件来重建任意时刻的对象状态。

// 记录商品变更事件
void recordProductEvent(String productId, ProductEvent event) {
    eventStore.save(new ProductEventRecord(productId, event));
}

// 重建特定时刻的商品状态
Product rebuildProductAtTime(String productId, LocalDateTime pointInTime) {
    List<ProductEventRecord> events = eventStore.findEventsBeforeTime(productId, pointInTime);
    Product product = null;

    for (ProductEventRecord record : events) {
        if (product == null) {
            // 初始事件创建产品
            product = record.getEvent().createProduct();
        } else {
            // 后续事件修改产品
            product = record.getEvent().applyTo(product);
        }
    }

    return product;
}

在这种模式下,我们不再存储对象的完整状态,而是存储导致状态变化的事件。Jimmer的不可变对象模型使得实现这种模式变得异常简单,因为每个事件都可以通过Draft机制清晰地表达其对对象状态的影响。

小结

在本节中,我们深入探讨了Jimmer不可变对象的实践应用,特别是在并发环境和历史记录追踪方面的优势:

  1. 不可变性的本质与优势
  2. 线程安全性 - 无需锁即可在多线程环境下安全使用
  3. 一致性视图 - 对象状态始终完整一致,不存在中间状态
  4. 结构共享 - 高效利用内存,减少资源消耗

  5. 并发环境中的应用

  6. 无锁并发 - 彻底消除了读写锁的需要
  7. CAS更新 - 高效地实现并发写入
  8. 性能优势 - 在多数场景下优于传统加锁方案

  9. 历史记录追踪的实现

  10. 简洁的记录机制 - 无需额外存储结构
  11. 自动差异检测 - 轻松比较版本间的变化
  12. 时间旅行能力 - 随时访问任意历史版本

不可变性不仅是一种技术特性,更是一种思维模式,它鼓励我们以更清晰、更可预测的方式组织和管理状态。在接下来的章节中,我们将继续探索Jimmer的高级特性,包括如何在复杂关联关系中应用不可变对象模型。

3.2 复杂关联映射

在前一节中,我们深入探讨了Jimmer不可变对象的特性及其在并发环境中的优势。本节将聚焦于Jimmer的另一个核心特性:复杂关联映射。在企业级应用中,实体之间的关联往往构成复杂的网状或树形结构,如何高效地表达和管理这些关联是ORM框架的关键挑战。

3.2.1 树形结构的关联映射

  • 业务场景

在电子商务平台中,商品分类通常形成多层级的树形结构。例如,"电子产品"作为顶级分类,下设"手机"、"笔记本"、"配件"等子分类,而"手机"又可以细分为"iPhone"、"Android"等子分类。在展示商品目录或进行分类导航时,系统需要高效地加载和遍历这种树形结构。

传统ORM框架在处理这类树形结构时往往面临以下挑战:

  1. N+1问题:递归加载子分类时可能导致大量的单独数据库查询
  2. 深度控制:难以灵活控制加载树的深度,要么完全加载,要么完全不加载
  3. 内存膨胀:大型树结构完全加载到内存可能导致性能问题
  4. 循环引用:处理不当可能导致对象序列化时的无限递归

Jimmer通过其独特的获取器(Fetcher)模式和不可变对象特性,提供了优雅的解决方案。下面让我们通过实际测试来验证Jimmer如何高效处理树形结构。

  • 测试定义

为了验证Jimmer处理树形结构的能力,我们设计了两个测试场景:

  1. 向下递归查询:从根节点开始,递归加载整棵分类树
  2. 向上查询路径:从叶子节点开始,向上查询到根节点的路径

首先,让我们来看第一个测试 - 递归加载整棵分类树:

@Test
@DisplayName("测试递归查询获取完整分类树")
public void testRecursiveTreeFetcher() {
    // 准备测试数据
    Map<String, String> ids = prepareTestCategoryTreeForRecursiveTest();
    String rootId = ids.get("rootId");

    // 创建递归获取器,抓取整个分类树
    Fetcher<Category> treeFetcher = CategoryFetcher.$
        .allScalarFields()
        .childCategories(
            CategoryFetcher.$.allScalarFields()
                .childCategories(
                    CategoryFetcher.$.allScalarFields()
                )
        );

    // 执行查询
    Category root = sqlClient
        .createQuery(CategoryTable.$)
        .where(CategoryTable.$.id().eq(rootId))
        .select(CategoryTable.$.fetch(treeFetcher))
        .fetchOne();

    // 验证根节点
    assertThat(root).isNotNull();
    assertThat(root.name()).isEqualTo("电子产品");

    // 验证子节点
    List<Category> level1Children = root.childCategories();
    assertThat(level1Children).hasSize(3);

    // 创建一个可变列表并按sortIndex排序
    List<Category> sortedChildren = new ArrayList<>(level1Children);
    sortedChildren.sort(Comparator.comparing(Category::sortIndex));

    // 验证排序后的结果
    assertThat(sortedChildren.get(0).name()).isEqualTo("手机");
    assertThat(sortedChildren.get(1).name()).isEqualTo("笔记本");
    assertThat(sortedChildren.get(2).name()).isEqualTo("配件");

    // 验证第二层级的"手机"分类下的子分类
    Category phones = findCategoryByName(level1Children, "手机");
    assertThat(phones.childCategories()).hasSize(2);

    List<String> phoneSubcategories = new ArrayList<>();
    for (Category subCategory : phones.childCategories()) {
        phoneSubcategories.add(subCategory.name());
    }
    assertThat(phoneSubcategories).containsExactlyInAnyOrder("iPhone", "Android");

    // 验证更多子分类...
}

接下来,看第二个测试 - 从叶子节点向上查询路径:

@Test
@DisplayName("测试从叶子节点向上查询分类路径")
public void testCategoryPathToRoot() {
    // 准备测试数据
    Map<String, String> ids = prepareTestCategoryTreeForPathTest();
    String iPhoneId = ids.get("iPhoneId");

    // 获取从iPhone分类到根节点的路径
    List<Category> path = getCategoryPathToRoot(iPhoneId);

    // 验证路径
    assertThat(path).hasSize(3); // 应该是三层:iPhone -> 手机 -> 电子产品

    // 验证路径顺序(从叶子到根)
    assertThat(path.get(0).name()).isEqualTo("iPhone");
    assertThat(path.get(1).name()).isEqualTo("手机");
    assertThat(path.get(2).name()).isEqualTo("电子产品");

    // 验证其他叶子节点路径...
}

这些测试用例验证了Jimmer在处理树形结构时的两种关键场景:自顶向下递归加载和自底向上查询路径。

  • 实现解析

Jimmer处理树形结构的核心在于实体定义和获取器(Fetcher)。首先,让我们看一下Category实体的定义:

@Entity
@Table(name = "t_category")
public interface Category {

    @Id
    String id();

    @Key
    String name();

    @Column(name = "sort_index")
    int sortIndex();

    @Nullable
    @ManyToOne
    @JoinColumn(name = "parent_id")
    Category parent();

    @OneToMany(mappedBy = "parent")
    @OrderBy("sortIndex asc")
    List<Category> childCategories();

    @OneToMany(mappedBy = "category")
    List<Product> products();
}

这个定义中有两个关键点:

  1. 自引用关联parent()方法定义了与自身类型的ManyToOne关联,表示当前分类的父分类
  2. 反向集合childCategories()方法定义了与parent字段的反向关联,表示当前分类的所有子分类

看起来很简单,对吧?但Jimmer的强大之处在于结合不可变对象和获取器(Fetcher)后,能够实现灵活而高效的关联加载策略。

在递归树加载测试中,我们使用了嵌套的获取器:

Fetcher<Category> treeFetcher = CategoryFetcher.$
    .allScalarFields()
    .childCategories(
        CategoryFetcher.$.allScalarFields()
            .childCategories(
                CategoryFetcher.$.allScalarFields()
            )
    );

这个定义告诉Jimmer:加载全部标量字段,同时递归加载两层子分类及其标量字段。Jimmer会智能地将这种嵌套结构转换为高效的SQL查询,而不是执行N+1查询。

在路径查询中,我们采用了更聪明的实现方式,避免递归加载可能带来的问题:

private List<Category> getCategoryPathToRoot(String categoryId) {
    // 一次性加载所有分类的基本信息
    List<Category> allCategories = sqlClient
        .createQuery(CategoryTable.$)
        .select(CategoryTable.$.fetch(CategoryFetcher.$.name()))
        .execute();

    // 一次性加载所有父子关系
    List<Tuple2<String, String>> relations = sqlClient
        .createQuery(CategoryTable.$)
        .select(
            CategoryTable.$.id(),
            CategoryTable.$.parent().id()
        )
        .execute();

    // 构建映射表和关系图   
    Map<String, Category> categoryMap = new HashMap<>();
    Map<String, String> parentIdMap = new HashMap<>();

    // 填充...

    // 构建从叶子到根的路径
    List<Category> path = new ArrayList<>();
    String currentId = categoryId;

    while (currentId != null) {
        Category category = categoryMap.get(currentId);
        if (category != null) {
            path.add(category);
            // 查找父ID
            currentId = parentIdMap.get(currentId);
        } else {
            currentId = null;
        }
    }

    return path;
}

这种实现方式通过两次高效查询和内存中的图遍历算法,避免了递归SQL查询,在处理大型树结构时表现更佳。

  • 技术内幕

Jimmer处理树形结构关联的核心原理包括以下几点:

  1. 多级嵌套Fetcher的SQL优化

当使用嵌套Fetcher结构时,Jimmer不会生成简单的N+1查询,而是通过以下策略优化SQL:

  • 智能批处理:对于相同深度的节点,会使用单次IN查询批量加载,而不是逐个查询
  • JOIN优化:在可行的情况下,会使用复杂JOIN语句一次性获取多层数据
  • 延迟加载控制:通过Fetcher明确指定需要加载的关联,避免不必要的数据获取

  • 不可变对象的关联处理

Jimmer的不可变对象特性在处理树形结构时带来独特优势:

  • 安全的对象共享:相同的节点实例可以安全地被多个父节点引用,无需担心修改传播问题
  • 深度控制更容易:由于对象不可变,可以安全地控制加载深度,不必担心延迟加载带来的修改风险
  • 内存优化:结构共享特性确保相同节点不会重复创建多个实例

  • 双向关联的自动维护

在用户创建或修改Category对象时,Jimmer会自动维护parent和childCategories之间的一致性:

// 创建分类及其父子关系
Category phones = CategoryDraft.$.produce(draft -> {
    draft.setId("phones");
    draft.setName("手机");
    draft.setSortIndex(1);
    draft.setParent(CategoryDraft.$.produce(p -> p.setId("electronics")));
});
sqlClient.getEntities().save(phones);

// 加载时,parent和childCategories会自动保持一致

当保存phones对象时,Jimmer会自动更新其父分类的childCategories集合。这种双向一致性维护大大简化了开发工作。

  • 最佳实践

在使用Jimmer处理树形结构时,以下最佳实践可以帮助您获得最佳性能和开发体验:

  1. 适当控制加载深度

对于大型树结构,避免一次性加载整棵树,而是分层加载或使用分页:

// 设置最大递归深度
Fetcher<Category> treeFetcher = CategoryFetcher.$
    .allScalarFields()
    .childCategories(cfg -> cfg
        .filter(childTable -> childTable.name().like("手机%"))
        .recursive(recursiveCfg -> recursiveCfg.depth(5))
    );
  1. 利用不可变性进行缓存

由于Jimmer对象不可变,可以安全地缓存并在多个请求间共享:

// 缓存根分类树
Category rootCategory = categoryCache.computeIfAbsent(
    "ROOT_TREE", 
    key -> sqlClient.findById(treeFetcher, rootId)
);
  1. 批量操作优先

处理树节点批量操作时,优先使用批量API而非逐个处理:

// 批量保存多个分类节点
List<Category> categories = /* ... */;
sqlClient.getEntities().saveAll(categories);
  1. 考虑使用物化路径

对于需要频繁执行祖先/后代查询的场景,考虑使用物化路径模式:

@Entity
public interface CategoryWithPath {
    @Id
    String id();

    String name();

    // 存储从根到当前节点的完整路径,如 "/1/12/123/"
    @Column(name = "path")
    String path();

    // 其他属性...
}

这种模式使得祖先/后代查询可以简化为简单的LIKE查询:WHERE path LIKE '/1/12/%'

3.2.2 多对多关系的高级映射

  • 业务场景

在电子商务平台中,产品和标签之间通常是多对多关系 - 一个产品可以有多个标签,一个标签也可以应用于多个产品。这种关系通常通过中间表实现,在查询和修改时都需要特别处理。

使用传统ORM框架时,开发者往往面临以下挑战:

  1. 中间表的处理:需要显式管理中间表实体和关系维护
  2. 级联操作:难以控制级联保存、更新和删除的行为
  3. 查询性能:多对多关联查询可能导致性能问题
  4. 集合差异检测:难以高效地检测和应用集合变更

让我们看看Jimmer如何简化多对多关系的处理。

  • 测试定义

为了验证Jimmer的多对多关系处理能力,我们设计以下测试场景:

@Test
@DisplayName("测试ManyToMany关系 - 查询商品及其标签")
public void testManyToManyAssociation() {
    // 准备测试数据
    Map<String, String> testData = prepareManyToManyTestData();
    String productId = testData.get("productId");

    // 定义查询,获取商品及其标签
    Fetcher<Product> fetcher = ProductFetcher.$
        .allScalarFields()
        .tags(TagFetcher.$
            .allScalarFields()
        );

    // 执行查询
    Product product = sqlClient
        .createQuery(ProductTable.$)
        .where(ProductTable.$.id().eq(productId))
        .select(ProductTable.$.fetch(fetcher))
        .fetchOne();

    // 验证查询结果
    assertThat(product).isNotNull();
    assertThat(product.name()).contains("iPhone 15 Pro");

    List<Tag> tags = product.tags();
    assertThat(tags).hasSize(3);

    // 验证标签内容
    List<String> tagNames = tags.stream()
        .map(Tag::name)
        .collect(Collectors.toList());
    assertThat(tagNames).containsExactlyInAnyOrder("高端", "手机", "Apple");
}

我们还测试了反向查询 - 根据标签查找产品:

@Test
@DisplayName("测试根据标签查询产品")
public void testQueryProductsByTag() {
    // 准备测试数据
    Map<String, String> testData = prepareProductTagQueryTestData();
    String tagId = testData.get("tagId");

    // 定义查询获取器
    Fetcher<Tag> fetcher = TagFetcher.$
        .allScalarFields()
        .products(ProductFetcher.$.allScalarFields());

    // 执行查询
    Tag tag = sqlClient
        .createQuery(TagTable.$)
        .where(TagTable.$.id().eq(tagId))
        .select(TagTable.$.fetch(fetcher))
        .fetchOne();

    // 验证查询结果
    assertThat(tag).isNotNull();
    assertThat(tag.name()).isEqualTo("高端");
    assertThat(tag.products()).hasSize(2);

    // 验证相关联的产品
    List<String> productNames = tag.products().stream()
        .map(Product::name)
        .collect(Collectors.toList());
    assertThat(productNames).containsExactlyInAnyOrder(
        "iPhone 15 Pro", "MacBook Pro 16"
    );
}
  • 实现解析

Jimmer的多对多关系映射建立在实体定义基础上。以产品和标签关系为例:

@Entity
@Table(name = "t_product")
public interface Product {
    // 其他属性...

    @ManyToMany
    @JoinTable(
        name = "t_product_tag",
        joinColumnName = "product_id",
        inverseJoinColumnName = "tag_id"
    )
    @OrderBy("name asc")
    List<Tag> tags();
}

@Entity
@Table(name = "t_tag")
public interface Tag {
    @Id
    String id();

    @Key
    String name();

    @ManyToMany(mappedBy = "tags")
    List<Product> products();
}

注意以下几点:

  1. @JoinTable注解:定义中间表及相关列,避免了创建中间表实体类
  2. mappedBy属性:在关系的一侧使用mappedBy,指定反向引用字段
  3. @OrderBy注解:指定集合排序规则,提升用户体验

通过这种定义,Jimmer生成的代码可以直接处理多对多关系,无需手动管理中间表:

// 创建带有标签的商品
Product product = ProductDraft.$.produce(draft -> {
    draft.setId("iphone");
    draft.setName("iPhone 15 Pro");
    draft.setPrice(new BigDecimal("8999.00"));
    // 省略其他属性...

    // 设置关联标签
    draft.setTags(Arrays.asList(
        TagDraft.$.produce(t -> t.setId("tag-1").setName("高端")),
        TagDraft.$.produce(t -> t.setId("tag-2").setName("手机")),
        TagDraft.$.produce(t -> t.setId("tag-3").setName("Apple"))
    ));
});

// 保存商品及其标签关系
sqlClient.getEntities().save(product);

当保存包含多对多关系的对象时,Jimmer会智能地处理:

  1. 插入商品记录(如果是新记录)
  2. 插入标签记录(如果是新记录)
  3. 在中间表中建立商品和标签的关联

  4. 技术内幕

Jimmer在多对多关系处理上有几个关键技术创新:

  1. 中间表的自动管理

Jimmer通过@JoinTable注解获取中间表信息,构建实体之间的映射,并在运行时自动生成相关SQL:

// 标签变更时,Jimmer会生成如下操作:
// 1. 删除移除的关联
DELETE FROM t_product_tag WHERE product_id = ? AND tag_id IN (?, ?, ...);
// 2. 添加新的关联
INSERT INTO t_product_tag(product_id, tag_id) VALUES (?, ?), (?, ?), ...;

这些SQL操作对开发者完全透明,极大简化了多对多关系维护。

  1. 高效的集合差异检测

Jimmer的不可变集合特性使其能够高效检测集合变更:

  1. 当修改Product对象的tags集合时,Jimmer能够精确识别哪些Tag被添加、哪些被移除
  2. 仅对发生变化的关联执行SQL操作,最小化数据库操作
// 修改商品标签
Product updatedProduct = ProductDraft.$.produce(existingProduct, draft -> {
    // 获取现有标签集合的可变副本
    List<Tag> tags = new ArrayList<>(existingProduct.tags());

    // 移除一个标签
    tags.removeIf(tag -> tag.name().equals("手机"));

    // 添加一个新标签
    tags.add(TagDraft.$.produce(t -> t.setId("tag-4").setName("新品")));

    // 设置更新后的标签集合
    draft.setTags(tags);
});

// 保存修改
sqlClient.getEntities().save(updatedProduct);

在这个例子中,Jimmer只会在中间表中删除"手机"标签关联并添加"新品"标签关联,其他关联保持不变。

  1. 查询优化

Jimmer的Fetcher机制使多对多关系查询更加高效:

  • 按需加载:只加载显式指定的关联,避免不必要的表连接
  • 批量加载:使用IN子查询批量加载多对多关系,避免N+1问题
  • 缓存友好:不可变结果可安全缓存,提升性能

  • 最佳实践

基于我们的测试和实践,以下是处理多对多关系的一些推荐做法:

  1. 控制中间表列

当中间表需要额外列时,使用@JoinTable的extraColumns属性:

@ManyToMany
@JoinTable(
    name = "t_product_tag",
    joinColumnName = "product_id",
    inverseJoinColumnName = "tag_id",
    extraColumnDefinition = {
        "assigned_time datetime not null",
        "assigned_by varchar(50)"
    }
)
List<Tag> tags();
  1. 性能优化

对于大量多对多关系的场景:

  • 使用分页加载多对多集合:tags(cfg -> cfg.limit(20).offset(0))
  • 为中间表中的外键列创建适当的索引
  • 考虑在高负载场景下使用异步批处理更新多对多关系

  • 避免双向级联

在双向多对多关系中谨慎使用级联操作,可能导致意外效果。最好在关系的一侧定义级联行为:

@ManyToMany
@JoinTable(/* ... */)
@OnDelete(deleteMode = DeleteMode.CASCADE)
List<Tag> tags();

@ManyToMany(mappedBy = "tags")
// 注意:没有级联删除配置
List<Product> products();

3.2.3 本节总结

在本节中,我们深入探讨了Jimmer在复杂关联映射方面的独特优势,尤其聚焦于两种常见的复杂关联场景:树形结构和多对多关系。通过实际测试和案例分析,我们看到Jimmer通过以下核心优势解决了传统ORM框架在复杂关联处理中面临的问题:

  1. 声明式关联定义 - Jimmer提供了简洁而强大的注解语法,使开发者可以轻松定义各种复杂关联,无需编写大量样板代码。

  2. 智能的加载策略 - 通过Fetcher机制,Jimmer允许开发者精确控制需要加载的关联和深度,避免过度加载和N+1问题。

  3. 不可变对象的优势 - 不可变对象模型确保关联对象的安全共享和一致性,尤其在树形结构和多级关联中体现出独特优势。

  4. 自动维护关系一致性 - 在保存、更新和删除操作中,Jimmer自动维护双向关联的一致性,减轻了开发者的负担。

  5. 高效的集合差异检测 - 基于不可变性的设计,Jimmer能够准确识别集合变更,最小化数据库操作,提高性能。

这些特性使Jimmer成为处理复杂关联关系的理想选择,特别是在需要高效管理树形结构、图形结构和多对多关系的企业级应用中。

通过深入理解和合理应用这些特性,开发者可以构建出结构清晰、性能优越且易于维护的领域模型,以应对各种复杂的业务场景。

3.3 动态查询

在上一节中,我们探讨了Jimmer如何处理复杂的实体关联关系。本节将深入Jimmer的另一个核心优势:强大而灵活的动态查询能力。动态查询是现代企业应用中不可或缺的功能,特别是在构建搜索、过滤和报表等功能时,需要根据用户输入动态构建查询条件。

传统ORM框架在处理动态查询时往往面临类型安全与灵活性难以兼顾的困境:使用字符串拼接容易引入SQL注入风险,而预定义查询又难以适应复杂多变的业务需求。Jimmer通过其独特的类型安全DSL和强大的动态查询API,优雅地解决了这一难题。

本节将通过一系列测试用例,展示Jimmer动态查询的高级特性,深入剖析其内部原理,并提供实际应用中的最佳实践。

3.3.1 基本动态查询与可选条件

在实际应用中,用户界面通常提供多个搜索条件,而用户可能只填写部分条件。例如,在电商平台的商品搜索页面,用户可能输入商品名称、选择价格区间或指定商品类别,系统需要根据用户实际填写的条件动态构建查询。

让我们先从一个简单的场景开始:商品搜索功能,允许用户根据名称关键词、最低价格和最高价格过滤商品。

  • 测试场景定义

首先,我们定义测试用例,验证系统能否正确处理部分条件的动态查询:

@Test
@DisplayName("测试基本动态查询 - 可选条件")
public void testBasicDynamicQuery() {
    // 准备测试数据
    java.util.Map<String, Object> testData = prepareDynamicQueryTestData();
    String testPrefix = (String) testData.get("testPrefix");
    String nameKeyword = (String) testData.get("nameKeyword");
    BigDecimal minPrice = (BigDecimal) testData.get("minPrice");
    BigDecimal maxPrice = (BigDecimal) testData.get("maxPrice"); // null

    // 构建查询
    var query = sqlClient.createQuery(ProductTable.$)
        .where(ProductTable.$.id().like(testPrefix + "-%")); // 只查询我们刚创建的测试数据

    // 名称关键词条件(如果提供)
    if (StringUtils.hasText(nameKeyword)) {
        query = query.where(ProductTable.$.name().like("%" + nameKeyword + "%"));
    }

    // 最低价格条件(如果提供)
    if (minPrice != null) {
        query = query.where(ProductTable.$.price().ge(minPrice));
    }

    // 最高价格条件(如果提供)
    if (maxPrice != null) {
        query = query.where(ProductTable.$.price().le(maxPrice));
    }

    // 执行查询
    List<Product> products = query.select(ProductTable.$).execute();

    // 验证查询结果
    assertFalse(products.isEmpty(), "应该找到包含'Pro'且价格大于1000的商品");

    // 验证结果符合条件
    for (Product product : products) {
        // 验证ID前缀正确
        assertTrue(product.id().startsWith(testPrefix), 
            "产品ID应以测试前缀开头: " + testPrefix);

        // 验证名称包含关键词
        assertTrue(product.name().contains(nameKeyword), 
            "产品名称应包含关键词: " + nameKeyword);

        // 验证价格大于等于最低价格
        assertTrue(product.price().compareTo(minPrice) >= 0, 
            "产品价格应大于等于: " + minPrice);
    }
}

这个测试用例展示了Jimmer处理可选查询条件的基本模式:通过if语句判断条件是否存在,然后动态添加相应的查询条件。为确保测试的隔离性和可重复性,我们为每个测试创建独立的测试数据:

private java.util.Map<String, Object> prepareDynamicQueryTestData() {
    // 为此测试创建唯一的数据标识前缀
    String testPrefix = "dq-test-" + java.util.UUID.randomUUID().toString().substring(0, 8);

    // 定义测试产品信息
    java.util.List<java.util.Map<String, Object>> productsInfo = java.util.Arrays.asList(
        java.util.Map.of(
            "id", testPrefix + "-macbook-pro",
            "name", testPrefix + "-MacBook Pro",
            "price", new BigDecimal("12999.00"),
            "stock", 30
        ),
        java.util.Map.of(
            "id", testPrefix + "-iphone-pro",
            "name", testPrefix + "-iPhone Pro",
            "price", new BigDecimal("8999.00"),
            "stock", 100
        ),
        // 更多测试产品...
    );

    // 创建测试产品
    for (java.util.Map<String, Object> productInfo : productsInfo) {
        Product product = ProductDraft.$.produce(draft -> {
            draft.setId((String) productInfo.get("id"));
            draft.setName((String) productInfo.get("name"));
            draft.setPrice((BigDecimal) productInfo.get("price"));
            draft.setStock((Integer) productInfo.get("stock"));
            draft.setDescription("测试产品");
            draft.setActive(true);
            draft.setCreatedTime(java.time.LocalDateTime.now());
        });
        sqlClient.getEntities().save(product);
    }

    // 返回测试参数
    java.util.Map<String, Object> result = new java.util.HashMap<>();
    result.put("testPrefix", testPrefix);
    result.put("nameKeyword", "Pro");
    result.put("minPrice", new BigDecimal("1000"));
    result.put("maxPrice", null);
    return result;
}
  • 原理分析

从这个简单的测试中,我们可以看到Jimmer动态查询的几个关键特性:

  1. 链式API:Jimmer提供了流畅的链式API,每次调用where()方法都会返回一个新的查询对象,使得条件的动态添加非常自然。

  2. 类型安全:与使用字符串拼接SQL不同,Jimmer的DSL是完全类型安全的。例如,对于price字段,编译器会确保我们只能使用适用于BigDecimal的操作符(如ge()le())。

  3. 条件组合:多次调用where()方法实际上是将多个条件用AND逻辑连接起来。

  4. 防SQL注入:由于查询条件是通过类型安全的API构建的,而不是字符串拼接,完全避免了SQL注入风险。

Jimmer在内部会将这些动态条件转换为对应的SQL语句,同时处理参数绑定。例如,当执行以上查询时,Jimmer生成的SQL大致如下:

SELECT t.* FROM product t 
WHERE t.id LIKE ? AND t.name LIKE ? AND t.price >= ?

参数会被正确绑定,确保查询的安全性和性能。

  • 应用场景与最佳实践

这种基本的动态查询模式适用于各种搜索场景,特别是当条件较为简单且相互独立时。在实际应用中,有几点最佳实践值得注意:

  1. 预处理输入参数:在添加条件前,应对用户输入进行适当的预处理,如去除空白字符、转换格式等。

  2. 空值处理策略:针对不同业务需求,我们可能需要不同的空值处理策略。例如,对于价格范围,null可能意味着"无上限/无下限";而对于关键词搜索,空字符串可能意味着"不使用此条件"。

  3. 性能考量:当可选条件较多时,应考虑查询性能。例如,使用索引字段作为查询条件,注意条件的顺序以利用索引等。

尽管这种基本模式简单直观,但在复杂查询场景下可能导致代码冗长且难以维护。接下来,我们将探讨更高级的动态查询模式,以应对更复杂的需求。

3.3.2 谓词构建器模式与复杂条件组合

基本的动态查询模式适用于简单场景,但在复杂业务需求下可能导致代码膨胀和难以维护。例如,当涉及多层条件嵌套、OR逻辑组合或复杂的业务规则时,简单的if语句链式调用会变得复杂难以理解。

为了解决这一问题,我们可以采用"谓词构建器"(Predicate Builder)模式,将查询条件构建逻辑封装,实现更优雅、更可维护的动态查询。

  • 测试场景定义

我们来设计一个更复杂的商品搜索场景:支持名称模糊匹配、价格范围过滤,以及更复杂的库存条件(如在多个库存范围区间中的商品)。

@Test
@DisplayName("测试复杂条件组合 - 谓词构建器")
public void testPredicateBuilder() {
    // 准备测试数据
    java.util.Map<String, Object> testData = prepareDynamicQueryTestData();
    String testPrefix = (String) testData.get("testPrefix");

    // 构建搜索参数对象
    ProductSearchParams params = new ProductSearchParams();
    params.setNamePattern("%" + testPrefix + "%");
    params.setMinPrice(new BigDecimal("3000"));
    params.setMaxPrice(new BigDecimal("15000"));
    // 设置多个库存区间:库存<50或库存>100
    params.setStockRanges(Arrays.asList(
        new StockRange(0, 50),
        new StockRange(100, Integer.MAX_VALUE)
    ));

    // 使用谓词构建器模式查询
    List<Product> products = searchProducts(params, testPrefix);

    // 验证结果
    assertFalse(products.isEmpty(), "应该找到符合复杂条件的商品");

    // 验证每个产品都符合条件
    for (Product product : products) {
        // 验证名称包含前缀
        assertTrue(product.name().contains(testPrefix),
            "产品名称应包含前缀: " + testPrefix);

        // 验证价格在范围内
        assertTrue(product.price().compareTo(params.getMinPrice()) >= 0,
            "产品价格应大于等于: " + params.getMinPrice());
        assertTrue(product.price().compareTo(params.getMaxPrice()) <= 0,
            "产品价格应小于等于: " + params.getMaxPrice());

        // 验证库存符合任一区间
        boolean stockInRange = false;
        for (StockRange range : params.getStockRanges()) {
            if (product.stock() >= range.getMin() && product.stock() <= range.getMax()) {
                stockInRange = true;
                break;
            }
        }
        assertTrue(stockInRange, "产品库存应在指定范围内");
    }
}

为了实现谓词构建器模式,我们首先定义了参数类和搜索方法:

// 产品搜索参数类
private static class ProductSearchParams {
    private String namePattern;
    private BigDecimal minPrice;
    private BigDecimal maxPrice;
    private List<StockRange> stockRanges;

    // getter和setter方法...
}

// 库存范围类
private static class StockRange {
    private final int min;
    private final int max;

    public StockRange(int min, int max) {
        this.min = min;
        this.max = max;
    }

    // getter方法...
}

// 使用谓词构建器模式搜索产品
private List<Product> searchProducts(ProductSearchParams params, String testPrefix) {
    ProductTable table = ProductTable.$;

    // 使用Consumer<List<Predicate>>模式构建复合条件
    List<Predicate> predicates = new ArrayList<>();

    // 添加测试数据前缀条件
    predicates.add(table.id().like(testPrefix + "-%"));

    // 添加基本条件 - 名称匹配
    if (StringUtils.hasText(params.getNamePattern())) {
        predicates.add(table.name().like(params.getNamePattern()));
    }

    // 添加价格范围条件
    if (params.getMinPrice() != null) {
        predicates.add(table.price().ge(params.getMinPrice()));
    }

    if (params.getMaxPrice() != null) {
        predicates.add(table.price().le(params.getMaxPrice()));
    }

    // 添加复杂库存条件 (多个范围的OR组合)
    if (params.getStockRanges() != null && !params.getStockRanges().isEmpty()) {
        List<Predicate> stockPredicates = new ArrayList<>();

        for (StockRange range : params.getStockRanges()) {
            stockPredicates.add(
                Predicate.and(
                    table.stock().ge(range.getMin()),
                    table.stock().le(range.getMax())
                )
            );
        }

        // 将多个库存范围条件用OR组合
        predicates.add(Predicate.or(stockPredicates.toArray(new Predicate[0])));
    }

    // 执行查询
    return sqlClient
        .createQuery(table)
        .where(Predicate.and(predicates.toArray(new Predicate[0])))
        .select(table)
        .execute();
}
  • 原理分析

谓词构建器模式的核心思想是:将条件构建逻辑与查询执行逻辑分离,统一收集所有谓词,最后一次性组合并应用到查询中。这种模式有几个明显优势:

  1. 条件集中管理:所有查询条件都在一个集合中集中管理,便于全局掌控。

  2. 复杂逻辑表达:可以轻松构建复杂的条件组合,如多层嵌套的AND/OR逻辑。

  3. 代码结构清晰:通过将条件构建逻辑封装在专门的方法中,主流程代码更加简洁易读。

  4. 便于扩展:新增查询条件只需添加相应的谓词,不影响整体结构。

Jimmer提供的Predicate类是实现这一模式的关键。它支持丰富的逻辑组合操作:

  • Predicate.and(...): 将多个条件以AND逻辑组合
  • Predicate.or(...): 将多个条件以OR逻辑组合
  • predicate.not(): 对条件取反

基于这些基本操作,我们可以构建任意复杂度的查询条件。例如,上述代码中的库存条件最终生成的SQL逻辑类似于:

... AND ((stock >= 0 AND stock <= 50) OR (stock >= 100))
  • 规约模式进阶

谓词构建器模式的一个更高级形式是"规约模式"(Specification Pattern),它将查询条件进一步封装为可组合的对象,实现更强大的条件复用和组合。我们来看一个实际实现:

// 规约接口
@FunctionalInterface
private interface ProductSpecification {
    Predicate toPredicate(ProductTable table);

    // 组合方法 - AND
    default ProductSpecification and(ProductSpecification other) {
        return table -> Predicate.and(
            this.toPredicate(table),
            other.toPredicate(table)
        );
    }

    // 组合方法 - OR
    default ProductSpecification or(ProductSpecification other) {
        return table -> Predicate.or(
            this.toPredicate(table),
            other.toPredicate(table)
        );
    }

    // 否定方法
    default ProductSpecification not() {
        return table -> this.toPredicate(table).not();
    }
}

// 产品规约工厂
private static class ProductSpecs {
    // 名称包含关键词
    public static ProductSpecification nameContains(String keyword) {
        return table -> StringUtils.hasText(keyword) ? 
            table.name().like("%" + keyword + "%") : 
            Predicate.or(table.id().eq(table.id()), table.id().ne(table.id()).not());
    }

    // 价格在指定范围
    public static ProductSpecification priceBetween(BigDecimal min, BigDecimal max) {
        // 实现略...
    }

    // 属于指定类别
    public static ProductSpecification inCategory(String categoryId) {
        // 实现略...
    }

    // 具有指定标签
    public static ProductSpecification hasTag(String tagId) {
        // 实现略...
    }
}

// 测试用例
@Test
@DisplayName("测试规约模式 - 可组合的查询条件")
public void testSpecificationPattern() {
    // 准备数据...

    // 创建基础规约 - 查询本测试添加的数据
    ProductSpecification baseSpec = ProductSpecs.idStartsWith(testPrefix);

    // 测试组合规约1:高端(premium)且便携(portable)的产品
    ProductSpecification highEndPortableSpec = baseSpec
        .and(ProductSpecs.hasTag(premiumId))
        .and(ProductSpecs.hasTag(portableId));

    List<Product> highEndPortables = findProductsBySpec(highEndPortableSpec);

    // 验证结果
    assertEquals(2, highEndPortables.size(), "应该找到2个同时具有高端和便携标签的产品");

    // 更多测试...
}

规约模式将查询条件封装为对象,并定义了组合操作,使得条件组合变得更加直观和灵活。这种方式特别适合于复杂的业务规则,可以将业务逻辑清晰地表达为规约的组合。

  • 应用场景与最佳实践

谓词构建器和规约模式特别适用于以下场景:

  1. 高级搜索功能:支持多条件、多字段的复杂查询,如电商平台的商品高级筛选。

  2. 动态报表过滤:根据用户选择的条件动态生成报表数据。

  3. 复杂业务规则查询:将业务规则(如促销条件、资格审核等)转化为查询条件。

  4. API查询参数:在RESTful API中根据请求参数动态构建查询条件。

使用这些模式时,有几点最佳实践值得注意:

  1. 条件的原子性:每个基本规约应该足够原子化,只表达一个逻辑概念,便于组合。

  2. 默认行为:当参数为空时,规约应该定义明确的默认行为(如返回全部、不添加条件等)。

  3. 性能考量:对于可能产生复杂SQL的规约组合,应注意查询性能,适当使用索引。

  4. 测试覆盖:由于条件组合的复杂性,应确保测试覆盖各种组合场景,验证条件逻辑的正确性。

通过谓词构建器和规约模式,我们可以将复杂的查询逻辑以更加优雅和可维护的方式表达出来,同时充分利用Jimmer类型安全的特性,确保查询的正确性和安全性。

3.3.3 多表关联查询

在实际业务场景中,单表查询往往无法满足需求,我们经常需要跨多个表进行关联查询。例如,在电商系统中,查询某个类别下且带有特定标签的产品,或者检索某个供应商提供的所有促销商品。传统SQL需要手写JOIN语句,而传统ORM框架通常需要预定义关联或编写HQL/JPQL等查询语言。

Jimmer提供了强大而直观的多表关联查询能力,允许我们通过类型安全的DSL动态构建复杂的关联查询,同时保持代码的可读性和可维护性。

  • 测试场景定义

我们来设计一个典型的多表关联查询场景:查询某个类别下、带有特定标签、且价格高于指定值的商品。这涉及产品表(Product)、类别表(Category)和标签表(Tag)三张表的关联。

@Test
@DisplayName("测试关联查询 - 多表JOIN")
public void testJoinQuery() {
    // 准备测试数据
    java.util.Map<String, Object> testData = prepareJoinQueryTestData();
    String testPrefix = (String) testData.get("testPrefix");
    String mobileDeviceTagId = (String) testData.get("mobileDeviceId");
    String mobileId = (String) testData.get("mobileId");

    // 查询符合以下条件的产品:
    // 1. 属于'移动设备'类别
    // 2. 有'移动设备'标签
    // 3. 价格大于1000

    // 构建查询
    List<Product> products = sqlClient
        .createQuery(ProductTable.$)
        .where(ProductTable.$.id().like(testPrefix + "-%")) // 只查询我们的测试数据
        .where(ProductTable.$.category().id().eq(mobileId))
        // 为Tag表使用lambda表达式,定义关联条件
        .where(ProductTable.$.tags(tag -> tag.id().eq(mobileDeviceTagId)))
        .where(ProductTable.$.price().gt(new BigDecimal("1000")))
        // 使用Fetcher获取完整对象图,包括关联的类别和标签
        .select(ProductTable.$.fetch(
            ProductFetcher.$
                .allScalarFields()
                .category(CategoryFetcher.$.allScalarFields())
                .tags(TagFetcher.$.allScalarFields())
        ))
        .execute();

    // 验证查询结果
    assertFalse(products.isEmpty(), "应该找到符合条件的产品");

    // 验证每个产品都满足条件
    for (Product product : products) {
        // 1. 验证是移动设备类别
        assertTrue(product.category() != null && product.category().id().equals(mobileId),
            "产品应属于移动设备类别");

        // 2. 验证有移动设备标签
        boolean hasMobileDeviceTag = false;
        for (Tag tag : product.tags()) {
            if (tag.id().equals(mobileDeviceTagId)) {
                hasMobileDeviceTag = true;
                break;
            }
        }
        assertTrue(hasMobileDeviceTag, "产品应有移动设备标签");

        // 3. 验证价格大于1000
        assertTrue(product.price().compareTo(new BigDecimal("1000")) > 0,
            "产品价格应大于1000");
    }
}

为测试准备了包含类别、标签和产品的复杂数据结构:

private java.util.Map<String, Object> prepareJoinQueryTestData() {
    // 为此测试创建唯一的数据标识前缀
    String testPrefix = "join-test-" + java.util.UUID.randomUUID().toString().substring(0, 8);

    // 创建类别
    String electronicsId = testPrefix + "-electronics";
    String mobileId = testPrefix + "-mobile";
    String computerAccessoriesId = testPrefix + "-accessories";

    // 准备类别
    Category electronics = CategoryDraft.$.produce(draft -> {
        draft.setId(electronicsId);
        draft.setName("电子产品");
        draft.setSortIndex(1);
    });
    sqlClient.getEntities().save(electronics);

    Category mobile = CategoryDraft.$.produce(draft -> {
        draft.setId(mobileId);
        draft.setName("移动设备");
        draft.setSortIndex(2);
        draft.setParent(CategoryDraft.$.produce(p -> p.setId(electronicsId)));
    });
    sqlClient.getEntities().save(mobile);

    // 创建标签
    String highEndId = testPrefix + "-high-end";
    String mobileDeviceId = testPrefix + "-mobile-device";

    // 创建产品及关联
    // 三星手机 - 移动设备分类,高端+移动设备标签
    Product productSamsung = ProductDraft.$.produce(draft -> {
        draft.setId(testPrefix + "-samsung-s23");
        draft.setName(testPrefix + "-Samsung Galaxy S23");
        draft.setPrice(new BigDecimal("6999.00"));
        draft.setStock(50);
        draft.setCategory(CategoryDraft.$.produce(c -> c.setId(mobileId)));
        draft.setTags(java.util.Arrays.asList(
            TagDraft.$.produce(t -> t.setId(highEndId)),
            TagDraft.$.produce(t -> t.setId(mobileDeviceId))
        ));
    });
    sqlClient.getEntities().save(productSamsung);

    // 其他测试数据...

    return result;
}
  • 原理分析

Jimmer的多表关联查询设计非常独特,它基于以下几个核心机制:

  1. 实体表达式:Jimmer允许我们通过类型安全的方式引用关联实体的属性,如ProductTable.$.category().id()。这种方式消除了手写JOIN语句的复杂性,同时保持了类型安全。

  2. Lambda表达式:对于集合关联(如一对多、多对多),Jimmer提供了Lambda语法来定义关联条件,如ProductTable.$.tags(tag -> tag.id().eq(tagId))。这种方式特别适合处理多对多关系的查询。

  3. 动态Fetcher:Jimmer的Fetcher机制允许我们精确控制要加载的关联数据,避免过度获取或N+1查询问题。例如,ProductFetcher.$.allScalarFields().category().tags()指定了要加载产品的所有标量字段、类别以及标签。

  4. 自动JOIN优化:Jimmer会根据查询条件和Fetcher自动生成高效的JOIN语句,开发者无需关心底层SQL的复杂性。

当执行上述查询时,Jimmer会生成类似以下的SQL(简化版):

SELECT 
    p.*, c.*, t.*
FROM 
    product p
    JOIN category c ON p.category_id = c.id
    JOIN product_tag pt ON p.id = pt.product_id
    JOIN tag t ON pt.tag_id = t.id
WHERE 
    p.id LIKE ? 
    AND c.id = ? 
    AND t.id = ? 
    AND p.price > ?

最关键的是,这些JOIN操作是自动生成的,我们只需通过类型安全的API描述关联条件,而不必担心SQL的细节。

  • 子查询能力

除了基本的JOIN操作外,Jimmer还支持子查询,这对于某些复杂查询场景非常有用。下面是一个使用EXISTS子句的示例:

@Test
@DisplayName("测试子查询 - EXISTS子句")
public void testSubQuery() {
    // 准备测试数据
    java.util.Map<String, Object> testData = prepareSubQueryTestData();
    String testPrefix = (String) testData.get("testPrefix");
    String promoTagId = (String) testData.get("promoTagId");

    // 查询包含促销标签的产品
    List<Product> products = sqlClient
        .createQuery(ProductTable.$)
        .where(
            // 只查询我们刚创建的测试数据
            ProductTable.$.id().like(testPrefix + "-%"),
            // 使用子查询查找包含指定标签的产品
            ProductTable.$.tags(tag -> tag.id().eq(promoTagId))
        )
        .select(ProductTable.$)
        .execute();

    // 验证结果
    assertFalse(products.isEmpty(), "应该找到有促销标签的产品");
    assertEquals(1, products.size(), "应该只找到一个有促销标签的产品");
}

在这个例子中,ProductTable.$.tags(tag -> tag.id().eq(promoTagId))实际上会被转换为一个EXISTS子查询,大致相当于:

SELECT p.* FROM product p 
WHERE p.id LIKE ? 
AND EXISTS (
    SELECT 1 FROM product_tag pt 
    JOIN tag t ON pt.tag_id = t.id 
    WHERE pt.product_id = p.id AND t.id = ?
)

这种方式比JOIN更加灵活,特别适合于条件过滤而不需要加载关联数据的场景。

  • 应用场景与最佳实践

多表关联查询在企业应用中应用广泛,特别是在以下场景:

  1. 复杂数据过滤:跨多个实体的条件过滤,如"查找某个类别下带有特定标签的产品"。

  2. 数据聚合报表:需要从多个相关实体收集和聚合数据,如"按供应商统计销售额"。

  3. 富数据视图:需要在单次查询中获取完整的对象图,包括所有必要的关联数据。

使用Jimmer进行多表关联查询时,有几点最佳实践值得注意:

  1. 选择性获取:使用Fetcher机制精确控制要加载的关联数据,避免获取不必要的数据。

  2. 考虑查询复杂度:过于复杂的关联查询可能导致性能问题,应考虑查询优化或拆分为多个简单查询。

  3. 利用批量加载:对于列表场景,可以先获取主实体列表,然后使用批量加载关联数据,避免N+1问题。

  4. 索引优化:确保关联字段有适当的索引,提高查询性能。

通过Jimmer的多表关联查询能力,我们可以以声明式的方式表达复杂的业务查询需求,同时保持代码的可读性和可维护性,避免手写复杂SQL带来的风险和维护成本。

3.3.4 动态排序与结果投影

在实际应用中,除了动态构建查询条件外,我们还经常需要根据用户的选择动态排序结果,以及按需返回不同的字段集合。这些需求在构建用户友好的界面时尤为重要,例如,允许用户点击表格列头来切换排序方式,或者选择查看简略或详细信息。

  • 动态排序

Jimmer提供了直观的API,使我们能够轻松实现动态排序。让我们看一个示例:

@Test
@DisplayName("测试动态排序")
public void testDynamicSorting() {
    // 准备测试数据
    java.util.Map<String, Object> testData = prepareDynamicQueryTestData();
    String testPrefix = (String) testData.get("testPrefix");

    // 定义不同的排序方式及预期顺序
    java.util.List<java.util.Map.Entry<String, String>> sortOptions = java.util.Arrays.asList(
        java.util.Map.entry("price", "DESC"),  // 价格从高到低
        java.util.Map.entry("price", "ASC"),   // 价格从低到高
        java.util.Map.entry("stock", "DESC"),  // 库存从多到少
        java.util.Map.entry("name", "ASC")     // 名称字母升序
    );

    for (java.util.Map.Entry<String, String> sortOption : sortOptions) {
        String field = sortOption.getKey();
        String direction = sortOption.getValue();

        // 构建动态排序查询
        List<Product> products = sortProducts(field, direction, testPrefix);

        // 验证排序结果
        assertFalse(products.isEmpty(), "产品列表不应为空");

        // 验证排序是否正确
        validateSorting(products, field, direction);
    }
}

// 动态排序产品
private List<Product> sortProducts(String field, String direction, String testPrefix) {
    ProductTable table = ProductTable.$;

    // 构建基本查询
    var query = sqlClient.createQuery(table)
        .where(table.id().like(testPrefix + "-%")); // 只查询我们的测试数据

    // 动态添加排序
    boolean isAsc = "ASC".equalsIgnoreCase(direction);

    // 根据字段名动态排序
    switch (field) {
        case "price":
            query = isAsc 
                ? query.orderBy(table.price().asc()) 
                : query.orderBy(table.price().desc());
            break;
        case "stock":
            query = isAsc 
                ? query.orderBy(table.stock().asc()) 
                : query.orderBy(table.stock().desc());
            break;
        case "name":
            query = isAsc 
                ? query.orderBy(table.name().asc()) 
                : query.orderBy(table.name().desc());
            break;
        default:
            // 如果是未知字段,默认按ID排序
            query = query.orderBy(table.id().asc());
    }

    // 执行查询
    return query.select(table).execute();
}

在这个例子中,我们根据传入的字段名和排序方向动态构建排序条件。Jimmer的orderBy方法支持asc()和desc()两种排序方向,可以根据需要灵活组合。

值得注意的是,Jimmer还支持多字段排序,只需连续调用orderBy方法即可:

query.orderBy(table.category().name().asc())
     .orderBy(table.price().desc())
     .orderBy(table.name().asc());

这将按类别名称升序、价格降序、产品名称升序的优先级顺序排序结果。

  • 动态投影

在许多场景下,我们不需要返回实体的所有字段,而是根据用户需求或性能考虑动态选择要返回的字段。例如,列表页可能只需要显示基本信息,而详情页则需要完整数据。Jimmer的Fetcher机制能够优雅地解决这一需求。

@Test
@DisplayName("测试动态投影 - 按需返回字段")
public void testDynamicProjection() {
    // 准备测试数据
    java.util.Map<String, Object> testData = prepareDynamicProjectionTestData();
    String productId = (String) testData.get("productId");

    // 测试只返回基本信息(名称和价格)
    ProductProjection basicInfo = queryProductWithDynamicProjection(
        productId, "name", "price"
    );

    // 验证结果 - 只有指定字段有值
    assertNotNull(basicInfo, "应该返回产品投影对象");
    assertNotNull(basicInfo.getName(), "名称字段应该存在");
    assertNotNull(basicInfo.getPrice(), "价格字段应该存在");
    assertNull(basicInfo.getStock(), "库存字段应该为空");
    assertNull(basicInfo.getCategory(), "类别字段应该为空");
    assertNull(basicInfo.getTags(), "标签字段应该为空");

    // 测试返回详细信息(包括类别)
    ProductProjection detailInfo = queryProductWithDynamicProjection(
        productId, "name", "price", "stock", "category"
    );

    // 验证结果
    assertNotNull(detailInfo, "应该返回产品投影对象");
    assertNotNull(detailInfo.getName(), "名称字段应该存在");
    assertNotNull(detailInfo.getPrice(), "价格字段应该存在");
    assertNotNull(detailInfo.getStock(), "库存字段应该存在");
    assertNotNull(detailInfo.getCategory(), "类别字段应该存在");
    assertNull(detailInfo.getTags(), "标签字段应该为空");
}

// 执行动态投影查询
private ProductProjection queryProductWithDynamicProjection(String productId, String... selectFields) {
    // 将字段名转换为集合,方便查询
    Set<String> fields = new java.util.HashSet<>(java.util.Arrays.asList(selectFields));

    // 创建查询和动态Fetcher
    ProductTable table = ProductTable.$;
    ProductFetcher fetcher = ProductFetcher.$.allScalarFields();

    // 动态添加字段
    if (fields.contains("name")) {
        fetcher = fetcher.name();
    }

    if (fields.contains("price")) {
        fetcher = fetcher.price();
    }

    if (fields.contains("stock")) {
        fetcher = fetcher.stock();
    }

    if (fields.contains("category")) {
        fetcher = fetcher.category(CategoryFetcher.$.name());
    }

    if (fields.contains("tags")) {
        fetcher = fetcher.tags(TagFetcher.$.name());
    }

    // 执行查询
    Product product = sqlClient
        .createQuery(table)
        .where(table.id().eq(productId))
        .select(table.fetch(fetcher))
        .fetchOne();

    if (product == null) {
        return null;
    }

    // 创建投影对象,注意根据选择的字段返回值
    return new ProductProjection(
        product.id(),
        fields.contains("name") ? product.name() : null,
        fields.contains("price") ? product.price() : null,
        fields.contains("stock") ? product.stock() : null,
        fields.contains("category") ? product.category() : null,
        fields.contains("tags") ? product.tags() : null
    );
}

在这个例子中,我们根据传入的字段名动态构建Fetcher,这样数据库只会返回我们需要的字段。为了支持更灵活的投影,我们创建了一个专门的DTO类来包含查询结果:

private static class ProductProjection {
    private final String id;
    private final String name;
    private final BigDecimal price;
    private final Integer stock;
    private final Category category;
    private final List<Tag> tags;

    // 构造函数和getter方法...
}
  • 聚合查询

Jimmer还支持强大的聚合查询功能,可以轻松实现分组、计数、求和等操作。以下是一个按类别统计产品数量、平均价格和总库存的示例:

@Test
@DisplayName("测试聚合查询 - 按类别统计")
public void testAggregateQuery() {
    // 准备测试数据
    java.util.Map<String, Object> testData = prepareAggregateQueryTestData();
    String testPrefix = (String) testData.get("testPrefix");

    // 查询每个类别的产品数量、平均价格和总库存
    var query = sqlClient
        .createQuery(ProductTable.$)
        .where(ProductTable.$.id().like(testPrefix + "-%")) // 只查询我们的测试数据
        .groupBy(ProductTable.$.category().id(), ProductTable.$.category().name());

    // 使用select方法选择要返回的列
    var result = query.select(
        ProductTable.$.category().id(),
        ProductTable.$.category().name(),
        ProductTable.$.count(),
        ProductTable.$.price().avg(),
        ProductTable.$.stock().sum()
    ).execute();

    // 验证结果
    assertFalse(result.isEmpty(), "应该返回聚合结果");
    assertEquals(2, result.size(), "应该有两个类别的统计结果");

    // 验证聚合字段存在且类型正确
    for (var row : result) {
        assertNotNull(row.get(0), "应该包含类别ID");
        assertNotNull(row.get(1), "应该包含类别名称");
        assertNotNull(row.get(2), "应该包含商品数量");
        assertNotNull(row.get(3), "应该包含平均价格");
        assertNotNull(row.get(4), "应该包含总库存");
    }
}

这个示例展示了Jimmer如何支持GROUP BY操作以及常见的聚合函数:count()、avg()和sum()。Jimmer还支持其他聚合函数如min()、max()等,以及HAVING子句进行聚合后的过滤。

  • 原理与优化

Jimmer的动态排序和投影功能建立在其强大的查询模型之上,有几个关键机制值得深入理解:

  1. 查询构建的不可变性:Jimmer的查询对象是不可变的,每次调用orderBy()、select()等方法都会返回一个新的查询对象,这确保了多线程环境下的安全性,也使得查询构建更加灵活。

  2. 智能Fetcher:Fetcher不仅控制返回的字段,还会影响JOIN语句的生成。Jimmer只会生成获取选定字段所需的JOIN操作,避免不必要的表连接。

  3. 延迟执行:Jimmer的查询在调用execute()、fetchOne()等方法前不会实际执行,这允许我们在完全构建好查询后再执行,提高灵活性。

  4. 类型安全:即使在动态投影和排序场景下,Jimmer依然保持类型安全,编译器会捕获大多数错误,如引用不存在的字段或使用不兼容的操作。

  5. 应用场景与最佳实践

动态排序和投影在以下场景特别有用:

  1. 用户自定义列表视图:允许用户选择显示哪些字段、如何排序。

  2. API数据优化:根据客户端需求返回不同的数据集,减少传输数据量。

  3. 多视图页面:同一数据在不同场景(列表、卡片、详情等)需要不同的字段集。

  4. 性能优化:只获取必要的字段,减少数据库传输和对象创建开销。

使用这些功能时,有几点最佳实践值得注意:

  1. 接口参数设计:设计明确的参数格式来指定排序和字段选择,如sortBy=field:direction格式。

  2. 安全性考虑:验证用户提供的字段名和排序方向,避免注入风险。

  3. 默认值设定:为排序和字段选择提供合理的默认值,确保即使用户不指定也能正常工作。

  4. 索引优化:确保常用的排序字段有适当的索引,特别是在大数据量场景下。

  5. 投影组合:设计常用的字段组合(如"基础信息"、"详细信息"等),减少动态构建的复杂度。

通过Jimmer的动态排序和投影功能,我们可以构建更加灵活、性能更优的应用,同时保持代码的可维护性和类型安全性。

3.3.5 动态查询的最佳实践与性能考量

在前面的小节中,我们探讨了Jimmer动态查询的各种高级特性。这些功能强大而灵活,但要在实际项目中有效应用,我们还需要考虑最佳实践和性能优化策略。本小节将总结动态查询的关键设计模式,探讨常见陷阱及其解决方案,并提供性能优化的实用建议。

  • 查询模式选择

Jimmer提供了多种构建动态查询的模式,应根据不同场景选择合适的模式:

  1. 简单条件模式:适用于条件较少且相互独立的简单场景。使用链式API和if判断构建查询。
var query = sqlClient.createQuery(ProductTable.$);
if (keyword != null) {
    query = query.where(ProductTable.$.name().like("%" + keyword + "%"));
}
if (minPrice != null) {
    query = query.where(ProductTable.$.price().ge(minPrice));
}
  1. 谓词构建器模式:适用于条件较多或有复杂逻辑组合的场景。使用Predicate集合集中管理条件。
List<Predicate> predicates = new ArrayList<>();
if (keyword != null) {
    predicates.add(ProductTable.$.name().like("%" + keyword + "%"));
}
if (categoryId != null) {
    predicates.add(ProductTable.$.category().id().eq(categoryId));
}
query.where(Predicate.and(predicates.toArray(new Predicate[0])));
  1. 规约模式:适用于业务规则复杂且需要重用的场景。将条件封装为可组合的规约对象。
ProductSpecification spec = baseSpec
    .and(ProductSpecs.inCategory(categoryId))
    .and(ProductSpecs.priceBetween(minPrice, maxPrice))
    .and(ProductSpecs.hasTag(tagId).not());
  • 代码组织与分层

良好的代码组织对于维护复杂的动态查询至关重要:

  1. Repository层封装:将动态查询逻辑封装在Repository层,向上层提供清晰的API。
public interface ProductRepository {
    List<Product> findByDynamicConditions(
        String keyword, BigDecimal minPrice, BigDecimal maxPrice, String categoryId);
}
  1. 查询条件与执行分离:将查询条件构建和查询执行分离,提高代码可测试性。
// 条件构建方法
private Predicate buildProductConditions(String keyword, BigDecimal minPrice) {
    List<Predicate> predicates = new ArrayList<>();
    // 添加条件...
    return Predicate.and(predicates.toArray(new Predicate[0]));
}

// 查询执行方法
public List<Product> findProducts(Predicate condition) {
    return sqlClient.createQuery(ProductTable.$)
        .where(condition)
        .select(ProductTable.$)
        .execute();
}
  1. 专用查询对象:对于复杂查询,创建专用的查询对象封装参数和验证逻辑。
public class ProductSearchCriteria {
    private String keyword;
    private BigDecimal minPrice;
    private BigDecimal maxPrice;
    private String categoryId;
    private List<String> tagIds;
    private String sortField;
    private String sortDirection;

    // 验证方法
    public void validate() {
        // 验证参数有效性
    }

    // getter和setter...
}
  • 常见陷阱与解决方案

在使用动态查询时,有几个常见陷阱需要注意:

  1. SQL注入风险:虽然Jimmer的DSL是类型安全的,但在处理LIKE条件时仍需注意。
// 错误:直接拼接用户输入
table.name().like("%" + userInput + "%") 

// 正确:转义特殊字符或使用参数化查询
table.name().like("%" + StringUtils.escapeWildcards(userInput) + "%")
  1. 空值处理不当:没有为null值定义明确的处理策略。
// 潜在问题:如果所有条件都为null,可能返回全表数据
if (keyword != null) query = query.where(...);
if (minPrice != null) query = query.where(...);

// 更安全的方式:添加一个基础条件,或明确处理全空条件
if (allConditionsEmpty()) {
    throw new IllegalArgumentException("至少需要一个搜索条件");
}
  1. 性能问题:复杂的动态查询可能导致低效的SQL。
// 潜在性能问题:使用多个OR条件
Predicate orCondition = Predicate.or(
    table.field1().like(...),
    table.field2().like(...),
    table.field3().like(...)
);

// 更高效的方式:考虑使用全文索引或其他优化方式
  1. 过度加载关联数据:不必要地加载所有关联,导致性能下降。
// 过度加载:加载所有关联
ProductFetcher.$.allScalarFields().category().tags()

// 按需加载:只加载必要字段
ProductFetcher.$.name().price().category(CategoryFetcher.$.name())
  • 性能优化策略

优化动态查询性能的关键策略包括:

  1. 选择性获取:只获取实际需要的字段和关联。
// 返回投影对象而非完整实体
query.select(
    table.id(),
    table.name(),
    table.price(),
    table.category().name()
)
  1. 优化条件顺序:将高选择性的条件放在前面,帮助数据库优化查询计划。
// 更可能使用索引的顺序
query.where(table.id().eq(id))  // 高选择性条件优先
    .where(table.category().id().eq(categoryId))
    .where(table.name().like(...));  // 低选择性条件靠后
  1. 分页处理:处理大结果集时始终使用分页。
query.limit(pageSize, offset);
  1. 批量关联加载:对于列表场景,使用批量加载替代即时加载关联数据。
// 先加载主实体
List<Product> products = query.select(ProductTable.$).execute();

// 批量加载关联数据
sqlClient.getAssociations(Product__.tags).load(products);
  1. 缓存查询结果:对于频繁执行的查询,考虑使用查询缓存。
// 使用Jimmer的查询缓存
query.withCache(true).select(...).execute();

小结

本节深入探讨了Jimmer动态查询的高级特性和最佳实践。我们从基本的可选条件查询开始,逐步深入到谓词构建器模式、规约模式、多表关联查询、动态排序和投影等高级特性。通过这些功能,Jimmer为我们提供了构建复杂业务查询的强大工具,同时保持了代码的可读性、可维护性和类型安全性。

Jimmer的动态查询能力远超传统ORM框架,它成功地在类型安全与灵活性之间找到了平衡点。通过类型安全的DSL,我们可以避免字符串拼接SQL的风险;通过丰富的API和灵活的模式,我们可以满足各种复杂的业务需求;通过智能的查询优化,我们可以在不牺牲性能的前提下享受ORM的便利。

在实际应用中,我们应该根据业务场景和复杂度选择合适的查询模式,合理组织代码结构,注意常见陷阱,并采用适当的性能优化策略。通过这些最佳实践,我们可以充分发挥Jimmer动态查询的威力,构建高效、可维护的企业应用。

在下一节中,我们将探讨Jimmer的另一个强大特性:映射的定制化。我们将学习如何通过自定义类型转换器、命名策略和其他高级映射技术,进一步提升Jimmer的灵活性和适应性,使其能够处理更加复杂和特殊的业务需求。

3.4 自定义类型映射

在实际项目开发中,我们常常需要处理一些特殊的数据类型。比如地理位置坐标、复杂的JSON数据结构、枚举值等。这些类型并不能直接映射到数据库的原生类型,或者需要特殊的映射逻辑。Jimmer作为一个现代化的ORM框架,提供了丰富灵活的自定义类型映射机制,可以满足各种复杂场景下的数据映射需求。

本小节我们将通过案例,探讨Jimmer中几种常见的自定义类型映射方式,包括枚举类型映射、JSON对象映射、以及自定义值对象映射等。

3.4.1 枚举类型映射

枚举是Java中表示有限可能值集合的一种类型。在业务建模中,枚举常用于表示状态、类型等概念。默认情况下,Jimmer会将枚举值按照名称(name)直接存储到数据库。但在实际项目中,我们可能希望以更灵活的方式映射枚举值,例如使用更短的代码标识,或者存储额外的信息。

以电商系统中的订单状态为例,我们可能有"待付款"、"已付款"、"已发货"、"已完成"、"已取消"等多种状态。除了状态名称,我们可能还需要每种状态的编码和描述信息。

让我们首先查看OrderStatus枚举的定义:

@EnumType(EnumType.Strategy.NAME)
public enum OrderStatus {
    @EnumItem(name = "P")
    PENDING("P", "待付款"),

    @EnumItem(name = "D")
    PAID("D", "已付款"),

    @EnumItem(name = "S")
    SHIPPED("S", "已发货"),

    @EnumItem(name = "C")
    COMPLETED("C", "已完成"),

    @EnumItem(name = "X")
    CANCELLED("X", "已取消");

    private final String code;
    private final String description;

    OrderStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public String getCode() {
        return code;
    }

    public String getDescription() {
        return description;
    }

    /**
     * 根据状态码查找对应的枚举
     */
    public static OrderStatus fromCode(String code) {
        if (code == null) {
            return null;
        }

        for (OrderStatus status : values()) {
            if (status.code.equals(code)) {
                return status;
            }
        }

        throw new IllegalArgumentException("Unknown order status code: " + code);
    }
}

在这个枚举定义中,我们使用了Jimmer提供的@EnumType@EnumItem注解。@EnumType(EnumType.Strategy.NAME)表示我们将使用名称映射策略,但每个枚举项的名称由@EnumItem指定为简短的编码。例如,PENDING枚举项在数据库中将存储为"P"。

每个枚举项还包含了更多信息,如描述文本,这些信息会被保留在Java对象中,但不会存储到数据库中。

接下来,我们编写一个测试用例来验证这种映射方式:

@Test
@Transactional
@DisplayName("测试自定义枚举映射 - 使用code值")
public void testEnumMappingByCode() {
    // 生成唯一测试ID
    String testId = "E" + UUID.randomUUID().toString().substring(0, 8);

    // 插入测试订单 - 使用PAID状态
    Order order = OrderDraft.$.produce(draft -> {
        draft.setId(testId);
        draft.setOrderNumber("ORD-" + testId);
        draft.setTotalAmount(new BigDecimal("499.99"));
        draft.setStatus(OrderStatus.PAID); // 已付款状态
        draft.setCreatedTime(LocalDateTime.now());
        draft.setPaidTime(LocalDateTime.now());
        draft.setDeleted(false);
    });

    // 保存订单
    sqlClient.getEntities().save(order);

    // 查询并验证
    Order savedOrder = sqlClient.findById(Order.class, testId);
    assertNotNull(savedOrder, "订单不应为空");
    assertEquals(OrderStatus.PAID, savedOrder.status(), "订单状态应为PAID");
    assertEquals("D", savedOrder.status().getCode(), "订单状态编码应为D");
    assertEquals("已付款", savedOrder.status().getDescription(), "订单状态描述应为'已付款'");
}

在这个测试中,我们创建了一个新订单,设置其状态为PAID,保存后再查询出来验证状态是否正确。通过测试验证,我们可以看到Jimmer正确地将PAID枚举项映射为数据库中的"D"值,同时在查询时又将"D"值正确地还原为PAID枚举项,包括其代码和描述信息。

Jimmer支持以下几种枚举映射策略:

  1. NAME:使用枚举项的名称或@EnumItem指定的名称
  2. ORDINAL:使用枚举项的序号值
  3. STRING_ORDINAL:将序号值转为字符串存储

我们还可以根据需要自定义枚举映射,例如使用枚举的某个特殊属性值作为数据库存储值。

3.4.2 复杂JSON对象映射

在现代应用中,非结构化或半结构化数据越来越常见。许多数据库(如PostgreSQL、MySQL、Oracle等)已经支持JSON类型,这使得存储和查询复杂嵌套结构变得更加方便。Jimmer提供了优秀的JSON对象支持,让我们可以将Java对象无缝映射到数据库的JSON列。

以电商系统中的产品规格为例,产品规格可能包含多组规格选项和多种属性,这种结构非常适合使用JSON存储。让我们看看ProductSpec类的定义:

public class ProductSpec implements Serializable {

    private List<SpecGroup> specGroups = new ArrayList<>();
    private Map<String, String> properties = new HashMap<>();

    public List<SpecGroup> getSpecGroups() {
        return specGroups;
    }

    public void setSpecGroups(List<SpecGroup> specGroups) {
        this.specGroups = specGroups;
    }

    public void addSpecGroup(SpecGroup group) {
        this.specGroups.add(group);
    }

    public Map<String, String> getProperties() {
        return properties;
    }

    public void setProperties(Map<String, String> properties) {
        this.properties = properties;
    }

    public void addProperty(String key, String value) {
        this.properties.put(key, value);
    }

    public static class SpecGroup implements Serializable {
        private String name;
        private List<SpecItem> items = new ArrayList<>();

        public SpecGroup() {
        }

        public SpecGroup(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public List<SpecItem> getItems() {
            return items;
        }

        public void setItems(List<SpecItem> items) {
            this.items = items;
        }

        public void addItem(SpecItem item) {
            this.items.add(item);
        }
    }

    public static class SpecItem implements Serializable {
        private String label;
        private String value;

        public SpecItem() {
        }

        public SpecItem(String label, String value) {
            this.label = label;
            this.value = value;
        }

        public String getLabel() {
            return label;
        }

        public void setLabel(String label) {
            this.label = label;
        }

        public String getValue() {
            return value;
        }

        public void setValue(String value) {
            this.value = value;
        }
    }
}

ProductSpec是一个普通的Java类,其中包含了嵌套的规格组和规格项类,以及一个属性映射表。所有这些复杂结构都可以被Jimmer自动转换为JSON存储在数据库中。

在产品实体中使用ProductSpec类型:

public interface Product {

    @Id
    String id();

    String name();

    BigDecimal price();

    int stock();

    boolean active();

    @Serialized
    ProductSpec spec();

    // ... 其他属性和关联
}

关键在于使用@Serialized注解标记spec属性,这告诉Jimmer将该属性序列化为JSON存储。Jimmer使用Jackson进行JSON序列化和反序列化,因此所有Jackson支持的特性(如注解、自定义序列化器等)都可以在这里使用。

接下来,我们编写一个测试来验证复杂JSON对象的映射:

@Test
@Transactional
@DisplayName("测试复杂JSON对象映射")
public void testComplexJsonMapping() {
    // 创建测试ID前缀
    String testPrefix = "test-" + UUID.randomUUID().toString().substring(0, 8);

    // 创建产品规格 - 包含更复杂的结构
    ProductSpec spec = new ProductSpec();

    // 添加规格组
    ProductSpec.SpecGroup sizeGroup = new ProductSpec.SpecGroup("尺寸");
    sizeGroup.addItem(new ProductSpec.SpecItem("小", "S"));
    sizeGroup.addItem(new ProductSpec.SpecItem("中", "M"));
    sizeGroup.addItem(new ProductSpec.SpecItem("大", "L"));
    spec.addSpecGroup(sizeGroup);

    // 添加材质规格组
    ProductSpec.SpecGroup materialGroup = new ProductSpec.SpecGroup("材质");
    materialGroup.addItem(new ProductSpec.SpecItem("纯棉", "Cotton"));
    materialGroup.addItem(new ProductSpec.SpecItem("涤纶", "Polyester"));
    materialGroup.addItem(new ProductSpec.SpecItem("羊毛", "Wool"));
    spec.addSpecGroup(materialGroup);

    // 添加多种属性
    spec.addProperty("品牌", "测试品牌");
    spec.addProperty("产地", "中国");
    spec.addProperty("适用季节", "夏季");
    spec.addProperty("洗涤说明", "手洗,不可漂白");
    spec.addProperty("保质期", "3年");

    // 创建测试产品
    String productId = testPrefix + "-product";
    Product product = insertTestProduct(
        productId, 
        "测试T恤", 
        new BigDecimal("99.99"), 
        100, 
        spec
    );

    // 验证产品基本信息
    assertNotNull(product, "产品不应为空");
    assertEquals(productId, product.id(), "产品ID应匹配");
    assertEquals("测试T恤", product.name(), "产品名称应匹配");
    assertEquals(new BigDecimal("99.99"), product.price(), "产品价格应匹配");

    // 验证规格JSON映射
    assertNotNull(product.spec(), "产品规格不应为空");
    assertEquals(2, product.spec().getSpecGroups().size(), "规格组数量应为2");

    // 验证第一个规格组 - 尺寸
    ProductSpec.SpecGroup firstGroup = product.spec().getSpecGroups().get(0);
    assertEquals("尺寸", firstGroup.getName(), "第一个规格组名称应为'尺寸'");
    assertEquals(3, firstGroup.getItems().size(), "尺寸规格项数量应为3");
    assertEquals("小", firstGroup.getItems().get(0).getLabel(), "第一个尺寸选项应为'小'");
    assertEquals("S", firstGroup.getItems().get(0).getValue(), "第一个尺寸选项值应为'S'");

    // 验证第二个规格组 - 材质
    ProductSpec.SpecGroup secondGroup = product.spec().getSpecGroups().get(1);
    assertEquals("材质", secondGroup.getName(), "第二个规格组名称应为'材质'");
    assertEquals(3, secondGroup.getItems().size(), "材质规格项数量应为3");
    assertEquals("纯棉", secondGroup.getItems().get(0).getLabel(), "第一个材质选项应为'纯棉'");
    assertEquals("Cotton", secondGroup.getItems().get(0).getValue(), "第一个材质选项值应为'Cotton'");

    // 验证属性映射
    Map<String, String> properties = product.spec().getProperties();
    assertEquals(5, properties.size(), "属性数量应为5");
    assertEquals("测试品牌", properties.get("品牌"), "品牌属性应正确映射");
    assertEquals("中国", properties.get("产地"), "产地属性应正确映射");
    assertEquals("夏季", properties.get("适用季节"), "适用季节属性应正确映射");
    assertEquals("手洗,不可漂白", properties.get("洗涤说明"), "洗涤说明属性应正确映射");
    assertEquals("3年", properties.get("保质期"), "保质期属性应正确映射");
}

在这个测试中,我们创建了一个具有复杂规格结构的产品,包含两个规格组(尺寸和材质)以及多个属性。我们验证了Jimmer能够正确地将这个复杂对象存储到数据库并再次正确地加载出来,保持所有嵌套结构和数据完整。

这种JSON映射能力使得Jimmer非常适合存储半结构化数据,比如产品规格、用户偏好、配置信息等。此外,如果数据库支持JSON操作符(如PostgreSQL),Jimmer还可以利用这些操作符进行高效的JSON内部查询。

3.4.3 自定义值对象映射

除了结构化对象映射到JSON,Jimmer还支持将自定义值对象映射到数据库原生类型。例如,地理位置坐标由纬度和经度两个数值组成,我们可以使用@Serialized注解将其映射为JSON字符串,但这不是最优的存储方式。更好的做法是创建一个自定义的值对象类型,并定义如何在数据库与Java对象之间进行转换。

以下是地理位置点GeoPoint类的定义:

public class GeoPoint implements Serializable {

    private final double latitude;
    private final double longitude;

    public GeoPoint(double latitude, double longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }

    public double getLatitude() {
        return latitude;
    }

    public double getLongitude() {
        return longitude;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        GeoPoint geoPoint = (GeoPoint) o;
        return Double.compare(geoPoint.latitude, latitude) == 0 &&
                Double.compare(geoPoint.longitude, longitude) == 0;
    }

    @Override
    public int hashCode() {
        return Objects.hash(latitude, longitude);
    }

    @Override
    public String toString() {
        return "{\"latitude\":" + latitude + ",\"longitude\":" + longitude + "}";
    }
}

在门店实体中使用该类型:

public interface Store {

    @Id
    String id();

    String name();

    @Nullable
    String address();

    @Nullable
    String phone();

    @Serialized
    @Column(name = "location")
    GeoPoint location();

    @Nullable
    @ManyToOne
    @JoinColumn(name = "category_id")
    Category category();

    boolean active();
}

我们使用@Serialized注解告诉Jimmer将GeoPoint对象序列化为JSON存储。但如果我们想要更高效的存储,例如使用数据库的特殊地理位置类型(如PostgreSQL的POINT类型),或者自定义字符串表示,我们可以提供自定义的类型转换器。

以下测试验证了GeoPoint类型的存储和加载:

@Test
@Transactional
@DisplayName("测试地理位置自定义类型转换")
public void testGeoPointMapping() {
    // 生成唯一测试ID
    String storeId = "S" + UUID.randomUUID().toString().substring(0, 8);

    // 创建经纬度坐标 - 北京天安门坐标
    GeoPoint location = new GeoPoint(39.9087243, 116.3952859);

    // 插入测试门店
    Store store = insertTestStore(
        storeId,
        "测试门店",
        "北京市东城区东长安街",
        location
    );

    // 验证门店基本信息
    assertNotNull(store, "门店不应为空");
    assertEquals(storeId, store.id(), "门店ID应匹配");
    assertEquals("测试门店", store.name(), "门店名称应匹配");
    assertEquals("北京市东城区东长安街", store.address(), "门店地址应匹配");

    // 验证经纬度映射
    assertNotNull(store.location(), "门店坐标不应为空");
    assertEquals(39.9087243, store.location().getLatitude(), 0.0000001, "纬度应匹配");
    assertEquals(116.3952859, store.location().getLongitude(), 0.0000001, "经度应匹配");

    // 验证经纬度值对象方法 - JSON格式的字符串表示
    assertEquals("{\"latitude\":39.9087243,\"longitude\":116.3952859}", 
                 store.location().toString(), 
                 "坐标字符串表示应匹配");

    // 更新经纬度 - 上海东方明珠坐标
    GeoPoint newLocation = new GeoPoint(31.2396889, 121.4997553);
    Store updatedStore = StoreDraft.$.produce(store, draft -> {
        draft.setLocation(newLocation);
    });

    // 保存更新
    sqlClient.getEntities().save(updatedStore);

    // 验证更新后的门店
    Store reloadedStore = sqlClient.findById(Store.class, storeId);
    assertNotNull(reloadedStore.location(), "更新后门店坐标不应为空");
    assertEquals(31.2396889, reloadedStore.location().getLatitude(), 0.0000001, "更新后纬度应匹配");
    assertEquals(121.4997553, reloadedStore.location().getLongitude(), 0.0000001, "更新后经度应匹配");
}

这个测试验证了Jimmer能够正确地存储和加载GeoPoint对象,包括其中的纬度和经度值,以及支持更新操作。

对于更复杂的类型转换需求,Jimmer支持自定义类型转换器。通过实现ImmutableTypeConverter接口,我们可以定义任意Java类型与数据库类型之间的转换逻辑,例如:

public class GeoPointConverter implements ImmutableTypeConverter<GeoPoint, String> {

    @Override
    public String output(GeoPoint input) {
        if (input == null) {
            return null;
        }
        return input.getLatitude() + "," + input.getLongitude();
    }

    @Override
    public GeoPoint input(String output) {
        if (output == null) {
            return null;
        }
        String[] parts = output.split(",");
        return new GeoPoint(
            Double.parseDouble(parts[0]),
            Double.parseDouble(parts[1])
        );
    }
}

然后在实体属性上使用@TypeConverter注解指定转换器:

@TypeConverter(GeoPointConverter.class)
@Column(name = "location")
GeoPoint location();

这种方式比使用@Serialized更灵活,可以根据业务需求自定义存储格式,并支持各种复杂的类型转换场景。

3.4.4 总结与实践建议

通过本小节的学习,我们了解了Jimmer中的几种自定义类型映射方式:枚举映射、JSON对象映射以及自定义值对象映射。这些映射机制使Jimmer能够灵活应对各种复杂的数据类型需求,无论是从数据库读取还是存储到数据库。

在实际应用中,我们有以下几点建议:

  1. 枚举映射:对于简单枚举,使用Jimmer内置的枚举映射策略即可;对于复杂枚举,考虑使用@EnumItem注解或自定义转换器。

  2. JSON对象映射:对于复杂的嵌套结构,使用@Serialized注解是最简单的方式。但要注意,使用JSON存储会导致无法直接使用SQL查询内部字段,除非数据库支持JSON操作符。

  3. 自定义值对象:对于有特定存储需求的自定义类型,使用类型转换器提供最大的灵活性。特别是当需要与特定数据库类型(如地理位置、IP地址等)互操作时。

  4. 性能考虑:JSON序列化和反序列化会有一定的性能开销。对于频繁查询的数据,考虑使用更高效的存储方式或建立适当的索引。

  5. 测试驱动开发:在开发自定义类型映射时,遵循TDD原则,先编写测试案例,确保映射行为符合预期。

3.5 从测试到原理,全方位掌握Jimmer核心特性

回顾第三章的内容,我们遵循测试驱动开发(TDD)的理念,构建了一个完整的电子商务领域模型,深入探究了Jimmer框架的核心特性与设计哲学。从不可变对象的设计理念到自定义类型映射的灵活应用,我们通过实际代码和测试用例验证了Jimmer在复杂业务场景中的强大能力。本节将对这些内容进行系统总结,

3.5.1 Jimmer的技术内核与价值剖析

  • 不可变对象:从设计理念到实现机制

通过Product实体的案例分析,我们深入理解了Jimmer不可变对象模型的核心价值:

// 测试不可变对象的基本特性
@Test
public void testProductImmutability() {
    Product product = sqlClient.findById(Product.class, "P001");

    // 创建新对象而非修改原对象
    Product updatedProduct = ProductDraft.$.produce(product, draft -> {
        draft.setPrice(new BigDecimal("199.99"));
    });

    // 原对象保持不变
    assertEquals(new BigDecimal("99.99"), product.price());
    // 新对象包含修改后的值
    assertEquals(new BigDecimal("199.99"), updatedProduct.price());
}

与传统ORM框架不同,Jimmer的不可变性设计并非仅是一种编程风格,而是一种架构决策,其价值体现在:

  1. 状态一致性保障:在分布式系统中,不可变对象天然避免了部分更新导致的数据不一致问题。当我们在微服务架构中共享领域对象时,不必担心对象在传递过程中被意外修改。

  2. 并发安全的本质优势:传统ORM的并发控制通常依赖锁机制或版本控制,而Jimmer不可变对象则从根本上规避了并发修改冲突。正如我们在ProductConcurrencyTest所验证的,多线程环境下无需锁即可安全操作对象。

  3. 内存优化的实现策略:Jimmer内部采用结构共享(Structural Sharing)技术,当创建对象的修改版本时,只复制发生变化的部分,大幅降低内存消耗。这与React的虚拟DOM优化策略类似,特别适合处理大型复杂对象图。

// Jimmer内部结构共享伪代码示意
class ProductImpl implements Product {
    private final Map<String, Object> properties; // 共享的属性容器

    @Override
    public BigDecimal price() {
        return (BigDecimal)properties.get("price");
    }

    // 创建修改版本时,通过复制+修改部分属性实现高效内存共享
    ProductImpl createModified(String key, Object value) {
        Map<String, Object> newProps = new HashMap<>(this.properties);
        newProps.put(key, value);
        return new ProductImpl(newProps);
    }
}

对比传统ORM框架,这种设计带来了质的飞跃:

特性 传统ORM (如JPA) Jimmer
对象修改 直接修改状态,需显式提交 创建新对象,原对象不变
并发安全 依赖锁机制或乐观锁 天然线程安全,无需额外机制
变更追踪 需额外快照比对或AOP拦截 通过Draft机制自然记录变更
内存效率 每个实例独立存储 结构共享,降低内存占用
  • 复杂关联映射:从声明到控制

我们通过多种实体关系的实践,发现Jimmer关联映射设计的独特之处在于"声明式定义,命令式控制"的完美结合:

  1. 精确控制的加载深度:通过Fetcher机制,Jimmer解决了传统ORM"全部加载"或"全部不加载"的粗粒度问题,允许精确定义数据图的形状:
// 精确控制订单数据图的加载边界
Fetcher<Order> orderFetcher = OrderFetcher.$.allScalarFields()
    .user(UserFetcher.$.allScalarFields())
    .items(
        OrderItemFetcher.$.allScalarFields()
            .product(ProductFetcher.$.name().price())
    );

// 按需获取复杂数据图
Order order = sqlClient.findById(orderFetcher, orderId);
  1. N+1问题的根本解决:Jimmer的批处理机制不仅可以解决N+1查询问题,更重要的是,它是自动的,无需开发者手动优化:
// 传统ORM常见的N+1问题
List<Order> orders = findAllOrders(); // 1次查询
for (Order order : orders) {
    User user = order.getUser(); // N次额外查询
    // ...
}

// Jimmer自动批处理优化
List<Order> orders = sqlClient
    .createQuery(Order.class)
    .select(
        OrderFetcher.$.allScalarFields().user()
    )
    .execute(); // 自动优化为2次查询而非N+1次
  1. 树形结构处理的优雅方案:针对树形结构,Jimmer提供了递归查询支持,既避免了无限递归的风险,又保持了代码的简洁优雅:
// 定义递归Fetcher
RecursiveFetcher<Category> recursiveFetcher = CategoryFetcher.$
    .name()
    .childCategories();

// 控制递归深度
Fetcher<Category> categoryFetcher = recursiveFetcher.recursive(3);

// 获取有限深度的树形结构
Category rootCategory = sqlClient.findById(categoryFetcher, rootId);
  • 动态查询:从类型安全到表达能力

3.3节中,我们深入分析了Jimmer查询DSL的设计哲学,它的核心优势在于以下几个方面:

  1. 查询构建的自然逻辑:Jimmer的DSL设计遵循"阅读即理解"的原则,与SQL语句结构高度一致,降低了学习成本:
// Jimmer查询DSL与SQL结构高度一致
List<Product> products = sqlClient.createQuery(Product.class)
    .where(ProductPredicate.$.price().between(
        new BigDecimal("100"), 
        new BigDecimal("200")
    ))
    .orderBy(ProductPredicate.$.price().desc())
    .select(Product.class)
    .execute();

// 对应的SQL语句
// SELECT * FROM t_product WHERE price BETWEEN 100 AND 200 ORDER BY price DESC
  1. 动态条件的优雅处理:相比其他框架,Jimmer的条件组合更加自然和简洁:
// 传统方式的动态条件
String sql = "SELECT * FROM product WHERE 1=1";
List<Object> params = new ArrayList<>();

if (minPrice != null) {
    sql += " AND price >= ?";
    params.add(minPrice);
}
if (maxPrice != null) {
    sql += " AND price <= ?";
    params.add(maxPrice);
}
// ...执行SQL

// Jimmer的动态条件表达
List<Product> products = sqlClient.createQuery(Product.class)
    .where(where -> {
        if (minPrice != null) {
            where.and(ProductPredicate.$.price().ge(minPrice));
        }
        if (maxPrice != null) {
            where.and(ProductPredicate.$.price().le(maxPrice));
        }
    })
    .select(Product.class)
    .execute();
  1. 性能考量的内部优化:Jimmer在查询执行过程中进行了多层次优化:

  2. 自动连接优化:智能分析查询条件和获取路径,最小化JOIN操作

  3. 批处理合并:自动将关联获取优化为批量加载
  4. 子查询转换:根据性能考量自动决定使用JOIN还是子查询

这些优化大多对开发者透明,减轻了开发负担的同时确保了查询性能。

  • 自定义类型映射:从基础到高级应用

在3.4节,我们探索了Jimmer的类型系统扩展能力,它提供了三种层次的类型映射策略:

  1. 简单映射:通过@EnumType处理枚举类型,满足基本需求:
@EnumType(EnumType.Strategy.ORDINAL) // 使用序号存储
public enum Gender { 
    MALE, FEMALE, OTHER 
}

@EnumType(EnumType.Strategy.NAME) // 使用名称存储
public enum OrderStatus {
    @EnumItem(name = "P") PENDING,
    @EnumItem(name = "S") SHIPPED,
    @EnumItem(name = "D") DELIVERED
}
  1. JSON序列化:通过@Serialized注解,优雅处理复杂结构:
@Entity
public interface Product {
    // 将复杂结构作为JSON存储
    @Serialized
    ProductSpec spec();
}
  1. 自定义转换器:通过实现ImmutableTypeConverter,提供完全的类型控制:
// 自定义转换器示例
public class GeoPointConverter implements ImmutableTypeConverter<GeoPoint, String> {
    @Override
    public String toColumnValue(GeoPoint geoPoint) {
        return geoPoint.getLatitude() + "," + geoPoint.getLongitude();
    }

    @Override
    public GeoPoint toPropertyValue(String dbValue) {
        String[] parts = dbValue.split(",");
        return new GeoPoint(
            Double.parseDouble(parts[0]),
            Double.parseDouble(parts[1])
        );
    }
}

这种分层的类型映射策略在实际应用中表现出极高的灵活性:

  • 基础类型:枚举、日期时间等基本类型有内置支持
  • 中等复杂度:结构化对象可通过JSON序列化简化存储
  • 高级需求:特殊格式和业务规则可通过自定义转换器实现

通过测试验证可见Jimmer的自定义类型映射能力:

@Test
@Transactional
@DisplayName("自定义类型映射测试")
public void testCustomTypeMapping() {
    // 测试GeoPoint自定义类型
    Store store = createTestStore();
    Store loadedStore = sqlClient.findById(Store.class, store.id());
    assertEquals(39.9042, loadedStore.location().getLatitude(), 0.0001);

    // 测试ProductSpec复杂JSON类型
    Product product = createTestProduct();
    Product loadedProduct = sqlClient.findById(Product.class, product.id());
    assertEquals("优质面料", loadedProduct.spec().getProperties().get("材质"));
}

与其他框架的类型处理相比,Jimmer在保持灵活性的同时,大幅降低了复杂度:

框架 类型扩展机制 开发复杂度 性能影响
JPA/Hibernate AttributeConverter 中等 较低
MyBatis TypeHandler 较高 较低
Jimmer @Serialized + ImmutableTypeConverter 可控

3.5.2 领域驱动的模型设计实践

本章围绕电子商务场景构建的领域模型不仅是技术示例,更是领域驱动设计(DDD)思想的具体实践。我们的核心实体包括:

Product(商品)──┬───► Category(分类)──┬───► ParentCategory
               │                     │
               └───► Tag(标签)        └───► ChildCategories
Order(订单)─────┼───► User(用户)
               └───► OrderItems───────────► Product

Store(商店)─────┴───► Location(GeoPoint)

这一领域模型体现了以下DDD设计原则:

  1. 聚合根与边界ProductOrderStore作为聚合根,各自管理其内部实体
  2. 值对象应用GeoPoint作为值对象,不可变且无独立身份
  3. 领域行为封装:实体方法体现业务规则,如订单状态转换
  4. 富领域模型:实体不仅包含数据,还包含业务逻辑

在Jimmer框架下,这些DDD概念获得了自然而优雅的实现方式:

// 使用Jimmer实现DDD模式的领域行为
public interface Order {
    // 领域属性
    OrderStatus status();
    BigDecimal totalAmount();

    // 领域行为(计算属性)
    @Transient
    default boolean canCancel() {
        return status() == OrderStatus.PENDING || 
               status() == OrderStatus.PAID;
    }

    // 值对象使用
    @Serialized
    Address shippingAddress();
}

与传统实现相比,Jimmer的DDD实现具有以下优势:

  1. 接口定义:使用接口而非具体类定义实体,更符合DDD的抽象思想
  2. 不可变性:天然支持值对象的不可变特性
  3. 计算属性:通过默认方法优雅实现领域行为
  4. 聚合加载:通过Fetcher精确控制聚合加载边界

3.5.3 从原理到实践的最佳经验

结合实际测试案例和源码分析,我们总结出以下Jimmer应用的核心最佳实践:

  • 接口设计原则

Jimmer的接口优先设计理念与传统ORM的区别不仅是形式上的,更是思维方式的转变:

// 传统JPA实体类
@Entity
public class Product {
    @Id
    private Long id;
    private String name;
    // getters and setters...
}

// Jimmer接口式实体
@Entity
public interface Product {
    @Id
    Long id();
    String name();
}

这种设计带来的好处在于:

  1. 关注点分离:实体定义只声明"是什么",而不涉及"如何实现"
  2. 实现灵活性:底层可以是数据库实体、远程服务响应或本地缓存
  3. 扩展性提升:不受单继承限制,可以实现多个接口

实践建议: - 保持实体接口的纯粹性,避免在其中包含具体实现逻辑 - 适度使用默认方法实现计算属性,但避免复杂业务逻辑 - 使用接口继承表达实体间的"是一种"关系

  • Draft API的高效应用

我们深入分析了Jimmer Draft机制的工作原理,从中总结出以下实践经验:

// 基本使用模式
Product newProduct = ProductDraft.$.produce(draft -> {
    draft.setName("新产品");
    draft.setPrice(new BigDecimal("99.99"));
});

// 修改现有对象
Product updatedProduct = ProductDraft.$.produce(existingProduct, draft -> {
    draft.setPrice(draft.price().multiply(new BigDecimal("0.9"))); // 9折
});

// 深层修改
Order newOrder = OrderDraft.$.produce(draft -> {
    draft.setStatus(OrderStatus.PENDING);
    // 创建嵌套对象
    draft.setItems(Arrays.asList(
        OrderItemDraft.$.produce(itemDraft -> {
            itemDraft.setProduct(product);
            itemDraft.setQuantity(2);
        })
    ));
});

关键实践建议:

  1. 遵循单一职责:每个Draft操作应该专注于一个业务目标
  2. 保持原子性:一个produce操作应该完成一个完整的业务变更
  3. 避免副作用:Draft回调中不应有外部状态修改
  4. 合理嵌套:复杂对象图修改通过嵌套Draft实现,而非多次独立操作

在实际项目中,应将Draft操作封装在领域服务层,而非直接暴露给表示层:

// 不推荐:在控制器中直接使用Draft
@PostMapping("/products")
public Product createProduct(@RequestBody ProductCreateDTO dto) {
    return ProductDraft.$.produce(draft -> {
        draft.setName(dto.getName());
        // ...
    });
}

// 推荐:封装在领域服务中
@Service
public class ProductService {
    public Product createProduct(ProductCreateDTO dto) {
        return ProductDraft.$.produce(draft -> {
            draft.setName(dto.getName());
            // 附加业务逻辑和验证
        });
    }
}
  • 查询优化策略

基于实践检验,我们总结出高效使用Jimmer查询的核心策略:

  1. 精确获取数据:使用Fetcher精确定义数据需求,避免过度获取
// 不同场景使用不同的Fetcher
// 列表页 - 只需基本信息
Fetcher<Product> listFetcher = ProductFetcher.$.name().price().imageUrl();

// 详情页 - 需要完整信息和关联
Fetcher<Product> detailFetcher = ProductFetcher.$.allScalarFields()
    .category(CategoryFetcher.$.name())
    .tags(TagFetcher.$.name());
  1. 批量加载思维:优先考虑一次性批量加载,而非逐个查询
// 避免N+1查询
List<Product> products = sqlClient.findByIds(
    ProductFetcher.$.allScalarFields().category(),
    Arrays.asList("P001", "P002", "P003")
);
  1. 查询条件组合:使用函数式接口组合复杂条件
// 通用查询构建器
public List<Product> searchProducts(ProductSearchDTO criteria) {
    return sqlClient.createQuery(Product.class)
        .where(where -> {
            // 1. 基本条件
            if (criteria.getCategoryId() != null) {
                where.and(ProductPredicate.$.category().id().eq(criteria.getCategoryId()));
            }

            // 2. 价格范围
            if (criteria.getMinPrice() != null && criteria.getMaxPrice() != null) {
                where.and(ProductPredicate.$.price().between(
                    criteria.getMinPrice(), criteria.getMaxPrice()
                ));
            }

            // 3. 关键词搜索 (使用OR组合)
            if (criteria.getKeyword() != null) {
                where.and(
                    Predicate.or(
                        ProductPredicate.$.name().like("%" + criteria.getKeyword() + "%"),
                        ProductPredicate.$.description().like("%" + criteria.getKeyword() + "%")
                    )
                );
            }
        })
        .select(Product.class)
        .execute();
}
  • 类型映射策略选择

基于Jimmer类型映射的实践,我们提出以下选择指南:

场景 推荐方案 说明
简单枚举 @EnumType(NAME) 数据库可读性好,兼容性高
含义丰富的枚举 @EnumType + fromXxx静态方法 允许基于多属性匹配枚举
简单结构化数据 @Serialized 适合整体存取的数据结构
需要独立查询的复杂数据 拆分为关联实体 适合需要单独过滤的数据
特殊格式数据 自定义ImmutableTypeConverter 完全控制序列化与反序列化
// 案例:地理位置数据的两种处理策略

// 1. 作为JSON存储 - 适合整体使用
@Entity
public interface Store {
    @Serialized
    GeoPoint location();
}

// 2. 作为拆分字段 - 适合单独查询
@Entity
public interface Store {
    @Column(name = "latitude")
    double latitude();

    @Column(name = "longitude") 
    double longitude();

    // 值对象视图
    @Transient
    default GeoPoint location() {
        return new GeoPoint(latitude(), longitude());
    }
}

3.5.4 Jimmer vs 传统ORM:核心差异与选择标准

经过本章的探索,我们可以总结出Jimmer与传统ORM框架的本质区别:

维度 传统ORM (JPA/Hibernate) Jimmer
设计哲学 对象-关系映射 不可变对象-关系映射
实体模型 基于类,可变对象 基于接口,不可变对象
关联加载 预设策略(EAGER/LAZY) 运行时精确控制(Fetcher)
查询API JPQL/HQL字符串或Criteria 类型安全DSL
变更跟踪 脏检查或快照比对 不可变对象比较
并发模型 锁或版本控制 不可变性+乐观锁

这些差异不仅是技术层面的,更反映了不同的系统设计思想。在选择ORM框架时,应考虑以下因素:

  1. 系统复杂度:复杂度越高,Jimmer的优势越明显
  2. 并发需求:高并发场景下,Jimmer的不可变设计更有优势
  3. 查询复杂性:动态查询需求强,Jimmer类型安全DSL更适合
  4. 开发团队:函数式编程背景的团队更容易适应Jimmer