前面的一系列文章介绍了AOP的方方面面:

  1. AOP的由来以及快速上手
  2. AOP的两种实现-Spring AOP以及AspectJ
  3. Spring AOP中提供的种种Aspects - Tracing相关
  4. Spring AOP中提供的种种Aspects - 异步执行
  5. Spring AOP中提供的种种Aspects - 并发控制

从本篇文章开始,会介绍一些基于AOP原理的自定义Aspect实现,用来解决在开发过程中可能遇到的各种常见问题。

方法的重试 - Retry

问题分析

在开发爬虫类应用的时候,经常需要处理的问题就是一次爬取过程失败了应该如何处理。其实爬取失败的比率在网络条件比较不稳定的情况下还是相当高的。解决办法一般都会考虑重新尝试这一最最基本和简单的方案。因此,在相关代码中就会出现很多这种结构:

/**
 * 带有失败重试功能的业务代码。
 *
 * @param in
 * @return
 * @throws Exception
 */
public OUT consume(IN in) throws Exception {
  while (shouldRetry()) {
    try {
      OUT output = request(in);
      if (isOutputOK(output)) {
        return output;
      } else {
        continue;
      }
    } catch (Exception e) {
      handleException(e);
    }
  }

  beforeExceptionalReturn();

  return null;
}

上述代码表达的是一个网络请求相关的通用处理结构。可以发现其中主要包含一个控制结构以及若干扩展点:

控制结构:

  • while循环 - 用来控制失败重试,这里是一个控制结构

扩展点:

  • shouldRetry - 用来控制是否需要下次重试
  • request - 关键的业务方法,根据输入得到输出,比如给定一个URL,得到对应的HTML文档
  • handleException - 发生异常的时候进行处理
  • beforeExceptionalReturn - 无法获取结果并且不再进行重试时需要调用的方法

因此,从业务的角度来看,真正关心的也许只是request这一个方法。当然,为了应用的健壮性和灵活性,上面的扩展点都可以根据需要进行扩展,但是大多数情况下采用默认实现也绝对是够用的。

如何Aspect化

想要开发一个Aspect,从它本身的定义来看,首先需要考虑的就是如何定义Advice以及Pointcut。

我们可以将上述扩展点中的request方法作为目标方法,单独定义一个Component用于Advice的定义,然后采用一个基于注解的方式来定义Pointcut,在注解会提供各种属性来帮助开发人员方便地定义各种扩展点。因此,大概的思路就是这样的:

  • 注解的定义 - 用来限定Pointcut的范围,以及一些扩展点的定义
  • Advice的定义 - 具体而言就是一个@Aspect Bean,其中定义了控制结构和各种扩展点
  • Pointcut的定义 - 结合自定义的注解,在目标方法上使用该注解完成Pointcut的定义

注解的定义

根据需要完成的功能的语义,就把这个注解称为@Retry吧,它的实现如下所示:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Retry {

  int maxRetryCount() default 5;

  String shouldRetry() default "";

  String isOutputOK() default "";

  String handleException() default "";

  String beforeExceptionalReturn() default "";

}

注解中定义了几个关键的信息:

  • maxRetryCount:最多重试的次数(包括第一次调用),默认值为5。即会在初次调用失败后最多重试4次
  • shouldRetry:判断是否需要重试的扩展点,传入的是一个方法名
  • isOutputOK:判断返回结果是否合法的扩展点,传入的是一个方法名
  • handleException:在发生异常后进行处理的扩展点,传入的是一个方法名
  • beforeExceptionalReturn:在所有重试都失败后进行处理的扩展点,传入的是一个方法名

目标业务方法的定义

为了测试整个@Retry以及相关的Aspect是否满足需求,下面也定义了一个简单的方法作为目标业务方法(RetryService.doRetryBusiness):

@Service
public class RetryService {

  private AtomicInteger retryCount = new AtomicInteger(0);

  @Retry(maxRetryCount = 2, beforeExceptionalReturn = "extendedBeforeExceptionalReturn")
  public String doRetryBusiness() {
    if (retryCount.getAndIncrement() < 4) {
      throw new RuntimeException(Thread.currentThread().getName() + ": 结果获取失败");
    }
    return Thread.currentThread().getName() + ": 这是最终结果";
  }

