開發與維運

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

何時使用代理模式

如果想為對象的某些方法做方法邏輯之外的附屬功能(例如 打印出入參、處理異常、校驗權限),但是又不想(或是無法)將這些功能的代碼寫到原有方法中,那麼可以使用代理模式。


愉快地使用代理模式

背景

剛開始開發模型平臺的時候,我們總是會需要一些業務邏輯之外的功能用於調試或者統計,例如這樣:


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 "";
}


方法增強處理器的實現



@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 是用來標記這個切面應該在哪一層,數字越小,則在越外層(越先進入,越後結束) —— 方法調用記錄的切面很明顯應該在大氣層(小編:王者榮耀術語,即最外層),因為方法調用記錄的切面應該最後結束,所以我們給一個小點的數字。


image.png


測試


現在我們就可以給開發時想要記錄調用信息的方法打上這個註解,然後通過日誌來觀察目標方法的調用情況。老規矩,弄個 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


image.png

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


擴展


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


定義相應的註解


/**
 * 用於異常處理的註解
 */
@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,異常處理的切面先結束:


image.png


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


image.png


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


思考


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

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

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

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

Leave a Reply

Your email address will not be published. Required fields are marked *