何時使用代理模式
如果想為對象的某些方法做方法邏輯之外的附屬功能(例如 列印出入參、處理異常、校驗權限),但是又不想(或是無法)将這些功能的代碼寫到原有方法中,那麼可以使用代理模式。
愉快地使用代理模式
背景
剛開始開發模型平台的時候,我們總是會需要一些業務邏輯之外的功能用于調試或者統計,例如這樣:
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 的方法裡面,傳回這種情況下的響應。