  public void extendedBeforeExceptionalReturn() {
    System.out.println(Thread.currentThread().getName() + ": 自定义的处理失败后扩展点");
  }

}

这个业务方法使用了上一步定义的@Retry注解进行修饰。它将最大重试的次数改成了2,也就是说最多只允许重试一次。另外还定义了beforeExceptionalReturn扩展点的实现方法的名称。这个方法对应的就是下方的:

public void extendedBeforeExceptionalReturn() {
  System.out.println("自定义的处理失败后扩展点");
}

因此我们期望的结果是当超过了调用业务方法的最大重试次数后,在返回空结果前会执行我们自定义的方法。由于注解中只包含了方法名称这一字符串类型的信息,毫无疑问在具体的Advice中会通过反射的方法来找到方法对象并调用之。

Aspect Bean的定义

很显然,并不是每次使用@Retry注解的时候都需要提供所有的扩展点实现。如果不提供的话则应该使用默认的实现。这些默认实现可以集中管理:

public abstract class RetrySupport {

  protected boolean shouldRetry() {
    return true;
  }

  protected boolean isOutputOK(Object output) {
    return Objects.nonNull(output);
  }

  protected void handleException(Exception e) {
    System.out.println(e.getMessage());
  }

  protected void beforeExceptionalReturn() {
    System.out.println("默认的处理失败后扩展点");
  }

}

这个类定义了所有的默认方法。当没有提供自定义的扩展方法的时候就会调用它们。

紧接着,就是Aspect本身了的定义了:

@Component
@Aspect
public class RetryAspect extends RetrySupport {

  private static ThreadLocal<Integer> retryCounters;

  static {
    retryCounters = ThreadLocal.withInitial(() -> {
      return 0;
    });
  }

  @Around("com.rxjiang.aop.custom.Pointcuts.retryPointcuts()")
  public Object retryAdvice(ProceedingJoinPoint pjp) throws Throwable {
    System.out.println(Thread.currentThread().getName() + ": 进入Advice");
    // 获取被调用的对象以及Retry注解对象
    MethodSignature signature = (MethodSignature) pjp.getSignature();
    String methodName = signature.getMethod().getName();
    Class<?>[] parameterTypes = signature.getMethod().getParameterTypes();
    Object calledObject = pjp.getTarget();
    Retry retryAnno =
        pjp.getTarget().getClass().getMethod(methodName, parameterTypes).getAnnotation(Retry.class);

    try {
      while (aspectShouldRetry(calledObject, retryAnno)) {
        try {
          Object result = pjp.proceed(pjp.getArgs());
          if (isOutputOK(result)) {
            return result;
          } else {
            continue;
          }
        } catch (Exception e) {
          System.out.println(Thread.currentThread().getName() + ": 捕获到了异常: " + e.getMessage());
          handleException(e);
        }
      }
    } finally {
      retryCounters.set(0);
    }

    aspectBeforeExceptionalReturn(calledObject, retryAnno);

    return null;

  }

  // 拓展点:失败返回前的处理
  private void aspectBeforeExceptionalReturn(Object calledObject, Retry retryAnno)
      throws Throwable {
    String beforeExceptionalReturnMethodName = retryAnno.beforeExceptionalReturn();
    if (StringUtils.isEmpty(beforeExceptionalReturnMethodName)) {
      super.beforeExceptionalReturn();
    } else {
      Method beforeExceptionalReturnMethod =
          calledObject.getClass().getMethod(beforeExceptionalReturnMethodName);
      if (beforeExceptionalReturnMethod == null) {
        super.beforeExceptionalReturn();
      } else {
        beforeExceptionalReturnMethod.invoke(calledObject, new Object[] {});
      }
    }
  }

  // 拓展点: 是否进行重试
  private boolean aspectShouldRetry(Object calledObject, Retry retryAnno) throws Throwable {
    Integer currentCount = retryCounters.get();
    retryCounters.set(currentCount + 1);
    if (++currentCount > retryAnno.maxRetryCount()) {
      return false;
    }

    boolean shouldRetry = false;
    String shouldRetryMethodName = retryAnno.shouldRetry();
    if (StringUtils.isEmpty(shouldRetryMethodName)) {
      shouldRetry = super.shouldRetry();
    } else {
      Method shouldRetryMethod = calledObject.getClass().getMethod(shouldRetryMethodName);
      if (shouldRetryMethod == null) {
        System.out.println("Method does not exist, fallback to default one.");
        shouldRetry = super.shouldRetry();
      } else {
        shouldRetry = (boolean) shouldRetryMethod.invoke(calledObject, new Object[] {});
      }
    }

    return shouldRetry;
  }

}

这个类比较长,但是逻辑还算清晰,主要分为以下几个部分:

  • ThreadLocal的定义,尽管这个Aspect被定义成一个单例对象,但是为了让它能够在多线程环境中正常工作,使用了一个ThreadLocal作为重试计数器
  • Around Advice的实现,该实现可以分为两个部分:
    • 通过反射获取被调用对象本身以及注解对象
    • 定义整体运行结构,即前文中提到的while循环部分
  • 默认方法和可能存在的扩展方法的选择

在主体结构中的while循环里面,会根据注解对象的信息来决定是调用自定义的扩展方法还是默认方法,以是否进行重试这个扩展点作为例子:

// 拓展点: 是否进行重试
private boolean aspectShouldRetry(Object calledObject, Retry retryAnno) throws Throwable {
  Integer currentCount = retryCounters.get();
  retryCounters.set(currentCount + 1);
  if (++currentCount > retryAnno.maxRetryCount()) {
    return false;
  }

  boolean shouldRetry = false;
  String shouldRetryMethodName = retryAnno.shouldRetry();
  if (StringUtils.isEmpty(shouldRetryMethodName)) {
    shouldRetry = super.shouldRetry();
  } else {
    Method shouldRetryMethod = calledObject.getClass().getMethod(shouldRetryMethodName);
    if (shouldRetryMethod == null) {
      System.out.println("Method does not exist, fallback to default one.");
      shouldRetry = super.shouldRetry();
    } else {
      shouldRetry = (boolean) shouldRetryMethod.invoke(calledObject, new Object[] {});
    }
  }

  return shouldRetry;
}

如果当前重试的计数已经超过了最大重试次数,那么直接返回false用来终止执行。否则会继续执行查看是否自定义了重试方法名称。如果定义且方法对象却是存在,那么会调用自定义的扩展方法;否则调用默认方法,有两种情况会调用默认的方法:

  • 注解对象中没有定义相应的属性,这里是shouldRetry字符串
  • 注解对象中定义了自定义方法名称,但是通过反射没法获取到相应的方法对象(字符串拼写错误等原因)

配置以及测试方法

整体配置:

首先是Spring整体的配置,比如开启对于AOP的支持,启动包扫描功能:

@Configuration
@EnableAspectJAutoProxy
@ComponentScan(basePackages = "com.rxjiang")
public class CustomAopConfiguration {

}

Pointcut的定义:

public class Pointcuts {

  @Pointcut("execution(@com.rxjiang.aop.custom.Retry * *(..))")
  public void retryPointcuts() {}

}

测试方法:

串行部分的测试:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {CustomAopConfiguration.class})
public class RetryTest {

  @Autowired
  private RetryService retryService;

  @Test
  public void testRetryMethod1() {
    System.out.println(retryService.doRetryBusiness());
  }

  @Test
  public void testRetryMethod2() {
    System.out.println(retryService.doRetryBusiness());
  }

  @Test
  public void testRetryMethod3() {
    System.out.println(retryService.doRetryBusiness());
  }

}

以上是串行部分的测试。最终的输出大概是这样的:

main: 进入Advice
main: 捕获到了异常: main: 结果获取失败
main: 结果获取失败
main: 捕获到了异常: main: 结果获取失败
main: 结果获取失败
main: 自定义的处理失败后扩展点
null
main: 进入Advice
main: 捕获到了异常: main: 结果获取失败
main: 结果获取失败
main: 捕获到了异常: main: 结果获取失败
main: 结果获取失败
main: 自定义的处理失败后扩展点
null
main: 进入Advice
main: 这是最终结果

以上总共会打印出4条获取失败的信息,因为在业务方法中定义了前四次调用都会返回失败。每个测试方法最多会重试2次(加上初次调用),因此测试方法testRetryMethod1和测试方法testRetryMethod2最后的结果都是null。此时已经一共尝试了4次,因此当testRetryMethod3方法执行的时候会成功得到结果。

并行部分的测试:

