加载数据

本章节中,我们将介绍从数据库加载实体的过程中,数据是如何以及何时被提取到内存的。

数据模型实体经常会有对其他实体的引用,例如,Order 通过 Order.customer 关联 Customer 实体。在 Jmix 的 Java 代码中可以使用引用属性的 getters 访问关联实体,如 order.getCustomer().getName(),而在数据感知的 UI 组件中,则用“.”,如 order.customer.name

通常来说,加载关联实体有两中策略:

  • Eager loading(预加载) 关联实体与主实体一起从数据库加载。

  • Lazy loading(懒加载) 仅当访问引用属性时,才从数据库透明地加载关联实体。

懒加载

当使用 DataManager 加载 JPA 实体时,Jmix 支持实体中引用属性的懒加载。也就是说,如果使用了 DataManager 或 UI 的 数据加载器 来加载实体,则可以直接访问其引用属性获得关联实体,Jmix 会按需加载。这个过程是递归的,因此可以通过访问引用属性遍历整个对象关系图。

示例:

String getCustomerName(Id<Order> orderId) {
    Order order = dataManager.load(orderId).one();
    return order.getCustomer().getName(); (1)
}

List<String> getProductNames(Id<Order> orderId) {
    Order order = dataManager.load(orderId).one();
    return order.getLines().stream() (2)
            .map(orderLine -> orderLine.getProduct().getName()) (3)
            .collect(Collectors.toList());
}
1 加载 Customer 实体。
2 加载 OrderLine 实体集合。
3 加载 Product 实体。

懒加载非常方便,但是通常无法提供最佳性能。对于处理实体集合尤其如此。如上面的 getProductNames() 方法:首先加载了 order lines 的集合,然后对于每个 order line,再次访问数据库加载关联的 product。这样就是 N+1 次查询,N 是集合中 order line 的数量。

另一个 N+1 问题的例子是在浏览界面加载一个实体。例如,加载一组 order,然后对于每个 order 展示其关联的 customer,那么需要定义一个 UI 表格的列展示 Order.customer 属性。此时懒加载会首先执行一次查询加载所有的 order,然后 N 次查询加载每个 order 的 customer,N 是 order 表格页的行数。

如果你遇到了懒加载的性能瓶颈,请使用下一节介绍的 fetch plans 进行预加载。

Fetch Plans

一个 fetch plan 定义在一次特定的操作中,需要从数据库预加载什么样的对象关系图。Fetch plan 可以用在 DataManager、UI 数据组件 中以提高性能,或者用在 REST API 中定义返回数据的格式,而无需创建单独的一组 DTO。

Fetch plan 与关联实体的懒加载完全兼容。也就是说,可以使用 fetch plan 加载实体关系图的其中一部分,当访问到某些未加载的引用时,再进行延迟加载。

Fetch Plan 使用示例

我们考虑一些围绕 Order 实体及其相关实体的示例,这些实体包括以下数据模型:

fetching diagram 1
  1. 假设我们要在一个浏览界面展示 order 列表,UI 表格中必须包含以下列:numberdateamountcustomer.name。那么,下面这个对象图应该是最佳加载方案了:

    fetching diagram 2

    为了预加载这个对象图,可以在界面中定义这样的 fetch plan:

    <collection id="ordersDc"
                class="dataaccess.ex1.entity.Order">
        <fetchPlan>
            <property name="number"/>
            <property name="date"/>
            <property name="amount"/>
            <property name="customer">
                <property name="name"/>
            </property>
        </fetchPlan>

    结果是,框架会执行一条单一的 SQL 查询语句,语句中对 order 和 customer 表进行关联查询,将相关的 customer 实体与 order 实体一起预加载。这样排除了对 customer 实体进行懒加载的 N+1 问题。

    还有,由于 fetch plan 中定义都是单独的本地属性,SQL 结果集也只包含这些属性而省略了 customer.email 字段。该字段不从数据库读取也不会消耗实例占用的服务器内存。性能上这样做当然是更好的选择,但是要小心处理此类 部分加载 的实体实例。

    当用 Studio 为一个实体创建界面时,界面创建向导默认会为主实体和选中的关联实体提供 _base fetch plan,因此 fetch plan 的定义会看上去有一点不同:

    <fetchPlan extends="_base">
        <property name="customer" fetchPlan="_base"/>
    </fetchPlan>

    _base 是一个内置的 fetch plan(参阅后面 章节 了解详情 ),会加载实体的所有本地属性。

  2. 现在考虑在编辑界面编辑 order 实体的情形。用户可以通过界面编辑 order 属性、选择 customer、以及创建和编辑 order line,并为之选择 product,最后重新计算 order 总价。这样的话,我们需要几乎所有的实体,只是有些关联实体的某些属性可以省略:

    fetching diagram 3

    如果用 Studio 界面向导并选择带默认 _base fetch plan 的关联实体,则会为界面创建下面的 fetch plan:

    <instance id="orderDc"
              class="dataaccess.ex1.entity.Order">
        <fetchPlan extends="_base">
            <property name="customer" fetchPlan="_base"/>
            <property name="lines" fetchPlan="_base">
                <property name="product" fetchPlan="_base"/>
            </property>
        </fetchPlan>
    虽然可以为实体选择单独的本地属性而不使用 _base fetch plan,但是在编辑界面我们不建议这么做。参阅下面关于实体部分加载的相关内容。

