天天看點

AOP實戰

HTTP 接口鑒權

首先讓我們來想象一下如下場景: 我們需要提供的 HTTP RESTful 服務, 這個服務會提供一些比較敏感的資訊, 是以對于某些接口的調用會進行調用方權限的校驗, 而某些不太敏感的接口則不設定權限, 或所需要的權限比較低(例如某些監控接口, 服務狀态接口等).

實作這樣的需求的方法有很多, 例如我們可以在每個 HTTP 接口方法中對服務請求的調用方進行權限的檢查, 當調用方權限不符時, 方法傳回錯誤. 當然這樣做并無不可, 不過如果我們的 api 接口很多, 每個接口都進行這樣的判斷, 無疑有很多備援的代碼, 并且很有可能有某個粗心的家夥忘記了對調用者的權限進行驗證, 這樣就會造成潛在的 bug.

那麼除了上面的所說的方法外, 還有沒有别的比較優雅的方式來實作呢? 當然有啦, 不然我在這啰嗦半天幹嘛呢, 它就是我們今天的主角: 

AOP

.

讓我們來提煉一下我們的需求:

  1. 可以定制地為某些指定的 HTTP RESTful api 提供權限驗證功能.
  2. 當調用方的權限不符時, 傳回錯誤.

根據上面所提出的需求, 我們可以進行如下設計:

  1. 提供一個特殊的注解 

    AuthChecker

    , 這個是一個方法注解, 有此注解所标注的 Controller 需要進行調用方權限的認證.
  2. 利用 Spring AOP, 以 @annotation 切點标志符來比對有注解 

    AuthChecker

     所标注的 joinpoint.
  3. 在 advice 中, 簡單地檢查調用者請求中的 Cookie 中是否有我們指定的 token, 如果有, 則認為此調用者權限合法, 允許調用, 反之權限不合法, 範圍錯誤.

根據上面的設計, 我們來看一下具體的源碼吧.

首先是 

AuthChecker

 注解的定義:

AuthChecker.java:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthChecker {
}
           

AuthChecker

 注解是一個方法注解, 它用于注解 RequestMapping 方法.

有了注解的定義, 那我們再來看一下 aspect 的實作吧:

HttpAopAdviseDefine.java:

@Component
@Aspect
public class HttpAopAdviseDefine {

    // 定義一個 Pointcut, 使用 切點表達式函數 來描述對哪些 Join point 使用 advise.
    @Pointcut("@annotation(com.xys.demo1.AuthChecker)")
    public void pointcut() {
    }

    // 定義 advise
    @Around("pointcut()")
    public Object checkAuth(ProceedingJoinPoint joinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
                .getRequest();

        // 檢查使用者所傳遞的 token 是否合法
        String token = getUserToken(request);
        if (!token.equalsIgnoreCase("123456")) {
            return "錯誤, 權限不合法!";
        }

        return joinPoint.proceed();
    }

    private String getUserToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return "";
        }
        for (Cookie cookie : cookies) {
            if (cookie.getName().equalsIgnoreCase("user_token")) {
                return cookie.getValue();
            }
        }
        return "";
    }
}
           

在這個 aspect 中, 我們首先定義了一個 pointcut, 以 @annotation 切點标志符來比對有注解 

AuthChecker

 所标注的 joinpoint, 即:

// 定義一個 Pointcut, 使用 切點表達式函數 來描述對哪些 Join point 使用 advise.
@Pointcut("@annotation(com.xys.demo1.AuthChecker)")
public void pointcut() {
}
           

然後再定義一個 advice:

// 定義 advise
@Around("pointcut()")
public Object checkAuth(ProceedingJoinPoint joinPoint) throws Throwable {
    HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
            .getRequest();

    // 檢查使用者所傳遞的 token 是否合法
    String token = getUserToken(request);
    if (!token.equalsIgnoreCase("123456")) {
        return "錯誤, 權限不合法!";
    }

    return joinPoint.proceed();
}
           

當被 

AuthChecker

 注解所标注的方法調用前, 會執行我們的這個 advice, 而這個 advice 的處理邏輯很簡單, 即從 HTTP 請求中擷取名為 

