设计模式最佳套路3 :愉快地使用代理模式
之叶2021-01-20

导读:代理模式(Proxy Pattern)即为某一个对象提供一个代理对象,由代理对象来接管被代理对象的各个方法的访问。


何时使用代理模式



如果想为对象的某些方法做方法逻辑之外的附属功能(例如 打印出入参、处理异常、校验权限),但是又不想(或是无法)将这些功能的代码写到原有方法中,那么可以使用代理模式。


愉快地使用代理模式


背景


刚开始开发模型平台的时候,我们总是会需要一些业务逻辑之外的功能用于调试或者统计,例如这样:

public Response processXxxBiz(Request request) {
    long startTime = System.currentMillis();

    try {
        // 业务逻辑
        ......
    } catch (Exception ex) {
        logger.error("processXxxBiz error, request={}", JSON.toJSONString(request), ex)
        // 生成出错响应
        ......
    }

    long costTime = (System.currentMillis() - startTime);
    // 调用完成后,记录出入参
    logger.info("processXxxBiz, costTime={}ms, request={}, response={}", costTime, JSON.toJSONString(request), JSON.toJSONString(response));
}


很容易可以看出,打印出入参、记录方法耗时、捕获异常并处理 这些都是和业务没有关系的,业务方法关心的,只应该是 业务逻辑代码 才对。如果不想办法解决,长此以往,坏处就非常明显:


  1. 违反了 DRY(Don't Repeat Yourself)原则,因为每个业务方法都会包括这些业务逻辑之外的且功能类似的代码
  2. 违反了 单一职责 原则,业务逻辑代码和附加功能代码杂糅在一起,增加后续维护和扩展的复杂度,且容易导致类爆炸


所以,为了不给以后的自己添乱,我就需要一种方式,来解决上面的问题 —— 很明显,我需要的就是代理模式:原对象的方法只需关心业务逻辑,然后由代理对象来处理这些附属功能。在 Spring 中,实现代理模式的方法多种多样,下面分享一下我目前基于 Spring 实现代理模式的 “最佳套路”(如果你有更好的套路,欢迎赐教和讨论哦)~


image.gif方案


大家都听过 Spring 有两大神器 —— IoC 和 AOP。AOP 即面向切面编程(Aspect Oriented Programming):通过预编译方式(CGLib)或者运行期动态代理(JDK Proxy)来实现程序功能代理的技术。在 Spring 中使用代理模式,就是 AOP 的完美应用场景,并且使用注解来进行 AOP 操作已经成为首选,因为注解实在是又方便又好用。我们简单复习下 Spring AOP 的相关概念:


  • Pointcut(切点),指定在什么情况下才执行 AOP,例如方法被打上某个注解的时候
  • JoinPoint(连接点),程序运行中的执行点,例如一个方法的执行或是一个异常的处理;并且在 Spring AOP 中,只有方法连接点
  • Advice(增强),对连接点进行增强(代理):在方法调用前、调用后 或者 抛出异常时,进行额外的处理
  • Aspect(切面),由 Pointcut 和 Advice 组成,可理解为:要在什么情况下(Pointcut)对哪个目标(JoinPoint)做什么样的增强(Advice)


复习了 AOP 的概念之后,我们的方案也非常清晰了,对于某个代理场景:


  • 先定义好一个注解,然后写好相应的增强处理逻辑
  • 建立一个对应的切面,在切面中基于该注解定义切点,并绑定相应的增强处理逻辑
  • 对匹配切点的方法(即打上该注解的方法),使用绑定的增强处理逻辑,对其进行增强


定义方法增强处理器


我们先定义出 ”代理“ 的抽象:方法增强处理器 MethodAdviceHandler 。之后我们定义的每一个注解,都绑定一个对应的 MethodAdviceHandler 的实现类,当目标方法被代理时,由对应的 MethodAdviceHandler 的实现类来处理该方法的代理访问。

/**
 * 方法增强处理器
 *
 * @param <R> 目标方法返回值的类型
 */
public interface MethodAdviceHandler<R> {

    /**
     * 目标方法执行之前的判断,判断目标方法是否允许执行。默认返回 true,即 默认允许执行
     *
     * @param point 目标方法的连接点
     * @return 返回 true 则表示允许调用目标方法;返回 false 则表示禁止调用目标方法。
     * 当返回 false 时,此时会先调用 getOnForbid 方法获得被禁止执行时的返回值,然后
     * 调用 onComplete 方法结束切面
     */
    default boolean onBefore(ProceedingJoinPoint point) { return true; }

    /**
     * 禁止调用目标方法时(即 onBefore 返回 false),执行该方法获得返回值,默认返回 null
     *
     * @param point 目标方法的连接点
     * @return 禁止调用目标方法时的返回值
     */
    default R getOnForbid(ProceedingJoinPoint point) { return null; }

    /**
     * 目标方法抛出异常时,执行的动作
     *
     * @param point 目标方法的连接点
     * @param e     抛出的异常
     */
    void onThrow(ProceedingJoinPoint point, Throwable e);

    /**
     * 获得抛出异常时的返回值,默认返回 null
     *
     * @param point 目标方法的连接点
     * @param e     抛出的异常
     * @return 抛出异常时的返回值
     */
    default R getOnThrow(ProceedingJoinPoint point, Throwable e) { return null; }

    /**
     * 目标方法完成时,执行的动作
     *
     * @param point     目标方法的连接点
     * @param startTime 执行的开始时间
     * @param permitted 目标方法是否被允许执行
     * @param thrown    目标方法执行时是否抛出异常
     * @param result    执行获得的结果
     */
    default void onComplete(ProceedingJoinPoint point, long startTime, boolean permitted, boolean thrown, Object result) { }
}


为了方便 MethodAdviceHandler 的使用,我们定义一个抽象类,提供一些常用的方法。


public abstract class BaseMethodAdviceHandler<R> implements MethodAdviceHandler<R> {

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 抛出异常时候的默认处理
     */
    @Override
    public void onThrow(ProceedingJoinPoint point, Throwable e) {
        String methodDesc = getMethodDesc(point);
        Object[] args = point.getArgs();
        logger.error("{} 执行时出错,入参={}", methodDesc, JSON.toJSONString(args, true), e);
    }

    /**
     * 获得被代理的方法
     *
     * @param point 连接点
     * @return 代理的方法
     */
    protected Method getTargetMethod(ProceedingJoinPoint point) {
        // 获得方法签名
        Signature signature = point.getSignature();
        // Spring AOP 只有方法连接点,所以 Signature 一定是 MethodSignature
        return ((MethodSignature) signature).getMethod();
    }

    /**
     * 获得方法描述,目标类名.方法名
     *
     * @param point 连接点
     * @return 目标类名.执行方法名
     */
    protected String getMethodDesc(ProceedingJoinPoint point) {
        // 获得被代理的类
        Object target = point.getTarget();
        String className = target.getClass().getSimpleName();

        Signature signature = point.getSignature();
        String methodName = signature.getName();

        return className + "." + methodName;
    }
}


定义方法切面的抽象


同理,将方法切面的公共逻辑抽取出来,定义出方法切面的抽象 —— 后续每定义一个注解,对应的方法切面继承自这个抽象类就好。


/**
 * 方法切面抽象类,由子类来指定切点和绑定的方法增强处理器的类型
 */
public abstract class BaseMethodAspect implements ApplicationContextAware {

    /**
     * 切点,通过 @Pointcut 指定相关的注解
     */
    protected abstract void pointcut();

    /**
     * 对目标方法进行环绕增强处理,子类需通过 pointcut() 方法指定切点
     *
     * @param point 连接点
     * @return 方法执行返回值
     */
    @Around("pointcut()")
    public Object advice(ProceedingJoinPoint point) {
        // 获得切面绑定的方法增强处理器的类型
        Class<? extends MethodAdviceHandler<?>> handlerType = getAdviceHandlerType();
        // 从 Spring 上下文中获得方法增强处理器的实现 Bean
        MethodAdviceHandler<?> adviceHandler = appContext.getBean(handlerType);
        // 使用方法增强处理器对目标方法进行增强处理
        return advice(point, adviceHandler);
    }

    /**
     * 获得切面绑定的方法增强处理器的类型
     */
    protected abstract Class<? extends MethodAdviceHandler<?>> getAdviceHandlerType();

    /**
     * 使用方法增强处理器增强被注解的方法
     *
     * @param point   连接点
     * @param handler 切面处理器
     * @return 方法执行返回值
     */
    private Object advice(ProceedingJoinPoint point, MethodAdviceHandler<?> handler) {
        // 执行之前,返回是否被允许执行
        boolean permitted = handler.onBefore(point);

        // 方法返回值
        Object result;
        // 是否抛出了异常
        boolean thrown = false;
        // 开始执行的时间
        long startTime = System.currentTimeMillis();

        // 目标方法被允许执行
        if (permitted) {
            try {
                // 执行目标方法
                result = point.proceed();
            } catch (Throwable e) {
                // 抛出异常
                thrown = true;
                // 处理异常
                handler.onThrow(point, e);
                // 抛出异常时的返回值
                result = handler.getOnThrow(point, e);
            }
        }
        // 目标方法被禁止执行
        else {
            // 禁止执行时的返回值
            result = handler.getOnForbid(point);
        }

        // 结束
        handler.onComplete(point, startTime, permitted, thrown, result);

        return result;
    }

    private ApplicationContext appContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        appContext = applicationContext;
    }
}


