查看原文
其他

设计模式最佳套路3 —— 愉快地使用代理模式

周密(之叶) 淘系技术 2021-08-11


导读:代理模式(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 实现代理模式的 “最佳套路”(如果你有更好的套路,欢迎赐教和讨论哦)~



▐  方案


大家都听过 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 "";}


方法增强处理器的实现


@Componentpublic 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)@Componentpublic 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&param=test



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



扩展


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


★  定义相应的注解


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


★  实现方法增强处理器


@Componentpublic 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)@Componentpublic 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,异常处理的切面先结束:



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



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


思考


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


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


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


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




淘系技术部-全域营销团队-诚招英才

战斗在阿里电商的核心地带,负责连接供需两端,支持电商营销领域的各类产品、平台和解决方案,其中包括聚划算、百亿补贴、天猫U先、天猫小黑盒、天猫新品孵化、品牌号等重量级业务。我们深度参与双11、618、99划算节等年度大促,不断挑战技术的极限! 团队成员背景多样,有深耕电商精研技术的老司机,也有朝气蓬勃的小萌新,更有可颜可甜的小姐姐,期待具有好奇心和思考力的你的加入!

【招聘岗位】Java 工程师 、数据工程师
如果您有兴趣可将简历发至 michaelchow.zm@alibaba-inc.com 或者添加作者微信 wx_zhou_mi 进行详细咨询,欢迎来撩~


✿  拓展阅读


作者|周密(之叶)

编辑|橙子君

出品|阿里巴巴新零售淘系技术

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存