部分实体

如果使用了不包含实体某些本地属性的 fetch plan,则实体只能部分加载:fetch plan 中不加载的属性在实体实例中为空。此时如果通过 getter/setter 或者 UI 组件访问这样的属性,框架会抛出异常:

java.lang.IllegalStateException: Cannot get unfetched attribute [foo] from
    detached object com.company.entity.Bar-7f9e689a-fe04-8b5f-35db-5fa51a9a9d71 [detached].

因此,我们推荐谨慎使用部分加载的 fetch plan,除非不加载全部属性真的能带来不小的性能提升。例如,你的实体很“宽”(具有几十或几百个属性),需要加载大量的实体及其关联实体,这种情况可以用部分 fetch plan 大大减少不需要的数据加载。

如果只是加载一个主实体或者很小的一组实体,那么请使用 懒加载 或基于 _base 的 fetch plan 以避免 N+1 问题,而不要使用部分实体。因为一方面,这种情况下使用部分实体 fetch plan 得到的性能提升十分有限;另一方面,如果不小心访问了未加载的属性,还容易遇到问题。

不论用什么 fetch plan,都会加载实体 id 和 版本 属性。

内置 Fetch Plans

Jmix 为每个实体提供了三个内置的 fetch plan:

  • _local - 包含所有本地属性(直接属性,非实体引用属性)。

  • _instance_name - 包含所有构成 the 实例名称 的属性。可以是本地属性或引用属性。如果实体未指定实例名称,则该 fetch plan 为空。

  • _base - 包含所有 _local_instance_name 内的属性。因此,仅当 _instance_name 包含引用属性时,该属性才与 _local 不同。

除非加载大量“宽”实体遇到性能问题,否则都应使用 _base fetch plan。这样能避免未加载属性引起的问题,参阅 部分实体 了解详情。

创建 Fetch Plans

可以用下面的方式自定义 fetch plans:

  1. UI 界面描述中的 inline fetch plan。上面的 示例 演示了这种方式。

  2. Java 中通过编程的方式。

    可以用 FetchPlans 工厂创建 fetch plan,并在 DataManager 操作中使用:

    @Autowired
    private FetchPlans fetchPlans;
    
    private List<Order> loadOrders() {
        FetchPlan fetchPlan = fetchPlans.builder(Order.class)
                .addFetchPlan(FetchPlan.BASE)
                .add("customer")
                .build();
    
        return dataManager.load(Order.class).all().fetchPlan(fetchPlan).list();
    }

    也可以在 DataManager 流式加载接口中使用 fetch plan builder:

    List<Order> orders = dataManager.load(Order.class)
            .all()
            .fetchPlan(fpb -> fpb.addFetchPlan(FetchPlan.BASE).add("customer"))
            .list();
  3. 在共享 fetch plan 仓库创建。

    在共享仓库中创建 fetch plan,然后在项目中通过名称使用,与 内置 的 fetch plan 类似。

    首先,在项目的根包内的资源根目录创建 fetch-plans.xml,并定义 jmix.core.fetch-plans-config 应用程序属性。Studio 中通过 Jmix 工具窗口的 New → Advanced → Fetch Plan Configuration File 创建该文件。

    创建好之后,Jmix 工具窗口的 New → Advanced 菜单会包含一个 Fetch Plan 条目。这里可以通过 设计器 定义 fetch plan。

    下面是定义在 fetch-plans.xml 中的 fetch plan。

    <fetchPlan class="dataaccess.ex1.entity.Order"
               name="full"
               extends="_base">
        <property name="customer" fetchPlan="_instance_name"/>
        <property name="lines">
            <property name="product" fetchPlan="_instance_name"/>
            <property name="quantity"/>
        </property>
    </fetchPlan>

    这个 fetch plan 可以在 DataManager 操作中通过名称使用:

    Order order = dataManager.load(Order.class).id(orderId).fetchPlan("full").one();

    另一个方法是从仓库获取 fetch plan 实例:

    @Autowired
    private FetchPlanRepository fetchPlanRepository;
    
    private Order loadOrder(UUID orderId) {
        FetchPlan fetchPlan = fetchPlanRepository.getFetchPlan(Order.class, "full");
        return dataManager.load(Order.class).id(orderId).fetchPlan(fetchPlan).one();
    }