7

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

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzAxNDEwNjk5OQ%3D%3D&%3Bmid=2650417780&%3Bidx=1&%3Bsn=a4076a47b2fc6800a8e10528a0ac1dd7
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

UBVzemq.gif!mobile

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

qA7ZBrj.gif!mobile

▐   方案

大家都听过 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),而不会修改现有代码,从而完美符合  对修改关闭,对扩展开放 设计模式理念。

bYRBZrR.gif!mobile

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

定义一个注解

/**
* 用于产生调用记录的注解,会记录下方法的出入参、调用时长
*/
@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 的方法来对目标方法进行增强处理。

UFzaArU.gif!mobile

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

mYna63m.png!mobile

测试

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

NZbUFzy.png!mobile

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

MfY3IzE.gif!mobile

扩展

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

★定义相应的注解

/**
* 用于异常处理的注解
*/
@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,异常处理的切面先结束:

3Mruyu.png!mobile

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

VJ7jUfI.png!mobile

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

6BFVfiz.gif!mobile

思考

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

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

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

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

fy6BriZ.jpg!mobile

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

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

【招聘岗位】 Java 技术专家 、 数据工程师

如果您有兴趣可将简历发至 [email protected] 或者添加作者微信  wx_zhou_mi 进行详细咨询,欢迎来撩~

✿    拓展阅读

jyM3m2b.png!mobile

2YFFf2A.png!mobile

MjEbaez.png!mobile

作者| 周密(之叶)

编辑| 橙子君

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

veE3IrM.jpg!mobile

u2IBfaQ.png!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK