設計模式最佳套路 3 —— 愉快地使用代理模式

導讀:代理模式(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 的相關概念:

複習了 AOP 的概念之後,我們的方案也非常清晰了,對於某個代理場景:

定義方法增強處理器

/**
 * 方法增強處理器
 *
 * @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&param=test

看出這個輸出的那一刻 —— 代理成功 —— 沒錯,這就是程序猿最幸福的感覺。

擴展

假設我們要在目標方法拋出異常時進行處理:拋出異常時,把異常信息異步發送到郵箱或者釘釘,然後根據方法的返回值類型,返回相應的錯誤響應。

★  定義相應的註解

/**
 * 用於異常處理的註解
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExceptionHandleAnno { }

★  實現方法增強處理器

如果返回值的類型是個 Map,那麼我們就返回調用出錯情況下的對應 Map 實例(真實情況一般是返回業務系統中的 Response)。

★  實現方法切面

異常處理一般是非常內層的切面,所以我們將 @Order 設置爲 10,讓 ExceptionHandleAspect 在 InvokeRecordAspect 更內層(即之後進入、之前結束),從而外層的 InvokeRecordAspect 也可以記錄到拋出異常時的返回值。修改測試用的方法,加上 @ExceptionHandleAnno:

訪問:localhost/proxy/test?biz=abc&param=test,異常處理的切面先結束:

方法調用記錄的切面後結束:

沒毛病,一切是那麼的自然、和諧、美好~

思考

小編:可以看到拋出異常時, InvokeRecordHandler 的 onThrow 方法沒有執行,爲什麼呢?

之葉:因爲 InvokeRecordAspect 比 ExceptionHandleAspect 在更外層,外層的 InvokeRecordAspect 在執行時,執行的已經是內層的 ExceptionHandleAspect 代理過的方法,而對應的 ExceptionHandleHandler 已經把異常 “消化” 了,即 ExceptionHandleAspect 代理過的方法已經不會再拋出異常。

小編:如果我們要 限制單位時間內方法的調用次數,比如 3s 內用戶只能提交表單 1 次,似乎也可以通過這個代理模式的套路來實現。

之葉:小場面。首先定義好註解(註解可以包含單位時間、最大調用次數等參數),然後在方法切面處理器的 onBefore 方法裏面,使用緩存記錄下單位時間內用戶的提交次數,如果超出最大調用次數,返回 false,那麼目標方法就不被允許調用了;然後在 getOnForbid 的方法裏面,返回這種情況下的響應。

作者 | 周密(之葉)

編輯 | 橙子君

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s?__biz=MzAxNDEwNjk5OQ==&amp;mid=2650417780&amp;idx=1&amp;sn=a4076a47b2fc6800a8e10528a0ac1dd7&amp;chksm=8396e26cb4e16b7a93828b1cef4eaedf1b3df8bbc9a9ddee2368d927c71369ffcf115fdddc0a&amp;scene=21#wechat_redirect