何时使用代理模式
如果想为对象的某些方法做方法逻辑之外的附属功能(例如 打印出入参、处理异常、校验权限),但是又不想(或是无法)将这些功能的代码写到原有方法中,那么可以使用代理模式。
愉快地使用代理模式
背景
刚开始开发模型平台的时候,我们总是会需要一些业务逻辑之外的功能用于调试或者统计,例如这样:
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)); }
很容易可以看出,打印出入参、记录方法耗时、捕获异常并处理 这些都是和业务没有关系的,业务方法关心的,只应该是 业务逻辑代码 才对。如果不想办法解决,长此以往,坏处就非常明显:
- 违反了 DRY(Don't Repeat Yourself)原则,因为每个业务方法都会包括这些业务逻辑之外的且功能类似的代码
- 违反了 单一职责 原则,业务逻辑代码和附加功能代码杂糅在一起,增加后续维护和扩展的复杂度,且容易导致类爆炸
所以,为了不给以后的自己添乱,我就需要一种方式,来解决上面的问题 —— 很明显,我需要的就是代理模式:原对象的方法只需关心业务逻辑,然后由代理对象来处理这些附属功能。在 Spring 中,实现代理模式的方法多种多样,下面分享一下我目前基于 Spring 实现代理模式的 “最佳套路”(如果你有更好的套路,欢迎赐教和讨论哦)~
方案
大家都听过 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),而不会修改现有代码,从而完美符合 对修改关闭,对扩展开放 设计模式理念。
下面便让我们基于这个小架子,实现我们的第一个增强功能:方法调用记录(记录方法的出入参和调用时长)。
定义一个注解
/** * 用于产生调用记录的注解,会记录下方法的出入参、调用时长 */ @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 的方法来对目标方法进行增强处理。
@Order 是用来标记这个切面应该在哪一层,数字越小,则在越外层(越先进入,越后结束) —— 方法调用记录的切面很明显应该在大气层(小编:王者荣耀术语,即最外层),因为方法调用记录的切面应该最后结束,所以我们给一个小点的数字。
测试
现在我们就可以给开发时想要记录调用信息的方法打上这个注解,然后通过日志来观察目标方法的调用情况。老规矩,弄个 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¶m=test
看出这个输出的那一刻 —— 代理成功 —— 没错,这就是程序猿最幸福的感觉。
扩展
假设我们要在目标方法抛出异常时进行处理:抛出异常时,把异常信息异步发送到邮箱或者钉钉,然后根据方法的返回值类型,返回相应的错误响应。
定义相应的注解
/** * 用于异常处理的注解 */ @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¶m=test,异常处理的切面先结束:
方法调用记录的切面后结束:
没毛病,一切是那么的自然、和谐、美好~
思考
小编:可以看到抛出异常时, InvokeRecordHandler 的 onThrow 方法没有执行,为什么呢?
之叶:因为 InvokeRecordAspect 比 ExceptionHandleAspect 在更外层,外层的 InvokeRecordAspect 在执行时,执行的已经是内层的 ExceptionHandleAspect 代理过的方法,而对应的 ExceptionHandleHandler 已经把异常 “消化” 了,即 ExceptionHandleAspect 代理过的方法已经不会再抛出异常。
小编:如果我们要 限制单位时间内方法的调用次数,比如 3s 内用户只能提交表单 1 次,似乎也可以通过这个代理模式的套路来实现。
之叶:小场面。首先定义好注解(注解可以包含单位时间、最大调用次数等参数),然后在方法切面处理器的 onBefore 方法里面,使用缓存记录下单位时间内用户的提交次数,如果超出最大调用次数,返回 false,那么目标方法就不被允许调用了;然后在 getOnForbid 的方法里面,返回这种情况下的响应。