此时,我们基于 AOP 的代理模式小架子就已经搭好了。之所以需要这个小架子,是为了后续新增注解时,能够进行横向的扩展:每次新增一个注解(XxxAnno),只需要实现一个新的方法增强处理器(XxxHandler)和新的方法切面 (XxxAspect),而不会修改现有代码,从而完美符合 对修改关闭,对扩展开放 设计模式理念。image.gif


下面便让我们基于这个小架子,实现我们的第一个增强功能:方法调用记录(记录方法的出入参和调用时长)。


定义一个注解

/**
 * 用于产生调用记录的注解,会记录下方法的出入参、调用时长
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeRecordAnno {

    /**
     * 调用说明
     */
    String value() default "";
}


方法增强处理器的实现



@Component
public class InvokeRecordHandler extends BaseMethodAdviceHandler<Object> {

    /**
     * 记录方法出入参和调用时长
     */
    @Override
    public void onComplete(ProceedingJoinPoint point, long startTime, boolean permitted, boolean thrown, Object result) {
        String methodDesc = getMethodDesc(point);
        Object[] args = point.getArgs();
        long costTime = System.currentTimeMillis() - startTime;

        logger.warn("\n{} 执行结束,耗时={}ms,入参={}, 出参={}",
                    methodDesc, costTime,
                    JSON.toJSONString(args, true),
                    JSON.toJSONString(result, true));
    }

    @Override
    protected String getMethodDesc(ProceedingJoinPoint point) {
        Method targetMethod = getTargetMethod(point);
        // 获得方法上的 InvokeRecordAnno
        InvokeRecordAnno anno = targetMethod.getAnnotation(InvokeRecordAnno.class);
        String description = anno.value();

        // 如果没有指定方法说明,那么使用默认的方法说明
        if (StringUtils.isBlank(description)) {
            description = super.getMethodDesc(point);
        }

        return description;
    }
}


方法切面的实现

@Aspect
@Order(1)
@Component
public class InvokeRecordAspect extends BaseMethodAspect {

    /**
     * 指定切点(处理打上 InvokeRecordAnno 的方法)
     */
    @Override
    @Pointcut("@annotation(xyz.mizhoux.experiment.proxy.anno.InvokeRecordAnno)")
    protected void pointcut() { }

    /**
     * 指定该切面绑定的方法切面处理器为 InvokeRecordHandler
     */
    @Override
    protected Class<? extends MethodAspectHandler<?>> getHandlerType() {
        return InvokeRecordHandler.class;
    }
}


@Aspect 用来告诉 Spring 这是一个切面,然后 Spring 在启动会时扫描 @Pointcut 匹配的方法,然后对这些目标方法进行织入处理:即使用切面中打上 @Around 的方法来对目标方法进行增强处理。image.gif


@Order 是用来标记这个切面应该在哪一层,数字越小,则在越外层(越先进入,越后结束) —— 方法调用记录的切面很明显应该在大气层(小编:王者荣耀术语,即最外层),因为方法调用记录的切面应该最后结束,所以我们给一个小点的数字。


屏幕快照 2021-01-20 下午1.42.32.png

测试


现在我们就可以给开发时想要记录调用信息的方法打上这个注解,然后通过日志来观察目标方法的调用情况。老规矩,弄个 Controller :

@RestController
@RequestMapping("proxy")
public class ProxyTestController {

    @GetMapping("test")
    @InvokeRecordAnno("测试代理模式")
    public Map<String, Object> testProxy(@RequestParam String biz,
                                         @RequestParam String param) {
        Map<String, Object> result = new HashMap<>(4);
        result.put("id", 123);
        result.put("nick", "之叶");

        return result;
    }
}


然后访问:localhost/proxy/test?biz=abc&param=test


image.gifimage


看出这个输出的那一刻 —— 代理成功 —— 没错,这就是程序猿最幸福的感觉。



扩展


假设我们要在目标方法抛出异常时进行处理:抛出异常时,把异常信息异步发送到邮箱或者钉钉,然后根据方法的返回值类型,返回相应的错误响应。


★  定义相应的注解


/**
 * 用于异常处理的注解
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExceptionHandleAnno { }


★  实现方法增强处理器


@Component
public class ExceptionHandleHandler extends BaseMethodAdviceHandler<Object> {

    /**
     * 抛出异常时的处理
     */
    @Override
    public void onThrow(ProceedingJoinPoint point, Throwable e) {
        super.onThrow(point, e);
        // 发送异常到邮箱或者钉钉的逻辑
    }

    /**
     * 抛出异常时的返回值
     */
    @Override
    public Object getOnThrow(ProceedingJoinPoint point, Throwable e) {
        // 获得返回值类型
        Class<?> returnType = getTargetMethod(point).getReturnType();

        // 如果返回值类型是 Map 或者其子类
        if (Map.class.isAssignableFrom(returnType)) {
            Map<String, Object> result = new HashMap<>(4);
            result.put("success", false);
            result.put("message", "调用出错");

            return result;
        }

        return null;
    }
}


如果返回值的类型是个 Map,那么我们就返回调用出错情况下的对应 Map 实例(真实情况一般是返回业务系统中的 Response)。


★  实现方法切面


@Aspect
@Order(10)
@Component
public class ExceptionHandleAspect extends BaseMethodAspect {

    /**
     * 指定切点(处理打上 ExceptionHandleAnno 的方法)
     */
    @Override
    @Pointcut("@annotation(xyz.mizhoux.experiment.proxy.anno.ExceptionHandleAnno)")
    protected void pointcut() { }

    /**
     * 指定该切面绑定的方法切面处理器为 ExceptionHandleHandler
     */
    @Override
    protected Class<? extends MethodAdviceHandler<?>> getAdviceHandlerType() {
        return ExceptionHandleHandler.class;
    }
}


异常处理一般是非常内层的切面,所以我们将@Order 设置为 10,让 ExceptionHandleAspect 在 InvokeRecordAspect 更内层(即之后进入、之前结束),从而外层的 InvokeRecordAspect 也可以记录到抛出异常时的返回值。修改测试用的方法,加上 @ExceptionHandleAnno:


@RestController
@RequestMapping("proxy")
public class ProxyTestController {

    @GetMapping("test")
    @ExceptionHandleAnno
    @InvokeRecordAnno("测试代理模式")
    public Map<String, Object> testProxy(@RequestParam String biz,
                                         @RequestParam String param) {
        if (biz.equals("abc")) {
            throw new IllegalArgumentException("非法的 biz=" + biz);
        }

        Map<String, Object> result = new HashMap<>(4);
        result.put("id", 123);
        result.put("nick", "之叶");

        return result;
    }
}


访问:localhost/proxy/test?biz=abc&param=test,异常处理的切面先结束:


image.gifimage


方法调用记录的切面后结束:


image.gifimage


没毛病,一切是那么的自然、和谐、美好~


image


思考


小编:可以看到抛出异常时, InvokeRecordHandler 的 onThrow 方法没有执行,为什么呢?


之叶:因为 InvokeRecordAspect 比 ExceptionHandleAspect 在更外层,外层的 InvokeRecordAspect 在执行时,执行的已经是内层的 ExceptionHandleAspect 代理过的方法,而对应的 ExceptionHandleHandler 已经把异常 “消化” 了,即 ExceptionHandleAspect 代理过的方法已经不会再抛出异常。


小编:如果我们要 限制单位时间内方法的调用次数,比如 3s 内用户只能提交表单 1 次,似乎也可以通过这个代理模式的套路来实现。


之叶:小场面。首先定义好注解(注解可以包含单位时间、最大调用次数等参数),然后在方法切面处理器的 onBefore 方法里面,使用缓存记录下单位时间内用户的提交次数,如果超出最大调用次数,返回 false,那么目标方法就不被允许调用了;然后在 getOnForbid 的方法里面,返回这种情况下的响应。


image