3分钟将LazyLoad应用于业务开发

1. 概览

在各大框架中,延迟加载是一种重要的性能优化手段,所依赖的数据按需逐步完成加载(比如 Hibernate 的延迟加载)。一来,避免了全部加载带来的性能损失;二来,降低业务人员频繁进行 null 判断 和 手工加载的工作量;

1.1. 背景

在微内核架构中,有一个重要的组件 “Context”,作为一个容器,它贯穿于整个处理流程,为各个插件提供数据和服务的共享。

微内核架构示意如下:

有一种典型场景,比如,Plugin-A 和 Plugin-a 在不同流程分支中都使用到了 User 这个对象,通常情况下两个插件都会这样写:

User user = null;
if(context.getUser() == nll){
    // 自己进行加载
    user = userRepository.getByUserId(context.getUserId());
    context.setUser(user);
}else{
    // 直接使用其他组件已经加载的数据,避免重复加载
    user = context.getUser();
}

其中,重复逻辑如下:

  1. 首先,判断 context 中是否存在 user 对象;

  2. 如果不存在,则从 仓库 里获取,并写回到 context,以便后续流程直接使用;

  3. 如果存在,则直接使用;

由于流程的动态性,难以保障在自己使用时, user 已经被其他组件完成了加载,为了避免 npe 自己不得不重新做一遍。

那结果就是:

  1. 到处都是重复代码,不便于维护;

  2. 散弹式修改,加载逻辑变化,修改点过于分散;

  3. 与业务逻辑耦合在一起,重点不够突出;

  4. 研发被细节羁绊,无法将精力放到核心逻辑上;

1.2. 目标

问题很明显,解决方案也很清晰,期望能实现:

  1. 支持延迟加载,在调用时进行自动加载;

  2. 支持数据缓存,避免数据的多次加载;

  3. 支持级联加载,所需数据尚未加载,则自动触发依赖数据的加载;

2. 快速入门

2.1. 添加 starter

在 Spring boot 项目的 pom 中增加如下依赖:

<dependency>
    <groupId>com.geekhalo.lego</groupId>
    <artifactId>lego-starter-loader</artifactId>
    <version>0.1.1</version>
</dependency>

2.2. 使用 LazyLoadProxyFactory 创建 Proxy

lego-starter-loader 将自动完成 LazyLoadProxyFactory Bean 的注册,在项目中直接引用即可。

示例代码如下:

@Autowired
private LazyLoadProxyFactory lazyLoadProxyFactory;

// 创建代理对象
CreateOrderContext proxyContext = this.lazyLoadProxyFactory.createProxyFor(context);

2.3. 使用 @LazyLoadBy 注解

在 Context 上增加 @LazyLoadBy 注解,并指定加载器。

示例代码如下:

@Data
public class CreateOrderContextV2 implements CreateOrderContext{

    private CreateOrderCmd cmd;

    @LazyLoadBy("#{@userRepository.getById(cmd.userId)}")
    private User user;

    @LazyLoadBy("#{@productRepository.getById(cmd.productId)}")
    private Product product;

    @LazyLoadBy("#{@addressRepository.getDefaultAddressByUserId(user.id)}")
    private Address defAddress;

    @LazyLoadBy("#{@stockRepository.getByProductId(product.id)}")
    private Stock stock;

    @LazyLoadBy("#{@priceService.getByUserAndProduct(user.id, product.id)}")
    private Price price;
}

@LazyLoadBy 使用 SpEL 对加载器进行描述,简单解释如下:
@LazyLoadBy(“#{@userRepository.getById(cmd.userId)}”)

  1. #{….} 为 SpEL 占位符;

  2. @userRepository.getById 指的是调用 userRepository Bean 的 getById 方法;

  3. cmd.userId 指的是,cmd 字段的 userId 属性。

整体含义为:以 cmd 中的 userId 作为参数,调用 userRepository Bean 的 getById 方法。

单元测试如下:

CreateOrderContext proxyContext = this.lazyLoadProxyFactory.createProxyFor(context);
Assertions.assertNotNull(proxyContext);
log.info("Get Command");
Assertions.assertNotNull(proxyContext.getCmd());

log.info("Get Price");
Assertions.assertNotNull(proxyContext.getPrice());

log.info("Get Stock");
Assertions.assertNotNull(proxyContext.getStock());

log.info("Get Default Address");
Assertions.assertNotNull(proxyContext.getDefAddress());

log.info("Get Product");
Assertions.assertNotNull(proxyContext.getProduct());

log.info("Get User");
Assertions.assertNotNull(proxyContext.getUser());

运行测试,获取如下结果:

c.g.l.loader.LazyLoadProxyFactoryTest    : Get Command
c.g.l.loader.LazyLoadProxyFactoryTest    : Get Price
c.g.lego.service.user.UserRepository     : Get User By Id 1
c.g.l.service.product.ProductRepository  : Get Product By Id 2
c.g.lego.service.price.PriceService      : Get Price for User 1 and Product 2
c.g.l.loader.LazyLoadProxyFactoryTest    : Get Stock
c.g.lego.service.stock.StockRepository   : Get Stock By Product Id 2
c.g.l.loader.LazyLoadProxyFactoryTest    : Get Default Address
c.g.l.service.address.AddressRepository  : Load Default Address For User 1
c.g.l.loader.LazyLoadProxyFactoryTest    : Get Product
c.g.l.loader.LazyLoadProxyFactoryTest    : Get User

对照测试代码可得,框架具有 “级联加载” 和 “数据缓存” 能力,具体如下:

  1. 级联加载

  • c.g.lego.service.price.PriceService      : Get Price for User 1 and Product 2

  • c.g.lego.service.user.UserRepository     : Get User By Id 1

  • c.g.l.service.product.ProductRepository  : Get Product By Id 2

  • user.id

  • product.id

  • c.g.l.loader.LazyLoadProxyFactoryTest    : Get Price

  • 调用 getPice 方法

  • 由于 price 依赖 user 和 product(”#{@priceService.getByUserAndProduct(user.id, product.id)}”))

  • 会先获取 user 和 product

  • 最后获取 Price

  • 数据缓存

    • c.g.l.loader.LazyLoadProxyFactoryTest    : Get Product

    • c.g.l.loader.LazyLoadProxyFactoryTest    : Get User

    • 在调用 getPrice 时已经完成了 user 和 product 的加载

    • 后续的 getUser 和 getProduct 没有触发加载操作

    2.4. 使用自定义注解

    @LazyLoadBy 注解过于底层,使用起来比较麻烦,存在明显的缺陷:

    1. 需要熟知加载逻辑,比如调用哪个 bean 的哪个方法;

    2. 形成散弹式修改,如果 bean 或 方法 发生变化,需要修改多处;

    3. 缺乏强约束,比如需要哪几个参数,他们都是什么;

    对此,可以使用 自定义注解 来规避上述问题。

    示例如下:

    首先,创建自定义注解

    // 只能标记在 字段上
    @Target({ElementType.FIELD})
    // 运行时生效
    @Retention(RetentionPolicy.RUNTIME)
    // LazyLoadBy 注解
    @LazyLoadBy("#{@userRepository.getById(${userId})}")
    public @interface LazyLoadUserById {

        String userId();
    }

    核心点有:

    1. 将 @LazyLoadBy 注解放在自定义注解上,用于声明所使用的加载器;

    2. LazyLoadBy 入参使用 ${methodName} 替代,其中 methodName 为自定义注解的方法名;

    类似的,多参数示例如下:

    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    @LazyLoadBy("#{@priceService.getByUserAndProduct(${userId}${productId})}")
    public @interface LazyLoadPriceByUserAndProduct {

        String userId();

        String productId();
    }

    在 Context 中使用自定义注解,具体如下:

    @Data
    public class CreateOrderContextV1 implements CreateOrderContext{

        private CreateOrderCmd cmd;

        @LazyLoadUserById(userId = "cmd.userId")
        private User user;

        @LazyLoadProduceById(productId = "cmd.productId")
        private Product product;

        @LazyLoadDefaultAddressByUserId(userId = "user.id")
        private Address defAddress;

        @LazyLoadStockByProductId(productId = "product.id")
        private Stock stock;

        @LazyLoadPriceByUserAndProduct(userId = "user.id",
                productId = "product.id")

        private Price price;
    }

    新的 CreateOrderContext 只关注入参即可,无需关注具体的加载细节。

    运行单元测试,获取同样的结果如下:

    c.g.l.loader.LazyLoadProxyFactoryTest    : Get Command
    c.g.l.loader.LazyLoadProxyFactoryTest    : Get Price
    c.g.lego.service.user.UserRepository     : Get User By Id 1
    c.g.l.service.product.ProductRepository  : Get Product By Id 2
    c.g.lego.service.price.PriceService      : Get Price for User 1 and Product 2
    c.g.l.loader.LazyLoadProxyFactoryTest    : Get Stock
    c.g.lego.service.stock.StockRepository   : Get Stock By Product Id 2
    c.g.l.loader.LazyLoadProxyFactoryTest    : Get Default Address
    c.g.l.service.address.AddressRepository  : Load Default Address For User 1
    c.g.l.loader.LazyLoadProxyFactoryTest    : Get Product
    c.g.l.loader.LazyLoadProxyFactoryTest    : Get User

    3. 设计&扩展

    3.1. 整体设计

    核心架构如下:

    整体运行流程如下:

    1. LazyLoadProxyFactory 将一个普通的 JavaBean 封装为具有 加载能力的 Proxy 对象;

    2. LazyLoaderInterceptor 对 getter 方法进行拦截,完成属性加载任务;

    3.2. 初始化流程

    核心对象初始化流程如下:

    具体流程如下:

    1. PropertyLazyLoaderFactory 依次遍历 Class 的所有 Field,读取 @LazyLoadBy 注解,并将其封装成 PropertyLoazyLoader;

    2. LazyLoaderInterceptorFactory 将 PropertyLoazyLoader 和 target 对象 封装为 LazyLoaderInterceptor;

    3. 最后由 LazyLoadProxyFactory 将 LazyLoaderInterceptor 应用于 Proxy 对象,使其具备延迟加载能力;

    3.3. 加载流程

    加载流程比较简单,具体如下:

    4. 项目信息

    项目仓库地址:https://gitee.com/litao851025/lego

    项目文档地址:https://gitee.com/litao851025/lego/wikis/support/Loader

    资源下载: