天天看點

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

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

測試

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

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

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

擴充

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

定義相應的注解

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

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

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

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

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

思考

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

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

小編:如果我們要 限制機關時間内方法的調用次數,比如 3s 内使用者隻能送出表單 1 次,似乎也可以通過這個代理模式的套路來實作。

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