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