@Test
public void testConcurrentRetry() throws InterruptedException {
  IntStream.range(0, 5).forEach(i -> {
    new Thread(() -> {
      try {
        System.out.println(Thread.currentThread().getName() + ": 启动了");
        System.out.println(retryService.doRetryBusiness());
      } catch (Exception e) {
        e.printStackTrace();
      }
    }).start();
  });

  Thread.sleep(10000);
}

这里启动了5个线程同时去访问业务方法,同样地前4次会故意设置成失败,因此最多只会打印出来4条失败信息:

Thread-2: 启动了
Thread-4: 启动了
Thread-3: 启动了
Thread-5: 启动了
Thread-6: 启动了
Thread-3: 进入Advice
Thread-4: 进入Advice
Thread-5: 进入Advice
Thread-6: 进入Advice
Thread-2: 进入Advice
Thread-4: 捕获到了异常: Thread-4: 结果获取失败
Thread-5: 捕获到了异常: Thread-5: 结果获取失败
Thread-5: 结果获取失败
Thread-2: 捕获到了异常: Thread-2: 结果获取失败
Thread-2: 结果获取失败
Thread-6: 捕获到了异常: Thread-6: 结果获取失败
Thread-6: 结果获取失败
Thread-6: 这是最终结果
Thread-3: 这是最终结果
Thread-2: 这是最终结果
Thread-5: 这是最终结果
Thread-4: 结果获取失败
Thread-4: 这是最终结果

而且值得注意的是每个线程都成功获取到了最终结果,这一行为和串行的方式有所差异。从打印的信息来看的话,线程2,4,5,6分别失败了一次,而每个线程最多是可以重试两次的,因此每个线程都获取了结果。

更多的扩展点

除了上述代码介绍的扩展点之外,其实还有很多地方可以扩展,比如:

  • 指定可重试的异常种类:这一点很好理解,并不是每种异常都可以通过重试的方案去解决的,对于网络相关的异常通常是可以恢复的,因此我们可以在注解中声明可重试的异常类型,只有抛出的异常种类相符的时候才会去重试
  • 指定不可重试的异常种类:和上面的情况正好相反,当发生了指定的不可重试的异常时,直接放弃重试
  • 调用fallback方法:当多次重试无效后,可以指定一个fallback(或者默认)方法,通过调用该fallback方法得到最终的结果

总结

本文介绍了一种基于AOP的重试机制的实现方法。在失败率比较高,但是可通过重试来解决的业务场景中可以考虑使用它来简化代码。这样做能够将和业务无关的代码剥离出去,尽可能地做到单一职责,让代码更加优雅。

这也是AOP的初衷,让各种模板代码从业务中独立出去,实现模板代码和业务代码的独立维护。

最新文章

  1. php cUrl模拟登录,cookie保存到文件中
  2. java 接口
  3. ng-template寄宿方式
  4. [Bundling and Minification ] 二、绑定的作用
  5. Careercup - Google面试题 - 5692127791022080
  6. 【leetcode】编辑距离
  7. div border-radius
  8. ajax请求在ie8下缓存问题
  9. Vim 实用技术,第 2 部分: 常用插件(转)
  10. Linq技术四:动态Linq技术 -- Linq.Expressions
  11. app集成微信支付服务端代码-php版本
  12. windows系统,优化C盘空间的方法
  13. java 轻量级同步volatile关键字简介与可见性有序性与synchronized区别 多线程中篇(十二)
  14. vue-router.esm.js:1905 TypeError: Cannot convert undefined or null to object
  15. Chapter 4 Invitations——21
  16. 42.输入一个递增排序的数组和一个数字S,在数组中查找两个数,使得他们的和正好是S, 如果有多对数字的和等于S,输出两个数的乘积最小的。
  17. 三、ASP.NET Core 部署Linux
  18. PAT基础6-5
  19. laravel之路由
  20. LightOJ - 1265 (概率)

热门文章

  1. go——切片(二)
  2. Windows安装多个Tomcat服务
  3. 笔记——Springboot response、ServletOutputStream、图形验证码显示慢
  4. 官方online ddl
  5. Windows 10 安装 到SSD硬盘
  6. SpringBoot MockMVC
  7. AtCoder Regular Contest 099
  8. All Classic Bluetooth profile for iPhone
  9. 还在纠结注册.com域名还是.cn域名?
  10. Rotate Image,N*N矩阵顺时针旋转90度