user_token

 的 cookie 的值, 如果它的值是 

123456

, 則我們認為此 HTTP 請求合法, 進而調用 

joinPoint.proceed()

 将 HTTP 請求轉交給相應的控制器處理; 而如果

user_token

 cookie 的值不是 

123456

, 或為空, 則認為此 HTTP 請求非法, 傳回錯誤.

接下來我們來寫一個模拟的 HTTP 接口:

DemoController.java:

@RestController
public class DemoController {
    @RequestMapping("/aop/http/alive")
    public String alive() {
        return "服務一切正常";
    }

    @AuthChecker
    @RequestMapping("/aop/http/user_info")
    public String callSomeInterface() {
        return "調用了 user_info 接口.";
    }
}
           

注意到上面我們提供了兩個 HTTP 接口, 其中 接口 /aop/http/alive 是沒有 

AuthChecker

 标注的, 而 /aop/http/user_info 接口則用到了 

@AuthChecker

 标注. 那麼自然地, 當請求了 /aop/http/user_info 接口時, 就會觸發我們所設定的權限校驗邏輯.

接下來我們來驗證一下, 我們所實作的功能是否有效吧.

首先在 Postman 中, 調用 /aop/http/alive 接口, 請求頭中不加任何參數:

AOP實戰

可以看到, 我們的 HTTP 請求完全沒問題.

那麼再來看一下請求 /aop/http/user_info 接口會怎樣呢:

AOP實戰

當我們請求 /aop/http/user_info 接口時, 服務傳回一個權限異常的錯誤, 為什麼會這樣呢? 自然就是我們的權限認證系統起了作為: 當一個方法被調用并且這個方法有 

AuthChecker

 标注時, 那麼首先會執行到我們的 

around advice

, 在這個 advice 中, 我們會校驗 HTTP 請求的 cookie 字段中是否有攜帶 

user_token

 字段時, 如果沒有, 則傳回權限錯誤.

那麼為了能夠正常地調用 /aop/http/user_info 接口, 我們可以在 Cookie 中添加 user_token=123456, 這樣我們可以愉快的玩耍了:

AOP實戰

注意

, Postman 預設是不支援 Cookie 的, 是以為了實作添加 Cookie 的功能, 我們需要安裝 Postman 的 

interceptor

 插件. 安裝方法可以看官網的文章

完整源碼

HTTP 接口鑒權

方法調用日志

第二個 AOP 執行個體是記錄一個方法調用的log. 這應該是一個很常見的功能了.首先假設我們有如下需求:

  1. 某個服務下的方法的調用需要有 log: 記錄調用的參數以及傳回結果.
  2. 當方法調用出異常時, 有特殊處理, 例如列印異常 log, 報警等.

根據上面的需求, 我們可以使用 before advice 來在調用方法前列印調用的參數, 使用 after returning advice 在方法傳回列印傳回的結果. 而當方法調用失敗後, 可以使用 after throwing advice 來做相應的處理.那麼我們來看一下 aspect 的實作:

@Component
@Aspect
public class LogAopAdviseDefine {
    private Logger logger = LoggerFactory.getLogger(getClass());

    // 定義一個 Pointcut, 使用 切點表達式函數 來描述對哪些 Join point 使用 advise.
    @Pointcut("within(NeedLogService)")
    public void pointcut() {
    }

    // 定義 advise
    @Before("pointcut()")
    public void logMethodInvokeParam(JoinPoint joinPoint) {
        logger.info("---Before method {} invoke, param: {}---", joinPoint.getSignature().toShortString(), joinPoint.getArgs());
    }

    @AfterReturning(pointcut = "pointcut()", returning = "retVal")
    public void logMethodInvokeResult(JoinPoint joinPoint, Object retVal) {
        logger.info("---After method {} invoke, result: {}---", joinPoint.getSignature().toShortString(), joinPoint.getArgs());
    }

    @AfterThrowing(pointcut = "pointcut()", throwing = "exception")
    public void logMethodInvokeException(JoinPoint joinPoint, Exception exception) {
        logger.info("---method {} invoke exception: {}---", joinPoint.getSignature().toShortString(), exception.getMessage());
    }
}
           

第一步, 自然是定義一個 

pointcut

, 以 within 切點标志符來比對類 

NeedLogService

 下的所有 joinpoint, 即:

@Pointcut("within(NeedLogService)")
public void pointcut() {
}
           

接下來根據我們前面的設計, 我們分别定義了三個 advice, 第一個是一個 before advice:

@Before("pointcut()")
public void logMethodInvokeParam(JoinPoint joinPoint) {
    logger.info("---Before method {} invoke, param: {}---", joinPoint.getSignature().toShortString(), joinPoint.getArgs());
}
           

它在一個符合要求的 joinpoint 方法調用前執行, 列印調用的方法名和調用的參數.

第二個是 after return advice:

@AfterReturning(pointcut = "pointcut()", returning = "retVal")
public void logMethodInvokeResult(JoinPoint joinPoint, Object retVal) {
    logger.info("---After method {} invoke, result: {}---", joinPoint.getSignature().toShortString(), joinPoint.getArgs());
}
           

這個 advice 會在方法調用成功後列印出方法名還反的參數.

最後一個是 after throw advice:

@AfterThrowing(pointcut = "pointcut()", throwing = "exception")
public void logMethodInvokeException(JoinPoint joinPoint, Exception exception) {
    logger.info("---method {} invoke exception: {}---", joinPoint.getSignature().toShortString(), exception.getMessage());
}
           

這個 advice 會在指定的 joinpoint 抛出異常時執行, 列印異常的資訊.

接下來我們再寫兩個 Service 類:

NeedLogService.java:

@Service
public class NeedLogService {
    private Logger logger = LoggerFactory.getLogger(getClass());
    private Random random = new Random(System.currentTimeMillis());

    public int logMethod(String someParam) {
        logger.info("---NeedLogService: logMethod invoked, param: {}---", someParam);
        return random.nextInt();
    }

    public void exceptionMethod() throws Exception {
        logger.info("---NeedLogService: exceptionMethod invoked---");
        throw new Exception("Something bad happened!");
    }
}
           

NormalService.java:

@Service
public class NormalService {
    private Logger logger = LoggerFactory.getLogger(getClass());

    public void someMethod() {
        logger.info("---NormalService: someMethod invoked---");
    }
}
           

根據我們 pointcut 的規則, 類 NeedLogService 下的所有方法都會被織入 advice, 而類 NormalService 則不會.

最後我們分别調用這幾個方法:

@PostConstruct
public void test() {
    needLogService.logMethod("xys");
    try {
        needLogService.exceptionMethod();
    } catch (Exception e) {
        // Ignore
    }
    normalService.someMethod();
}
           

我們可以看到有如下輸出:

---Before method NeedLogService.logMethod(..) invoke, param: [xys]---
---NeedLogService: logMethod invoked, param: xys---
---After method NeedLogService.logMethod(..) invoke, result: [xys]---

---Before method NeedLogService.exceptionMethod() invoke, param: []---
---NeedLogService: exceptionMethod invoked---
---method NeedLogService.exceptionMethod() invoke exception: Something bad happened!---

---NormalService: someMethod invoked---
           

根據 log, 我們知道, NeedLogService.logMethod 執行的前後确實有 advice 執行了, 并且在 NeedLogService.exceptionMethod 抛出異常後, 

logMethodInvokeException

 這個 advice 也被執行了. 而由于 pointcut 的比對規則, 在 

NormalService

 類中的方法則不會織入 advice.

完整源碼

方法調用日志

方法耗時統計

作為程式員, 我們都知道服務監控對于一個服務能夠長期穩定運作的重要性, 是以很多公司都有自己内部的監控報警系統, 或者是使用一些開源的系統, 例如小米的 Falcon 監控系統.

那麼在程式監控中, AOP 有哪些用武之地呢? 我們來假想一下如下場景:

有一天, leader 對小王說, "小王啊, 你負責的那個服務不太穩定啊, 經常有逾時發生! 你有對這些服務接口進行過耗時統計嗎?"

耗時統計? 小王嘀咕了, 小聲的回答到: "還沒有加呢."

leader: "你看着辦吧, 我明天要看到各個時段的服務接口調用的耗時分布!"

小王這就犯難了, 雖然說計算一個方法的調用耗時并不是一個很難的事情, 但是整個服務有二十來個接口呢, 一個一個地添加統計代碼, 那還不是要累死人了.

看着同僚一個一個都下班回家了, 小王眉頭更加緊了. 不過此時小王靈機一動: "噫, 有了!".

小王想到了一個好方法, 立即動手, 吭哧吭哧地幾分鐘就搞定了.

那麼小王的解決方法是什麼呢? 自然是我們的主角 

AOP

 啦.

首先讓我們來提煉一下需求:

  1. 為服務中的每個方法調用進行調用耗時記錄.
  2. 将方法調用的時間戳, 方法名, 調用耗時上報到監控平台

有了需求, 自然設計實作就很簡單了. 首先我們可以使用 around advice, 然後在方法調用前, 記錄一下開始時間, 然後在方法調用結束後, 記錄結束時間, 它們的時間差就是方法的調用耗時.

我們來看一下具體的 aspect 實作:

ExpiredAopAdviseDefine.java:

@Component
@Aspect
public class ExpiredAopAdviseDefine {
    private Logger logger = LoggerFactory.getLogger(getClass());

    // 定義一個 Pointcut, 使用 切點表達式函數 來描述對哪些 Join point 使用 advise.
    @Pointcut("within(SomeService)")
    public void pointcut() {
    }

    // 定義 advise
    // 定義 advise
    @Around("pointcut()")
    public Object methodInvokeExpiredTime(ProceedingJoinPoint pjp) throws Throwable {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        // 開始
        Object retVal = pjp.proceed();
        stopWatch.stop();
        // 結束

        // 上報到公司監控平台
        reportToMonitorSystem(pjp.getSignature().toShortString(), stopWatch.getTotalTimeMillis());

        return retVal;
    }


    public void reportToMonitorSystem(String methodName, long expiredTime) {
        logger.info("---method {} invoked, expired time: {} ms---", methodName, expiredTime);
        //
    }
}
           

aspect 一開始定義了一個 

pointcut

, 比對 

SomeService

 類下的所有的方法.

接着呢, 定義了一個 around advice:

@Around("pointcut()")
public Object methodInvokeExpiredTime(ProceedingJoinPoint pjp) throws Throwable {
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    // 開始
    Object retVal = pjp.proceed();
    stopWatch.stop();
    // 結束

    // 上報到公司監控平台
    reportToMonitorSystem(pjp.getSignature().toShortString(), stopWatch.getTotalTimeMillis());

    return retVal;
}
           

advice 中的代碼也很簡單, 它使用了 Spring 提供的 StopWatch 來統計一段代碼的執行時間. 首先我們先調用 stopWatch.start() 開始計時, 然後通過 

pjp.proceed()

 來調用我們實際的服務方法, 當調用結束後, 通過 stopWatch.stop() 來結束計時.

接着我們來寫一個簡單的服務, 這個服務提供一個 someMethod 方法用于模拟一個耗時的方法調用:

SomeService.java:

@Service
public class SomeService {
    private Logger logger = LoggerFactory.getLogger(getClass());
    private Random random = new Random(System.currentTimeMillis());

    public void someMethod() {
        logger.info("---SomeService: someMethod invoked---");
        try {
            // 模拟耗時任務
            Thread.sleep(random.nextInt(500));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
           

這樣當 

SomeService

 類下的方法調用時, 我們所提供的 advice 就會被執行, 是以就可以自動地為我們統計此方法的調用耗時, 并自動上報到監控系統中了.

看到 

AOP

 的威力了吧, 我們這裡僅僅使用了寥寥數語就把一個需求完美地解決了, 并且還與原來的業務邏輯完全解耦, 擴充及其友善.

完整源碼

方法耗時統計

總結

通過上面的幾個簡單例子, 我們對 

Spring AOP

 的使用應該有了一個更為深入的了解了. 其實 Spring AOP 的使用的地方不止這些, 例如 Spring 的 

聲明式事務

 就是在 AOP 之上建構的. 讀者朋友也可以根據自己的實際業務場景, 合理使用 Spring AOP, 發揮它的強大功能!